Chapter 3 — A flexible & reusable API client

The idea: separate what from how

  • Varies per endpoint: the path (/auth/login), the method (POST), the query, the body, the
  • Same for every endpoint: building the full URL, setting Content-Type/Accept, encoding the
flowchart LR Call["client.send(API.login(...))"] --> EP["Endpoint<TokenResponse>
(path, method, body)"] EP --> Client["APIClient.send"] Client --> Build["build URLRequest"] --> Session["await URLSession"] --> Validate["check status"] --> Decode["decode Response"] Decode --> Result["TokenResponse"]

Describing an endpoint

// Chirp/Networking/HTTPMethod.swift
enum HTTPMethod: String {
    case get = "GET", post = "POST", patch = "PATCH", delete = "DELETE"
}
// Chirp/Networking/Endpoint.swift
import Foundation

struct Endpoint<Response: Decodable> {
    var path: String                          // e.g. "/auth/login"
    var method: HTTPMethod = .get
    var queryItems: [URLQueryItem] = []       // e.g. ?page=2
    var body: (any Encodable)? = nil          // the request body, if any
    var headers: [String: String] = [:]       // per-endpoint extra headers
}
// Chirp/Networking/API.swift
import Foundation

enum API {
    static func login(email: String, password: String) -> Endpoint<TokenResponse> {
        struct Body: Encodable { let email, password: String }
        return Endpoint(path: "/auth/login", method: .post,
                        body: Body(email: email, password: password))
    }

    static func register(username: String, email: String, password: String) -> Endpoint<PublicUser> {
        struct Body: Encodable { let username, email, password: String }
        return Endpoint(path: "/auth/register", method: .post,
                        body: Body(username: username, email: email, password: password))
    }

    static func feed(page: Int, per: Int = 20) -> Endpoint<Page<Post>> {
        Endpoint(path: "/feed", queryItems: [
            .init(name: "page", value: String(page)),
            .init(name: "per", value: String(per)),
        ])
    }

    static func profile(userID: UUID) -> Endpoint<ProfileDTO> {
        Endpoint(path: "/users/\(userID)/profile")
    }
}

The client

// Chirp/Networking/APIClient.swift
import Foundation

final class APIClient {
    private let baseURL: URL
    private let session: URLSession
    private let decoder: JSONDecoder
    private let encoder: JSONEncoder

    init(baseURL: URL, session: URLSession = .shared) {
        self.baseURL = baseURL
        self.session = session
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        self.decoder = decoder
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        self.encoder = encoder
    }

    func send<Response>(_ endpoint: Endpoint<Response>) async throws -> Response {
        let request = try makeRequest(for: endpoint)
        let (data, response) = try await session.data(for: request)
        try validate(response, data: data)                 // throws APIError on non-2xx
        return try decode(data)
    }

    // MARK: - Build a URLRequest from an Endpoint

    private func makeRequest<Response>(for endpoint: Endpoint<Response>) throws -> URLRequest {
        // Combine base URL + path + query safely with URLComponents.
        var components = URLComponents(
            url: baseURL.appendingPathComponent(endpoint.path),
            resolvingAgainstBaseURL: false)!
        if !endpoint.queryItems.isEmpty { components.queryItems = endpoint.queryItems }

        var request = URLRequest(url: components.url!)
        request.httpMethod = endpoint.method.rawValue
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        for (key, value) in endpoint.headers { request.setValue(value, forHTTPHeaderField: key) }

        if let body = endpoint.body {
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            request.httpBody = try encoder.encode(body)     // encodes `any Encodable` (Swift 5.7+)
        }
        return request
    }

    // MARK: - Validate status & decode

    private func validate(_ response: URLResponse, data: Data) throws {
        guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
        guard (200..<300).contains(http.statusCode) else {
            // Try to surface the API's structured error; fall back to the raw status.
            let apiError = try? decoder.decode(APIErrorResponse.self, from: data)
            throw APIError.server(status: http.statusCode,
                                  reason: apiError?.reason ?? "Request failed.",
                                  code: apiError?.code)
        }
    }

    private func decode<Response: Decodable>(_ data: Data) throws -> Response {
        if Response.self == EmptyResponse.self { return EmptyResponse() as! Response }  // 204s
        do { return try decoder.decode(Response.self, from: data) }
        catch { throw APIError.decoding(error) }
    }
}

// For endpoints that return no body (204 No Content), like/unfollow/delete.
struct EmptyResponse: Decodable {}
// Chirp/Networking/APIError.swift
enum APIError: Error {
    case invalidResponse
    case server(status: Int, reason: String, code: String?)   // the API said no, with a reason
    case decoding(Error)
    case transport(Error)                                     // no network, timeout (added Ch 4)

    var isUnauthorized: Bool {                                // used by token refresh (Ch 6)
        if case .server(let status, _, _) = self { return status == 401 }
        return false
    }
}

The payoff at the call site

let client = APIClient(baseURL: URL(string: "https://chirp-api.example.com")!)

let tokens = try await client.send(API.login(email: email, password: password))   // TokenResponse
let feed   = try await client.send(API.feed(page: 1))                             // Page<Post>
let me     = try await client.send(API.profile(userID: someID))                   // ProfileDTO

Why the generic Response type matters so much

  • The compiler picks the decode target. send(API.feed(...)) returns Page<Post> with no cast, no
  • Autocomplete guides you. API. shows every endpoint; each returns exactly its type.
  • Refactors are safe. Change an endpoint's response shape and every mismatched call site stops

A note on the body: encoding any Encodable

What we built

  • Split what varies (a typed Endpoint<Response> — path, method, query, body) from **what's the
  • Wrote endpoint factories (API.login, API.feed, …) that encode each route's contract, including
  • Built send to build → await → validate status → decode, surfacing the API's structured
  • Reduced every call site to one typed line.

Mental model to take away

  • A reusable client = a typed endpoint description + one generic send that does the universal
  • Carrying the Response type on the endpoint lets the compiler choose the decode target and makes
  • Adding an endpoint is a one-line factory; cross-cutting concerns (auth, retries) will slot into