Tutoriel API de films Swift avec SwiftUI
Ce tutoriel construit une application iOS 16 en Swift 5.9 qui montre les films tendance de la semaine dans une liste SwiftUI avec affiches, années et notes. Elle utilise URLSession avec async/await et AsyncImage, gère 401 et 429, et explique pourquoi une application en production doit appeler amodb via votre propre serveur.
Ce que vous construisez
Vous construisez Trending, une application iOS d'un seul écran : une liste des films tendance de la semaine avec une affiche, un titre, une année et une note. Il vous faut Xcode avec Swift 5.9 ou plus récent, une cible de déploiement iOS 16 et une clé amodb. L'application utilise URLSession avec async/await, des modèles Decodable et AsyncImage, sans paquets. Prévoyez environ 20 minutes.
Une clé dans une application peut être extraite
Considérez tout secret livré dans une application comme public. Quelqu'un peut lire des chaînes dans le binaire, inspecter le trafic réseau ou lire le stockage de l'application. Pour ce tutoriel, la clé vient d'une variable de schéma Xcode, ce qui convient sur votre propre appareil. Pour une application que vous publiez, faites tourner un petit serveur qui détient la clé, transmet l'appel à amodb et renvoie le JSON. Réglez baseURL dans le client ci-dessous sur ce serveur et authentifiez vos utilisateurs là-bas. La limite de débit s'applique par clé : un serveur permet aussi de mettre en cache une seule réponse pour tous vos utilisateurs.
Étape 1 : obtenir une clé d'API
Comparez les offres sur les tarifs. Chaque offre renvoie toutes les données : le choix porte donc sur les requêtes par minute. Créez une clé dans le tableau de bord de votre compte. Vérifiez-la depuis un terminal avant d'écrire du Swift :
Un 200 signifie que la clé fonctionne. Un 401 signifie qu'elle est fausse. La page authentification explique les deux façons d'envoyer la clé.
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"Étape 2 : créer le projet Xcode
Créez un projet d'application iOS nommé Trending avec l'interface SwiftUI et réglez la cible de déploiement minimale sur iOS 16. Supprimez le ContentView.swift généré. Dans votre schéma, ouvrez Edit Scheme, choisissez Run, puis Arguments, et ajoutez une variable d'environnement nommée AMODB_KEY avec votre clé. Xcode ne la transmet à l'application que lorsque vous lancez depuis Xcode.
Étape 3 : envoyer la première requête
Lisez la clé dans l'environnement et envoyez-la en en-tête Bearer. Réglez un délai d'expiration de 10 secondes pour qu'une connexion bloquée se termine.
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)Étape 4 : décoder la réponse et afficher les affiches
La réponse est une page avec un tableau results de 20 éléments. Chaque film a id, title, release_date, poster_path et vote_average, plus media_type réglé sur movie. Réglez keyDecodingStrategy sur .convertFromSnakeCase et déclarez des propriétés en camel case. Les champs que vous ne déclarez pas sont ignorés, ce qui convient aux champs qui apparaîtront plus tard. poster_path peut valoir null et release_date peut manquer ou être une chaîne vide : le modèle déclare donc les deux comme facultatifs.
poster_path commence par une barre oblique : la chaîne ci-dessus assemble donc correctement la taille et le nom du fichier. Les affiches viennent de img.amodb.dev, qui n'exige aucune clé et met les images en cache 30 jours. La page images liste les tailles, et tendances et listes couvre le point de terminaison.
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)")
}
}Étape 5 : gérer les erreurs et les limites de débit
Associez chaque statut à une erreur que l'écran peut afficher.
| Statut | Signification | Ce que fait l'application |
|---|---|---|
| 401 | Clé absente, fausse ou suspendue | Afficher un message, pas de nouvelle tentative. |
| 402 | La période de l'offre est terminée | Afficher un message. Renouveler sur les tarifs. |
| 429 | Au-dessus de vos requêtes par minute | Attendre Retry-After secondes, plafonnées à 30, réessayer au plus deux fois. |
| 500 ou 504 | Échec côté serveur | Afficher le statut et proposer de tirer pour actualiser. |
URLError | Hors ligne, délai dépassé, connexion perdue | Afficher localizedDescription. |
Voir erreurs et limites de débit pour tous les codes.
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)
}Étape 6 : mettre la liste en cache
La périphérie met en cache les listes de tendances 30 minutes : redemander plus tôt renvoie les mêmes films. La limite de débit compte quand même l'appel. Gardez le dernier résultat dans le modèle de vue et ne rechargez que s'il a plus de 30 minutes ou si l'utilisateur tire pour actualiser.
Étape 7 : l'application complète
Ce fichier unique contient les modèles, le client, le modèle de vue et les vues. Collez-le à la place du fichier d'application généré.
Lancez l'application sur un simulateur. La liste se remplit après le premier appel, et tirer vers le bas force un rechargement. Les notes sont le vote_average agrégé de la liste, et un titre sans vote affiche 0.0. Pour les notes IMDb ou Kinopoisk, demandez un film avec append_. L'objet ratings contient imdb, kinopoisk, metacritic, rotten_tomatoes et tmdb, chacun valant null ou un objet avec value et votes, comme le montre la page notes.
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)
}
}
}Pour aller plus loin
- Ajoutez un écran de détails qui charge
/movie/{id}avecappend_, et voyez films pour les champs.to_ response= credits, ratings - Faites passer le paramètre de requête
languageà la langue de l'appareil, avec les 14 langues de base de la page langues. - Déplacez la clé sur votre serveur et pointez
baseURLvers lui avant de publier l'application. - Construisez la même liste pour le web avec Next.js.