Modern networking in Swift
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:
- 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:
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
:
nextValidator
: just pass the handle to the next validatorfailChain
: stop the chain and return an error for that requestretry
: 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):
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.