Chapter 2 — Requests, responses & Codable

The three parts of a network call

The request

var request = URLRequest(url: URL(string: "https://chirp-api.example.com/auth/login")!)
request.httpMethod = "POST"                                             // the verb
request.setValue("application/json", forHTTPHeaderField: "Content-Type") // "my body is JSON"
request.setValue("application/json", forHTTPHeaderField: "Accept")       // "send me JSON back"
request.httpBody = try JSONEncoder().encode(["email": email, "password": password])  // the body
  • url — where you're calling.
  • httpMethod — the verb: "GET", "POST", "PATCH", "DELETE" (default is GET).
  • Headers — metadata. Content-Type tells the server what your body is; Accept tells it what
  • httpBody — the payload, as Data. For JSON, you encode a Swift value with JSONEncoder.

Sending it, with async/await

let (data, response) = try await URLSession.shared.data(for: request)

Interpreting the response

guard let http = response as? HTTPURLResponse else {
    throw APIError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
    // Non-2xx: the server rejected us. Try to decode its error body.
    throw APIError.httpError(status: http.statusCode, data: data)
}

Decoding JSON with Codable

// Chirp/Networking/DTOs/TokenResponse.swift
struct TokenResponse: Decodable {
    let accessToken: String
    let refreshToken: String
    let expiresIn: Int
}
let decoder = JSONDecoder()
let tokens = try decoder.decode(TokenResponse.self, from: data)

Two decoder settings you'll always consider

  • Dates. JSON has no date type — dates are strings, and the format varies by API. Chirp sends
    decoder.dateDecodingStrategy = .iso8601
    
  • Key naming. Chirp's JSON uses camelCase (accessToken), matching Swift, so no setting is
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    

Modeling the Chirp responses

// Chirp/Networking/DTOs/Models.swift
import Foundation

struct PublicUser: Decodable, Identifiable, Hashable {
    let id: UUID
    let username: String
    let createdAt: Date
}

struct Post: Decodable, Identifiable, Hashable {
    let id: UUID
    let text: String
    let author: PublicUser
    let createdAt: Date
    let likeCount: Int
    let commentCount: Int
    let likedByMe: Bool
}

// The paginated envelope the feed returns.
struct Page<Item: Decodable>: Decodable {
    let items: [Item]
    let metadata: Metadata
    struct Metadata: Decodable { let page: Int; let per: Int; let total: Int }
}

// The consistent error shape the API returns for every failure.
struct APIErrorResponse: Decodable {
    let error: Bool
    let reason: String
    let code: String?
}
  • Post nests PublicUser. The API embeds the author object inside each post, and Codable
  • Page<Item> is generic. The API wraps every paginated list in { items, metadata }, so one

Your first real call, start to finish

enum APIError: Error {
    case invalidResponse
    case httpError(status: Int, data: Data)
    case decoding(Error)
}

func login(email: String, password: String) async throws -> TokenResponse {
    // 1. Build the request.
    var request = URLRequest(url: URL(string: "https://chirp-api.example.com/auth/login")!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONEncoder().encode(["email": email, "password": password])

    // 2. Send it.
    let (data, response) = try await URLSession.shared.data(for: request)

    // 3. Check the status.
    guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
    guard (200..<300).contains(http.statusCode) else {
        throw APIError.httpError(status: http.statusCode, data: data)
    }

    // 4. Decode the success body.
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .iso8601
    do { return try decoder.decode(TokenResponse.self, from: data) }
    catch { throw APIError.decoding(error) }
}
flowchart LR Build["1. URLRequest
(url, POST, headers, JSON body)"] --> Send["2. await URLSession.data(for:)"] Send --> Status["3. cast HTTPURLResponse
check 2xx"] Status -->|"2xx"| Decode["4. JSONDecoder → TokenResponse"] Status -->|"non-2xx"| Err["throw httpError"] Send -->|"no network"| Err2["throw (URLError)"]

Why this doesn't scale — and what's next

What we built

  • Made a real end-to-end call with URLRequest + await URLSession.data(for:), and learned the
  • Understood that URLSession only throws for transport errors — you must **check the HTTP status
  • Decoded JSON with Codable, configured .iso8601 dates (and noted convertFromSnakeCase),
  • Saw why per-endpoint functions don't scale, motivating a reusable client.

Mental model to take away

  • A call is build the URLRequestawait data(for:) → check status → decode — and the status
  • Codable maps JSON ↔ Swift types by key name; set date and key strategies to match the
  • Repeating this per endpoint is the problem: factor out the same parts (status, decode, headers,