# Tutorial de API de filmes em Swift com SwiftUI

> Este tutorial cria um app para iOS 16 em Swift 5.9 que mostra os filmes em alta da semana em uma lista SwiftUI com pôsteres, anos e notas. Ele usa URLSession com async/await e AsyncImage, trata 401 e 429 e explica por que um app em produção deve chamar o amodb por meio do seu próprio servidor.

## O que você vai construir

Você vai criar o `Trending`, um app iOS com uma tela: uma lista dos filmes em alta da semana com pôster, título, ano e nota. Você precisa do Xcode com Swift 5.9 ou mais novo, de um deployment target iOS 16 e de uma chave do amodb. O app usa `URLSession` com async/await, modelos `Decodable` e `AsyncImage`, sem pacotes. Reserve cerca de 20 minutos.

## Uma chave dentro de um app pode ser extraída

Trate qualquer segredo que vai dentro de um app como público. Alguém pode ler strings do binário, inspecionar o tráfego de rede ou ler o armazenamento do app. Neste tutorial a chave vem de uma variável de scheme do Xcode, o que é aceitável no seu próprio aparelho. Para um app que você vai publicar, rode um servidor pequeno que guarda a chave, repassa a chamada ao amodb e devolve o JSON. Aponte `baseURL` no cliente abaixo para esse servidor e autentique seus usuários nele. O limite de requisições vale por chave, então um servidor também permite fazer cache de uma resposta para todos os seus usuários.

## Passo 1: obtenha uma chave de API

Compare os planos em [preços](https://amodb.dev/pt/pricing/). Todo plano retorna todos os dados, então a escolha depende das requisições por minuto. Crie uma chave no painel da sua conta. Teste-a em um terminal antes de escrever qualquer 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"
```

Um `200` significa que a chave funciona. Um `401` significa que ela está errada. A página de [autenticação](https://amodb.dev/pt/authentication/) explica as duas formas de enviar a chave.

## Passo 2: crie o projeto no Xcode

Crie um projeto iOS App chamado `Trending` com a interface SwiftUI e defina o deployment target mínimo como iOS 16. Apague o `ContentView.swift` gerado. No seu scheme, abra Edit Scheme, escolha Run, depois Arguments, e adicione uma variável de ambiente chamada `AMODB_KEY` com a sua chave. O Xcode a repassa ao app apenas quando você executa pelo Xcode.

## Passo 3: envie a primeira requisição

Leia a chave do ambiente e envie-a como cabeçalho Bearer. Defina um timeout de 10 segundos para que uma conexão travada 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)
```

## Passo 4: decodifique a resposta e mostre os pôsteres

A resposta é uma página com um array `results` de 20 itens. Cada filme tem `id`, `title`, `release_date`, `poster_path` e `vote_average`, além de `media_type` com o valor `movie`. Defina `keyDecodingStrategy` como `.convertFromSnakeCase` e declare propriedades em camel case. Campos que você não declara são ignorados, o que serve para campos que aparecem depois. `poster_path` pode ser `null` e `release_date` pode estar ausente ou ser uma string vazia, então o modelo declara os dois como opcionais.

```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` começa com uma barra, então a string acima junta corretamente o tamanho e o nome do arquivo. Os pôsteres vêm de `img.amodb.dev`, que não precisa de chave e guarda imagens em cache por 30 dias. A página de [imagens](https://amodb.dev/pt/images-api/) lista os tamanhos, e [em alta e listas](https://amodb.dev/pt/trending-lists/) cobre o endpoint.

## Passo 5: trate erros e limites de requisições

Associe cada status a um erro que a tela possa mostrar.

| Status     | Significado                           | O que o app faz                                                                        |
| ---------- | ------------------------------------- | -------------------------------------------------------------------------------------- |
| 401        | Chave ausente, incorreta ou suspensa  | Mostra uma mensagem, sem nova tentativa.                                               |
| 402        | O período do plano terminou           | Mostra uma mensagem. Renove em [preços](https://amodb.dev/pt/pricing/).                |
| 429        | Acima das suas requisições por minuto | Espera `Retry-After` segundos, com limite de 30, e tenta de novo no máximo duas vezes. |
| 500 ou 504 | Falha no lado do servidor             | Mostra o status e oferece puxar para atualizar.                                        |
| `URLError` | Sem conexão, timeout, conexão perdida | Mostra `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)
}
```

Veja [erros](https://amodb.dev/pt/errors/) e [limites de requisições](https://amodb.dev/pt/rate-limits/) para todos os códigos.

## Passo 6: faça cache da lista

A borda faz cache das listas em alta por 30 minutos, então pedir de novo antes disso retorna os mesmos filmes. O limite de requisições ainda conta a chamada. Guarde o último resultado no view model e recarregue apenas quando ele tiver mais de 30 minutos ou quando o usuário puxar para atualizar.

## Passo 7: o app completo

Este único arquivo contém os modelos, o cliente, o view model e as views. Cole-o sobre o arquivo do app gerado.

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

Execute o app em um simulador. A lista é preenchida depois da primeira chamada, e puxar para baixo força um recarregamento. As notas são o `vote_average` agregado da lista, e um título que ainda não tem votos mostra 0.0. Para notas do IMDb ou do Kinopoisk, peça um filme com `append_to_response=ratings`. O objeto `ratings` contém `imdb`, `kinopoisk`, `metacritic`, `rotten_tomatoes` e `tmdb`, cada um `null` ou um objeto com `value` e `votes`, como mostra a página de [avaliações](https://amodb.dev/pt/ratings/).

## Próximos passos

- Adicione uma tela de detalhes que carregue `/movie/{id}` com `append_to_response=credits,ratings` e veja [filmes](https://amodb.dev/pt/movies/) para os campos.
- Troque o query item `language` pelo idioma do aparelho, usando os 14 idiomas base da página de [idiomas](https://amodb.dev/pt/languages/).
- Mova a chave para o seu servidor e aponte `baseURL` para ele antes de publicar o app.
- Crie a mesma lista para a web com [Next.js](https://amodb.dev/pt/nextjs-movie-app/).

## Perguntas e respostas

### É seguro colocar minha chave de API em um app iOS?

Não. Tudo que fica no binário do app ou nas suas configurações pode ser extraído. Mantenha a chave em um servidor que você controla, faça o app chamar esse servidor e use uma chave dentro do app só para testes locais.

### Quais versões de iOS e Swift este tutorial exige?

Swift 5.9 ou mais novo com Xcode e um deployment target de iOS 16. Isso dá acesso a NavigationStack, Task.sleep com duração e AsyncImage. O view model usa ObservableObject, então também roda no iOS 16.

### Como mostro pôsteres sem bibliotecas extras?

Monte a URL como https://img.amodb.dev/t/p/w185 mais o valor de poster_path e passe para o AsyncImage. O host de imagens não precisa de chave. Use w342 ou w500 para layouts maiores e trate a fase de falha com um placeholder.

### O que o app deve fazer depois de uma resposta 429?

Leia o cabeçalho Retry-After, espere essa quantidade de segundos com um limite e tente de novo no máximo duas vezes. Se ainda falhar, mostre uma mensagem e deixe o usuário puxar para atualizar. Não faça um laço sem limite.

### Posso usar outro idioma para os títulos?

Sim. Adicione um query item language, como ru-RU ou pt-BR. Quatorze idiomas base são atendidos, e qualquer outra tag volta para o inglês. Os títulos chegam localizados quando existe tradução.

## Páginas relacionadas

- [Tutorial de app de filmes com Next.js](https://amodb.dev/pt/nextjs-movie-app/): Crie um app de filmes com Next.js 15 em JavaScript puro: server components buscam em alta, busca e detalhes no amodb com revalidate e mantêm a chave privada.
- [Endpoints de em alta e de listas](https://amodb.dev/pt/trending-lists/): API de filmes em alta com caminhos do TMDB v3: rankings do dia e da semana, listas em cartaz, populares, mais bem avaliados e em breve. Cache de 30 minutos.
- [API de imagens e host de imagens](https://amodb.dev/pt/images-api/): API de imagens de filmes com caminhos do TMDB v3: liste pôsteres, backdrops, logos e stills por título e carregue-os de img.amodb.dev em 10 tamanhos, sem chave.
- [Autenticação](https://amodb.dev/pt/authentication/): Como enviar sua chave do amodb: api_key na consulta, um cabeçalho Authorization Bearer ou apikey nas chamadas OMDb. Formato, armazenamento, rotação e erros 401.
- [Limites de requisições](https://amodb.dev/pt/rate-limits/): O amodb limita cada chave por requisições por minuto em uma janela de 60 segundos: 20, 40 ou 120 por plano. Leia os cabeçalhos e trate o 429 com Retry-After.
- [Início rápido](https://amodb.dev/pt/quickstart/): Faça sua primeira chamada ao amodb: obtenha uma chave, peça um filme em curl, JavaScript, Python, PHP ou Go e carregue o pôster. Menos de 2 minutos.

Atualizado: 2026-10-18. Source: https://amodb.dev/pt/swift-movie-api/
