lukecsmith.co.uk

My favoured networking stack, using ReactiveSwift, with stubbing.

Luke, 4 July 2019

I wanted to get down a description of my current favoured setup for networking in a Swift project, incorporating use of ReactiveSwift, and with stubbing. By that I mean, I am able to build a stubbed version, where all networking calls are replaced with an immediate return of JSON data that is stored within the app, meaning I can test without networking and use it offline. It’s really useful for unit tests of course, but it’s also handy when I want to demonstrate specific data, and if Im working within an environment which is more difficult to access. For example Ive been working with Nationwide Building Society in the UK, and naturally they have highly secure systems, which meant having a stubbed version was very handy for demonstrations etc, and when remote working.

I wanted the maximum simplicity for this stubbed version, so that adding a JSON response within the app would be really quick and easy to do, with good organisation. I think this setup achieves it, so heres a description of what it is. First – the networking code itself.

My current favoured networking stack in Swift has two central protocols that organise everything : APIClient and APIRequesting. They are built around ReactiveSwift – I’ll be converting these to use the new Swift reactive library Combine soon. Note that the send function within APIClient allows the passing of any type that conforms to the standard Swift Codable protocol, and returns a SignalProducer of that type – thats a standard ReactiveSwift object which represents a stream of results rather than a single object. This generic setup allows the same send call for any Codable type, and on return, it tries (literally) to create objects of that type with the resulting JSON.

public protocol APIClient {
    func send<T: APIRequesting>(_ request: T) -> SignalProducer<T.Response, Error>
}
public protocol APIRequesting: Encodable {
    associatedtype Response: Decodable
    
    /// Endpoint for this request (the last part of the URL)
    var resourceName: String { get }
    var httpMethod: String? { get }
    var bodyData: Encodable? { get }
}

public extension APIRequesting {
    
    var httpMethod: String? { return "GET" }
    var bodyData: Encodable? { return nil }
}

So these are the templates around which I will create an actual APIClient, and various APIRequests. For stubbing purposes, I will also create a StubAPIClient, which will change the implementation of the send method to use local JSON objects, rather than doing a proper network call. More on that later.

Heres my implementation of the APIClient used for actual live calls.

import Foundation
import ReactiveSwift

public class MobileAPIClient: APIClient {
    private var baseEndpointUrl: URL?
    private var session: NationwideURLSession?
    
    init(baseUrl: String, session: NationwideURLSession) {
        self.baseEndpointUrl = URL(string: baseUrl)!
        self.session = session
    }
    
    public func send<T: APIRequesting>(_ request: T) -> SignalProducer<T.Response, Error> {
        let endpoint = self.endpoint(for: request)
        print("Calling : \(endpoint.absoluteString)")
        var apiRequest = URLRequest(url: endpoint, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 10)
        apiRequest.httpMethod = request.httpMethod
        apiRequest.httpBody = request.bodyData?.toJSONData()
        apiRequest.allHTTPHeaderFields = ["accept": "application/json", "Content-Type": "application/json"]
        
        return SignalProducer { (observer, disposable) in
            if let session = self.session {
                let task = session.dataTask(with: apiRequest) { data, response, error in
                    if let response = response as? HTTPURLResponse {
                        switch response.statusCode {
                        case 200:
                            if let data = data {
                                do {
                                    let decoder = JSONDecoder()
                                    decoder.dateDecodingStrategy = .iso8601
                                    let apiResponse = try decoder.decode(T.Response.self, from: data)
                                    
                                    observer.send(value: apiResponse)
                                    observer.sendCompleted()
                                } catch {
                                    observer.send(error: error)
                                }
                        }
                        case 201:
                            observer.sendCompleted()
                        default:
                            print("Response: \(response)")
                            let err = NSError(domain: "", code: response.statusCode, userInfo: nil)
                            observer.send(error:err)
                        }
                    }
                    else if let error = error {
                        observer.send(error: error)
                    }
                }
                
                task.resume()
            }
        }
    }
    
    private func endpoint<T: APIRequesting>(for request: T) -> URL {
        guard let url = URL(string: request.resourceName, relativeTo: baseEndpointUrl) else {
            fatalError("Bad resourceName: \(request.resourceName)")
        }
        return url
    }
}

And heres the implentation of the APIClient used for the stubbed client version. Note how theres no networking in this – instead its trying to decode JSON from a local source.

public class StubAPIClient: APIClient {
    public init() {}
    
    public func send<T: APIRequesting>(_ request: T) -> SignalProducer<T.Response, Error>  {
        var data: Data?
        
        if let stubbedRequest = request as? Stubs {
            data = stubbedRequest.stubResponse
        }
        
        return SignalProducer { observer, disposable in
            if let data = data {
                let decoder = JSONDecoder()
                decoder.dateDecodingStrategy = .iso8601
                do {
                    let apiResponse = try decoder.decode(T.Response.self, from: data)
                    observer.send(value: apiResponse)
                } catch {
                    print(error)
                    fatalError("Couldn't decode the test data")
                }
            }
            observer.sendCompleted()
            }.observe(on: QueueScheduler())
    }
}

Just inside the send func above, we do a simple check to see if the passed in APIRequesting object also conforms to Stubs protocol. The Stubs protocol is really simple :

public protocol Stubs {
    var stubResponse : Data { get }
}

It requires that the conforming object supplies a Data object. This object is then used as the source for the JSON, rather than the network call. The StubAPIClient object above then decodes that, rather than decoding the networking response.

For a live call then, I would create a particular APIRequest depending on the endpoint I was talking to. Heres an example :

public struct GetCustomerRequest: APIRequesting {
    
    public typealias Response = Customer
    
    public var resourceName: String {
        return "Customers/\(self.customerNumber)"
    }
    
    // Request parameters
    public let customerNumber: String
    
    public init(customerNumber: String) {
        self.customerNumber = customerNumber
    }
}

You can see there the specified endpoint (the base URL is specified when creating the APIClient object), and the type of object I will attempt to decode from the resulting JSON, in this case my own custom type called Customer.

A standard call here would look like this, using the above APIRequesting object :

private let client: APIClient

public func getCustomer(customerNumber: String) -> SignalProducer<Customer, Error> {
    let getCustomerRequest = GetCustomerRequest(customerNumber: customerNumber)
    return client.send(getCustomerRequest)
}

The response from that can then be handled in a standard ReactiveSwift type way, such as :

self.getCustomer(customerNumber: customerIds.customerNo)
            .observe(on: UIScheduler())
            .on(failed: { error in
                // todo: log error
                print("Failed : \(error.localizedDescription)")
            }, value: { customer in
                //get the customer in here
                print("Customer : \(customer)")
                .. do stuff with the Customer object(s) ..
            })
            .start();

With the stubbed version though, theres just one more thing we need to add, and all of the other code can stay exactly the same. We make the APIRequesting object above also conform to the Stubs protocol so it can be used in the stubbed version.



extension GetCustomerRequest: Stubs {
    public var stubResponse: Data {
        return Data("""
            {
                "customerNumber": "123",
                "accounts": [
                    {
                        "accountId": "123",
                        "visaDocumentId": "abcdef"
                    },
                    {
                        "accountId": "456",
                        "visaDocumentId": "defgh"
                    }
                ]
            }
        """.utf8)
    }
}

Here we’ve supplied the stubResponse property that the Stubs protocol requires, and which is used as the source of the JSON in the stubbed version.

So theres only one thing missing from this – how do we dictate when we use the MobileAPIClient, and when to use the StubAPIClient? Simply what I do is to create a different target for the stubbed version. The only APIClient within the Stubbed target is the StubAPIClient, and within my main live target, my APIClient is the normal MobileAPIClient. I do this via target membership, ticking the box in XCode to ensure the correct APIClient is a member of the right target. I use dependency injection, and I inject the APIClient into each place that requires it. In the case of the stubbed client, I inject the StubAPIClient into all those places. Because all they ask is for an object that conforms to the APIClient protocol, it doesnt matter whether I inject the stubbed version or the full networking version. The extension to the APIRequesting object that contains the JSON can be similarly placed in a separate swift file, and then only made a member of the stub target, and not the live one.

Once this setup has been done, it means the only thing required to allow a network call to work in a stubbed way, is to add the conformance to Stubs protocol for any APIRequesting object, via extension, as above. Ive found this nicely separates out the concerns and it means all production code can be written in a single way for both systems. Thoughts on improvements very welcome.

Apples Documentation on OSLog

Tagged with: