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.

.swift Swift Aggiornato il Vedi in Markdown

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. 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:

Un 200 significa che la chiave funziona. Un 401 significa che è sbagliata. La pagina autenticazione spiega entrambi i modi di inviare la chiave.

Shell
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"

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.

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 elenca le dimensioni, e tendenze e liste tratta l'endpoint.

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

Passaggio 5: gestisci errori e limiti di richieste

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

StatoSignificatoCosa fa l'app
401Chiave mancante, sbagliata o sospesaMostra un messaggio, nessun nuovo tentativo.
402Il periodo del piano è terminatoMostra un messaggio. Rinnova nella pagina dei prezzi.
429Oltre le tue richieste al minutoAttende i secondi di Retry-After, con un massimo di 30, ritenta al massimo due volte.
500 o 504Errore lato serverMostra lo stato e propone di trascinare per aggiornare.
URLErrorOffline, timeout, connessione persaMostra localizedDescription.

Vedi errori e limiti di richieste per tutti i codici.

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

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.

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.

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

Prossimi passi

  • Aggiungi una schermata di dettaglio che carica /movie/{id} con append_to_response=credits,ratings, e vedi film per i campi.
  • Passa il parametro di query language alla lingua del dispositivo, usando le 14 lingue di base della pagina lingue.
  • 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.