Modern networking layers in iOS using async/await

A fresh new look to networking topic that takes advantage of the new Swift’s Concurrency Model

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 2008/9, 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 which 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 think: 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.

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 easily setting all the relevant attributes for a call: of course, 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:

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:

  • Extensible interface: you can create a custom body container for your own data structure and set them directly. Just make it conforms to the HTTPSerializableBody protocol to allow the automatic serialization to data stream when needed.
  • Easy to use APIs set: you can create all of these containers directly from the static methods offered by the HTTPBody struct

That’s an example of the multi-part 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 on 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 & deserialization of the data stream is 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 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 & 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 Responses

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:

  • nextValidator: just pass the handle to the next validator
  • failChain: stop the chain and return an error for that request
  • retry: retry the origin request with a strategy

Retry Strategies

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.

This 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
     }
}
The retry strategy is a bit more complex than this. Follow the link above for a detailed flow.

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. Btw RealHTTP also includes custom strategies for retry.

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 extension both 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 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.

Assembling an API

At this time we have created a modern lightweight networking infrastructure.
But what about our API implementation?

For smaller apps, using HTTPClient directly without creating an API definition can be acceptable. But it’s generally a good idea to define the available APIs somewhere to reduce the clutter in your code and avoid possible errors due to duplication.

Personally, I don’t like the Moya approach where you model APIs as an enum, and each property has a separate switch.
I think it’s generally confusing because you have all the properties which configure a request scattered and mixed in a single file.
Ultimately it’s hard to read and modify and when you add a new endpoint you should move up and down through this big chunk of code.

My approach is to have an object which is able to configure a valid HTTPRequest ready to be passed to a HTTPClient.
For this example, we’ll use the MovieDB APIs 🍿 (you should register for a free account to get a valid API Key).

Let’s now use our built network layer as a practical example. For sake of simplicity, we’ll consider 2 APIs, one to get upcoming/popular/top rated movies, another for search.

First of all, we want to use namespacing via enum to create a container where we’ll put all the resources for a particular context, in our case Rankings and Movies.

public enum Rankings { }
public enum Movies { }

A Resource describes a particular service offered from a remote service; it takes several input parameters and uses them to generate a valid HTTPRequest ready to be executed. TheAPIResourceConvertible protocol describes this process:

public protocol APIResourceConvertible {
    associatedtype Result: Decodable // the output object of the service
    func request() -> HTTPRequest // function which generate a request
}

Search is a Resource to search for a movie inside the MovieDB.
It can be initialized with a required parameter (query string) and two other optional parameters, (release)year  and includeAdults filter.

public extension Movies {

  struct Search: APIResourceConvertible {
    public typealias Result = MoviesPage

    var query: String
    var includeAdult: Bool = false
    var year: Int?
    
    public init(_ query: String, year: Int? = nil) {
        self.query = query
        self.year = year
    }
    
    func request() -> HTTPRequest {
        HTTPRequest {
            $0.method = .get
            $0.path = "/search/movie"
            $0.addQueryParameter(name: "query", value: query)
            $0.addQueryParameter(name: "include_adult", value: String(includeAdult))
            if let year = year {
                $0.addQueryParameter(name: "year", value: String(year))
            }
        }
    }
  }
  
}

The request() function generate a valid request according to the MovieDB API doc.
We can repeat this step for each to create a Lists Resources to get the ranking list for upcoming, popular and topRated movies. We’ll put it into the namespace Rankings:

extension Rankings {

    struct List: APIResourceConvertible {
        public typealias Result = MoviesPage

        public enum Category: String, CaseIterable {
            case upcoming, popular
            case topRated = "top_rated"
        }

        public enum Region: String {
            case Italy = "IT"
            case USA = "US"
        }

        public var category: Category
        public var region: Region
        public var page = 1

        func request() -> HTTPRequest {
            HTTPRequest {
                $0.method = .get
                $0.path = "/movie/(category.rawValue)"
                $0.addQueryParameter(name: "region", value: region.rawValue)
                $0.addQueryParameter(name: "page", value: String(page))
            }
        }
    }
    
}

MoviesPage represent a Codable object which reflects the result of each call of the MovieDB:
With this approach we got 3 benefits:

  • API Calls are organized in namespaces based upon their context
  • Each Resource describe a type-safe approach to create a remote request
  • Each Resource contains all the logic which generate a valid HTTP Request

One last thing; we should be allowed an HTTPClient to execute a APIResourceConvertible call and return a type-safe object as described.
This is pretty easy:

public extension HTTPClient {
    
    func fetch<T: APIResourceConvertible>(_ convertible: T) async throws -> T.Result {
        let result = try await fetch(convertible.request())
        return try result.decode(T.Result.self)
    }
    
}

Finally, we’ll create our HTTPClient:

var client: HTTPClient = {
  client = HTTPClient(baseURL: "https://api.themoviedb.org/3")
  client.queryParams = [
    .init(name: "api_key", value: "<API KEY>"),
    .init(name: "language", value: "IT-it")
  ]
  return client
}()

and we can execute our calls:

// The movies search in action
let movies = try await client.fetch(Movies.Search("Godfather", year: 1972))

// The ranking in action
let page = try await client.fetch(Rankings.List(.topRated, region: .US))

You can find the complete source code of this example here.

Conclusion

Now we have an easy-to-use modern networking layer based upon async/await that we can customize. We have complete control over its functionality and a complete understanding of its mechanics.

The complete library for networking is released under MIT License and it’s called RealHTTP; we are maintaining and evolving it (it also includes an extensive set of unit tests). If you liked this article please consider adding a star to the project or contributing to its development.

GitHub – immobiliare/RealHTTP: 🌀Web API client & Stubber built with async/await for Swift
🌀Web API client & Stubber built with async/await for Swift – GitHub – immobiliare/RealHTTP: 🌀Web API client & Stubber built with async/await for Swift

Leave a Reply

Made with love in Rome, Italy