CodeGym /Courses /Swift SELF /ApiClient.fetch(request: URLRequest)

ApiClient.fetch(request: URLRequest)

Swift SELF
Level 66, Lesson 1
Available

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
200...299
Success Try to decode JSON (or return the result)
300...399
Redirects In our teaching client, treat them as a status error and return httpStatus
400...499
Client error (parameters, authorization) NetworkError.httpStatus(code:body:)
500...599
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.

1
Task
Swift SELF, level 66, lesson 1
Locked
Status Sentinel
Status Sentinel
1
Task
Swift SELF, level 66, lesson 1
Locked
Response preview
Response preview
1
Task
Swift SELF, level 66, lesson 1
Locked
Mini ApiClient
Mini ApiClient
1
Task
Swift SELF, level 66, lesson 1
Locked
Error messages
Error messages
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION