Nearly every iOS app talks to a server, and nearly every one grows the same problem: a dozen
places that build a URLRequest, each with its own idea about headers, timeouts and what
a failure means.
This lesson builds the alternative — one type that performs requests, and everything else describing them as data. Third-party clients exist and are fine; doing it once by hand is worth it, because what they give you for free is exactly what is below.
A request as a value
The key move is that building a request performs no I/O:
struct Endpoint {
let path: String
let method: HTTPMethod
let queryItems: [URLQueryItem]
/// Whether to attach the bearer token. Public endpoints (the menu, guest checkout) do not need it.
let requiresAuthentication: Bool
let encodeBody: (@Sendable () throws -> Data)?
An Endpoint is inert. That makes the whole routing layer testable without a server,
a mock protocol or a single byte crossing the network — "does this produce
/api/orders/mine?page=2&size=20?" is a plain assertion.
The body is a closure rather than some Encodable because a protocol with
Self requirements cannot be stored in a property. Encoding lazily has a second benefit:
a body is only serialised if the request is actually sent.
A convenience initialiser hides that from every call site:
init(
path: String,
method: HTTPMethod,
queryItems: [URLQueryItem] = [],
requiresAuthentication: Bool = false,
body: some Encodable & Sendable
) {
self.init(
path: path,
method: method,
queryItems: queryItems,
requiresAuthentication: requiresAuthentication,
encodeBody: { try JSONCoding.encoder.encode(body) }
)
}
Build URLs with URLComponents
func url(relativeTo baseURL: URL) -> URL? {
guard var components = URLComponents(
url: baseURL.appendingPathComponent(path),
resolvingAgainstBaseURL: false
) else { return nil }
if !queryItems.isEmpty { components.queryItems = queryItems }
return components.url
}
Never by concatenating strings. A search term containing a space or an ampersand has to be
percent-encoded, and "\(base)\(path)?q=\(value)" is exactly how that becomes a
malformed URL nobody notices until a customer types an apostrophe.
Every route in one file
enum CatalogEndpoints {
static let products = Endpoint(path: "/api/products")
static let toppings = Endpoint(path: "/api/toppings")
static let crusts = Endpoint(path: "/api/crusts")
}
Grouped by resource, one caseless enum each. Two things fall out of writing them this way. The app's entire API surface is answered by reading one file rather than grepping for string literals. And the one security-relevant flag in the whole layer sits next to the path:
static func myOrders(page: Int, size: Int) -> Endpoint {
Endpoint(
path: "/api/orders/mine",
queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "size", value: String(size)),
],
requiresAuthentication: true
)
}
Declaring requiresAuthentication at the endpoint rather than at the call site is what
stops /api/me/addresses ever being sent without a token by accident. It is also
directly testable — the demo app has a test that walks every profile endpoint and asserts both that
it is authenticated and that its path carries no user id.
The client, behind a protocol
protocol HTTPClient: Sendable {
func send<Response: Decodable>(_ endpoint: Endpoint, as type: Response.Type) async throws -> Response
}
Everything above this — repositories, stores, views — depends on the protocol rather than on
URLSession. That is what makes the whole app testable without a server: a test injects
a client returning canned values, and no feature code knows the difference.
Two extensions make it pleasant to use:
extension HTTPClient {
func send<Response: Decodable>(_ endpoint: Endpoint) async throws -> Response {
try await send(endpoint, as: Response.self)
}
func send(_ endpoint: Endpoint) async throws {
_ = try await send(endpoint, as: EmptyResponse.self)
}
}
The first lets type inference do the work — let user: User = try await client.send(…).
The second is for a DELETE whose response nobody reads.
What replaced AbortController
The implementation is where Swift's concurrency model earns its keep. The React Native version of
this same client threads an AbortSignal through every call and wires it to a timeout by
hand, because fetch has no other way to be interrupted.
Swift needs none of that. Cancelling a Task cancels every await inside
it, so a view that disappears cancels its own requests simply by its task going away. The timeout is
a session property:
let sessionConfiguration = URLSessionConfiguration.default
sessionConfiguration.timeoutIntervalForRequest = configuration.requestTimeout
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
self.session = URLSession(configuration: sessionConfiguration)
The cache policy is deliberate: nobody wants a cached menu served as fresh, because prices change and a stale cart line is a support ticket. Opting out here rather than per request means a new endpoint cannot forget to.
The request
var request = URLRequest(url: url)
request.httpMethod = endpoint.method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let encodeBody = endpoint.encodeBody {
do {
request.httpBody = try encodeBody()
} catch {
throw APIError.decoding(message: "Could not encode the request body: \(error).")
}
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
if endpoint.requiresAuthentication, let token = await tokenProvider?.currentToken() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
One place decides where the API lives, how the token is attached, how long to wait, and how an
error response becomes a thrown Swift error. Calling URLSession from a view model would
scatter all four, and they would drift.
Cancellation is not a failure
} catch let error as URLError {
if error.code == .cancelled { throw CancellationError() }
A cancelled request means the caller went away — a screen dismissed, a search superseded.
Rethrowing it as CancellationError keeps Task.isCancelled checks upstream
working, and lets every catch in the app distinguish "this failed" from "nobody is
waiting any more".
Decoding, and the failures that only happen in production
// 204 No Content, or a 200 with an empty body, has nothing to decode.
if data.isEmpty || response.statusCode == 204 {
guard let empty = EmptyResponse() as? Response else {
throw APIError.decoding(message: "Expected a body from \(endpoint.path) but got none.")
}
return empty
}
JSONDecoder throws on empty input rather than returning "nothing", so without this
short circuit every DELETE in the app would need its own non-generic overload.
When decoding does fail, the detail belongs in the log and not on screen:
logger.error("Decoding \(String(describing: type)) from \(endpoint.path, privacy: .public) failed: \(String(describing: error), privacy: .private)")
throw APIError.decoding(message: "The server returned a response this app could not read.")
A decoding failure is our bug, not the customer's connection. The mismatch details are
exactly what is needed to fix it and exactly what must not leak into the UI — or, given
privacy: .private, into a diagnostic file somebody emails around. That privacy
annotation is the reason to use Logger over print: interpolated values are
redacted by default in logs collected from a customer's phone.
Not everything that fails is JSON
guard let body = try? JSONCoding.decoder.decode(APIErrorBody.self, from: data) else {
logger.error("HTTP \(statusCode) from \(endpoint.path, privacy: .public) with an unreadable body")
return .api(
status: statusCode,
message: "Request failed with \(statusCode).",
body: nil
)
}
A proxy or a crashed server returns an HTML error page. Letting the decoder throw there would surface "Unexpected character '<'" over the top of the real problem, which is a 502.
Codable, and one decision about keys
The response side is Codable, which for most models means declaring the conformance
and nothing else — the compiler matches property names to JSON keys and writes the parsing.
enum JSONCoding {
static let encoder: JSONEncoder = {
let encoder = JSONEncoder()
return encoder
}()
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
return decoder
}()
}
One encoder and one decoder, shared. Constructing them per call is cheap but not free, and more importantly a decoder built at each call site is a decoder whose configuration can drift. The day a date strategy is needed, it needs to be needed in one place.
Note what is not set: keyDecodingStrategy. This API already speaks
lowerCamelCase, so .convertFromSnakeCase would be a no-op that quietly mangles any
field that ever arrives with an underscore in it. Where a Swift name must differ from the wire name,
the model declares its own CodingKeys — visible at the model rather than implied
globally.
Transport errors worth reading
private static func transportMessage(for error: URLError, baseURL: URL) -> String {
switch error.code {
case .timedOut:
"The server took too long to respond. Check your connection."
case .notConnectedToInternet, .networkConnectionLost:
"You appear to be offline. Check your connection and try again."
default:
"Could not reach the server at \(baseURL.absoluteString). Is the backend running?"
}
}
URLError.localizedDescription says "A server with the specified hostname could not be
found", which tells a customer nothing and a developer almost nothing. Naming the host we actually
tried turns the two failures that happen daily in development into self-answering questions — and
the offline case is the one that matters in production, because on a phone a lost signal is far more
likely than a backend that is down.
Where the server is
The last piece is configuration, and it is the one thing a mobile app cannot hard-code. The
simulator shares your Mac's network stack, so localhost works. A real device is a
different machine on the Wi-Fi, where localhost is the phone. And a release build talks
to a real host over HTTPS.
struct APIConfiguration: Equatable, Sendable {
let baseURL: URL
let requestTimeout: TimeInterval
let stripePublishableKey: String?
A struct, not a singleton. A static let shared would make every test that touches
networking depend on the app's real configuration; passing an instance means a test can construct
one pointing nowhere and be certain of it.
The values come from the build rather than the source, and the fallback is deliberately
asymmetric: a debug build with nothing configured defaults to localhost, and a release build with
nothing configured traps. Shipping a binary that silently talks to localhost is far
worse than one that refuses to launch on the machine of the person who can still fix it.
Repositories on top
Features do not call the client directly. A thin repository sits between them, and the thinness is the point:
struct RemoteCatalogRepository: CatalogRepository {
private let client: HTTPClient
init(client: HTTPClient) { self.client = client }
func products() async throws -> [Product] { try await client.send(CatalogEndpoints.products) }
func toppings() async throws -> [Topping] { try await client.send(CatalogEndpoints.toppings) }
func crusts() async throws -> [Crust] { try await client.send(CatalogEndpoints.crusts) }
}
An endpoint in, a decoded model out. A repository that also cached, retried or mapped errors would be a place for logic to accumulate where no test would look for it. Lesson 13 makes the architectural case for the protocol these implement.
What this does not do
Deliberately: no retry policy, no request deduplication, no caching layer, no reachability monitoring. Each is a real requirement for some apps and none of them belongs in the client — retrying is a decision only a caller can make, because only it knows whether the request is safe to repeat.
Next
This layer throws a typed error, and so far nothing has said what a caller should do with it. The next lesson is error handling that reaches the user: an error type whose cases are decisions, field messages from a server, and a state model that makes the eternal spinner impossible to write.