Tutorial de API de películas en Swift con SwiftUI

Este tutorial crea una app de iOS 16 en Swift 5.9 que muestra las películas en tendencia de la semana en una lista de SwiftUI con pósteres, años y puntuaciones. Usa URLSession con async/await y AsyncImage, gestiona el 401 y el 429, y explica por qué una app en producción debe llamar a amodb a través de tu propio servidor.

.swift Swift Actualizado el Ver en Markdown

Qué vas a construir

Vas a construir Trending, una app de iOS con una sola pantalla: una lista de las películas en tendencia de esta semana con póster, título, año y puntuación. Necesitas Xcode con Swift 5.9 o superior, un objetivo de despliegue de iOS 16 y una clave de amodb. La app usa URLSession con async/await, modelos Decodable y AsyncImage, sin paquetes. Calcula unos 20 minutos.

Una clave dentro de una app puede extraerse

Trata como público cualquier secreto que se distribuya en una app. Alguien puede leer cadenas del binario, inspeccionar el tráfico de red o leer el almacenamiento de la app. En este tutorial la clave viene de una variable del esquema de Xcode, lo cual está bien en tu propio dispositivo. Para una app que publiques, ejecuta un servidor pequeño que guarde la clave, reenvíe la llamada a amodb y devuelva el JSON. Define baseURL en el cliente de abajo con la dirección de ese servidor y autentica allí a tus usuarios. El límite de solicitudes se aplica por clave, así que un servidor también te permite guardar en caché una respuesta para todos tus usuarios.

Paso 1: obtén una clave de API

Compara los planes en precios. Todos los planes devuelven todos los datos, así que la elección depende de las solicitudes por minuto. Crea una clave en el panel de tu cuenta. Compruébala desde una terminal antes de escribir nada de Swift:

Un 200 significa que la clave funciona. Un 401 significa que es incorrecta. La página de autenticación explica las dos formas de enviar la clave.

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"

Paso 2: crea el proyecto de Xcode

Crea un proyecto de app de iOS llamado Trending con la interfaz SwiftUI y fija el objetivo de despliegue mínimo en iOS 16. Elimina el ContentView.swift generado. En tu esquema, abre Edit Scheme, elige Run, luego Arguments, y añade una variable de entorno llamada AMODB_KEY con tu clave. Xcode se la pasa a la app solo cuando la ejecutas desde Xcode.

Paso 3: envía la primera solicitud

Lee la clave del entorno y envíala como encabezado Bearer. Fija un tiempo de espera de 10 segundos para que una conexión detenida 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)

Paso 4: decodifica la respuesta y muestra los pósteres

La respuesta es una página con un array results de 20 elementos. Cada película tiene id, title, release_date, poster_path y vote_average, además de media_type con el valor movie. Fija keyDecodingStrategy en .convertFromSnakeCase y declara propiedades en camel case. Los campos que no declares se ignoran, algo adecuado para campos que aparezcan más adelante. poster_path puede ser null y release_date puede faltar o ser una cadena vacía, así que el modelo declara ambos como opcionales.

poster_path empieza con una barra, así que la cadena de arriba une correctamente el tamaño y el nombre del archivo. Los pósteres vienen de img.amodb.dev, que no necesita clave y guarda las imágenes en caché durante 30 días. La página de imágenes lista los tamaños, y tendencias y listas cubre el 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)")
    }
}

Paso 5: gestiona errores y límites de solicitudes

Asocia cada estado con un error que la pantalla pueda mostrar.

EstadoSignificadoQué hace la app
401Clave ausente, incorrecta o suspendidaMuestra un mensaje, sin reintento.
402El periodo del plan terminóMuestra un mensaje. Renueva en precios.
429Superaste tus solicitudes por minutoEspera Retry-After segundos, con un tope de 30, y reintenta como máximo dos veces.
500 o 504Fallo del lado del servidorMuestra el estado y ofrece actualizar deslizando hacia abajo.
URLErrorSin conexión, tiempo agotado, conexión perdidaMuestra localizedDescription.

Consulta errores y límites de solicitudes para ver todos los códigos.

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

Paso 6: guarda la lista en caché

El caché perimetral guarda las listas de tendencias durante 30 minutos, así que volver a pedirlas antes devuelve las mismas películas. El límite de solicitudes cuenta la llamada de todas formas. Guarda el último resultado en el modelo de vista y recarga solo cuando tenga más de 30 minutos o cuando el usuario deslice hacia abajo para actualizar.

Paso 7: la app completa

Este único archivo contiene los modelos, el cliente, el modelo de vista y las vistas. Pégalo sobre el archivo de la app generado.

Ejecuta la app en un simulador. La lista se rellena tras la primera llamada, y deslizar hacia abajo fuerza una recarga. Las puntuaciones son el vote_average agregado de la lista, y un título que aún no tiene votos muestra 0.0. Para las notas de IMDb o Kinopoisk, solicita una película con append_to_response=ratings. El objeto ratings contiene imdb, kinopoisk, metacritic, rotten_tomatoes y tmdb, cada uno null o un objeto con value y votes, como muestra la página de puntuaciones.

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

Próximos pasos

  • Añade una pantalla de detalle que cargue /movie/{id} con append_to_response=credits,ratings, y consulta películas para ver los campos.
  • Cambia el elemento de consulta language al idioma del dispositivo, usando los 14 idiomas base de la página de idiomas.
  • Mueve la clave a tu servidor y apunta baseURL hacia él antes de publicar la app.
  • Crea la misma lista para la web con Next.js.