1. Why do we need ApiClient?
If you are building a networking layer for the first time, it is very tempting to say: "Well, there is URLSession; let’s just call await session.data(for: request) straight from CLI commands and live happily ever after." And for the first two days it may even seem like you have won. But then the classic problems begin: in one place you forgot to check statusCode, in another you forgot to handle a decoding error, and in a third you used try! and it "suddenly" crashed right in the user’s hands (of course, the user is to blame — they "clicked the wrong thing," right?).
ApiClient is our way of stopping the copy-paste of the same sequence of actions across the whole project. We want to describe "the right way" once: send a request, make sure the status is successful, decode JSON, and if something goes wrong — return a categorized error (transport vs httpStatus vs decoding). It is like a kitchen in a cafe: the waiter (the CLI command) does not need to understand the grill temperature; they just bring the dish. And the kitchen (the networking layer) sets up the process once.
To keep things clear, let’s define the boundary:
- HTTPClient (transport) delivers the request → returns (Data, HTTPURLResponse) or throws a delivery error.
- ApiClient (interpretation) checks statusCode, decodes JSON, and turns everything into a typed result or a NetworkError.
2. The fetch contract: input, output, and guarantees
When you write fetch, it is useful to pause for a second and describe the contract in plain human language. The method should accept a ready-made URLRequest. Why ready-made? Because building the URL, query parameters, and headers is a separate responsibility (we already discussed that in the course). ApiClient should not "guess" where you wanted to go; it should correctly execute what you gave it.
The fetch method will return T, where T: Decodable. This means one simple thing: "I, ApiClient, promise to try to interpret the server response as JSON of the required shape and turn it into your DTO type." If the server returned HTML with a "502 Bad Gateway" page (yes, that happens too), then decoding is not required to succeed — and that will be a separate error.
Here is the minimal signature we will aim for:
import Foundation
struct ApiClient {
func fetch<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
fatalError("Implement me")
}
}
Notice that we accept a URLRequest (from Foundation) and work with the HTTP response through HTTPURLResponse.
3. Step 1: delivery — get Data and HTTPURLResponse
Now we move smoothly to the first step inside fetch. We do not want ApiClient to depend directly on URLSession, because then it is hard to test and difficult to swap out the transport (for example, with a mock). That is why we rely on the HTTPClient protocol, which returns exactly HTTPURLResponse (we need the statusCode).
The minimal transport contract looks like this:
import Foundation
protocol HTTPClient {
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
It is important to catch the intuition here: the transport must not decide whether the status is successful, and it must not decode JSON. It only performs delivery. If we start stuffing "intelligence" into the transport (checking 2xx, JSONDecoder, converting to DTO), we get a layer that is impossible to reuse and hard to test in isolation.
In fetch, the first part usually looks like this: "we tried to send it, and if an error came back — it is either already a NetworkError, or some 'raw' transport error that needs to be wrapped in NetworkError.transport." We are not creating a new error model; we are simply using the one already adopted.
From a code perspective, this often looks like a neat do/catch, where we do not "erase" the error type:
do {
let (data, response) = try await http.send(request)
// continue
} catch let error as NetworkError {
throw error
} catch {
throw NetworkError.transport(error)
}
And yes, at this stage we still cannot say "success." The bytes may arrive with a 404 or 500 status — and that is normal: the server responded, but the response was not the one we wanted.
4. Step 2: status code — success is 2xx
Now comes the moment that breaks a beginner programmer’s brain about as much as Optional did in the early chapters: "If the data arrived, then everything is fine." No, it does not mean that.
In HTTP, success is the range 200...299. This is not only 200 OK, but also, for example, 204 No Content (successful, but with no body), and other variants. So we check by range.
Here is a tiny helper function (yes, you can write it exactly like this, no magic):
func isSuccessStatus(_ code: Int) -> Bool {
(200...299).contains(code)
}
print(isSuccessStatus(200)) // true
print(isSuccessStatus(404)) // false
If the status is not 2xx, ApiClient must throw NetworkError.httpStatus. Important: it is useful to include the response body (Data) in this error. Even if it is not JSON, it may contain a diagnostic message from the server. This helps especially when the server returns a JSON error (for example { "error": "invalid api key" }), which you would otherwise throw away.
Below is a small table to keep things clearer in your head (these are not "laws of the universe," but a practical cheat sheet for our client):
| statusCode | What it usually means | What we do in ApiClient |
|---|---|---|
|
Success | Try to decode JSON (or return the result) |
|
Redirects | In our teaching client, treat them as a status error and return httpStatus |
|
Client error (parameters, authorization) | NetworkError.httpStatus(code:body:) |
|
Server error | NetworkError.httpStatus(code:body:) |
We are not overcomplicating things and we are not adding special handling for 301/302, because that is already application-level policy, not the job of the base client.
5. Step 3: decoding and diagnostics
Decoding: JSONDecoder and the decoding error
At this step, we have two things: Data and the knowledge that the status is successful. Now we try to turn the bytes into the T model. And here it is important not to "mix everything together": a decoding error is not a network problem and not an HTTP status problem. It is a separate category: "the server returned something unexpected for our DTO."
In Swift, we usually do this through JSONDecoder().decode(T.self, from: data). It is important to remember that the very idea of "decoding JSON into a model" is based on Codable/Decodable, which we have already used in the course.
Mini example of DTO and decoding:
import Foundation
struct PingDTO: Decodable {
let message: String
}
func decodePing(from data: Data) throws -> PingDTO {
try JSONDecoder().decode(PingDTO.self, from: data)
}
In ApiClient.fetch, we do the same thing, but T can be any Decodable. We catch the decoding error and turn it into NetworkError.decoding(error).
And one more important design point: it is better to keep JSONDecoder as a dependency of ApiClient. First, this makes the behavior more predictable. Second, it simplifies testing. Third, it prevents random settings from being spread across the codebase.
Diagnostics: response body preview without crashes
There is a classic bug: you received NetworkError.httpStatus(code: 500, body: <some bytes>), and then in the logs you print "HTTP 500" — and that is it. But inside body there was "Database is down", and you could have saved yourself an hour of life (which is about three coffees or one and a half attempts to fix Wi‑Fi).
So it is useful to have a small helper that makes a "body preview" as a UTF-8 string, trimming the size. This is not part of the fetch contract; it is just a convenient tool for logging and debugging.
import Foundation
func previewBody(_ data: Data) -> String {
let prefix = data.prefix(80)
return String(data: prefix, encoding: .utf8) ?? "<non-utf8>"
}
Notice that we do not promise that the body is UTF-8. We honestly say: "If it did not work, we will say so." And yes, in practice the server may return binary data, a gzipped body, or something exotic. We do not turn diagnostics into another reason for the program to crash.
6. Full implementation of fetch
Now let’s put everything together into one implementation. I am intentionally keeping the code compact so it is actually readable, rather than something to admire from afar like a museum of modern art ("beautiful, but unclear").
First, let me remind you (as context) of the shape of NetworkError, which we already accepted in the course and are not "reinventing." The structure of the cases matters here, and they distinguish categories:
import Foundation
enum NetworkError: Error {
case transport(Error)
case invalidResponse
case httpStatus(code: Int, body: Data)
case decoding(Error)
}
Now the ApiClient itself. Notice that it depends on HTTPClient and JSONDecoder.
import Foundation
struct ApiClient {
let http: HTTPClient
let decoder: JSONDecoder
init(http: HTTPClient, decoder: JSONDecoder = JSONDecoder()) {
self.http = http
self.decoder = decoder
}
}
And finally, fetch. Here we intentionally fix the order of steps: send → status → decode.
import Foundation
extension ApiClient {
func fetch<T: Decodable>(_ request: URLRequest, as type: T.Type = T.self) async throws -> T {
do {
let (data, response) = try await http.send(request)
guard (200...299).contains(response.statusCode) else {
throw NetworkError.httpStatus(code: response.statusCode, body: data)
}
return try decoder.decode(T.self, from: data)
} catch let error as NetworkError {
throw error
} catch {
throw NetworkError.decoding(error)
}
}
}
There is a subtle point that people often stumble over: the final catch in my example wraps any non-NetworkError error into decoding. In a real project, you will often use two do/catch blocks: one around http.send, and the second around decode, so that you can classify errors more precisely.
For a teaching project, it is better to make the code a little more "expanded," but more accurate. Here is a neater version (and I like it more):
import Foundation
extension ApiClient {
func fetch<T: Decodable>(_ request: URLRequest, as type: T.Type = T.self) async throws -> T {
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await http.send(request)
} catch let e as NetworkError {
throw e
} catch {
throw NetworkError.transport(error)
}
guard (200...299).contains(response.statusCode) else {
throw NetworkError.httpStatus(code: response.statusCode, body: data)
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw NetworkError.decoding(error)
}
}
}
Yes, it is a few more lines, but the error categorization is more "honest": transport errors are not masked as decoding errors. And that is very helpful when debugging.
7. Example of use in a CLI
To avoid leaving everything in a vacuum, let’s make a small example of how to use this. Suppose our CLI application LibraryCLI should be able to fetch a book by id from an external API (for now, without "command integration," just a call example).
DTO expected from the server:
import Foundation
struct RemoteBookDTO: Decodable {
let id: Int
let title: String
let author: String
}
Request-building function (without !, because we agreed not to set traps for ourselves):
import Foundation
func makeBookRequest(id: Int) throws -> URLRequest {
guard let url = URL(string: "https://example.com/api/books/\\(id)") else {
throw URLError(.badURL)
}
return URLRequest(url: url)
}
And an example of calling fetch with categorized error handling:
import Foundation
func loadBook(id: Int, api: ApiClient) async {
do {
let request = try makeBookRequest(id: id)
let book = try await api.fetch(request, as: RemoteBookDTO.self)
print("Loaded: \\(book.title) by \\(book.author)") // Loaded: ...
} catch let e as NetworkError {
print("NetworkError: \\(e)") // later we will make this human-readable
} catch {
print("Unexpected error: \\(error)")
}
}
This example is small, but it shows the main idea: the calling code no longer thinks about status codes and JSONDecoder. It thinks in terms of a scenario: "load a book." And the details of correct interpretation live inside ApiClient.
8. Typical mistakes
Mistake #1: decode JSON before checking statusCode.
This happens very naturally: you get Data, immediately call decoder.decode(...), and then wonder why you got "Expected to decode Dictionary…" at status 500. In reality, that was not book JSON at all, but a server error (or HTML). The correct order in fetch is ironclad: first statusCode, then decode.
Mistake #2: treating only 200 as success and ignoring the rest of 2xx.
Sometimes a student writes if response.statusCode == 200 { ... } else { ... }. That works... right up until the first 204 No Content or any API that uses different codes. The range 200...299 is more readable and more correct in meaning.
Mistake #3: turning all errors into one string.
It is very tempting to write throw NetworkError.message("Something went wrong"). But then you lose the most important thing: the category. A user-facing message can be obtained from NetworkError by a separate function, but the error itself must remain structured: transport, status, decoding — these are different causes, and they are fixed in different ways.
Mistake #4: catching catch {} and "swallowing" the error without details.
When people write catch { print("error") }, debugging turns into tea-leaf reading. Inside NetworkError, we deliberately preserve the original error (transport(Error) and decoding(Error)), as well as the response body for httpStatus. That is not a luxury; it is how you avoid spending forever searching for the cause.
Mistake #5: keep JSONDecoder() inside every call and then try to "somehow" configure it differently later.
If the decoder is created "on the spot" in a dozen functions, later you will not be able to centrally enable the desired date/key strategy or make behavior consistent. Keeping decoder as a dependency in ApiClient is a simple rule that saves a lot of nerves.
GO TO FULL VERSION