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.

.swift Swift Atualizado em Ver em Markdown

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

Um 200 significa que a chave funciona. Um 401 significa que ela está errada. A página de autenticação explica as duas formas de enviar a chave.

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"

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.

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 lista os tamanhos, e em alta e listas cobre o 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)")
    }
}

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

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

StatusSignificadoO que o app faz
401Chave ausente, incorreta ou suspensaMostra uma mensagem, sem nova tentativa.
402O período do plano terminouMostra uma mensagem. Renove em preços.
429Acima das suas requisições por minutoEspera Retry-After segundos, com limite de 30, e tenta de novo no máximo duas vezes.
500 ou 504Falha no lado do servidorMostra o status e oferece puxar para atualizar.
URLErrorSem conexão, timeout, conexão perdidaMostra localizedDescription.

Veja erros e limites de requisições para todos os 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)
}

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.

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.

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 passos

  • Adicione uma tela de detalhes que carregue /movie/{id} com append_to_response=credits,ratings e veja filmes para os campos.
  • Troque o query item language pelo idioma do aparelho, usando os 14 idiomas base da página de idiomas.
  • 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.