# Урок за 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/bg/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/bg/authentication/) обяснява и двата начина за подаване на ключа.

## Стъпка 2: Създайте проекта в Xcode

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

## Стъпка 3: Изпратете първата заявка

Прочетете ключа от средата и го изпратете като заглавка Bearer. Задайте timeout от 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 дни. Страницата [изображения](https://amodb.dev/bg/images-api/) изброява размерите, а [тенденции и списъци](https://amodb.dev/bg/trending-lists/) описва ендпойнта.

## Стъпка 5: Обработка на грешки и лимити

Съпоставете всеки статус с грешка, която екранът може да покаже.

| Статус      | Значение                                     | Какво прави приложението                                                              |
| ----------- | -------------------------------------------- | ------------------------------------------------------------------------------------- |
| 401         | Ключът липсва, е грешен или е спрян          | Показва съобщение, без повторение.                                                    |
| 402         | Периодът на плана е изтекъл                  | Показва съобщение. Подновете от страницата с [цените](https://amodb.dev/bg/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/bg/errors/) и [лимити на заявките](https://amodb.dev/bg/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/bg/ratings/).

## Следващи стъпки

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

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