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"]
(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(...))returnsPage<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
sendto 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
sendthat does the universal - Carrying the
Responsetype 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