Skip to content

Modern networking in Swift

Published: at 11:25 AMSuggest Changes

I’ve got a confession to make: Making networking layers has always been an exciting topic for me. Since the first days of iOS programming, in early 2007, each new project represented a fresh opportunity to refine or even break the entire approach I have used so far. My last attempt to write something on this topic is dated 2017, and I considered it a milestone after the switch to Swift language.

It’s been a long time since then; the language evolved like the system frameworks, and recently, with the introduction of the new Swift’s Concurrency Model, I decided to take a further step forward and update my approach to networking layers. This new version went through a radical redesign that allows you to write a request in just a single line of code:

let todo = try await HTTPRequest("https://jsonplaceholder.typicode.com/todos/1").fetch(Todo.self)

At this time, you may be thinking: why should I make my client instead of relying on Alamofire? You’re right. A new implementation is inevitably immature and a source of issues for a certain amount of time. Despite all, you have the opportunity to create a fine-tuned integration with your software and avoid third-party dependencies. Moreover, you can take advantage of the new Apple technologies like URLSession, Codable, Async/Await & Actors.
You can find the code on GitHub; the project is called RealHTTP.

The Client

Let’s start by defining a type for representing a client. A client (formerly HTTPClient) is a structure that comes with cookies, headers, security options, validator rules, timeout, and all other shared settings you may have in common between a group of requests. When you run a request in a client, all these properties are automatically from the client unless you customize it in a single request.

For example, when you execute an auth call and receive a JWT token, you may want to set the credentials at the client level, so any other request incorporate these data. The same happens with validators: to avoid duplicating the logic for data validation, you may want to create a new validator and let the client execute it for every request it fetches. A client is also an excellent candidate to implement retry mechanisms not available on basic URLSession implementation.

The Request

As you may imagine, a request (formerly HTTPRequest) encapsulate a single call to an endpoint.

If you have read some other articles on this topic, you may find often a common choice is to use Swift’s Generic to handle the output of a request.
Something like: struct HTTPRequest<Response>.

It allows you to strongly link the output object type to the request itself. While it’s a clever use of this fantastic construct, I found it makes the request a bit restrictive. From a practical perspective, you may need to use type erasure to handle this object outside its context. Also, conceptually, I prefer to keep the request stages (fetch ~> get raw data ~> object decode) separated and easily identifiable.

For these reasons, I chose to avoid generics and return a raw response (HTTPResponse) from a request; the object will therefore include all the functions to allow easy decode (we’ll take a look at it below).

Configure a Request

As we said, a request must allow us to easily set all the relevant attributes for a call, especially “HTTP Method” “Path,” “Query Variables,” and “Body.” What do Swift developers love more than anything else? Type-safety.

I’ve accomplished it in two ways: using configuration objects instead of literal and protocols to provide an extensible configuration along with a set of pre-made builder functions.

This is an example of request configuration:

let req = HTTPRequest {
    $0.url = URL(string: "https://.../login")!
    $0.method = .post
    $0.timeout = 15
    $0.redirectMode = redirect
    $0.maxRetries = 4
    $0.headers = HTTPHeaders([
        .init(name: .userAgent, value: myAgent),
        .init(name: "X-API-Experimental", value: "true")
    ])
    // Setup URL query params & body
    $0.addQueryParameter(name: "full", value: "1")
    $0.addQueryParameter(name: "autosignout", value: "30")
    $0.body = .json(["username": username, "pwd": pwd])
}

A typical example of type safety in action is the HTTP Method which became an enum; but also the headers which are managed using a custom HTTPHeader object, so you can write something like the following:

req.headers[.contentType] = .bmp
req.headers = .init([
  .contentType: .bmp
  "X-Custom-Header": "abc"
])

It supports both type-safe keys declaration and custom literal.

The best example of the usage of protocols is the body setup of the request. While it’s ultimately a binary stream, I decided to create a struct to hold the data content and add a set of utility methods to make the most common body structures (HTTPBody): multi-part form, JSON encoded objects, input stream, URL encoded body, etc.

The result is an:

Here’s an example of a multipart form:

req.body = .multipart(boundary: nil, {
  $0.add(string: "value", name: "param_1")
  $0.add(fileURL: fileURL, name: "image", mimeType: .gif)
  $0.add(string: "some other", name: "param_2")
})

Making a body with a JSON encoded object is also one line of code away:

let myObject = ...
req.body = .json(myObject)

When a request is passed to a client, the associated URLSessionTask is created automatically (in another thread) and the standard URLSession flow is therefore executed. The underlying logic still uses the URLSessionDelegate(and the other delegates of the family); you can find more in the HTTPDataLoader class.

Execute a Request

HTTPClient takes full advantage of async/await, returning the raw response from the server. Running a request is easy: just call its fetch() function. It takes an optional client argument; if not set, the default singleton HTTPClient instance is used (it means cookies, headers, and other configuration settings are related to this shared instance).

Therefore, the request is added to the destination client and, accordingly with the configuration, will be executed asynchronously. Both serialization and deserialization of the data stream are made in another Task (for the sake of simplicity, another thread). This allows us to reduce the amount of work done on the HTTPClient.

let result: HTTPResponse = try await req.fetch()

The Response

The request’s response is of type HTTPResponse; this object encapsulates all the stuff about the operation, including the raw data, the status code, optional error (received from the server or generated by a response validator), and the metrics data valid for integration debugging purposes.

The next step is to transform the raw response into a valid object (with/without a DAO). The decode() function allows you to pass the expected output object class. Usually, it’s an Codable object, but it’s also essential to enable custom object decoding, so you can also use any object that conforms to the HTTPDecodableResponse protocol. This protocol just defines a static function: static func decode(_ response: HTTPResponse) throws -> Self?.

Implementing the custom decode() function, you can do whatever you want to get the expected output. For example, I’m a firm fan of SwiftyJSON. It initially may seem a little more verbose than ‘Codable,’ but it also offers more flexibility over the edge cases, better failure handling, and a less opaque transformation process.

Since most of the time, you may want just to end up with the output decoded object, the fetch() operation also presents the optional decode parameter, so you can do fetch & decode in a single pass without passing from the raw response.

let loggedUser = try await login.fetch(User.self)

This alternate fetch() function combines both the fetch and decode in a single function; you may find it helpful when you don’t need to get the inner details of the response but just the decoded object.

Validate/Modify a Response

Using a custom client and not the shared one is to customize the logic behind the communication with your endpoint. For example, we would communicate with two different endpoints with different logic (oh man, the legacy environments…). It means both the result and errors are handled differently.

For example, the old legacy system is far away from being a REST-like system and puts errors inside the request’s body; the new one uses the shiny HTTP status code.

To handle these and more complex cases, we introduced the concept of response validators, which are very similar’s to Express’s Validators. Basically, a validator is defined by a protocol and a function that provides the request and its raw response, allowing you to decide the next step.

You can refuse the response and throw an error, accept the response or modify it, make an immediate retry or retry after executing an alternate request (this is the example for an expired JWT token that needs to be refreshed before making a further attempt with the original request).

Validators are executed in order before the response is sent to the application’s level. You can assign multiple validators to the client, and all of them can concur to the final output. This is a simplified version of the standard HTTPResponseValidator:

func validate(response: HTTPResponse, forRequest request: HTTPRequest) -> HTTPResponseValidatorResult {
if !(200..<300).contains(response.statusCode) {
// invalid response, we want to fail the request with error
        throw HTTPError.invalidResponse
    }
return .nextValidator // everything is okay, move to next validator
}

You can extend/configure it with different behavior. Moreover, the HTTPAltResponseValidator is the right validator to implement retry/after call logic. A validator can return one of the following actions defined by HTTPResponseValidatorResult:

Retry Strategy

One of the advantages of Alamofire is the infrastructure for adapting and retrying requests. Reimplementing it with callbacks is far from easy, but with async/await, it’s way easier. We want to implement two kinds of retry strategies: a simple retry with delay and a more complex one to execute an alternate call followed by the origin request.

Retry strategies are handled inside the URLSessionDelegate which is managed by a custom internal object called HTTPDataLoader.

The following is an over-simplified version of the logic you can find here(along with comments):

public func fetch() async throws {
    // prepare the request and execute it
    let task = try await request.urlSessionTask(inClient: client)
    let response = try await fetch(request, task: sessionTask: task)
    
    // ask to validator the action to perform on this request
    let action = client.validate(response: response, forRequest: request)
    switch action {
       case .failChain(let error):
          return HTTPResponse(error: error) // fail with error
                
       case .retry(let strategy):
          if request.isAltRequest || request.reachedMaxRetries {
            // alt request cannot be retried to avoid infinite loops
            return response
          } else {
            // perform retry strategy
            let retryResponse = try await performRetryStrategy(strategy, 
                              forRequest: request, task: sessionTask,
                              withResponse: response)
             return retryResponse
          }
       case .nextValidator:
          return response // validation okay
     }
}

If you are thinking about using auto-retries for connectivity issues, consider using waitsForConnectivity instead. If the request does fail with a network issue, it’s usually best to communicate an error to the user. With NWPathMonitor you can still monitor the connection to your server and retry automatically.

Debugging

Debugging is important; a standard way to exchange networking calls with backend teams is cURL. It doesn’t need an introduction. There is an extensionboth for HTTPRequest and HTTPResponse which generates a cURL command for the underlying URLRequest.

Ideally, you should call cURLDescription on request/response and you will get all the information automatically, including the parent’s HTTPClient settings.

Other Features

This article would have been a lot longer. We didn’t cover topics like SSL Pinning, Large File Download/Resume, Requests Mocking, and HTTP Caching. All these features are currently implemented and working on the GitHub project, so if you are interested you can look directly at sources. By the way, I’ve reused the same approaches you have seen above.


Previous Post
The broken window principle applied to software
Next Post
The economy of tech debt