# Poradnik API filmów w Swift z SwiftUI

> Ten poradnik buduje aplikację na iOS 16 w Swift 5.9, która pokazuje filmy będące w tym tygodniu w trendach na liście SwiftUI z plakatami, latami i wynikami. Używa URLSession z async/await i AsyncImage, obsługuje 401 i 429 oraz wyjaśnia, czemu aplikacja produkcyjna powinna wywoływać amodb przez twój własny serwer.

## Co zbudujesz

Zbudujesz `Trending`, aplikację na iOS z jednym ekranem: listą filmów będących w tym tygodniu w trendach z plakatem, tytułem, rokiem i wynikiem. Potrzebujesz Xcode ze Swift 5.9 lub nowszym, docelowej wersji iOS 16 i klucza amodb. Aplikacja używa `URLSession` z async/await, modeli `Decodable` i `AsyncImage`, bez pakietów. Zaplanuj około 20 minut.

## Klucz w aplikacji da się wyciągnąć

Każdy sekret dostarczany w aplikacji traktuj jak publiczny. Ktoś może czytać ciągi z pliku binarnego, podglądać ruch sieciowy lub czytać pamięć aplikacji. W tym poradniku klucz pochodzi ze zmiennej schematu Xcode, co jest w porządku na twoim własnym urządzeniu. W aplikacji, którą wydajesz, uruchom mały serwer, który trzyma klucz, przekazuje wywołanie do amodb i zwraca JSON. Ustaw `baseURL` w kliencie poniżej na ten serwer i tam uwierzytelniaj swoich użytkowników. Limit żądań obowiązuje na klucz, więc serwer pozwala też zapisać jedną odpowiedź w cache dla wszystkich twoich użytkowników.

## Krok 1: Zdobądź klucz API

Porównaj plany na stronie [cennik](https://amodb.dev/pl/pricing/). Każdy plan zwraca wszystkie dane, więc wybór dotyczy żądań na minutę. Utwórz klucz w panelu konta. Sprawdź go z terminala, zanim napiszesz jakikolwiek kod 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` oznacza, że klucz działa. `401` oznacza, że jest błędny. Strona [uwierzytelnianie](https://amodb.dev/pl/authentication/) wyjaśnia oba sposoby wysyłania klucza.

## Krok 2: Utwórz projekt Xcode

Utwórz projekt aplikacji iOS o nazwie `Trending` z interfejsem SwiftUI i ustaw minimalną docelową wersję na iOS 16. Usuń wygenerowany `ContentView.swift`. W swoim schemacie otwórz Edit Scheme, wybierz Run, potem Arguments i dodaj zmienną środowiskową o nazwie `AMODB_KEY` z twoim kluczem. Xcode przekazuje ją aplikacji tylko wtedy, gdy uruchamiasz z Xcode.

## Krok 3: Wyślij pierwsze żądanie

Odczytaj klucz ze środowiska i wyślij go jako nagłówek Bearer. Ustaw 10-sekundowy timeout, by zawieszone połączenie się kończyło.

```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)
```

## Krok 4: Zdekoduj odpowiedź i pokaż plakaty

Odpowiedź to strona z tablicą `results` z 20 pozycjami. Każdy film ma `id`, `title`, `release_date`, `poster_path` i `vote_average`, a także `media_type` ustawione na `movie`. Ustaw `keyDecodingStrategy` na `.convertFromSnakeCase` i zadeklaruj właściwości w camel case. Pola, których nie zadeklarujesz, są ignorowane, co pasuje do pól pojawiających się później. `poster_path` może być `null`, a `release_date` może brakować lub być pustym ciągiem, więc model deklaruje oba jako opcjonalne.

```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` zaczyna się od ukośnika, więc ciąg powyżej poprawnie łączy rozmiar i nazwę pliku. Plakaty pochodzą z `img.amodb.dev`, który nie wymaga klucza i trzyma obrazy w cache przez 30 dni. Rozmiary wymienia strona [obrazy](https://amodb.dev/pl/images-api/), a endpoint opisuje strona [trendy i listy](https://amodb.dev/pl/trending-lists/).

## Krok 5: Obsłuż błędy i limity żądań

Zmapuj każdy status na błąd, który ekran może pokazać.

| Status      | Znaczenie                               | Co robi aplikacja                                                                |
| ----------- | --------------------------------------- | -------------------------------------------------------------------------------- |
| 401         | Brak klucza, jest błędny lub zawieszony | Pokazuje komunikat, bez ponawiania.                                              |
| 402         | Okres planu dobiegł końca               | Pokazuje komunikat. Odnów go na stronie [cennik](https://amodb.dev/pl/pricing/). |
| 429         | Przekroczono żądania na minutę          | Czeka `Retry-After` sekund, maksymalnie 30, ponawia najwyżej dwa razy.           |
| 500 lub 504 | Awaria po stronie serwera               | Pokazuje status i proponuje odświeżenie przeciągnięciem w dół.                   |
| `URLError`  | Offline, timeout, utracone połączenie   | Pokazuje `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)
}
```

Wszystkie kody znajdziesz na stronach [błędy](https://amodb.dev/pl/errors/) i [limity żądań](https://amodb.dev/pl/rate-limits/).

## Krok 6: Zapisuj listę w cache

Edge trzyma listy trendów w cache przez 30 minut, więc wcześniejsze ponowne zapytanie zwraca te same filmy. Limit żądań nadal liczy to wywołanie. Trzymaj ostatni wynik w modelu widoku i ładuj ponownie tylko wtedy, gdy jest starszy niż 30 minut albo użytkownik odświeża przeciągnięciem w dół.

## Krok 7: Kompletna aplikacja

Ten pojedynczy plik zawiera modele, klienta, model widoku i widoki. Wklej go na wygenerowany plik aplikacji.

```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)
        }
    }
}
```

Uruchom aplikację w symulatorze. Lista wypełnia się po pierwszym wywołaniu, a przeciągnięcie w dół wymusza ponowne załadowanie. Wyniki to zagregowane `vote_average` z listy, a tytuł bez głosów pokazuje 0.0. Dla wyników z IMDb lub Kinopoisk zażądaj filmu z `append_to_response=ratings`. Obiekt `ratings` zawiera `imdb`, `kinopoisk`, `metacritic`, `rotten_tomatoes` i `tmdb`, każde to `null` albo obiekt z `value` i `votes`, jak pokazuje strona [oceny](https://amodb.dev/pl/ratings/).

## Kolejne kroki

- Dodaj ekran szczegółów, który ładuje `/movie/{id}` z `append_to_response=credits,ratings`, a pola znajdziesz na stronie [filmy](https://amodb.dev/pl/movies/).
- Przełącz element zapytania `language` na język urządzenia, korzystając z 14 języków bazowych ze strony [języki](https://amodb.dev/pl/languages/).
- Przenieś klucz na swój serwer i skieruj na niego `baseURL`, zanim opublikujesz aplikację.
- Zbuduj tę samą listę dla internetu z [Next.js](https://amodb.dev/pl/nextjs-movie-app/).

## Pytania i odpowiedzi

### Czy bezpiecznie jest umieścić klucz API w aplikacji na iOS?

Nie. Wszystko, co jest zapisane w pliku binarnym aplikacji lub jej ustawieniach, można wyciągnąć. Trzymaj klucz na serwerze, który kontrolujesz, niech aplikacja wywołuje ten serwer, a klucza w aplikacji używaj tylko do lokalnych testów.

### Jakich wersji iOS i Swift wymaga ten poradnik?

Swift 5.9 lub nowszy z Xcode i docelowa wersja iOS 16. To daje NavigationStack, Task.sleep z czasem trwania i AsyncImage. Model widoku używa ObservableObject, więc działa też na iOS 16.

### Jak pokazać plakaty bez dodatkowych bibliotek?

Zbuduj URL jako https://img.amodb.dev/t/p/w185 plus wartość poster_path i przekaż go do AsyncImage. Host obrazów nie wymaga klucza. Dla większych układów użyj w342 lub w500, a fazę błędu obsłuż zastępczym obrazem.

### Co ma zrobić aplikacja po odpowiedzi 429?

Odczytać nagłówek Retry-After, poczekać tyle sekund z górnym limitem i ponowić najwyżej dwa razy. Jeśli nadal się nie uda, pokazać komunikat i pozwolić użytkownikowi odświeżyć przeciągnięciem w dół. Nie zapętlaj bez limitu.

### Czy mogę użyć innego języka dla tytułów?

Tak. Dodaj element zapytania language, na przykład ru-RU lub pt-BR. Obsługiwanych jest czternaście języków bazowych, a każdy inny tag wraca do angielskiego. Tytuły wracają wtedy zlokalizowane, jeśli tłumaczenie istnieje.

## Powiązane strony

- [Poradnik aplikacji filmowej w Next.js](https://amodb.dev/pl/nextjs-movie-app/): Zbuduj aplikację filmową w Next.js 15 w czystym JavaScript: komponenty serwerowe pobierają trendy, wyszukiwanie i szczegóły z amodb z revalidate i kryją klucz.
- [Trendy i listy](https://amodb.dev/pl/trending-lists/): API trendów filmowych ze ścieżkami TMDB v3: wykresy dzienne i tygodniowe oraz listy now playing, popular, top rated i upcoming, w cache 30 minut.
- [API obrazów i host obrazów](https://amodb.dev/pl/images-api/): API obrazów filmów ze ścieżkami TMDB v3: wypisz plakaty, tła, loga i kadry każdego tytułu, a potem ładuj je z img.amodb.dev w 10 rozmiarach bez klucza.
- [Uwierzytelnianie](https://amodb.dev/pl/authentication/): Jak wysłać klucz amodb: api_key w zapytaniu, nagłówek Authorization Bearer albo apikey dla wywołań OMDb. Format klucza, przechowywanie, rotacja i błędy 401.
- [Limity żądań](https://amodb.dev/pl/rate-limits/): amodb limituje klucz liczbą żądań na minutę w przesuwanym oknie 60 sekund: 20, 40 lub 120 zależnie od planu. Czytaj nagłówki limitu i obsłuż 429 z Retry-After.
- [Szybki start](https://amodb.dev/pl/quickstart/): Pierwsze wywołanie amodb: zdobądź klucz, pobierz szczegóły filmu w curl, JavaScript, Python, PHP lub Go, potem załaduj plakat z img.amodb.dev. Poniżej 2 minut.

Zaktualizowano: 2026-10-18. Source: https://amodb.dev/pl/swift-movie-api/
