How to implement universal networking module in Swift (pt.2)

Let’s pick up where we left off!

So far in part 1 we’ve discussed how to make a universal and robust networking client. Now, let’s take a look at how we can make it even better.

Arguably, the most important feature of any universal tool is extendibility. We cannot account for all the use cases our module would be used in. We must allow the users to extend the built-in networking client functionalities.

Next, you’ve probably noticed that the networking client relies on callback-based API. It’s perfectly fine in legacy projects, but some users might want to leverage modern, async/await or Combine-based API.

Finally, it’s always appreciated to share your knowledge with the community. We’ll take a look at the requirements and good practices related to making our tool an open-source

Let's do this!

Extendibility is the key!

No matter how thoroughly we designed the networking module, there always will be an unforeseen situation that the users would have to handle. To list just a few:

  • Adding an access token to selected requests.
  • Adding API rate limitation cookies to requests not requiring an access token.
  • Reading and storing an updated access token from the backend response.

What do all of these cases have in common? Modifying selected requests before they are sent, and / or processing a response to get some data out of it. Does this sound familiar to you? Yes! It’s a calling card of the Command design pattern.

If we can properly define a protocol describing such commands, we could offer the users a convenient option to extend the built-in behavior of the Network Module. Let’s to just that:

public protocol NetworkModuleAction: AnyObject {

    func performBeforeExecutingNetworkRequest(request: NetworkRequest?, urlRequest: inout URLRequest)

    func performAfterExecutingNetworkRequest(request: NetworkRequest?, networkResponse: NetworkResponse)
}

And an empty default implementation:

extension NetworkModuleAction {

    public func performBeforeExecutingNetworkRequest(request: NetworkRequest?, urlRequest: inout URLRequest) {}

    public func performAfterExecutingNetworkRequest(request: NetworkRequest?, networkResponse: NetworkResponse) {}
}

As you can see, we defined a method that the Network Module should call before sending every request, and one executed after receiving a response. To keep things simple, the empty default implementations allow users to focus only on implementing actions they really need. 

Please keep in mind one thing though: the order in which these actions will be executed is important! The output of the first command will be the input to the second command, etc. 

Finally, let’s explore arguably the most sought-after addition to the Network Module – an Action inserting an access token to selected requests:

public final class AddAuthenticationTokenNetworkModuleAction: NetworkModuleAction {

    public func performBeforeExecutingNetworkRequest(request: NetworkRequest?, urlRequest: inout URLRequest) {
        guard request?.requiresAuthenticationToken == true else {
            return
        }

        let authenticationToken = authenticationTokenProvider.authenticationToken
        var headerFields = urlRequest.allHTTPHeaderFields ?? [:]
        headerFields[authenticationTokenHeaderFieldName] = authenticationToken
        urlRequest.allHTTPHeaderFields = headerFields
    }
}

As you can see, we leverage the requiresAuthenticationToken property of a NetworkRequest abstraction. If a request requires an access token, we retrieve it from the AccessTokenProvider and add it to the outgoing URLRequest header. Both: the AccessTokenProvider and the header field name must be provided when initializing the Action

Modern APIs? No problem!

If you made it this far, you might be scratching your head asking: where are all these modern APIs URLSession provides? Returning a callback with a Result object is soooo 2018…

And you are absolutely right! Let’s add some spice to our Network Module introducing a Combine-based API. But before we write any code, let’s think for a moment on how this API will be consumed. At least some users are not into reactive programming. I think it’s fair to assume that they might prefer using the “standard” API. But how can we allow only a selected group of users an access to the reactive API?

The answer is simple. We’ll create a separate SPM product exposing the API that only these users would need. And to ensure no code duplication, we’ll make this component depend on the code we already have:

let package = Package(
    name: "NgNetworkModule",
    
    products: [
        .library(
            name: "NgNetworkModuleCore",
            targets: ["NgNetworkModuleCore"]
        ),
        .library(
            name: "ReactiveNgNetworkModule",
            targets: ["ReactiveNgNetworkModule"]
        )
    ],
    dependencies: [],
    targets: [
        .target(
            name: "NgNetworkModuleCore",
            dependencies: []
        ),
        .target(
            name: "ReactiveNgNetworkModule",
            dependencies: ["NgNetworkModuleCore"]
        )
        ...
    ]
)

As a result, to use the Network Module users would always have to import its Core component. If they want to leverage the reactive API as well, they would have to import the ReactiveNgNetworkModule component as well. Plain and simple!

import NgNetworkModuleCore
import ReactiveNgNetworkModule

Let’s take a look at the implementation of this reactive API:

public extension NetworkModule {

    func perform(urlRequest: URLRequest) -> any Publisher<NetworkResponse, NetworkError> {
        Publishers.NetworkResponsePublisher(urlRequest: urlRequest, networkModule: self)
    }

    func perform(request: NetworkRequest) -> any Publisher<NetworkResponse, NetworkError> {
        Publishers.NetworkResponsePublisher(request: request, networkModule: self)
    }

    func performAndDecode<T: Decodable, Coder: TopLevelDecoder>(
        urlRequest: URLRequest,
        responseType: T.Type,
        decoder: Coder = JSONDecoder()
    ) -> any Publisher<T, NetworkError> where Coder.Input == Data {
        perform(urlRequest: urlRequest).handleAndDecode(to: responseType, decoder: decoder)
    }

    func performAndDecode<T: Decodable, Coder: TopLevelDecoder>(
        request: NetworkRequest,
        responseType: T.Type,
        decoder: Coder = JSONDecoder()
    ) -> AnyPublisher<T, NetworkError> where Coder.Input == Data {
        perform(request: request).handleAndDecode(to: responseType, decoder: decoder)
    }
}

Where:

extension Publisher where Output == NetworkResponse, Failure == NetworkError {

    func handleAndDecode<T: Decodable, Coder: TopLevelDecoder>(
        to responseType: T.Type,
        decoder: Coder = JSONDecoder()
    ) -> AnyPublisher<T, NetworkError> where Coder.Input == Data {
        tryMap { response -> Data in
            guard let data = response.data else {
                throw NetworkError.noResponseData
            }
            return data
        }
        .decode(type: responseType, decoder: decoder)
        .mapError { error in
            switch error {
            case is NetworkError:
                return error as? NetworkError ?? NetworkError.unknown
            case is DecodingError:
                return NetworkError.responseParsingFailed
            default:
                return NetworkError.unknown
            }
        }
        .eraseToAnyPublisher()
    }
}

As you can see, all the Reactive API does is to wrap the Core API in a Combine Publisher and return it to the user. As always, we stay true to the tolerant API approach, allowing users to select the type of request and response (decoded or not) they prefer.

That’s all nice, but what if I wanted to use modern Swift Concurrency instead? The choice between old callbacks and reactive programming is not the most appealing one. Not to some people at least…

Nothing can be simpler! Just create another SPM product, exposing another API:

let package = Package(
    name: "NgNetworkModule",
    
    products: [
        .library(
            name: "NgNetworkModuleCore",
            targets: ["NgNetworkModuleCore"]
        ),
        .library(
            name: "ReactiveNgNetworkModule",
            targets: ["ReactiveNgNetworkModule"]
        ),
        .library(
            name: "ConcurrentNgNetworkModule",
            targets: ["ConcurrentNgNetworkModule"]
        )
    ],
    dependencies: [],
    targets: [
        .target(
            name: "NgNetworkModuleCore",
            dependencies: []
        ),
        .target(
            name: "ReactiveNgNetworkModule",
            dependencies: ["NgNetworkModuleCore"]
        ),
        .target(
            name: "ConcurrentNgNetworkModule",
            dependencies: ["NgNetworkModuleCore"]
        )
        
    ]
)

As for the implementation, it’d be even simpler as all we need to do is to surround a call to the classic API in a thread-safe wrapper.

public extension NetworkModule {

    func perform(urlRequest: URLRequest) async throws -> NetworkResponse {
        try await withCheckedThrowingContinuation { continuation in
            perform(urlRequest: urlRequest) { [weak self] result in
                self?.handle(networkCallResult: result, continuation: continuation)
            }
        }
    }

    func perform(request: NetworkRequest) async throws -> NetworkResponse {
        try await withCheckedThrowingContinuation { continuation in
            perform(request: request) { [weak self] result in
                self?.handle(networkCallResult: result, continuation: continuation)
            }
        }
    }

    func performAndDecode<T: Decodable>(
        urlRequest: URLRequest,
        responseType: T.Type,
        decoder: JSONDecoder = JSONDecoder()
    ) async throws -> T {
        try await withCheckedThrowingContinuation { continuation in
            performAndDecode(
                urlRequest: urlRequest,
                responseType: responseType,
                decoder: decoder
            ) { [weak self] result in
                self?.handle(networkCallResult: result, continuation: continuation)
            }
        }
    }

    func performAndDecode<T: Decodable>(
        request: NetworkRequest,
        responseType: T.Type,
        decoder: JSONDecoder = JSONDecoder()
    ) async throws -> T {
        try await withCheckedThrowingContinuation { continuation in
            performAndDecode(
                request: request,
                responseType: responseType,
                decoder: decoder
            ) { [weak self] result in
                self?.handle(networkCallResult: result, continuation: continuation)
            }
        }
    }
}

And:

extension NetworkModule {

    func handle<T>(
        networkCallResult result: Result<T, NetworkError>,
        continuation: CheckedContinuation<T, Error>
    ) {
        switch result {
        case let .success(decodedResponse):
            continuation.resume(returning: decodedResponse)
        case let .failure(error):
            continuation.resume(throwing: error)
        }
    }
}

As a result we’ve managed to create:

  • A robust, extendable Network Module Core
  • Additional, importable SPM products, exposing the same functionality in a form of reactive and concurrent APIs.

Testing networking module without sending a single request

Having a comprehensive Unit Tests suite can be a difference between life or death for your tool. Especially when the library is actively developed by the community. An ability to verify a PR against a possible regression is invaluable. 

Ok, I get it – automated tests have many benefits. How about disadvantages? Testing a networking client inevitably means sending out network requests right? To some kind of backend server. It would not only take its sweet time to respond, but may also throw unexpected errors, rendering the entire test suite unreliable. After all, we should not execute our tests in an environment we don’t fully control

You’re absolutely right! Bad tests are far worse than no tests. But what should we do then? We’ll naturally not be sending any physical network requests. Instead, we’ll try to test how the Network Module interacts with the Network Session. Welcome to the BehaviorBehaviour Driven Development (BDD) 101 🤣

Before we start coding, let’s answer one critical question: how does the Network Module interact with the Network Session? It creates a DataTask to execute a provided URL Request. Let’s write this down then:

public protocol NetworkSession: AnyObject {

    func dataTask(
        with request: URLRequest,
        completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void
    ) -> URLSessionDataTask
}

Because the function signature matches URLSession API, we can make it conform to the NetworkSession protocol just like this:

extension URLSession: NetworkSession {}

Remember how we initialize the Network Module implementation?

public final class DefaultNetworkModule: NetworkModule {
   
   

   public init(

        urlSession: NetworkSession = URLSession.shared
        
    ) 

See what we did there? With this simple trick, our Network Module no longer depends on an actual URLSession. It depends on its abstraction. And, as the abstraction matches 1:1 the URL Session API method we require, we’ll surely use its instance in the production code. But we are no longer obliged to do so in Tests as well! We are free to create any kind of mock or fake, as long as it conforms to NetworkSession protocol

Let’s create such a Fake Network Session:

final class FakeNetworkSession: NetworkSession {
    var simulatedURLSessionDataTask: URLSessionDataTask?
    private(set) var lastProcessedRequest: URLRequest?
    private var completionHandler: ((Data?, URLResponse?, Error?) -> Void)?

    func dataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
        self.completionHandler = completionHandler
        return simulatedURLSessionDataTask ?? URLSession.shared.dataTask(with: request)
    }
}

And to be able to easily simulate a request success or failure:

extension FakeNetworkSession {

    func simulateSuccess(data: Data?, response: URLResponse?) {
        completionHandler?(data, response, nil)
    }

    func simulateFailure(response: URLResponse?, error: Error?) {
        completionHandler?(nil, response, error)
    }
}

Now, finally we can write some tests 🤣

Let’s begin with preparing a test case for a happy path:

func test_whenExecutingNetworkRequest_andSuccessResponseIsReturned_shouldDecodeAndReturnIt() {
        //  given:
        let fixtureResponse = makeURLResponse()
        let fakeDecodedResponse = FakeDecodedResponse(foo: "bar")
        let fakeResponseData = fakeDecodedResponse.encoded()!
        fakeRequestBuilder.simulatedUrlRequest = fixtureUrlRequest

        //  when:
        performAndDecode(request: fakeNetworkRequest, responseType: FakeDecodedResponse.self)
        fakeNetworkSession.simulateSuccess(data: fakeResponseData, response: fixtureResponse)

        

}

Where we capture received response (or an error) using this nifty test helper:

func performAndDecode<T: Decodable>(request: NetworkRequest, responseType: T.Type) {
        lastRecordedNetworkTask = sut.performAndDecode(request: request, responseType: T.self) { result in
            switch result {
            case let .success(response):
                self.lastRecordedDecodedResponse = response
            case let .failure(error):
                self.lastRecordedError = error
            }
        }
    }

Finally, we need to check if the lastRecordedDecodedResponse is equal in value to the data we jerry-rigged our fake Network Session to return. Plain and simple – BDD at its finest!

//  then:

let expectedNetworkResponse = NetworkResponse(data: fakeResponseData, networkResponse: fixtureResponse)
XCTAssertEqual(lastRecordedDecodedResponse as? FakeDecodedResponse, fakeDecodedResponse, "Should decode received data into proper response")

Ok, how does testing the unhappy path look like? Let’s take a look at how error handling process can be covered:

func test_whenExecutingNetworkRequest_andGenericErrorIsReturned_shouldReturnProperError() {
        // given:
        let fixtureErrorCode = 420
        let fixtureError = NSError(domain: "fixtureDomain", code: fixtureErrorCode)
        fakeRequestBuilder.simulatedUrlRequest = fixtureUrlRequest

        // when:
        performAndDecode(request: fakeNetworkRequest, responseType: FakeDecodedResponse.self)
        fakeNetworkSession.simulateFailure(response: makeURLResponse(code: fixtureErrorCode), error: fixtureError)

        // then:
        XCTAssertEqual(lastRecordedError, NetworkError.invalidRequest(code: fixtureErrorCode, message: fixtureError.localizedDescription), "Should return a proper error")
        XCTAssertNil(fakeNetworkModuleAction.lastResponsePostExecutionActionPerformedOn, "Should NOT trigger any post-execution actions")
    }

On top of that, there are also many other edge cases we should code. To name just a few:

  • The request failed to build properly.
  • The request was executed successfully, but the received data was empty.
  • The request was executed successfully, but received data could not be decoded onto a provided data type.
  • The request was canceled.

If you have a minute, please take a look at the full listing

And just like that – we verified the Network Module works as intended without firing a single shot network request.

Sharing with the community

Sharing is caring! Making your tool an open-source is not only a great way to promote yourself / your organization, but also engage the wider community in the tool development process. If your tool solves a relevant issue, makes it easier to interact with certain APIs, etc., you’ll be surprised by the size of the community you could build around it. More often than not, such a group of people can help you steamroll the tool development and suggest ideas you otherwise wouldn’t have thought of. 

What should we do to make the repository secure, well-documented and appealing to the community? Here are some of the most important points to consider:

  • Safety first! Ensure your main branch is protected and that at least one approval is required to merge a PR. Also, verify that the committed files and comments do not contain any GDPR-sensitive data, access tokens, etc. Not only in the latest commits, but also throughout the entire repo history
  • Prepare a clear and comprehensive README.md file. It is a single point of entry into your tool after all 🤣. Make sure you included platform requirements, main features and basic code samples. Don’t forget to mention the repository owner(s) and maintainer(s) and how to contact them.
  • Set up collaboration rules. Imagine how you’d like people to contribute to your tool and put these rules in the CONTRIBUTING.md file. It’s ok to take a look around and draw inspiration from your favorite libraries 🤣
  • Think about the license you’d like to distribute your work under. In 99.9% of cases, you’d be fine with the MIT license. But there are other options as well: BSD, GPL, etc. Put the details of the license you chose in the LICENSE.md file.
  • Set up the CI to cover the PR flow. That is, unless you enjoy manually pulling every PR to make sure it passes all the tests 🤣. I’d recommend taking a look into Github Features as it’s free for all open-source projects.
  • If you have time, a sample app or playground would explain your tool to the users even better.

Fortunately, there are plenty of nifty templates you can use to create your LICENSE.md and CONTRIBUTING.md files. In addition, most of the leading tools in the iOS space like Alamofire have great README.md files you can get inspiration from. Also, don’t be hard on yourself if you don’t create a perfect documentation for your tool the first time. It’s way better to get to the market ASAP with what you have, then constantly delay releasing the tool to make it “perfect”. 

Summary

Congrats! You’ve made it to the end! And I salute you for that.

Across these 2 posts we’ve been through some basic rules of creating a robust, scalable and testable networking client. We’ve also covered how we can make it extendable for the users and how to gradually keep adding new APIs without having to rewrite the entire tool. Finally, thanks to BDD, we’ve shown how a comprehensive Unit Tests suite can be implemented without the need to execute a single, physical network request. As a bonus, we’ve also discussed what we could to prepare such a tool to be made open-source.

Although we’ve used a networking client as an example, this approach can be used as a blueprint for similar tools: local storage helper, peripheral connectivity assistant, etc. Just think about it next time you’ll be about to add another CocoaPods or SPM dependency to your project 🤣.

Don’t miss any new blogposts!

    By subscribing, you agree with our privacy policy and our terms and conditions.

    Disclaimer: All memes used in the post were made for fun and educational purposes only 😎
    They were not meant to offend anyone 🙌
    All copyrights belong to their respective owners🧐
    The thumbnail was generated with text2image ❤️