Swift movie API tutorial with SwiftUI

This tutorial builds an iOS 16 app in Swift 5.9 that shows this week's trending movies in a SwiftUI list with posters, years and scores. It uses URLSession with async/await and AsyncImage, handles 401 and 429, and explains why a production app should call amodb through your own server.

.swift Swift Updated View as Markdown

What you build

You build Trending, an iOS app with one screen: a list of this week's trending movies with a poster, title, year and score. You need Xcode with Swift 5.9 or newer, an iOS 16 deployment target and an amodb key. The app uses URLSession with async/await, Decodable models and AsyncImage, with no packages. Plan on about 20 minutes.

A key inside an app can be extracted

Treat any secret that ships in an app as public. Someone can read strings from the binary, inspect network traffic or read the app's storage. For this tutorial the key comes from an Xcode scheme variable, which is fine on your own device. For an app you release, run a small server that holds the key, forwards the call to amodb and returns the JSON. Set baseURL in the client below to that server and authenticate your users there. The rate limit applies per key, so a server also lets you cache one answer for all of your users.

Step 1: Get an API key

Compare the plans on pricing. Every plan returns all data, so the choice is about requests per minute. Create a key in your account dashboard. Check it from a terminal before you write any Swift:

A 200 means the key works. A 401 means it is wrong. The authentication page explains both ways to send the key.

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"

Step 2: Create the Xcode project

Create an iOS App project named Trending with the SwiftUI interface and set the minimum deployment target to iOS 16. Delete the generated ContentView.swift. In your scheme, open Edit Scheme, choose Run, then Arguments, and add an environment variable named AMODB_KEY with your key. Xcode passes it to the app only when you run from Xcode.

Step 3: Send the first request

Read the key from the environment and send it as a Bearer header. Set a 10 second timeout so a stalled connection ends.

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)

Step 4: Decode the response and show posters

The reply is a page with a results array of 20 items. Each movie has id, title, release_date, poster_path and vote_average, plus media_type set to movie. Set keyDecodingStrategy to .convertFromSnakeCase and declare camel case properties. Fields you do not declare are ignored, which suits fields that appear later. poster_path can be null and release_date can be missing or an empty string, so the model declares both as optional.

poster_path starts with a slash, so the string above joins the size and the file name correctly. Posters come from img.amodb.dev, which needs no key and caches images for 30 days. The images page lists the sizes, and trending and lists covers the 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)")
    }
}

Step 5: Handle errors and rate limits

Map each status to an error the screen can show.

StatusMeaningWhat the app does
401Key missing, wrong or suspendedShow a message, no retry.
402The plan period has endedShow a message. Renew on pricing.
429Over your requests per minuteWait Retry-After seconds, capped at 30, retry at most twice.
500 or 504Server side failureShow the status and offer pull to refresh.
URLErrorOffline, timeout, lost connectionShow localizedDescription.

See errors and rate limits for every code.

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

Step 6: Cache the list

The edge caches trending lists for 30 minutes, so asking again sooner returns the same movies. The rate limit still counts the call. Keep the last result in the view model and reload only when it is older than 30 minutes or the user pulls to refresh.

Step 7: The complete app

This single file holds the models, the client, the view model and the views. Paste it over the generated app file.

Run the app on a simulator. The list fills after the first call, and pulling down forces a reload. Scores are the aggregate vote_average from the list, and a title with no votes yet shows 0.0. For IMDb or Kinopoisk scores, request a movie with append_to_response=ratings. The ratings object holds imdb, kinopoisk, metacritic, rotten_tomatoes and tmdb, each either null or an object with value and votes, as the ratings page shows.

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

Next steps

  • Add a detail screen that loads /movie/{id} with append_to_response=credits,ratings, and see movies for the fields.
  • Switch the language query item to the device language, using the 14 base languages on the languages page.
  • Move the key to your server and point baseURL at it before you publish the app.
  • Build the same list for the web with Next.js.