# Руководство по Swift movie API с SwiftUI

> Руководство создает приложение для iOS 16 на Swift 5.9, которое показывает фильмы в тренде этой недели в списке SwiftUI с постерами, годами и оценками. Используются URLSession с async/await и AsyncImage, обрабатываются ответы 401 и 429, а также объясняется, почему боевое приложение должно вызывать amodb через ваш сервер.

## Что вы создадите

Вы создадите `Trending`, приложение для iOS с одним экраном: списком фильмов в тренде этой недели с постером, названием, годом и оценкой. Нужны Xcode со Swift 5.9 или новее, целевая версия iOS 16 и ключ amodb. Приложение использует `URLSession` с async/await, модели `Decodable` и `AsyncImage` без сторонних пакетов. Заложите около 20 минут.

## Ключ внутри приложения можно извлечь

Считайте любой секрет, который поставляется в приложении, публичным. Кто угодно может прочитать строки из бинарника, перехватить сетевой трафик или прочитать хранилище приложения. В этом руководстве ключ берется из переменной схемы Xcode, что допустимо на вашем собственном устройстве. Для приложения, которое вы публикуете, запустите небольшой сервер, который хранит ключ, пересылает вызов в amodb и возвращает JSON. Укажите этот сервер в `baseURL` в клиенте ниже и аутентифицируйте пользователей на нем. Лимит запросов действует на каждый ключ, поэтому сервер заодно позволяет кэшировать один ответ для всех ваших пользователей.

## Шаг 1: получите ключ API

Сравните тарифы на странице [цены](https://amodb.dev/ru/pricing/). Каждый тариф отдает все данные, поэтому выбор зависит от числа запросов в минуту. Создайте ключ в личном кабинете. Проверьте его в терминале, прежде чем писать код на 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"
```

Код `200` означает, что ключ работает. Код `401` означает, что он неверный. На странице [аутентификация](https://amodb.dev/ru/authentication/) описаны оба способа передать ключ.

## Шаг 2: создайте проект Xcode

Создайте проект iOS App с именем `Trending` и интерфейсом SwiftUI и задайте минимальную целевую версию iOS 16. Удалите сгенерированный `ContentView.swift`. В своей схеме откройте Edit Scheme, выберите Run, затем Arguments и добавьте переменную окружения `AMODB_KEY` с вашим ключом. Xcode передает ее приложению только при запуске из Xcode.

## Шаг 3: отправьте первый запрос

Прочитайте ключ из окружения и отправьте его в заголовке Bearer. Задайте таймаут 10 секунд, чтобы зависшее соединение завершалось.

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

## Шаг 4: декодируйте ответ и покажите постеры

Ответ представляет собой страницу с массивом `results` из 20 элементов. У каждого фильма есть `id`, `title`, `release_date`, `poster_path` и `vote_average`, а также `media_type` со значением `movie`. Задайте `keyDecodingStrategy` равным `.convertFromSnakeCase` и объявите свойства в camel case. Необъявленные поля игнорируются, что удобно для полей, которые появятся позже. `poster_path` может быть `null`, а `release_date` может отсутствовать или быть пустой строкой, поэтому модель объявляет оба как необязательные.

```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` начинается со слеша, поэтому строка выше правильно склеивает размер и имя файла. Постеры загружаются с `img.amodb.dev`, который не требует ключа и кэширует изображения на 30 дней. Размеры перечислены на странице [API изображений](https://amodb.dev/ru/images-api/), а эндпоинт описан на странице [списки трендов](https://amodb.dev/ru/trending-lists/).

## Шаг 5: обработайте ошибки и лимиты запросов

Сопоставьте каждый статус с ошибкой, которую может показать экран.

| Статус      | Значение                                    | Что делает приложение                                                             |
| ----------- | ------------------------------------------- | --------------------------------------------------------------------------------- |
| 401         | Ключ отсутствует, неверен или приостановлен | Показывает сообщение, без повтора.                                                |
| 402         | Период тарифа закончился                    | Показывает сообщение. Продлите на странице [цены](https://amodb.dev/ru/pricing/). |
| 429         | Превышен лимит запросов в минуту            | Ждет `Retry-After` секунд, не более 30, повторяет не более двух раз.              |
| 500 или 504 | Сбой на стороне сервера                     | Показывает статус и предлагает потянуть список вниз для обновления.               |
| `URLError`  | Нет сети, таймаут, потеря соединения        | Показывает `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)
}
```

Все коды описаны на страницах [ошибки](https://amodb.dev/ru/errors/) и [лимиты запросов](https://amodb.dev/ru/rate-limits/).

## Шаг 6: кэшируйте список

Edge-кэш хранит списки трендов 30 минут, поэтому повторный запрос раньше возвращает те же фильмы. Лимит запросов при этом все равно учитывает вызов. Храните последний результат в модели представления и перезагружайте его, только когда он старше 30 минут или пользователь потянул список для обновления.

## Шаг 7: полное приложение

Этот единственный файл содержит модели, клиент, модель представления и представления. Вставьте его поверх сгенерированного файла приложения.

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

Запустите приложение на симуляторе. Список заполняется после первого вызова, а потягивание вниз принудительно перезагружает его. Оценкой служит агрегированный `vote_average` из списка, а название без голосов пока показывает 0.0. Для оценок IMDb или Kinopoisk запросите фильм с `append_to_response=ratings`. Объект `ratings` содержит `imdb`, `kinopoisk`, `metacritic`, `rotten_tomatoes` и `tmdb`, и каждое значение равно `null` или объекту с `value` и `votes`, как показано на странице [рейтинги](https://amodb.dev/ru/ratings/).

## Что дальше

- Добавьте экран с деталями, который загружает `/movie/{id}` с `append_to_response=credits,ratings`, а поля смотрите на странице [фильмы](https://amodb.dev/ru/movies/).
- Переключите параметр запроса `language` на язык устройства, используя 14 базовых языков со страницы [языки](https://amodb.dev/ru/languages/).
- Перенесите ключ на свой сервер и направьте на него `baseURL`, прежде чем публиковать приложение.
- Создайте тот же список для веба на [Next.js](https://amodb.dev/ru/nextjs-movie-app/).

## Вопросы и ответы

### Безопасно ли хранить ключ API в приложении для iOS?

Нет. Все, что хранится в бинарнике приложения или его настройках, можно извлечь. Держите ключ на своем сервере, пусть приложение обращается к нему, а ключ внутри приложения используйте только для локального тестирования.

### Какие версии iOS и Swift нужны для этого руководства?

Swift 5.9 или новее с Xcode и целевая версия iOS 16. Это дает NavigationStack, Task.sleep с длительностью и AsyncImage. Модель представления использует ObservableObject, поэтому тоже работает на iOS 16.

### Как показать постеры без дополнительных библиотек?

Соберите URL как https://img.amodb.dev/t/p/w185 плюс значение poster_path и передайте его в AsyncImage. Хосту изображений ключ не нужен. Для крупных макетов берите w342 или w500, а для фазы ошибки используйте заглушку.

### Что должно делать приложение после ответа 429?

Прочитать заголовок Retry-After, подождать столько секунд с ограничением сверху и повторить не более двух раз. Если снова не вышло, показать сообщение и дать пользователю обновить список жестом. Не запускайте цикл без ограничения.

### Можно ли получать названия на другом языке?

Да. Добавьте параметр запроса language, например ru-RU или pt-BR. Поддерживаются четырнадцать базовых языков, любой другой тег откатывается на английский. Названия приходят локализованными, если есть перевод.

## Связанные страницы

- [Руководство по Next.js movie app](https://amodb.dev/ru/nextjs-movie-app/): Создайте приложение с фильмами на Next.js 15 и чистом JavaScript: серверные компоненты берут тренды, поиск и детали из amodb через revalidate и прячут ключ.
- [Эндпоинты трендов и списков](https://amodb.dev/ru/trending-lists/): API фильмов в тренде с путями TMDB v3: чарты за день и неделю, а также списки идущих в кино, популярных, лучших и скорых фильмов и сериалов, кэш 30 минут.
- [API изображений и хост изображений](https://amodb.dev/ru/images-api/): API изображений фильмов с путями TMDB v3: списки постеров, фонов, логотипов и кадров для каждого проекта, загрузка с img.amodb.dev в 10 размерах без ключа.
- [Аутентификация](https://amodb.dev/ru/authentication/): Как передать ключ amodb: api_key в запросе, заголовок Authorization Bearer или apikey для вызовов OMDb. Формат ключа, хранение, ротация и ошибки 401.
- [Лимиты запросов](https://amodb.dev/ru/rate-limits/): amodb ограничивает ключ запросами в минуту в скользящем окне 60 секунд: 20, 40 или 120 по тарифу. Читайте заголовки лимита и обрабатывайте 429 с Retry-After.
- [Быстрый старт](https://amodb.dev/ru/quickstart/): Первый вызов amodb: получите ключ, запросите детали фильма через curl, JavaScript, Python, PHP или Go и загрузите постер с img.amodb.dev. Меньше 2 минут.

Обновлено: 2026-10-18. Source: https://amodb.dev/ru/swift-movie-api/
