Modern Networking Layers in iOS Using Async/Await

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

Daniele Margutti
Published in
9 min readMar 16, 2022

--

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:

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:

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:

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

Here’s an example of a multipart form:

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

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.

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.

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:

https://gist.github.com/malcommac/decbd7a0c57218dae2c5b9af6b4af246

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 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):

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

Assembling 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 two 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.

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:

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

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:

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

  • API Calls are organized in namespaces based upon their context
  • Each Resource describes 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 as you can see below:

Finally, we’ll create our HTTPClient:

and we can execute our calls:

You can find the complete source code for 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. If you liked this article, please consider adding a star to the project or contributing to its development.

Want to Connect?Check out my offnotes newsletter here.

--

--