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. 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.
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.
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.
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.
| 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. |
| 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. |
Vedi errori e limiti di richieste per tutti i codici.
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_. 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.
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}conappend_, e vedi film per i campi.to_ response= credits, ratings - Passa il parametro di query
languagealla lingua del dispositivo, usando le 14 lingue di base della pagina lingue. - Sposta la chiave sul tuo server e punta
baseURLverso di esso prima di pubblicare l'app. - Costruisci la stessa lista per il web con Next.js.