⌘ kean.blog

Web API Client in Swift

  

It’s been more than four years since my previous API Client in Swift (Archived) post. A lot has changed since then. With the addition of Async/Await and Actors, it’s now easier and more fun than ever to design custom web API clients in Swift. The new version went through a radical redesign and I can’t wait to share more about it.

I’m going to focus on REST APIs and use GitHub API as an example. Before I jump in, here is a quick look at the final result:

let client = APIClient(host: "api.github.com")

// Using the client directly
let user: User = try await client.send(.get("/user"))
try await client.send(.post("/user/emails", body: ["kean@example.com"]))

// Using a predefined API definition
let repos = try await client.send(Resources.users("kean").repos.get)

The code from this article is available at kean/APIClient.

Overview #

Every backend has its quirks and usually requires a client optimized for it. This article is a collection of ideas that you can use for writing your clients. The goal is to use the minimum number of abstractions and make the code easy to understand and extend.

The previous version of the client was built using Alamofire and RxSwift. It was a good design at the time, but with the recent Swift changes, I don’t think you need dependencies anymore. I’m going with Apple technologies exclusively for this project: URLSession, Codable, Async/Await, and Actors.

Implementing a Client #

Let’s start by defining a type for representing requests. It can be as complicated as you need, but this is a good starting point:

public struct Request<Response> {
    var method: String
    var path: String
    var query: [String: String]?
    var body: AnyEncodable?
}

To make it easier to define REST APIs, let’s add a few factory methods which are also going to help us erase generic types that we don’t need to carry around:

extension Request {
    public static func get(_ path: String, query: [String: String]? = nil) -> Request {
        Request(method: "GET", path: path, query: query)
    }
    
    public static func post<U: Encodable>(_ path: String, body: U) -> Request {
        Request(method: "POST", path: path, body: AnyEncodable(body))
    }
    
    public static func patch<U: Encodable>(_ path: String, body: U) -> Request {
        Request(method: "PATCH", path: path, body: AnyEncodable(body))
    }
    
    // ...
}

What do Swift developers love more than anything else? Type-safety. By separating each HTTP method, Request stops invalid parameter combinations at compile time. For example, you shouldn’t pass body to GET requests, and URLSession throws an error at runtime if you try. With Request, you can’t pass it.

To execute the requests, you use a client1. It’s a small wrapper on top of URLSession that is easy to modify and extend. You initialize it with a host making it easy to change the environments at runtime.

public actor APIClient {
    private let session: URLSession
    private let host: String
    // ..
    
    public init(host: String,
                configuration: URLSessionConfiguration = .default,
                delegate: APIClientDelegate? = nil) {
        self.host = host
        self.session = URLSession(configuration: configuration)
        self.delegate = delegate ?? DefaultAPIClientDelegate()
    }

There are two types of send() methods – one for Decodable types and one for Void that isn’t decodable.

extension APIClient {
    public func send<T: Decodable>(_ request: Request<T>) async throws -> T {
        try await send(request, serializer.decode)
    }
    
    public func send(_ request: Request<Void>) async throws -> Void {
        try await send(request, { _ in () })
    }

    private func send<T>(_ request: Request<T>, 
                         _ decode: @escaping (Data) async throws -> T) async throws -> T {
        let request = try await makeRequest(for: request)
        let (data, response) = try await send(request)
        try validate(response: response, data: data)
        return try await decode(data)
    }

    // The final implementation is a bit more complicated because it supports auto-retries
    private func send(_ request: URLRequest) async throws -> (Data, URLResponse) {
        try await session.data(for: request, delegate: nil)
    }
}

APIClient takes full advantage of async/await, including the new URLSession async/await APIs. It also performs encoding and decoding on a separate actor reducing the amount of work2 done on the APIClient.

private actor Serializer {
    func encode<T: Encodable>(_ entity: T) async throws -> Data {
        try JSONEncoder().encode(entity)
    }
    
    func decode<T: Decodable>(_ data: Data) async throws -> T {
        try JSONDecoder().decode(T.self, from: data)
    }
}

Initially, I wasn’t sure whether using actors to send work to a different “thread” was a good idea – the primary role of actors is to protect mutable state. But if you watch Swift concurrency: Update a sample app (WWDC21), you’ll see on minute 36 Ben Cohen suggesting replacing a serial DispatchQueue with an actor to perform work in background. It’s not exactly the same thing, because actor runtime doesn’t use GCD – actors have an advantage that they use a cooperative pool of threads. And if you want to parallelize decoding, you’ll need to look for other approaches, e.g. Task.detached.

Getting back to Codable, I think we, as a developer community, have finally tackled the challenge of parsing JSON in Swift. So I’m not going to focus on it. If you want to learn more, I wrote a post a couple of years ago with some Codable tips – most are still relevant today. For example, it has some ideas on encoding PATCH parameters, which is useful for REST APIs.

The rest of the code is relatively straightforward. I’m using URLComponents to create URLs, which is important because it percent-encodes the parts of the URLs that need it.

func makeRequest<T>(for request: Request<T>) async throws -> URLRequest {
    let url = try makeURL(path: request.path, query: request.query)
    return try await makeRequest(url: url, method: request.method, body: request.body)
}

func makeURL(path: String, query: [String: String]?) throws -> URL {
    guard let url = URL(string: path),
          var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
        throw URLError(.badURL)
    }
    if path.starts(with: "/") {
        components.scheme = "https"
        components.host = host
    }
    if let query = query {
        components.queryItems = query.map(URLQueryItem.init)
    }
    guard let url = components.url else {
        throw URLError(.badURL)
    }
    return url
}

The client works with JSON, so its sets the respective “Content-Type” and “Accept” HTTP header values automatically.

func makeRequest(url: URL, method: String, body: AnyEncodable?) async throws -> URLRequest {
    var request = URLRequest(url: url)
    request.httpMethod = method
    if let body = body {
        request.httpBody = try await serializer.encode(body)
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    }
    request.setValue("application/json", forHTTPHeaderField: "Accept")
    return request
}

Being able to use async functions right inside the makeRequest() method without all the boilerplate of using closures is just pure joy.

The only remaining bit is a validate() method where I check that the response status code is acceptable. It also gives a delegate a chance to decide what error to throw. Most APIs will have a standard JSON format for errors – this is a good place to parse it.

 func validate(response: URLResponse, data: Data) throws {
    guard let httpResponse = response as? HTTPURLResponse else { return }
    if !(200..<300).contains(httpResponse.statusCode) {
        throw delegate.client(self, didReceiveInvalidResponse: httpResponse, data: data)
    }
}

And that’s it, a baseline APIClient. It already covers most of the needs of most users and there many ways to extend it.

Extending the Client #

Getting the basics right is usually easy, but what about more advanced use-cases?

User Authorization #

Every authorization system has its quirks. If you use OAuth 2.0 or a similar protocol, you need to send an access token with every request. One of the common ways is by setting an “Authorization” header.

One of the advantages of Alamofire is the infrastructure for adapting and retrying requests which is often used for authorization. Reimplementing it with callbacks is no fun, but with async/await, it’s a piece of cake.

The first piece is the client(_:willSendRequest:) delegate method that you can use to “sign” the requests.

final class YourAPIClientDelegate: APIClientDelegate {
    func client(_ client: APIClient, willSendRequest request: inout URLRequest) {
        request.setValue("Bearer: \(accessToken)", forHTTPHeaderField: "Authorization")
    }
}

If you look at setValue(_:forHTTPHeaderField:) documentation, you’ll see a list of Reserved HTTP Headers that you shouldn’t set manually. “Authorization” is one of them… URLSession supports seemingly every authorization mechanism except the most popular one. Setting an “Authorization” header manually is still the least worst option.

The client(_:willSendRequest:) method is also a good way to provide default headers, like “User-Agent”. But you can also provide fields that don’t change using httpAdditionalHeaders property of URLSessionConfiguration.

If your access tokens are short-lived, it is important to implement a proper refresh flow. Again, easy with async/await.

extension APIClient {
    private func send(_ request: URLRequest) async throws -> (Data, URLResponse) {
        do {
            return try await actuallySend(request) 
        } catch {
            guard await delegate.shouldClientRetry(self, withError: error) else { throw error }
            return try await actuallySend(request)
        }
    }

    private func actuallySend(_ request: URLRequest) async throws -> (Data, URLResponse) {
        var request = request
        delegate.client(self, willSendRequest: &request)
        return try await session.data(for: request, delegate: nil)
    }
}

I count four suspension points. Now think how more complicated and error-prone it would’ve been to implement it with callbacks.

Now all you need is to implement a shouldClientRetry(_:withError:) method in your existing delegate and add the token refresh logic.

final class YourAPIClientDelegate: APIClientDelegate {
    func shouldClientRetry(_ client: APIClient, withError error: Error) async -> Bool {
        if case .unacceptableStatusCode(let status) = (error as? YourError), status == 401 {
            return await refreshAccessToken()
        }
        return false
    }
    
    private func refreshAccessToken() async -> Bool {
        // TODO: Refresh access token
    }
}

The client might call shouldClientRetry(_:withError:) multiple times (once for each failed request). Make sure to coalesce the requests to refresh the token and handle the scenario with an expired refresh token.

I didn’t show this code in the original APIClient implementation to not over-complicate things, but the project you find at GitHub already supports it.

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.

Client Authorization #

On top of authorizing the user, most services will also have a way of authorizing the client. If it’s an API key, you can set it using the same way as an “Authorization” header. You may also want to obfuscate it, but remember that client secrecy is impossible.

Another less common but more interesting approach is mTLS (mutual TLS) where it’s not just the server sending a certificate – the client does too. One of the advantages of using certificates is that the secret (private key) never leaves the device.

URLSession supports mTLS natively and it’s easy to implement, even when using the new async/await API (thanks, Apple!).

final class YourTaskDelegate: URLSessionTaskDelegate {
    func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge)
        async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        let protectionSpace = challenge.protectionSpace
        if protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {
            // You'll probably need to create it somewhere else.
            let credential = URLCredential(identity: ..., certificates: ..., persistence: ...)
            return (.useCredential, credential)
        } else {
            return (.performDefaultHandling, nil)
        }
    }
}

The main challenge with mTLS is getting the private key to the client. You can embed an obfuscated .p12 file in the app, making it hard to discover, but it’s still not impenetrable.

SSL Pinning #

The task delegate method for handling the server challenges is also a good place for implementing SSL pinning.

There are multiple ways how to approach this, especially when it comes to what to pin. If you pin leaf certificates, you sign up for constant maintenance because certificates need to be rotated. The simplest option seems to be pinning CA certificates or public keys. Starting with iOS 14, it can be done very easily by adding the keys to the app’s plist file.

HTTP Caching #

Caching is a great way to improve application performance and end-user experience. Developers often overlook HTTP cache natively supported by URLSession To enable HTTP caching the server sends special HTTP headers along with the request. Here is an example:

HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
Expires: Mon, 26 Jan 2016 17:45:57 GMT
Last-Modified: Mon, 12 Jan 2016 17:45:57 GMT
ETag: "686897696a7c876b7e"

This response is cacheable and will be fresh for 1 hour. When it becomes stale, the client validates it by making a conditional request using the If-Modified-Since and/or If-None-Match headers. If the response is still fresh the server returns status code 304 Not Modified to instruct the client to use cached data, or it would return 200 OK with a new data otherwise.

By default, URLSession uses URLCache.shared with a small disk and memory capacity. You might not know it, but already be taking advantage of HTTP caching.

HTTP caching is a flexible system where both the server and the client get a say over what gets cached and how. With HTTP, a server can set restrictions on which responses are cacheable, set an expiration age for responses, provide validators (ETag, Last-Modified) to check stale responses, force revalidation on each request, and more.

URLSession (and URLCache) support HTTP caching out of the box. There is a set of requirements for a response to be cached. It’s not just the server that has control. For example, you can use URLRequest.CachePolicy to modify caching behavior from the client. You can easily extend APIClient to support it if needed.

extension APIClient {
    func send<Response>(
        _ request: Request<Response>,
        cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
    ) async throws -> Response where Response: Decodable {
        // Pass the policy to the URLRequest
    }
}

Downloads #

By default, the send() method of APIClient uses data(for:delegate:) method from URLSession, but with the existing design, you can easily extend it to support other types of tasks, such as downloads.

extension APIClient {
    public func download(_ request: Request<Void>) async throws -> (URL) {
        let request = try await makeRequest(for: request)
        let (url, response) = try await session.download(for: request, delegate: nil)
        try validate(response: response)
        return url
    }
}

And if the Request type is not working for you, it’s easy to extend too. You can also just as easily add new request types, or even URLRequest directly if you need to. Although for a typical REST API, Request is all you are ever going to need.

Environments #

APIClient is initialized with a host, making it easy to switch between the environments in runtime. I typically have a debug menu in the apps with all sorts of debug settings, including an environment picker – it’s fast and easy to build with SwiftUI. I added the code generating this screen in a gist.

Defining an API #

For smaller apps, using APIClient 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 the rest of the code and remove possible duplication.

I’ve tried a few different approaches for defining APIs using APIClient, but couldn’t decide which one was the best. They all have their pros and cons and are often just a matter of personal preference.

REST APIs are designed around resources. One of the ideas I had was to create a separate type to represent each of the resources and expose HTTP methods that are available on them. It works best for APIs that closely follow (REST API design. GitHub API is a great example of a REST API, so that’s why I used it in the examples.

public enum Resources {}

// MARK: - /users/{username}

extension Resources {
    public static func users(_ name: String) -> UsersResource {
        UsersResource(path: "/users/\(name)")
    }
    
    public struct UsersResource {
        public let path: String

        public var get: Request<User> { .get(path) }
    }
}

// MARK: - /users/{username}/repos

extension Resources.UsersResource {
    public var repos: ReposResource { ReposResource(path: path + "/repos") }
    
    public struct ReposResource {
        public let path: String

        public var get: Request<[Repo]> { .get(path) }
    }
}

Usage:

let repos = try await client.send(Resources.users("kean").repos.get)

This API is visually appealing, but it can be a bit tedious to write and less discoverable than simply listing all available calls. I’m also still a bit cautious about over-using nesting. I used to avoid it in the past, but the recent improvements to the Xcode code completion made working with nested APIs much easier. But again, this is just an example.

I’ve seen suggestions to model APIs as an enum where each propery has a separate switch. This isn’t ideal because you are setting yourself for merge conflicts, and it’s harder to read and modify than other approaches. When you add a new call, you should ideally only need to make a change in one place.

Tools #

It’s not just Swift that’s getting better. The modern tooling is also fantastic.

OpenAPI #

Remember the infamous Working with JSON in Swift post from 2016? Yeah, we are way ahead now. Ask your backend developers to provide OpenAPI spec for their APIs and use code generation to create Codable entities.

Postman and Paw are great ways to explore APIs. You can import an OpenAPI spec and it will generate a Collection for you so that you have all the APIs always at your fingertips. GitHub API that’s I’m using in the examples also recently git its own OpenAPI spec. You can download it and import it into Postman to try it.

Generating Codable entities is also extremely easy. There are a ton of tools available for working with OpenAPI specs. For example, you can use swagger-codegen. It supports all popular languages, including Swift. Here is an example of one of the generated types.

public struct PublicUser: Codable {
    public var login: String
    public var id: Int
    public var nodeId: String
    // ...

    public init(login: String, id: Int, nodeId: String, /* ... */) {
        self.login = login
        self.id = id
        self.nodeId = nodeId
        // ...
    }

    public enum CodingKeys: String, CodingKey { 
        case login
        case id = "id"
        case nodeId = "node_id"
        // ...
}

By default, it generates structs, which is not always ideal – passing large structs around can be expensive. Fortunately, there is a way to generate classes instead (see useModelClasses option):

swagger-codegen generate \
    -i api.github.com.yaml \
    -o ~/github-api
    -l swift5
    --additional-properties useModelClasses=true

By default, swagger-codegen generates not just the Codable entities but also attempts to generate an entire API client. Unfortunately, it leaves much to be desired. I suggest only using it for generating entities.

If you don’t have an OpenAPI spec, you can turn a sample JSON into a Codable struct using quicktype.io and tweak it.

Logging #

Pulse is a powerful logging system for Apple Platforms. In addition to regular messages, it records URLSession tasks and allows you to inspect logs right from your iOS app using Pulse Console. You can also share and view them in a dedicated macOS app.

It’s easy to integrate Pulse in an APIClient by modifying the send() method and providing a custom task delegate. The advantage of doing it at that level is that you don’t need to worry about TLS and SSL pinning, and you collect more information than a typical network proxy does thanks to the direct access to URLSession.

import PulseCore

public actor APIClient {
    private let logger = LoggerStore.default

    public func send(_ request: URLRequest) async throws -> (Data, URLResponse) {
        let delegate = TaskDelegate()
        func log(response: URLResponse? = nil, data: Data? = nil, error: Error? = nil) {
            logger.storeRequest(delegate.currentRequest ?? request, response: response,
                error: error, data: data, metrics: delegate.metrics)
        }
        do {
            let (data, response) = try await session.data(for: request, delegate: delegate)
            log(response: response, data: data)
            return (data, response)
        } catch {
            log(error: error)
            throw error
        }
    }
}

private final class TaskDelegate: NSObject, URLSessionTaskDelegate {
    var currentRequest: URLRequest?
    var metrics: URLSessionTaskMetrics?
        
    func urlSession(_ session: URLSession, task: URLSessionTask,
                    didFinishCollecting metrics: URLSessionTaskMetrics) {
        self.currentRequest = task.currentRequest
        self.metrics = metrics
    }
}

For a complete guide on using Pulse, see the official documentation.

Network Debugging Proxies #

Tools like Proxyman or Charles are indispensable for debugging your apps because they allow you not only to inspect the traffic but also manipulate the requests and responses without changing your app’s code.

Mocking #

My preferred way of testing ViewModels is by writing integration tests where I mock only the network responses. These tests are easy to write and maintain and they give you a lot of confidence in your app.

There are a many mocking tools to chose from. I used Mocker for unit-testing APIClient itself.

private let host = "api.github.com"

override func setUp() {
    super.setUp()
    
    let configuration = URLSessionConfiguration.default
    configuration.protocolClasses = [MockingURLProtocol.self]

    client = APIClient(host: host, configuration: configuration)
}

func testGet() async throws {
    // Given
    let url = URL(string: "https://\(host)\(Resources.user.get.path)")!
    Mock(url: url, dataType: .json, statusCode: 200, data: [
        .get: json(named: "user")
    ]).register()
    
    // When
    let user = try await client.send(Resources.user.get)
                                        
    // Then
    XCTAssertEqual(user.login, "kean")
}

cURL #

cURL doesn’t need an introduction. There is an extension to URLRequest that I borrowed from Alamofire that I love. It creates a cURL command for URLRequest.

Ideally, you should call cURLDescription on task’s currentRequest - it’s has all of the cookies and additional HTTP headers automatically added by the system.

Final Thoughts #

Before I started using Async/Await in Swift, I thought it was mostly syntax sugar, but oh how wrong I was. The way Async/Await is integrated into the language is just brilliant. It solves a bunch of common problems in an elegant way and I barely touched the surface in this article. For example, I’m particularly excited about the new threading model that can reduce or eliminate timesharing of threads. In the future async APIs should be defined exclusively using async functions – bye callbacks, bye [weak self].

Actors take full advantage of Async/Await and the new threading model. It’s just as amazing of an addition to the language, and it solves a very common problem. It’s a bit unfortunate that in the case of the API client, I only used them to move work to the background, but there was no mutable state to protect.

If you want to learn more about Async/Await and Structured Concurrency, look no further than this year WWDC session videos. They are all fantastically well made, and, if you want to dive a bit deeper, read the Swift Evolution proposals.

If you just look at the surface level, let’s see how much code I wrote to implement most of the features from this article:

$ cloc Sources/APIClient/.
       2 text files.
       2 unique files.
       0 files ignored.

github.com/AlDanial/cloc v 1.90  T=0.01 s (305.1 files/s, 17795.7 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Swift                            3             26             11            148
-------------------------------------------------------------------------------
SUM:                             3             26             11            148
-------------------------------------------------------------------------------

It is not a direct comparison, but Alamofire version 5.4.4 has 7428 lines. This is bonkers. And to think how far we’ve come in just about 10 years – remember ASIHTTPRequest?

References

  1. The client is defined as an actor, but in this case, it doesn’t have to be – in the sample code there is no mutable state to protect. But by making it actor, I make sure the requests are created in started in the background. Based on my performance testing in Nuke, creating requests is a relatively expensive operation and it can be advantageous to move it out of the main thread. In the case of the client, it’s just a matter of changing “class” to “actor” – the rest of the APIs are already async, so there is no change in the APIs needed. But it can be swapped out back to “class”. 

  2. By making a serializer a separate actor, I make sure that it performs the work in the background and, if the work is expensive, doesn’t delay other network requests from being started.