# Посібник зі Swift 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/uk/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/uk/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` і оголосіть властивості в camelCase. Поля, яких ви не оголосили, ігноруються, що пасує полям, які з'являться пізніше. `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/uk/images-api/) перелічує розміри, а [списки трендів](https://amodb.dev/uk/trending-lists/) описують ендпоінт.

## Крок 5: обробка помилок і лімітів запитів

Зіставте кожен статус з помилкою, яку може показати екран.

| Статус      | Значення                                 | Що робить застосунок                                                                    |
| ----------- | ---------------------------------------- | --------------------------------------------------------------------------------------- |
| 401         | Ключа немає, він хибний або призупинений | Показує повідомлення, без повторів.                                                     |
| 402         | Період тарифу закінчився                 | Показує повідомлення. Поновіть тариф на сторінці [ціни](https://amodb.dev/uk/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/uk/errors/) та [ліміти запитів](https://amodb.dev/uk/rate-limits/).

## Крок 6: кешуйте список

Кеш на периферії зберігає списки трендів 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/uk/ratings/).

## Наступні кроки

- Додайте екран деталей, що завантажує `/movie/{id}` з `append_to_response=credits,ratings`, а поля дивіться на сторінці [фільми](https://amodb.dev/uk/movies/).
- Перемкніть елемент запиту `language` на мову пристрою, використовуючи 14 базових мов зі сторінки [мови](https://amodb.dev/uk/languages/).
- Перенесіть ключ на свій сервер і спрямуйте `baseURL` на нього, перш ніж публікувати застосунок.
- Побудуйте такий самий список для вебу на [Next.js](https://amodb.dev/uk/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](https://amodb.dev/uk/nextjs-movie-app/): Створіть застосунок фільмів на Next.js 15 на JavaScript: серверні компоненти беруть тренди, пошук і деталі з amodb через revalidate, а ключ лишається приватним.
- [Ендпоінти трендів і списків](https://amodb.dev/uk/trending-lists/): API трендів фільмів зі шляхами TMDB v3: чарти за день і тиждень, а також списки в прокаті, популярних, найкращих і майбутніх фільмів та серіалів, кеш 30 хвилин.
- [API зображень і хост зображень](https://amodb.dev/uk/images-api/): API зображень фільмів зі шляхами TMDB v3: списки постерів, фонів, логотипів і кадрів проєкту, а потім завантаження з img.amodb.dev у 10 розмірах без ключа.
- [Автентифікація](https://amodb.dev/uk/authentication/): Як надсилати ключ amodb: api_key у запиті, заголовок Authorization Bearer або apikey для викликів OMDb. Формат ключа, зберігання, заміна та помилки 401.
- [Ліміти запитів](https://amodb.dev/uk/rate-limits/): amodb обмежує кожен ключ запитами на хвилину в ковзному вікні 60 секунд: 20, 40 або 120 за тарифом. Читайте заголовки ліміту й обробляйте 429 із Retry-After.
- [Швидкий старт](https://amodb.dev/uk/quickstart/): Перший виклик amodb: отримайте ключ, запросіть деталі фільму в curl, JavaScript, Python, PHP чи Go і завантажте постер з img.amodb.dev. Менш ніж за 2 хвилини.

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