I walk through my favorite REST networking layer, and why I prefer it.

Motivation

A great networking layer is hard to come by. Some people pull in 3rd party dependencies with interceptors and lots of layers of abstraction, and some just use URLSession and GCD. I personally don't like either option, at least for simple REST calls. This article will be a bit long, but I'll walk you through my preferred networking layer and explain why I like it.

NOTE: All code for this example can be found on my GitHub random projects page

GCD, Combine, or Async/Await

Let's be honest, Grand Central Dispatch (GCD) is kind of a mess. The closure-based APIs aren't very friendly and they're error-prone. Most people who write asynchronous operations using GCD don't even consider cancellation. GCD's quirkiness is why you see network layers with interceptor patterns. This was fine a few years ago, but I think we can do better.

async/await is fraught with perils and people don't often immediately notice them. This is especially true with the cooperative cancellation paradigm, which requires you to be smart about checking whether a task has been cancelled frequently (ideally, after every await boundary).

This is why I prefer Combine, Apple's reactive framework. Its declarative interface, cancellation model, and flexibility with backpressure are incredibly useful when designing a networking layer. I would argue that it is still preferable to async/await. Although I would use async/await for on-device concurrency concerns.

What's more, Combine forces users to store an AnyCancellable, and the most common methods of storing them result in appropriate cancellation. For example, if you store a Set<AnyCanellable> on a UIViewController or SwiftUI @StateObject, they are all cancelled when the view is removed from the hierarchy. So, if a user were to hit the "back" button in a navigation stack, for example, all ongoing requests for that view would simply cancel.

Service design

Ideally, other parts of the code utilize the network layer through a service). For example, if I had an API that stored and retrieved posts on a forum, I'd create a PostService that returned deserialized Post objects. Other parts of my code would ask the PostService for things, and it would either reach out over the network, pull from a cache, or any other number of things.

To that end, our network layer should make it easy for a service to use it with extreme flexibility, but not expose things outside of those services. I think a protocol is a great way of handling this. What if we had something like this:

protocol PostService {
    var getPosts: AnyPublisher<Result<[Post], Error>, Never> { get }
}

struct _PostService: RESTAPIProtocol, PostService {
    var baseURL = "https://api.myforum.com"
    var getPosts: AnyPublisher<Result<[Post], Error>, Never> {
        self.get(endpoint: "posts") { request in 
            request
            .addingBearerAuthorization(accessToken: User.shared.accessToken)
            .receivingJSON()
        }
        .catchHTTPErrors()
        .catchUnauthorizedAndRetryRequestWithFreshAccessToken()
        .map(\.data)
        .decode(type: [Post].self, decoder: JSONDecoder())
        .map(Result.success)
        .catch { Just(.failure($0)) }
        .eraseToAnyPublisher()
    }
}

Consumers only know about PostService which exposes a way to get posts, but the service itself (_PostService) knows lots of details, like the base URL, endpoint, the fact it needs authentication, it can handle backpressure issues like receiving a 401 and retrying the request, it knows we're using REST and JSON and it knows how to deserialize into an array of Post.

Creating a RESTAPIProtocol

One quirk of making requests with Swift is that you get a URLResponse which needs to be converted into an HTTPURLResponse to check things like the status code. To make this easier, our protocol should return an HTTPURLResponse.

Let's start with a simple protocol definition:

import Foundation
import Combine

public protocol RESTAPIProtocol {
    typealias ErasedHTTPDataTaskPublisher = AnyPublisher<(data: Data, response: HTTPURLResponse), Error>
    typealias Output = ErasedHTTPDataTaskPublisher.Output
    typealias Failure = ErasedHTTPDataTaskPublisher.Failure

    var baseURL: String { get }
    var urlSession: URLSession { get }
}

@available(iOS 13.0, macOS 11.0, tvOS 13.0, watchOS 7.0, *)
extension RESTAPIProtocol {
    public var urlSession: URLSession { URLSession.shared }

    public func get(endpoint: String) -> ErasedHTTPDataTaskPublisher {
        guard let url = URL(string: "\(baseURL)")?.appendingPathComponent(endpoint) else {
            return Fail<Output, Failure>(error: URLError(.badURL)).eraseToAnyPublisher()
        }
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        return createPublisher(for: request)
    }

    // Other verbs, put/post/patch/delete

    func createPublisher(for request: URLRequest) -> ErasedHTTPDataTaskPublisher {
        Just(request)
            .flatMap { [urlSession] in
                urlSession.dataTaskPublisher(for: $0)
            }
            .tryMap {
                guard let res = $0.response as? HTTPURLResponse else {
                    throw URLError(.badServerResponse)
                }
                return (data: $0.data, response: res)
            }
            .eraseToAnyPublisher()
    }
}

Our protocol now exposes a way to make GET requests... but it doesn't allow people to modify the outgoing request. Consumers of our protocol want 2 specific behaviors:

  • The ability to modify a request before it is sent.
  • If the publisher retries (like when a 401 is returned) then the request modifier should be recalculated.
    • To expand on this idea, look at the example in our PostService when the publisher chain restarts the new access token needs to be used, not the old one.

Because Just is a little fiddly, anything we put in there will be cached, we need to be a little bit clever. Let's modify RESTAPIProtocol

public protocol RESTAPIProtocol {
    typealias RequestModifier = ((URLRequest) -> URLRequest)
    ...
}

@available(iOS 13.0, macOS 11.0, tvOS 13.0, watchOS 7.0, *)
extension RESTAPIProtocol {
    ...

    public func get(endpoint: String, requestModifier: @escaping RequestModifier = { $0 }) -> ErasedHTTPDataTaskPublisher {
        ...
        return createPublisher(for: request, requestModifier: requestModifier)
    }

    func createPublisher(for request: URLRequest, requestModifier: @escaping RequestModifier) -> ErasedHTTPDataTaskPublisher {
        Just(request)
            .flatMap { [urlSession] in
                urlSession.dataTaskPublisher(for: requestModifier($0))
            }
            ...
    }
}

Now consumers can modify a request just like in our PostService example. Calculating the requestModifier in the flatMap gives us the behavior we want when the chain is restarted.

Fluent request modification

You may have noticed that in my proposed service, we used a fluent API. This not only fits well with Combine, which is already fluent, but it makes it easy to compose sets of headers. Here's how we can do that:

extension URLRequest {
    public func addingValue(_ value: String, forHTTPHeaderField header: String) -> URLRequest {
        var request = self
        request.setValue(value, forHTTPHeaderField: header)
        return request
    }
}

This also has the advantage of not mutating the original request, avoiding mutation where we can is generally of great benefit. You'll notice the existing URLRequest is copied into a new variable, then that is modified using Apple APIs.

Error handling

We can create a series of HTTPError types, some for 400-499 HTTPClientError types and some for 500-599 HTTPServerError types. These can even peak into a request and find standard headers that give more error info. For example, a 429 usually comes with a Retry-After header indicating how long you should wait before attempting the request again.

Once those error types are created, we can create a Combine modifier that handles them, here's an example:

extension Publisher {
    public func catchHTTPErrors() -> Publishers.TryMap<Self, Output> where Output == RESTAPIProtocol.Output {
        tryMap {
            guard let err: any HTTPError = HTTPClientError(code: UInt($0.response.statusCode)) ?? HTTPServerError(code: UInt($0.response.statusCode)) else {
                return $0
            }

            if $0.response.statusCode == 429 {
                throw HTTPClientError.tooManyRequests(retryAfter: $0.response.retryAfter)
            }

            throw err
        }
    }
}

Users may also want to be able to catch specific kinds of errors, which Combine doesn't quite allow on its own. This gives them the ability to add custom logic to the request chain. Here's an example that responds to rate limiting (a 429)

extension Publisher {
    public func tryCatch<E: Error & Equatable,
                         P: Publisher>(_ error: E,
                                       _ handler: @escaping (E) throws -> P) -> Publishers.TryCatch<Self, P> where Failure == Error {
        tryCatch { err in
            guard let unwrappedError = (err as? E),
                    unwrappedError == error else { throw err }
            return try handler(unwrappedError)
        }
    }

    public func respondToRateLimiting(maxSecondsToWait: Double = 1) -> AnyPublisher<Output, Failure> where Output == RESTAPIProtocol.Output, Failure == Error {
        catchHTTPErrors()
            .tryCatch(HTTPClientError.tooManyRequests()) { err -> AnyPublisher<Output, Failure> in
                guard case .tooManyRequests(let retryAfter) = err else {
                    throw err // shouldn't ever really happen
                }

                let delayInSeconds: Double = {
                    if let serverDelay = retryAfter?.converted(to: .seconds).value,
                       serverDelay < maxSecondsToWait {
                        return serverDelay
                    }
                    return maxSecondsToWait
                }()

                return Just(()).delay(for: .seconds(delayInSeconds),
                                      scheduler: DispatchQueue.global(qos:.userInitiated),
                                      options: nil)
                .flatMap { _ in self.catchHTTPErrors() }
                .eraseToAnyPublisher()
            }
            .eraseToAnyPublisher()
    }
}

There are a few complicated Combine-type things to learn, but look at just how easy it is to handle rate limiting! No interceptors and complex retry logic, just a simple combination of existing Combine operators. I'll leave it as an exercise for the reader to imagine how you could add even more flexibility (like retrying on a 401) to this. Alternatively, check out the GitHub repo to see an example.

Testing

Okay, so while reactive programming might be new to people, this whole layer isn't too intimidating. But how hard is it to test? I personally use OHTTPStubs and create my own fluent wrapper around it to make this dead simple.

Let's start by defining a Combine test helper:

extension Publisher {
    func firstValue(timeout: TimeInterval = 0.3,
                    file: StaticString = #file,
                    line: UInt = #line) async -> Result<Output, Error> where Failure == Error {
        await withCheckedContinuation { continuation in
            var result: Result<Output, Error>?
            let expectation = XCTestExpectation(description: "Awaiting publisher")

            let cancellable = map(Result<Output, Error>.success)
                .catch { Just(.failure($0)) }
                .sink {
                    result = $0
                    expectation.fulfill()
                }

            XCTWaiter().wait(for: [expectation], timeout: timeout)
            cancellable.cancel()

            do {
                let unwrappedResult = try XCTUnwrap(
                    result,
                    "Awaited publisher did not produce any output",
                    file: file,
                    line: line
                )
                continuation.resume(returning: unwrappedResult)
            } catch {
                continuation.resume(returning: .failure(error))
            }
        }
    }
}

Now I'll show you how easy it is to test our rate-limiting logic:

import Foundation
import Combine
import XCTest

import OHHTTPStubs
import OHHTTPStubsSwift

import RESTNetworkLayer

final class HTTPOperatorsTests: XCTestCase {
    struct JSONPlaceholder: RESTAPIProtocol {
        var baseURL: String = "https://jsonplaceholder.typicode.com"
    }

    override func setUpWithError() throws {
        HTTPStubs.removeAllStubs()

        stub { _ in true } response: { req in
            XCTFail("Unexpected request made: \(req)")
            return HTTPStubsResponse(error: URLError.init(.badURL))
        }
    }

    func testCatchingTooManyRequests() async throws {
        let url = try XCTUnwrap(URL(string: "https://www.google.com"))

        let error = HTTPClientError.tooManyRequests()

        let response = try XCTUnwrap(HTTPURLResponse(url: url,
                                                     statusCode: Int(error.statusCode),
                                                     httpVersion: nil,
                                                     headerFields: ["Retry-After": "1.5"]))

        let result = await Just((data: Data(), response: response))
            .setFailureType(to: Error.self)
            .catchHTTPErrors()
            .firstValue()

        guard case .failure(let failure) = result else {
            XCTFail("Publisher succeeded, expected failure with HTTPError")
            return
        }

        guard let actualError = failure as? (any HTTPError) else {
            XCTFail("Error: \(failure) thrown by publisher was not an HTTPError")
            return
        }

        XCTAssertEqual(actualError.statusCode, error.statusCode)

        if case HTTPClientError.tooManyRequests(.some(let retryAfter)) = actualError {
            XCTAssertEqual(retryAfter.converted(to: .seconds).value, 1.5)
        } else {
            XCTFail("RetryAfter value not in error.")
        }
    }

    func testRetryAfterServerSpecifiedTime() async throws {
        let json = try XCTUnwrap("""
        [
            {
                userId: 1,
                id: 1,
                title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
                body: "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto"
            },
        ]
        """.data(using: .utf8))
        let retryAfter = Double.random(in: 0.100...0.240)
        let requestDate: Date = Date()
        StubResponse(on: isAbsoluteURLString("https://jsonplaceholder.typicode.com/posts") && isMethodGET()) { _ in
            HTTPStubsResponse(data: Data(), statusCode: Int32(HTTPClientError.tooManyRequests().statusCode), headers: ["Retry-After": "\(retryAfter)"])
        }
        .thenRespond(on: isAbsoluteURLString("https://jsonplaceholder.typicode.com/posts") && isMethodGET()) { _ in
            HTTPStubsResponse(data: json, statusCode: 200, headers: nil)
        }

        let api = JSONPlaceholder()

        let value = try await api.get(endpoint: "posts")
            .respondToRateLimiting()
            .firstValue()
            .get()

        XCTAssertGreaterThan(Date().timeIntervalSince1970 - requestDate.timeIntervalSince1970, Measurement(value: retryAfter, unit: UnitDuration.milliseconds).converted(to: .seconds).value)
        XCTAssertEqual(value.response.statusCode, 200)
        XCTAssertEqual(String(data: value.data, encoding: .utf8), String(data: json, encoding: .utf8))
    }

    func testRespondToRateLimitingOnlyRetriesOnce() async throws {
        let retryAfter = Double.random(in: 0.100...0.300)
        let requestDate: Date = Date()
        StubResponse(on: isAbsoluteURLString("https://jsonplaceholder.typicode.com/posts") && isMethodGET()) { _ in
            HTTPStubsResponse(data: Data(), statusCode: Int32(HTTPClientError.tooManyRequests().statusCode), headers: ["Retry-After": "\(retryAfter)"])
        }
        .thenRespond(on: isAbsoluteURLString("https://jsonplaceholder.typicode.com/posts") && isMethodGET()) { _ in
            HTTPStubsResponse(data: Data(), statusCode: Int32(HTTPClientError.tooManyRequests().statusCode), headers: ["Retry-After": "\(retryAfter)"])
        }
        .thenRespond(on: isAbsoluteURLString("https://jsonplaceholder.typicode.com/posts") && isMethodGET()) { _ in
            XCTFail("Should not have made a 3rd request")
            return HTTPStubsResponse(data: Data(), statusCode: Int32(HTTPClientError.tooManyRequests().statusCode), headers: ["Retry-After": "\(retryAfter)"])
        }

        let api = JSONPlaceholder()

        var publisherRetries = 0
        let result = await api.get(endpoint: "posts")
            .map { val in
                publisherRetries += 1
                return val
            }
            .respondToRateLimiting(maxSecondsToWait: 0)
            .firstValue()

        XCTAssertGreaterThan(Date().timeIntervalSince1970 - requestDate.timeIntervalSince1970, Measurement(value: retryAfter, unit: UnitDuration.milliseconds).converted(to: .seconds).value)
        XCTAssertThrowsError(try result.get()) { error in
            guard let actualError = error as? (any HTTPError) else {
                XCTFail("Error: \(error) thrown by publisher was not an HTTPError")
                return
            }

            XCTAssertEqual(actualError.statusCode, HTTPClientError.tooManyRequests().statusCode)
        }
        XCTAssertEqual(publisherRetries, 2)
    }

    func testRateLimitingShouldDoNothingUnlessCorrectStatusCodeIsGiven() async throws {
        let json = try XCTUnwrap("""
        [
            {
                userId: 1,
                id: 1,
                title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
                body: "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto"
            },
        ]
        """.data(using: .utf8))
        StubResponse(on: isAbsoluteURLString("https://jsonplaceholder.typicode.com/posts") && isMethodGET()) { _ in
            HTTPStubsResponse(data: json, statusCode: 200, headers: nil)
        }

        let api = JSONPlaceholder()

        let value = try await api.get(endpoint: "posts")
            .respondToRateLimiting()
            .firstValue()
            .get()

        XCTAssertEqual(value.response.statusCode, 200)
        XCTAssertEqual(String(data: value.data, encoding: .utf8), String(data: json, encoding: .utf8))
    }

    func testRateLimitingDoesNotRetryIfADifferentErrorIsThrown() async throws {
        var requestCount = 0
        StubResponse(on: isAbsoluteURLString("https://jsonplaceholder.typicode.com/posts") && isMethodGET()) { _ in
            requestCount += 1
            return HTTPStubsResponse(data: Data(), statusCode: 401, headers: nil)
        }

        let api = JSONPlaceholder()

        let result = await api.get(endpoint: "posts")
            .respondToRateLimiting()
            .firstValue()

        XCTAssertThrowsError(try result.get()) {
            XCTAssertEqual($0 as? HTTPClientError, .unauthorized)
        }

        XCTAssertEqual(requestCount, 1)
    }
}

There may be a lot of code, but each test is actually quite simple and understandable.

Wrapping up

It's fair to say this probably isn't a beginner-level networking layer. But the power and flexibility of Combine, coupled with the cancellation model make it a really useful tool. This article certainly didn't cover all the details, check out the git repo to see even more of how it all came together.

post-image
Tyler Thompson

Tyler Thompson is a Principal Engineer with over 15 years experience. He currently works as a Principal Software Development Engineer for Zillow Group. Before working at Zillow he was a Principal Software Engineer for World Wide Technology and worked across many different industries.