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

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

```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` significa que la clave funciona. Un `401` significa que es incorrecta. La página de [autenticación](https://amodb.dev/es/authentication/) explica las dos formas de enviar la clave.

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

```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` 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](https://amodb.dev/es/images-api/) lista los tamaños, y [tendencias y listas](https://amodb.dev/es/trending-lists/) cubre el endpoint.

## Paso 5: gestiona errores y límites de solicitudes

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

| Estado     | Significado                                    | Qué hace la app                                                                      |
| ---------- | ---------------------------------------------- | ------------------------------------------------------------------------------------ |
| 401        | Clave ausente, incorrecta o suspendida         | Muestra un mensaje, sin reintento.                                                   |
| 402        | El periodo del plan terminó                    | Muestra un mensaje. Renueva en [precios](https://amodb.dev/es/pricing/).             |
| 429        | Superaste tus solicitudes por minuto           | Espera `Retry-After` segundos, con un tope de 30, y reintenta como máximo dos veces. |
| 500 o 504  | Fallo del lado del servidor                    | Muestra el estado y ofrece actualizar deslizando hacia abajo.                        |
| `URLError` | Sin conexión, tiempo agotado, conexión perdida | Muestra `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)
}
```

Consulta [errores](https://amodb.dev/es/errors/) y [límites de solicitudes](https://amodb.dev/es/rate-limits/) para ver todos los códigos.

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

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

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](https://amodb.dev/es/ratings/).

## Próximos pasos

- Añade una pantalla de detalle que cargue `/movie/{id}` con `append_to_response=credits,ratings`, y consulta [películas](https://amodb.dev/es/movies/) 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](https://amodb.dev/es/languages/).
- 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](https://amodb.dev/es/nextjs-movie-app/).

## Preguntas y respuestas

### ¿Es seguro poner mi clave de API en una app de iOS?

No. Todo lo que se guarda en el binario de una app o en sus ajustes puede extraerse. Guarda la clave en un servidor que controles, haz que la app llame a ese servidor y usa una clave dentro de la app solo para pruebas locales.

### ¿Qué versiones de iOS y Swift necesita este tutorial?

Swift 5.9 o superior con Xcode, y un objetivo de despliegue de iOS 16. Eso te da NavigationStack, Task.sleep con duración y AsyncImage. El modelo de vista usa ObservableObject, así que también funciona en iOS 16.

### ¿Cómo muestro pósteres sin bibliotecas adicionales?

Construye la URL como https://img.amodb.dev/t/p/w185 más el valor de poster_path y pásala a AsyncImage. El host de imágenes no necesita clave. Usa w342 o w500 para diseños más grandes y trata la fase de fallo con un marcador de posición.

### ¿Qué debe hacer la app tras una respuesta 429?

Lee el encabezado Retry-After, espera esa cantidad de segundos con un tope y reintenta como máximo dos veces. Si sigue fallando, muestra un mensaje y deja que el usuario actualice deslizando hacia abajo. No hagas un bucle sin límite.

### ¿Puedo usar otro idioma para los títulos?

Sí. Añade un elemento de consulta language como ru-RU o pt-BR. Se sirven catorce idiomas base, y cualquier otra etiqueta recurre al inglés. Los títulos llegan entonces localizados cuando existe una traducción.

## Páginas relacionadas

- [Tutorial de app de películas con Next.js](https://amodb.dev/es/nextjs-movie-app/): Crea una app de películas con Next.js 15: los componentes de servidor piden tendencias, búsqueda y detalles a amodb con revalidate y la clave privada.
- [Endpoints de tendencias y listas](https://amodb.dev/es/trending-lists/): API de películas en tendencia con rutas TMDB v3: rankings diarios y semanales, más cartelera, populares, mejor valoradas y estrenos. Caché de 30 minutos.
- [API de imágenes y host de imágenes](https://amodb.dev/es/images-api/): API de imágenes de películas con rutas TMDB v3: lista pósters, fondos, logos y fotogramas por título y cárgalos desde img.amodb.dev en 10 tamaños sin clave.
- [Autenticación](https://amodb.dev/es/authentication/): Cómo enviar tu clave de amodb: api_key en la consulta, un encabezado Bearer o apikey en llamadas OMDb. Formato, almacenamiento, rotación y errores 401.
- [Límites de solicitudes](https://amodb.dev/es/rate-limits/): amodb limita cada clave por solicitudes por minuto en una ventana deslizante de 60 segundos: 20, 40 o 120 según el plan. Lee los encabezados y gestiona el 429.
- [Inicio rápido](https://amodb.dev/es/quickstart/): Haz tu primera llamada a amodb: consigue una clave, pide los detalles de una película en curl, JavaScript, Python, PHP o Go y carga el póster. En 2 minutos.

Actualizado: 2026-10-18. Source: https://amodb.dev/es/swift-movie-api/
