# Tutorial API film Swift con SwiftUI

> Questo tutorial costruisce un'app iOS 16 in Swift 5.9 che mostra i film di tendenza della settimana in una lista SwiftUI con locandine, anni e punteggi. Usa URLSession con async/await e AsyncImage, gestisce 401 e 429, e spiega perché un'app in produzione dovrebbe chiamare amodb tramite un tuo server.

## Cosa costruisci

Costruisci `Trending`, un'app iOS con una sola schermata: una lista dei film di tendenza della settimana con locandina, titolo, anno e punteggio. Ti servono Xcode con Swift 5.9 o successivo, un target di distribuzione iOS 16 e una chiave amodb. L'app usa `URLSession` con async/await, modelli `Decodable` e `AsyncImage`, senza pacchetti. Calcola circa 20 minuti.

## Una chiave dentro un'app può essere estratta

Tratta come pubblico qualsiasi segreto distribuito in un'app. Qualcuno può leggere le stringhe dal binario, ispezionare il traffico di rete o leggere lo storage dell'app. Per questo tutorial la chiave arriva da una variabile dello scheme di Xcode, il che va bene sul tuo dispositivo. Per un'app che rilasci, fai girare un piccolo server che tiene la chiave, inoltra la chiamata ad amodb e restituisce il JSON. Imposta `baseURL` nel client qui sotto su quel server e autentica lì i tuoi utenti. Il limite di richieste vale per chiave, quindi un server permette anche di mettere in cache una sola risposta per tutti i tuoi utenti.

## Passaggio 1: ottieni una chiave API

Confronta i piani nella pagina dei [prezzi](https://amodb.dev/it/pricing/). Ogni piano restituisce tutti i dati, quindi la scelta riguarda le richieste al minuto. Crea una chiave nella dashboard del tuo account. Verificala da un terminale prima di scrivere qualsiasi riga di 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` significa che la chiave funziona. Un `401` significa che è sbagliata. La pagina [autenticazione](https://amodb.dev/it/authentication/) spiega entrambi i modi di inviare la chiave.

## Passaggio 2: crea il progetto Xcode

Crea un progetto di app iOS chiamato `Trending` con l'interfaccia SwiftUI e imposta il target di distribuzione minimo su iOS 16. Elimina il `ContentView.swift` generato. Nel tuo scheme apri Edit Scheme, scegli Run, poi Arguments, e aggiungi una variabile d'ambiente chiamata `AMODB_KEY` con la tua chiave. Xcode la passa all'app solo quando esegui da Xcode.

## Passaggio 3: invia la prima richiesta

Leggi la chiave dall'ambiente e inviala come header Bearer. Imposta un timeout di 10 secondi così una connessione bloccata termina.

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

## Passaggio 4: decodifica la risposta e mostra le locandine

La risposta è una pagina con un array `results` di 20 elementi. Ogni film ha `id`, `title`, `release_date`, `poster_path` e `vote_average`, più `media_type` impostato su `movie`. Imposta `keyDecodingStrategy` su `.convertFromSnakeCase` e dichiara proprietà in camel case. I campi che non dichiari vengono ignorati, il che si adatta ai campi che compariranno più avanti. `poster_path` può essere `null` e `release_date` può mancare o essere una stringa vuota, quindi il modello dichiara entrambi come opzionali.

```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` inizia con una barra, quindi la stringa qui sopra unisce correttamente la dimensione e il nome del file. Le locandine vengono da `img.amodb.dev`, che non richiede chiave e mette in cache le immagini per 30 giorni. La pagina [immagini](https://amodb.dev/it/images-api/) elenca le dimensioni, e [tendenze e liste](https://amodb.dev/it/trending-lists/) tratta l'endpoint.

## Passaggio 5: gestisci errori e limiti di richieste

Associa ogni stato a un errore che la schermata può mostrare.

| Stato      | Significato                          | Cosa fa l'app                                                                           |
| ---------- | ------------------------------------ | --------------------------------------------------------------------------------------- |
| 401        | Chiave mancante, sbagliata o sospesa | Mostra un messaggio, nessun nuovo tentativo.                                            |
| 402        | Il periodo del piano è terminato     | Mostra un messaggio. Rinnova nella pagina dei [prezzi](https://amodb.dev/it/pricing/).  |
| 429        | Oltre le tue richieste al minuto     | Attende i secondi di `Retry-After`, con un massimo di 30, ritenta al massimo due volte. |
| 500 o 504  | Errore lato server                   | Mostra lo stato e propone di trascinare per aggiornare.                                 |
| `URLError` | Offline, timeout, connessione persa  | Mostra `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)
}
```

Vedi [errori](https://amodb.dev/it/errors/) e [limiti di richieste](https://amodb.dev/it/rate-limits/) per tutti i codici.

## Passaggio 6: metti la lista in cache

L'edge mette in cache le liste di tendenze per 30 minuti, quindi chiedere prima restituisce gli stessi film. Il limite di richieste conta comunque la chiamata. Tieni l'ultimo risultato nel view model e ricarica solo quando ha più di 30 minuti o quando l'utente trascina per aggiornare.

## Passaggio 7: l'app completa

Questo singolo file contiene i modelli, il client, il view model e le viste. Incollalo al posto del file dell'app generato.

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

Esegui l'app su un simulatore. La lista si riempie dopo la prima chiamata, e trascinare verso il basso forza un ricaricamento. I punteggi sono il `vote_average` aggregato della lista, e un titolo senza ancora voti mostra 0.0. Per i punteggi IMDb o Kinopoisk, richiedi un film con `append_to_response=ratings`. L'oggetto `ratings` contiene `imdb`, `kinopoisk`, `metacritic`, `rotten_tomatoes` e `tmdb`, ciascuno `null` oppure un oggetto con `value` e `votes`, come mostra la pagina [valutazioni](https://amodb.dev/it/ratings/).

## Prossimi passi

- Aggiungi una schermata di dettaglio che carica `/movie/{id}` con `append_to_response=credits,ratings`, e vedi [film](https://amodb.dev/it/movies/) per i campi.
- Passa il parametro di query `language` alla lingua del dispositivo, usando le 14 lingue di base della pagina [lingue](https://amodb.dev/it/languages/).
- Sposta la chiave sul tuo server e punta `baseURL` verso di esso prima di pubblicare l'app.
- Costruisci la stessa lista per il web con [Next.js](https://amodb.dev/it/nextjs-movie-app/).

## Domande e risposte

### È sicuro mettere la mia chiave API in un'app iOS?

No. Qualsiasi cosa salvata nel binario di un'app o nelle sue impostazioni può essere estratta. Tieni la chiave su un server che controlli, fai chiamare quel server all'app, e usa una chiave dentro l'app solo per i test in locale.

### Di quali versioni di iOS e Swift ha bisogno questo tutorial?

Swift 5.9 o successivo con Xcode, e un target di distribuzione iOS 16. Così hai NavigationStack, Task.sleep con una durata e AsyncImage. Il view model usa ObservableObject, quindi gira anche su iOS 16.

### Come mostro le locandine senza librerie aggiuntive?

Costruisci l'URL come https://img.amodb.dev/t/p/w185 più il valore di poster_path e passalo ad AsyncImage. L'host delle immagini non richiede chiave. Usa w342 o w500 per layout più grandi, e gestisci la fase di errore con un segnaposto.

### Cosa deve fare l'app dopo una risposta 429?

Leggere l'header Retry-After, attendere quel numero di secondi con un tetto, e ritentare al massimo due volte. Se fallisce ancora, mostrare un messaggio e lasciare che l'utente trascini per aggiornare. Non fare cicli senza limite.

### Posso usare un'altra lingua per i titoli?

Sì. Aggiungi un parametro di query language come ru-RU o pt-BR. Sono servite quattordici lingue di base, e qualsiasi altro tag ripiega sull'inglese. I titoli tornano poi localizzati quando esiste una traduzione.

## Pagine correlate

- [Tutorial app film con Next.js](https://amodb.dev/it/nextjs-movie-app/): Crea un'app film Next.js 15 in JavaScript: i server component leggono tendenze, ricerca e dettagli da amodb con revalidate e tengono privata la chiave.
- [Tendenze e endpoint delle liste](https://amodb.dev/it/trending-lists/): API tendenze con percorsi TMDB v3: classifiche giorno e settimana, più liste di film e serie in sala, popolari, più votati e in arrivo, cache di 30 minuti.
- [API delle immagini e host delle immagini](https://amodb.dev/it/images-api/): API immagini dei film con percorsi TMDB v3: elenca locandine, sfondi, loghi e fermi immagine di ogni titolo e caricali da img.amodb.dev in 10 dimensioni.
- [Autenticazione](https://amodb.dev/it/authentication/): Come inviare la chiave amodb: api_key nella query, un header Authorization Bearer o apikey per le chiamate OMDb. Formato, conservazione, rotazione, errori 401.
- [Limiti di richieste](https://amodb.dev/it/rate-limits/): amodb limita ogni chiave per richieste al minuto in una finestra scorrevole di 60 secondi: 20, 40 o 120 in base al piano. Leggi gli header, gestisci il 429.
- [Avvio rapido](https://amodb.dev/it/quickstart/): Fai la prima chiamata ad amodb: ottieni una chiave, richiedi i dettagli di un film in curl, JavaScript, Python, PHP o Go, carica la locandina. Meno di 2 minuti.

Aggiornato: 2026-10-18. Source: https://amodb.dev/it/swift-movie-api/
