# 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](https://amodb.dev/fr/pricing/). 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 :

```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` signifie que la clé fonctionne. Un `401` signifie qu'elle est fausse. La page [authentification](https://amodb.dev/fr/authentication/) explique les deux façons d'envoyer la clé.

## É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.

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

## É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.

```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` 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](https://amodb.dev/fr/images-api/) liste les tailles, et [tendances et listes](https://amodb.dev/fr/trending-lists/) couvre le point de terminaison.

## É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](https://amodb.dev/fr/pricing/). |
| 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`.                                                 |

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

Voir [erreurs](https://amodb.dev/fr/errors/) et [limites de débit](https://amodb.dev/fr/rate-limits/) pour tous les codes.

## É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é.

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

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_to_response=ratings`. 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](https://amodb.dev/fr/ratings/).

## Pour aller plus loin

- Ajoutez un écran de détails qui charge `/movie/{id}` avec `append_to_response=credits,ratings`, et voyez [films](https://amodb.dev/fr/movies/) pour les champs.
- Faites passer le paramètre de requête `language` à la langue de l'appareil, avec les 14 langues de base de la page [langues](https://amodb.dev/fr/languages/).
- Déplacez la clé sur votre serveur et pointez `baseURL` vers lui avant de publier l'application.
- Construisez la même liste pour le web avec [Next.js](https://amodb.dev/fr/nextjs-movie-app/).

## Questions et réponses

### Est-il sûr de mettre ma clé d'API dans une application iOS ?

Non. Tout ce qui est stocké dans le binaire d'une application ou dans ses réglages peut être extrait. Gardez la clé sur un serveur que vous contrôlez, laissez l'application appeler ce serveur, et n'utilisez une clé dans l'application que pour les tests locaux.

### De quelles versions d'iOS et de Swift ce tutoriel a-t-il besoin ?

Swift 5.9 ou plus récent avec Xcode, et une cible de déploiement iOS 16. Cela donne NavigationStack, Task.sleep avec une durée et AsyncImage. Le modèle de vue utilise ObservableObject : il fonctionne donc aussi sur iOS 16.

### Comment afficher les affiches sans bibliothèque supplémentaire ?

Construisez l'URL comme https://img.amodb.dev/t/p/w185 plus la valeur de poster_path et passez-la à AsyncImage. L'hôte d'images n'exige aucune clé. Utilisez w342 ou w500 pour des mises en page plus grandes, et gérez la phase d'échec avec une image de remplacement.

### Que doit faire l'application après une réponse 429 ?

Lire l'en-tête Retry-After, attendre ce nombre de secondes avec un plafond, et réessayer au plus deux fois. Si cela échoue encore, afficher un message et laisser l'utilisateur tirer pour actualiser. Ne bouclez pas sans limite.

### Puis-je utiliser une autre langue pour les titres ?

Oui. Ajoutez un paramètre de requête language comme ru-RU ou pt-BR. Quatorze langues de base sont servies, et toute autre balise se replie sur l'anglais. Les titres reviennent alors localisés quand une traduction existe.

## Pages liées

- [Tutoriel application de films Next.js](https://amodb.dev/fr/nextjs-movie-app/): Application de films Next.js 15 en JavaScript : des composants serveur lisent tendances, recherche et détails d'amodb avec revalidate, la clé reste privée.
- [Tendances et points de terminaison de listes](https://amodb.dev/fr/trending-lists/): API de tendances aux chemins TMDB v3 : classements jour et semaine, plus films et séries à l'affiche, populaires, mieux notés et à venir, cache de 30 minutes.
- [API d'images et hôte d'images](https://amodb.dev/fr/images-api/): API d'images de films aux chemins TMDB v3 : listez affiches, fonds, logos et images par titre, puis chargez-les depuis img.amodb.dev en 10 tailles sans clé.
- [Authentification](https://amodb.dev/fr/authentication/): Comment envoyer votre clé amodb : api_key dans la requête, en-tête Authorization Bearer, ou apikey pour OMDb. Format, stockage, rotation et erreurs 401.
- [Limites de débit](https://amodb.dev/fr/rate-limits/): amodb limite chaque clé en requêtes par minute sur une fenêtre glissante de 60 secondes : 20, 40 ou 120 selon l'offre. Lisez les en-têtes et gérez le 429.
- [Démarrage rapide](https://amodb.dev/fr/quickstart/): Faites votre premier appel amodb : obtenez une clé, demandez un film en curl, JavaScript, Python, PHP ou Go, puis chargez l'affiche. En moins de 2 minutes.

Mis à jour: 2026-10-18. Source: https://amodb.dev/fr/swift-movie-api/
