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 URLSession URLComponents JSONDecoder Codable Logger (OSLog) Sendable 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) } }