Episode #610

Modernizing NSScreencast: Building the API Layer

34 minutes
Published on July 31, 2026

This video is only available to subscribers. Get access to this video and 609 others.

Kicking off a rewrite of the NSScreencast iOS app, this episode replaces an antiquated all-at-once episodes API with paginated and incremental sync endpoints, then builds a Swift API client using async/await, URLSession, URLComponents, and JSONDecoder. Along the way we model decodable, sendable response types and use opaque type identifiers to create type-safe IDs and pagination cursors that can't be accidentally interchanged.

Links

Code

We start by modeling the API response types under a versioned namespace:

enum API {
    enum V2 {}
}
extension API.V2 {
    struct Episode: Identifiable, Decodable, Hashable, Sendable {
        let id: ID<Episode>
        let episodeNumber: Int
        let title: String
        let slug: String
        let description: String
        let publishedAt: Date
        let updatedAt: Date
        let duration: Int
        let episodeType: EpisodeType
        let artworkUrls: ImageSet
        let videoUrl: URL
        let subtitleUrl: URL?
        let hlsUrl: URL?
        let webUrl: URL
        let showNotesUrl: URL
        let downloadSizeInBytes: Int
        let tags: [String]
        let versionTags: [String]
        let seriesIds: [Int]
    }

    enum EpisodeType: String, Decodable, Hashable, Sendable {
        case free
        case paid
    }
}

Phantom types make cursors and IDs type-safe even though they share the same underlying shape:

struct Cursor<T>: Decodable, Hashable, Sendable, CustomStringConvertible {
    let value: String

    var description: String { value }

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        value = try container.decode(String.self)
    }
}

struct ID<T>: Decodable, Hashable, Sendable {
    let value: Int

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        value = try container.decode(Int.self)
    }
}

Fetching a page of episodes with async/await, converting failures into a unified APIError:

func fetchEpisodePage(limit: Int? = nil, after: Cursor<NextPage>? = nil) async throws -> APIV2.EpisodePage {
    var components = URLComponents(url: baseURL.appending(path: "/api/v2/episodes"),
                                   resolvingAgainstBaseURL: false)!
    var queryItems: [URLQueryItem] = []
    if let limit {
        queryItems.append(URLQueryItem(name: "limit", value: String(limit)))
    }
    if let after {
        queryItems.append(URLQueryItem(name: "after", value: after.value))
    }
    components.queryItems = queryItems

    let request = URLRequest(url: components.url!)
    do {
        let (data, response) = try await session.data(for: request)
        guard let httpResponse = response as? HTTPURLResponse else {
            throw APIError.invalidResponse
        }
        guard (200..<300).contains(httpResponse.statusCode) else {
            throw APIError.http(statusCode: httpResponse.statusCode)
        }
        return try decoder.decode(APIV2.EpisodePage.self, from: data)
    } catch let e as DecodingError {
        logger.error("Decoding error: \(e.debugDescription)")
        throw APIError.decodingError(e.localizedDescription)
    } catch let e as APIError {
        throw e
    } catch {
        throw APIError.unhandled(error)
    }
}

This episode uses Xcode 27.0-beta-4.