Руководство по 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
Сравните тарифы на странице цены. Каждый тариф отдает все данные, поэтому выбор зависит от числа запросов в минуту. Создайте ключ в личном кабинете. Проверьте его в терминале, прежде чем писать код на Swift:
Код 200 означает, что ключ работает. Код 401 означает, что он неверный. На странице аутентификация описаны оба способа передать ключ.
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"Шаг 2: создайте проект Xcode
Создайте проект iOS App с именем Trending и интерфейсом SwiftUI и задайте минимальную целевую версию iOS 16. Удалите сгенерированный ContentView.swift. В своей схеме откройте Edit Scheme, выберите Run, затем Arguments и добавьте переменную окружения AMODB_KEY с вашим ключом. Xcode передает ее приложению только при запуске из Xcode.
Шаг 3: отправьте первый запрос
Прочитайте ключ из окружения и отправьте его в заголовке Bearer. Задайте таймаут 10 секунд, чтобы зависшее соединение завершалось.
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 может отсутствовать или быть пустой строкой, поэтому модель объявляет оба как необязательные.
poster_path начинается со слеша, поэтому строка выше правильно склеивает размер и имя файла. Постеры загружаются с img.amodb.dev, который не требует ключа и кэширует изображения на 30 дней. Размеры перечислены на странице API изображений, а эндпоинт описан на странице списки трендов.
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)")
}
}Шаг 5: обработайте ошибки и лимиты запросов
Сопоставьте каждый статус с ошибкой, которую может показать экран.
| Статус | Значение | Что делает приложение |
|---|---|---|
| 401 | Ключ отсутствует, неверен или приостановлен | Показывает сообщение, без повтора. |
| 402 | Период тарифа закончился | Показывает сообщение. Продлите на странице цены. |
| 429 | Превышен лимит запросов в минуту | Ждет Retry-After секунд, не более 30, повторяет не более двух раз. |
| 500 или 504 | Сбой на стороне сервера | Показывает статус и предлагает потянуть список вниз для обновления. |
URLError | Нет сети, таймаут, потеря соединения | Показывает localizedDescription. |
Все коды описаны на страницах ошибки и лимиты запросов.
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)
}Шаг 6: кэшируйте список
Edge-кэш хранит списки трендов 30 минут, поэтому повторный запрос раньше возвращает те же фильмы. Лимит запросов при этом все равно учитывает вызов. Храните последний результат в модели представления и перезагружайте его, только когда он старше 30 минут или пользователь потянул список для обновления.
Шаг 7: полное приложение
Этот единственный файл содержит модели, клиент, модель представления и представления. Вставьте его поверх сгенерированного файла приложения.
Запустите приложение на симуляторе. Список заполняется после первого вызова, а потягивание вниз принудительно перезагружает его. Оценкой служит агрегированный vote_average из списка, а название без голосов пока показывает 0.0. Для оценок IMDb или Kinopoisk запросите фильм с append_. Объект ratings содержит imdb, kinopoisk, metacritic, rotten_tomatoes и tmdb, и каждое значение равно null или объекту с value и votes, как показано на странице рейтинги.
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)
}
}
}Что дальше
- Добавьте экран с деталями, который загружает
/movie/{id}сappend_, а поля смотрите на странице фильмы.to_ response= credits, ratings - Переключите параметр запроса
languageна язык устройства, используя 14 базовых языков со страницы языки. - Перенесите ключ на свой сервер и направьте на него
baseURL, прежде чем публиковать приложение. - Создайте тот же список для веба на Next.js.