# מדריך API לסרטים ב-Swift עם SwiftUI

> המדריך בונה אפליקציית iOS 16 ב-Swift 5.9 שמציגה את הסרטים הפופולריים של השבוע ברשימת SwiftUI עם פוסטרים, שנים וציונים. היא משתמשת ב-URLSession עם async/await וב-AsyncImage, מטפלת ב-401 וב-429, ומסבירה למה אפליקציה בייצור צריכה לקרוא ל-amodb דרך השרת שלכם. הקוד כתוב ב-Swift 5.9 ומיועד ל-iOS 16 ומעלה.

## מה בונים

בונים את `Trending`, אפליקציית iOS עם מסך אחד: רשימה של הסרטים הפופולריים של השבוע עם פוסטר, כותרת, שנה וציון. צריך Xcode עם Swift 5.9 ומעלה, יעד פריסה של iOS 16 ומפתח amodb. האפליקציה משתמשת ב-`URLSession` עם async/await, במודלים של `Decodable` וב-`AsyncImage`, בלי חבילות. תכננו כ-20 דקות.

## מפתח בתוך אפליקציה אפשר לחלץ

התייחסו לכל סוד שנשלח בתוך אפליקציה כאל ציבורי. מישהו יכול לקרוא מחרוזות מקובץ ההרצה, לבדוק תעבורת רשת או לקרוא את האחסון של האפליקציה. במדריך הזה המפתח מגיע ממשתנה של סכמת Xcode, וזה בסדר במכשיר שלכם. באפליקציה שאתם משחררים, הפעילו שרת קטן שמחזיק את המפתח, מעביר את הקריאה ל-amodb ומחזיר את ה-JSON. הגדירו את `baseURL` בלקוח שלמטה לשרת הזה ואמתו שם את המשתמשים שלכם. מגבלת הקצב חלה לכל מפתח, ולכן שרת גם מאפשר לשמור במטמון תשובה אחת לכל המשתמשים שלכם.

## שלב 1: קבלת מפתח API

השוו מסלולים בעמוד [תמחור](https://amodb.dev/he/pricing/). כל מסלול מחזיר את כל הנתונים, ולכן הבחירה נוגעת לבקשות לדקה. צרו מפתח בלוח הבקרה של החשבון. בדקו אותו מהטרמינל לפני שכותבים שורת Swift:

```bash
export AMODB_KEY=YOUR_API_KEY
curl -s -o /dev/null -w "%{http_code}\n" \
  "https://api.amodb.dev/3/trending/movie/week?api_key=$AMODB_KEY"
```

`200` אומר שהמפתח עובד. `401` אומר שהוא שגוי. עמוד [אימות](https://amodb.dev/he/authentication/) מסביר את שתי הדרכים לשלוח את המפתח.

## שלב 2: יצירת פרויקט Xcode

צרו פרויקט iOS App בשם `Trending` עם ממשק SwiftUI והגדירו את יעד הפריסה המינימלי ל-iOS 16. מחקו את `ContentView.swift` שנוצר. בסכמה שלכם פתחו Edit Scheme, בחרו Run ואז Arguments, והוסיפו משתנה סביבה בשם `AMODB_KEY` עם המפתח שלכם. Xcode מעביר אותו לאפליקציה רק כשמריצים מ-Xcode.

## שלב 3: שליחת הבקשה הראשונה

קראו את המפתח מהסביבה ושלחו אותו ככותרת Bearer. קבעו פסק זמן של 10 שניות כדי שחיבור תקוע יסתיים.

```swift
let key = ProcessInfo.processInfo.environment["AMODB_KEY"] ?? ""
var request = URLRequest(url: URL(string: "https://api.amodb.dev/3/trending/movie/week?language=en-US")!)
request.timeoutInterval = 10
request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")

let (data, response) = try await URLSession.shared.data(for: request)
print((response as? HTTPURLResponse)?.statusCode ?? 0, data.count)
```

## שלב 4: פענוח התגובה והצגת פוסטרים

התגובה היא עמוד עם מערך `results` של 20 פריטים. לכל סרט יש `id`, `title`, `release_date`, `poster_path` ו-`vote_average`, ובנוסף `media_type` שמוגדר ל-`movie`. הגדירו את `keyDecodingStrategy` ל-`.convertFromSnakeCase` והצהירו על מאפיינים ב-camel case. שדות שלא הצהרתם עליהם מתעלמים מהם, וזה מתאים לשדות שמופיעים בהמשך. `poster_path` יכול להיות `null` ו-`release_date` יכול להיות חסר או מחרוזת ריקה, ולכן המודל מצהיר על שניהם כאופציונליים.

```swift
struct Movie: Decodable, Identifiable {
    let id: Int
    let title: String
    let releaseDate: String?
    let posterPath: String?
    let voteAverage: Double

    var posterURL: URL? {
        guard let posterPath else { return nil }
        return URL(string: "https://img.amodb.dev/t/p/w185\(posterPath)")
    }
}
```

`poster_path` מתחיל בלוכסן, ולכן המחרוזת שלמעלה מחברת נכון את הגודל ואת שם הקובץ. פוסטרים מגיעים מ-`img.amodb.dev`, שלא דורש מפתח ושומר תמונות במטמון 30 יום. רשימת הגדלים בעמוד [תמונות](https://amodb.dev/he/images-api/), ונקודת הקצה מוסברת בעמוד [טרנדים ורשימות](https://amodb.dev/he/trending-lists/).

## שלב 5: טיפול בשגיאות ובמגבלות קצב

מפו כל סטטוס לשגיאה שהמסך יכול להציג.

| סטטוס      | משמעות                        | מה האפליקציה עושה                                               |
| ---------- | ----------------------------- | --------------------------------------------------------------- |
| 401        | המפתח חסר, שגוי או מושעה      | להציג הודעה, בלי ניסיון חוזר.                                   |
| 402        | תקופת המסלול הסתיימה          | להציג הודעה. לחדש בעמוד [תמחור](https://amodb.dev/he/pricing/). |
| 429        | מעל הבקשות שלכם לדקה          | להמתין `Retry-After` שניות, עד 30, ולנסות שוב פעמיים לכל היותר. |
| 500 או 504 | כשל בצד השרת                  | להציג את הסטטוס ולהציע משיכה לרענון.                            |
| `URLError` | לא מקוון, פסק זמן, חיבור שאבד | להציג את `localizedDescription`.                                |

```swift
switch status {
case 200..<300:
    return try Self.decoder.decode(T.self, from: data)
case 401:
    throw AmodbError.invalidKey
case 429 where attempt < 2:
    let header = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
    try await Task.sleep(for: .seconds(min(Int(header ?? "") ?? 5, 30)))
    attempt += 1
case 429:
    throw AmodbError.rateLimited
default:
    throw AmodbError.server(status)
}
```

את כל הקודים ראו בעמודים [שגיאות](https://amodb.dev/he/errors/) ו[מגבלות קצב](https://amodb.dev/he/rate-limits/).

## שלב 6: שמירת הרשימה במטמון

הקצה שומר רשימות טרנדים במטמון 30 דקות, ולכן בקשה מוקדמת יותר מחזירה את אותם סרטים. מגבלת הקצב עדיין סופרת את הקריאה. שמרו את התוצאה האחרונה במודל התצוגה וטענו מחדש רק כשהיא ישנה מ-30 דקות או כשהמשתמש מושך לרענון.

## שלב 7: האפליקציה המלאה

קובץ יחיד זה מחזיק את המודלים, הלקוח, מודל התצוגה והתצוגות. הדביקו אותו על קובץ האפליקציה שנוצר.

```swift
import SwiftUI

// MARK: Models

struct TrendingPage: Decodable {
    let results: [Movie]
}

struct Movie: Decodable, Identifiable {
    let id: Int
    let title: String
    let releaseDate: String?
    let posterPath: String?
    let voteAverage: Double

    var year: String { String((releaseDate ?? "").prefix(4)) }

    var posterURL: URL? {
        guard let posterPath else { return nil }
        return URL(string: "https://img.amodb.dev/t/p/w185\(posterPath)")
    }
}

// MARK: Client

enum AmodbError: LocalizedError {
    case missingKey
    case invalidKey
    case rateLimited
    case server(Int)

    var errorDescription: String? {
        switch self {
        case .missingKey: return "Set AMODB_KEY in the Xcode scheme."
        case .invalidKey: return "The API key was rejected (401)."
        case .rateLimited: return "Too many requests. Try again in a minute."
        case .server(let status): return "The service answered with status \(status)."
        }
    }
}

struct AmodbClient {
    var baseURL = URL(string: "https://api.amodb.dev/3")!
    let apiKey: String

    private static let decoder: JSONDecoder = {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return decoder
    }()

    func get<T: Decodable>(_ path: String, query: [URLQueryItem] = []) async throws -> T {
        guard !apiKey.isEmpty else { throw AmodbError.missingKey }
        var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
        components.queryItems = query
        var request = URLRequest(url: components.url!, timeoutInterval: 10)
        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")

        var attempt = 0
        while true {
            let (data, response) = try await URLSession.shared.data(for: request)
            let status = (response as? HTTPURLResponse)?.statusCode ?? 0
            switch status {
            case 200..<300:
                return try Self.decoder.decode(T.self, from: data)
            case 401:
                throw AmodbError.invalidKey
            case 429 where attempt < 2:
                let header = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
                try await Task.sleep(for: .seconds(min(Int(header ?? "") ?? 5, 30)))
                attempt += 1
            case 429:
                throw AmodbError.rateLimited
            default:
                throw AmodbError.server(status)
            }
        }
    }
}

// MARK: View model

@MainActor
final class TrendingViewModel: ObservableObject {
    @Published private(set) var movies: [Movie] = []
    @Published private(set) var errorMessage: String?
    @Published private(set) var isLoading = false

    private let client: AmodbClient
    private var loadedAt: Date?

    init(client: AmodbClient) {
        self.client = client
    }

    func load(force: Bool = false) async {
        if !force, let last = loadedAt, !movies.isEmpty, Date().timeIntervalSince(last) < 30 * 60 {
            return
        }
        isLoading = true
        defer { isLoading = false }
        do {
            let page: TrendingPage = try await client.get(
                "trending/movie/week",
                query: [URLQueryItem(name: "language", value: "en-US")]
            )
            movies = page.results
            loadedAt = Date()
            errorMessage = nil
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

// MARK: Views

struct MovieRow: View {
    let movie: Movie

    var body: some View {
        HStack(spacing: 12) {
            AsyncImage(url: movie.posterURL) { phase in
                switch phase {
                case .success(let image):
                    image.resizable().scaledToFill()
                case .failure:
                    Color.gray.opacity(0.2)
                default:
                    ProgressView()
                }
            }
            .frame(width: 60, height: 90)
            .clipShape(RoundedRectangle(cornerRadius: 6))

            VStack(alignment: .leading, spacing: 4) {
                Text(movie.title).font(.headline)
                Text(movie.year).font(.subheadline).foregroundStyle(.secondary)
                Label(String(format: "%.1f", movie.voteAverage), systemImage: "star.fill")
                    .font(.caption)
            }
        }
    }
}

struct ContentView: View {
    @StateObject private var model: TrendingViewModel

    init(client: AmodbClient) {
        _model = StateObject(wrappedValue: TrendingViewModel(client: client))
    }

    var body: some View {
        NavigationStack {
            List(model.movies) { movie in
                MovieRow(movie: movie)
            }
            .overlay {
                if model.isLoading && model.movies.isEmpty {
                    ProgressView()
                } else if let message = model.errorMessage, model.movies.isEmpty {
                    Text(message).multilineTextAlignment(.center).padding()
                }
            }
            .navigationTitle("Trending")
            .refreshable { await model.load(force: true) }
            .task { await model.load() }
        }
    }
}

@main
struct TrendingApp: App {
    private let client = AmodbClient(apiKey: ProcessInfo.processInfo.environment["AMODB_KEY"] ?? "")

    var body: some Scene {
        WindowGroup {
            ContentView(client: client)
        }
    }
}
```

הריצו את האפליקציה בסימולטור. הרשימה מתמלאת אחרי הקריאה הראשונה, ומשיכה למטה כופה טעינה מחדש. הציונים הם `vote_average` המצטבר מהרשימה, וכותר בלי הצבעות עדיין מציג 0.0. לציונים של IMDb או Kinopoisk, בקשו סרט עם `append_to_response=ratings`. האובייקט `ratings` מכיל את `imdb`, `kinopoisk`, `metacritic`, `rotten_tomatoes` ו-`tmdb`, וכל אחד מהם `null` או אובייקט עם `value` ו-`votes`, כפי שמוצג בעמוד [דירוגים](https://amodb.dev/he/ratings/).

## הצעדים הבאים

- הוסיפו מסך פרטים שטוען את `/movie/{id}` עם `append_to_response=credits,ratings`, וראו את השדות בעמוד [סרטים](https://amodb.dev/he/movies/).
- החליפו את פריט השאילתה `language` לשפת המכשיר, עם 14 שפות הבסיס שבעמוד [שפות](https://amodb.dev/he/languages/).
- העבירו את המפתח לשרת שלכם והפנו אליו את `baseURL` לפני שמפרסמים את האפליקציה.
- בנו את אותה רשימה לאינטרנט עם [Next.js](https://amodb.dev/he/nextjs-movie-app/).

## שאלות ותשובות

### האם בטוח לשים את מפתח ה-API באפליקציית iOS?

לא. כל דבר שנשמר בקובץ ההרצה של האפליקציה או בהגדרות שלה אפשר לחלץ. שמרו את המפתח בשרת שבשליטתכם, תנו לאפליקציה לקרוא לשרת הזה, והשתמשו במפתח בתוך האפליקציה רק לבדיקות מקומיות.

### אילו גרסאות iOS ו-Swift המדריך דורש?

Swift 5.9 ומעלה עם Xcode, ויעד פריסה של iOS 16. כך מקבלים NavigationStack, את Task.sleep עם משך ואת AsyncImage. מודל התצוגה משתמש ב-ObservableObject, ולכן הוא רץ גם על iOS 16.

### איך מציגים פוסטרים בלי ספריות נוספות?

בנו את הכתובת כ-https://img.amodb.dev/t/p/w185 ועוד הערך של poster_path והעבירו אותה ל-AsyncImage. שרת התמונות לא דורש מפתח. השתמשו ב-w342 או ב-w500 לפריסות גדולות יותר, וטפלו בשלב הכשל עם תמונת מחדל.

### מה האפליקציה צריכה לעשות אחרי תגובת 429?

לקרוא את הכותרת Retry-After, להמתין כמספר השניות הזה עם תקרה ולנסות שוב פעמיים לכל היותר. אם זה עדיין נכשל, להציג הודעה ולתת למשתמש למשוך לרענון. אל תבצעו לולאה בלי הגבלה.

### אפשר להשתמש בשפה אחרת לכותרות?

כן. הוסיפו פריט שאילתה language כמו ru-RU או pt-BR. מוגשות 14 שפות בסיס, וכל תגית אחרת חוזרת לאנגלית. הכותרות חוזרות מתורגמות כשיש תרגום.

## עמודים קשורים

- [מדריך אפליקציית סרטים ב-Next.js](https://amodb.dev/he/nextjs-movie-app/): בנו אפליקציית סרטים ב-Next.js 15 ב-JavaScript רגיל: רכיבי שרת שולפים מ-amodb סרטים פופולריים, חיפוש ופרטים עם revalidate ושומרים את המפתח פרטי.
- [נקודות קצה של מגמות ורשימות](https://amodb.dev/he/trending-lists/): API סרטים פופולריים עם נתיבי TMDB v3: טבלאות יומיות ושבועיות, ורשימות מוקרנים עכשיו, פופולריים, מדורגים וצפויים לסרטים ולסדרות, במטמון 30 דקות.
- [API תמונות ושרת תמונות](https://amodb.dev/he/images-api/): API תמונות סרטים עם נתיבי TMDB v3: הציגו פוסטרים, תמונות רקע, לוגואים ותמונות סצנה לכל כותר, וטענו אותם מ-img.amodb.dev ב-10 גדלים בלי מפתח.
- [אימות](https://amodb.dev/he/authentication/): איך שולחים את מפתח amodb: api_key בשאילתה, כותרת Authorization Bearer או apikey בקריאות OMDb. פורמט המפתח, שמירה, החלפה ושגיאות 401 שכדאי להכיר.
- [מגבלות קצב](https://amodb.dev/he/rate-limits/): amodb מגביל כל מפתח לפי בקשות לדקה בחלון נע של 60 שניות: 20, 40 או 120 בהתאם למסלול שלכם. קראו את כותרות מגבלת הקצב וטפלו ב-429 עם Retry-After.
- [מדריך למתחילים](https://amodb.dev/he/quickstart/): בצעו את הקריאה הראשונה ל-amodb: קבלו מפתח, בקשו פרטי סרט ב-curl, JavaScript, Python, PHP או Go, ואז טענו את הפוסטר מ-img.amodb.dev. פחות מ-2 דקות.

עודכן: 2026-10-18. Source: https://amodb.dev/he/swift-movie-api/
