# Tutorial API filme Swift cu SwiftUI

> Acest tutorial construiește o aplicație iOS 16 în Swift 5.9 care arată filmele în tendințe ale săptămânii într-o listă SwiftUI cu postere, ani și scoruri. Folosește URLSession cu async/await și AsyncImage, tratează 401 și 429 și explică de ce o aplicație de producție ar trebui să apeleze amodb printr-un server propriu.

## Ce construiești

Construiești `Trending`, o aplicație iOS cu un singur ecran: o listă a filmelor în tendințe ale săptămânii, cu poster, titlu, an și scor. Ai nevoie de Xcode cu Swift 5.9 sau mai nou, o țintă de implementare iOS 16 și o cheie amodb. Aplicația folosește `URLSession` cu async/await, modele `Decodable` și `AsyncImage`, fără pachete. Socotește cam 20 de minute.

## O cheie dintr-o aplicație poate fi extrasă

Tratează orice secret livrat într-o aplicație ca fiind public. Cineva poate citi șiruri din binar, poate inspecta traficul de rețea sau poate citi stocarea aplicației. În acest tutorial cheia vine dintr-o variabilă de mediu din schema Xcode, ceea ce e în regulă pe propriul dispozitiv. Pentru o aplicație pe care o lansezi, rulează un mic server care ține cheia, retransmite apelul către amodb și întoarce JSON-ul. Setează `baseURL` în clientul de mai jos la acel server și autentifică-ți acolo utilizatorii. Limita de cereri se aplică pe cheie, deci un server îți permite și să ții în cache un răspuns pentru toți utilizatorii tăi.

## Pasul 1: obține o cheie API

Compară planurile la [prețuri](https://amodb.dev/ro/pricing/). Fiecare plan întoarce toate datele, deci alegerea ține de cererile pe minut. Creează o cheie în panoul contului tău. Verifică-o dintr-un terminal înainte să scrii orice 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"
```

Un `200` înseamnă că cheia funcționează. Un `401` înseamnă că este greșită. Pagina [autentificare](https://amodb.dev/ro/authentication/) explică ambele moduri de a trimite cheia.

## Pasul 2: creează proiectul Xcode

Creează un proiect iOS App numit `Trending` cu interfața SwiftUI și setează ținta minimă de implementare la iOS 16. Șterge `ContentView.swift` generat. În schema ta, deschide Edit Scheme, alege Run, apoi Arguments și adaugă o variabilă de mediu numită `AMODB_KEY` cu cheia ta. Xcode o transmite aplicației doar când rulezi din Xcode.

## Pasul 3: trimite prima cerere

Citește cheia din mediu și trimite-o ca antet Bearer. Setează un timeout de 10 secunde, ca o conexiune blocată să se încheie.

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

## Pasul 4: decodează răspunsul și arată posterele

Răspunsul este o pagină cu un array `results` de 20 de elemente. Fiecare film are `id`, `title`, `release_date`, `poster_path` și `vote_average`, plus `media_type` setat la `movie`. Setează `keyDecodingStrategy` la `.convertFromSnakeCase` și declară proprietăți în camel case. Câmpurile pe care nu le declari sunt ignorate, ceea ce se potrivește câmpurilor care apar mai târziu. `poster_path` poate fi `null`, iar `release_date` poate lipsi sau poate fi un șir gol, deci modelul le declară pe ambele ca opționale.

```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` începe cu o bară oblică, deci șirul de mai sus lipește corect dimensiunea și numele fișierului. Posterele vin de la `img.amodb.dev`, care nu cere cheie și ține imaginile în cache 30 de zile. Pagina [imagini](https://amodb.dev/ro/images-api/) listează dimensiunile, iar [tendințe și liste](https://amodb.dev/ro/trending-lists/) acoperă endpoint-ul.

## Pasul 5: tratează erorile și limitele de cereri

Mapează fiecare stare la o eroare pe care ecranul o poate afișa.

| Stare       | Semnificație                         | Ce face aplicația                                                                 |
| ----------- | ------------------------------------ | --------------------------------------------------------------------------------- |
| 401         | Cheie lipsă, greșită sau suspendată  | Arată un mesaj, fără reîncercare.                                                 |
| 402         | Perioada planului s-a încheiat       | Arată un mesaj. Reînnoiește la [prețuri](https://amodb.dev/ro/pricing/).          |
| 429         | Ai depășit cererile pe minut         | Așteaptă `Retry-After` secunde, cu plafon de 30, reîncearcă de cel mult două ori. |
| 500 sau 504 | Eroare pe partea serverului          | Arată starea și oferă tragere pentru reîmprospătare.                              |
| `URLError`  | Offline, timeout, conexiune pierdută | Arată `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)
}
```

Vezi [erori](https://amodb.dev/ro/errors/) și [limite de cereri](https://amodb.dev/ro/rate-limits/) pentru fiecare cod.

## Pasul 6: păstrează lista în cache

Marginea ține listele în tendințe în cache 30 de minute, deci o nouă cerere mai devreme întoarce aceleași filme. Limita de cereri numără oricum apelul. Păstrează ultimul rezultat în modelul de vizualizare și reîncarcă doar când este mai vechi de 30 de minute sau când utilizatorul trage pentru reîmprospătare.

## Pasul 7: aplicația completă

Acest singur fișier conține modelele, clientul, modelul de vizualizare și vizualizările. Lipește-l peste fișierul de aplicație generat.

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

Rulează aplicația pe un simulator. Lista se umple după primul apel, iar tragerea în jos forțează o reîncărcare. Scorurile sunt `vote_average` agregat din listă, iar un titlu fără voturi încă arată 0.0. Pentru scorurile IMDb sau Kinopoisk, cere un film cu `append_to_response=ratings`. Obiectul `ratings` conține `imdb`, `kinopoisk`, `metacritic`, `rotten_tomatoes` și `tmdb`, fiecare fiind `null` sau un obiect cu `value` și `votes`, după cum arată pagina [evaluări](https://amodb.dev/ro/ratings/).

## Pașii următori

- Adaugă un ecran de detalii care încarcă `/movie/{id}` cu `append_to_response=credits,ratings` și vezi [filme](https://amodb.dev/ro/movies/) pentru câmpuri.
- Schimbă query item-ul `language` la limba dispozitivului, folosind cele 14 limbi de bază de pe pagina [limbi](https://amodb.dev/ro/languages/).
- Mută cheia pe serverul tău și îndreaptă `baseURL` spre el înainte să publici aplicația.
- Construiește aceeași listă pentru web cu [Next.js](https://amodb.dev/ro/nextjs-movie-app/).

## Întrebări și răspunsuri

### Este sigur să pun cheia API într-o aplicație iOS?

Nu. Orice este stocat în binarul unei aplicații sau în setările ei poate fi extras. Ține cheia pe un server pe care îl controlezi, lasă aplicația să apeleze acel server și folosește o cheie în aplicație doar pentru teste locale.

### De ce versiuni iOS și Swift are nevoie acest tutorial?

Swift 5.9 sau mai nou cu Xcode și o țintă de implementare iOS 16. Asta îți dă NavigationStack, Task.sleep cu durată și AsyncImage. Modelul de vizualizare folosește ObservableObject, deci rulează și pe iOS 16.

### Cum arăt postere fără biblioteci suplimentare?

Construiește URL-ul ca https://img.amodb.dev/t/p/w185 plus valoarea poster_path și transmite-l la AsyncImage. Gazda de imagini nu cere cheie. Folosește w342 sau w500 pentru machete mai mari și tratează faza de eșec cu un substituent.

### Ce ar trebui să facă aplicația după un răspuns 429?

Citește antetul Retry-After, așteaptă atâtea secunde cu un plafon și reîncearcă de cel mult două ori. Dacă tot eșuează, arată un mesaj și lasă utilizatorul să tragă pentru reîmprospătare. Nu repeta fără limită.

### Pot folosi altă limbă pentru titluri?

Da. Adaugă un query item language, precum ru-RU sau pt-BR. Sunt servite paisprezece limbi de bază, iar orice altă etichetă revine la engleză. Titlurile revin apoi localizate când există o traducere.

## Pagini conexe

- [Tutorial aplicație de filme Next.js](https://amodb.dev/ro/nextjs-movie-app/): Aplicație de filme Next.js 15 în JavaScript: server components preiau tendințe, căutare și detalii din amodb cu revalidate, iar cheia rămâne privată.
- [Endpoint-uri pentru tendințe și liste](https://amodb.dev/ro/trending-lists/): API filme în tendințe cu căi TMDB v3: topuri pe zi și săptămână, plus now playing, populare, bine cotate și viitoare pentru filme și TV, în cache 30 de minute.
- [API imagini și gazdă de imagini](https://amodb.dev/ro/images-api/): API imagini filme cu căi TMDB v3: listează postere, imagini de fundal, logouri și cadre, apoi încarcă-le de la img.amodb.dev în 10 dimensiuni, fără cheie.
- [Autentificare](https://amodb.dev/ro/authentication/): Cum trimiți cheia amodb: api_key în interogare, antetul Authorization Bearer sau apikey pentru apelurile OMDb. Format, stocare, rotire și erorile 401.
- [Limite de cereri](https://amodb.dev/ro/rate-limits/): amodb limitează fiecare cheie în cereri pe minut, în fereastră glisantă de 60 de secunde: 20, 40 sau 120. Citește antetele și tratează 429 cu Retry-After.
- [Ghid rapid](https://amodb.dev/ro/quickstart/): Fă primul apel amodb: ia o cheie, cere detaliile unui film în curl, JavaScript, Python, PHP sau Go, apoi încarcă posterul de pe img.amodb.dev. Sub 2 minute.

Actualizat: 2026-10-18. Source: https://amodb.dev/ro/swift-movie-api/
