Building a Reliable and Testable Networking Stack on iOS

Building a Reliable and Testable Networking Stack on iOS

πŸ’‘
This is the first article in my networking series where I am teaching how to build and test a reliable network stack. In the second and final article, I have written about How to Write Comprehensive and Robust Unit Tests for your Network Stack if you want to give it a read.

someone asks you what does the iOS app consist of? The simple answer is UI and network calls. Unless it's an elementary iOS app that needs no interaction with backend services, almost all iOS apps need to connect to backend services to fetch and upload data.

So networking is the bread and butter of any iOS developer intending to build an app for end users. In today's blog post, we will look at how to build a performant, scalable, reusable, and testable network stack in Swift on the iOS platform.

This article is not about how to make a network call using Swift in the iOS app. You can easily find it anywhere on the internet. This is specifically about how to build a production-grade networking stack for performing network operations in Swift. We will also learn best practices for building a networking stack and how to make it unit-testable so that we can easily monitor, catch and fix any breaking changes to our stack
  1. Building an APIRoute

The first part of building a network service is creating an APIRoute enum. This enum will hold information on which API endpoint to call and which parameters it needs through associated enum values.

For the sake of this tutorial, we will assume we need three endpoints to perform the following activities,

A. Login the user

B. Getting User Info

C. Disable Account

Let's start building a basic structure for APIRoute enum


enum APIRoute {
    case login(_ email: String, _ password: String)
    case userInfo(_ userId: Int)
    case disableAccount(_ userId: Int)
}
πŸ’‘
Please note the use of named associated enums that helps us to associate passed variables to their names

The APIRoute will also encode additional information about the endpoint and associated parameters as follow,

  1. Base URL
  2. Full URL
  3. URL Query parameters
  4. HTTP Method
  5. HTTP body
  6. Creating an URLRequest
  7. Authorization and Credentials information

With this setup, everything we need to make a network call remains inside the APIRoute enum. If we need to make any changes, we can make them in just one place instead of modifying multiple call sites potentially causing inconsistencies in case we forget to update every place it's used.

With this setup, let's write a complete code for APIRoute enum which will help us make a call to three endpoints to perform a login, fetch user info, and disable the user account.


import Foundation

enum APIRoute {
    case login(_ email: String, _ password: String)
    case userInfo(_ userId: Int)
    case disableAccount(_ userId: Int)

    private var baseURLString: String { "https://testapi.com/v1/" }

    private var authToken: String { "rtTTRE2312312&%$$#$" }

    private var url: URL? {
        switch self {
        case .login:
            return URL(string: baseURLString + "accounts/login")
        case .userInfo(let userId):
            return URL(string: baseURLString + "accounts/\(userId)")
        case .disableAccount(let userId):
            return URL(string: baseURLString + "accounts/disable/\(userId)")
        }
    }

    private var parameters: [URLQueryItem] {
        switch self {
        case .login, .userInfo, .disableAccount:
            return []
        }
    }

    private var httpMethod: String {
        switch self {
        case .login:
            return "POST"
        case .userInfo:
            return "GET"
        case .disableAccount:
            return "PUT"
        }
    }

    private var httpBody: Data? {
        switch self {
        case .login(let username, let password):
            return try? JSONSerialization.data(withJSONObject: [
                "username": username,
                "password": password
            ])
        case .userInfo, .disableAccount:
            return nil
        }
    }

    func asRequest() -> URLRequest {
        guard let url = url else {
            preconditionFailure("Missing URL for route: \(self)")
        }

        var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
        components?.queryItems = parameters

        guard let parametrizedURL = components?.url else {
            preconditionFailure("Missing URL with parameters for url: \(url)")
        }

        var request =  URLRequest(url: parametrizedURL)

        request.httpMethod = httpMethod
        request.httpBody = httpBody

        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.setValue("gzip, deflate, br", forHTTPHeaderField: "Accept-Encoding")

        request.setValue(authToken, forHTTPHeaderField: "Authorization")

        return request
    }
}
πŸ’‘
In case you need to add more endpoints, you can extend the APIRoute enum by adding more cases and providing HTTP body, HTTP method, parameters, and URL for them for constructing URLRequest

2. Building an enum for capturing data load error

In case API fails to return data or we can't decode the incoming response, we need to notify the user of the occurred error condition. We will create another enum DataLoadError that conforms to Error protocol and will be used to report and indicate an error during the data load operation.


enum DataLoadError: Error {
    case badURL
    case genericError(String)
    case noData
    case malformedContent
    case invalidResponseCode(Int)
    case decodingError(String)

    func errorMessageString() -> String {
        switch self {
        case .badURL:
            return "Invalid URL encountered. Please enter the valid URL and try again"
        case let .genericError(message):
            return message
        case .noData:
            return "No data received from the server. Please try again later"
        case .malformedContent:
            return "Received malformed content. Error may have been logged on the server to investigate further"
        case let .invalidResponseCode(code):
            return "Server returned invalid response code. Expected between the range 200-299. Server returned \(code)"
        case let .decodingError(message):
            return message
        }
    }
}

Please note that the above enum captures only frequently occurring error cases. You can add more error cases to catch the specialized error conditions

You can use and return appropriate DataLoadError conditions that error case occurs.

3. Building a request handler

The next and final part of building a network service is the network handler. This part is responsible for consuming APIRoute, creating and sending the URL request, getting and decoding the data, and either successfully returning decoded Codable model object or DataLoadError in case of failure.

To support testability, we won't build the request handler directly. We will make it conform to another protocol named RequestHandling so that we can easily mock it in our unit tests.


//RequestHandling.swift protocol

protocol RequestHandling {
    func request<T: Decodable>(route: APIRoute, completion: @escaping (Result<T, DataLoadError>) -> Void)
}

//RequestHandler.swift implementation

final class RequestHandler: RequestHandling {

    let urlSession: URLSession
    let decoder: JSONDecoder

    init(urlSession: URLSession = .shared, decoder: JSONDecoder?) {
        self.urlSession = urlSession

        if let decoder {
            self.decoder = decoder
        } else {
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .iso8601
            self.decoder = decoder
        }
    }

    func request<T: Decodable>(route: APIRoute, completion: @escaping (Result<T, DataLoadError>) -> Void) {
        let task = urlSession.dataTask(with: route.asRequest()) { (data, response, error) in

            if let error = error {
                completion(.failure(.genericError(error.localizedDescription)))
                return
            }

            guard let data = data else {
                completion(.failure(.noData))
                return
            }

            if let responseCode = (response as? HTTPURLResponse)?.statusCode, responseCode != 200 {
                completion(.failure(.invalidResponseCode(responseCode)))
                return
            }

            do {
                let decoder = JSONDecoder()
                let responsePayload = try decoder.decode(T.self, from: data)
                completion(.success(responsePayload))
            } catch {
                completion(.failure(.malformedContent))
            }
        }
        task.resume()
    }
}

There are a few things to note in this implementation,

  1. RequestHandler implements the RequestHandling which has a request method
  2. The request method works with a generic Decodable object. It takes in the APIRoute parameter, constructs an API request, decodes the object and if everything goes right, returns the Result enum with success case and decoded object
  3. In case there is an error (Captured by multiple checks along the way), it returns the Result enum with failure case and the DataLoadError error case
  4. We inject URLSession and JSONDecoder objects while instantiating RequestHandler. This will allow us to mock these values and control the implementation and replace it with mock responses during testing

4. Making a network call

Now that all our pieces are in place, let's integrate them and call a network service to log the user in with credentials


import Foundation

struct UserAccount: Decodable {
    let id: String
    let name: String
}

final class NetworkingDemo {

    let requestHandler: RequestHandling

    init(requestHandler: RequestHandling) {
        self.requestHandler = requestHandler
    }

    func sendNetworkRequest() {
        requestHandler.request(route: .login("test123", "password123")) { [weak self] (result: Result<UserAccount, DataLoadError>) -> Void in
            //TODO
        }
    }
}

In the above example, completion closure returns the Result enum which encloses either success or failure case. We will unbox and handle each case individually.


func sendNetworkRequest() {
    requestHandler.request(route: .login("test123", "password123")) { [weak self] (result: Result<UserAccount, DataLoadError>) -> Void in
        switch result {
        case .success(let userAccount):
            print("User id \(userAccount.id) and user name \(userAccount.name)")
        case .failure(let error):
            print(error.errorMessageString())
        }
    }
}

Discussion and Considerations

  • APIRoute can become bloated

In our example, we just had 3 cases in the APIRoute enum. However, things can easily go out of control when we are dealing with API endpoints. The moderate-sized app can easily end up using 40-50 different endpoints including those related for 3rd party services and analytics in addition to core app functionality.

We can simplify APIRoute extension by nesting each case in its domain case. The number of domains won't grow as fast as endpoints. We can keep the number of domains and API routes in a manageable state compared to the growth in the total number of endpoints used across the app.


import Foundation

enum APIRouteDomain {
    case userLoginManagement(APIRoute)
    case accountInfoManagement(APIRoute)
    case accountManagement(APIRoute)

    var associatedApiRoute: APIRoute {
        switch self {
        case .userLoginManagement(let apiRoute), .accountInfoManagement(let apiRoute), .accountManagement(let apiRoute):
            return apiRoute
        }
    }
}

In this example, I am using APIRoute type for all the associative enum cases. But you can create your own custom associative type for each domain you're dealing with.

  • There is no way to switch the base URL

When your app reaches a certain scale, you want to experiment with how the app behaves and reacts with different endpoints. Right now we are using only one base URL, but there are cases when we want to switch to different endpoints - Either during the API migration or during testing pointing them at the dev, staging, or production version.

Unfortunately, the way enums work, we cannot use the current implementation to be able to switch to different base URLs in a cleaner and more reliable way.

In order to switch the API endpoint based on the passed flag during run time, we will refactor the APIRoute a bit and make it more flexible.

We will create a brand new enum named Environment with cases for dev, staging, and production environment


enum Environment {
    case dev
    case staging
    case production

    var url: String {
        switch self {
        case .dev:
            return "https://testapi.com/v1/"
        case .staging:
            return "https://stagingapi.com/v1/"
        case .production:
            return "https://testapi.com/v1/"
        }
    }
}

We will also modify each APIRoute associated enum case to take in extra Environment parameter and default to nil so that we will pass the value only when we need to specify an endpoint other than the production version.


enum APIRoute {

    case login(_ email: String, _ password: String, _ environment: Environment? = nil)
    case userInfo(_ userId: Int, _ environment: Environment? = nil)
    case disableAccount(_ userId: Int, _ environment: Environment? = nil)

}

We will change how we used to retrieve the base URL. Earlier we would use the hard-coded version, but now with the environment variable kicking, we will extract the base URL from passed Environment type.


private var url: URL? {
    switch self {
    case .login(_, _, let environment):
        return URL(string: getBaseURL(from: environment) + "accounts/login")
    case .userInfo(let userId, let environment):
        return URL(string: getBaseURL(from: environment) + "accounts/\(userId)")
    case .disableAccount(let userId, let environment):
        return URL(string: getBaseURL(from: environment) + "accounts/disable/\(userId)")
    }
}

func getBaseURL(from environment: Environment?) -> String {
    if let environment {
        return environment.url
    }
    return Environment.production.url
}
πŸ’‘
It doesn't have to be limited to this. You can also modify the Environment object to consider the API versioning or add tweaks based on the passed feature flags

Summary

So this was all about building and setting the network service stack on iOS with Swift. Hope this post was useful for helping you understand how to go beyond basics and build a network stack that is reliable, scalable, and flexible enough for additional requirements.

In the next post, we will learn how to write unit tests for the network stack we built and make sure our tests cover all the edge case scenarios and provide a safeguard against unintentional modification and improve reliability and stability in the long term.

The second and final article in this series talks about Mocking URLProtocol and Unit Testing the Network Stack on iOS using Swift

Source Code

The full source code for this tutorial is available on GitHub Gist for further reference.

Support and Feedback

If you have any comments or questions, please feel free to reach out to me on LinkedIn.

If you like my blog content and wish to keep me going, please consider donating on Buy Me a Coffee or Patreon. Help, in any form or amount, is highly appreciated and it's a big motivation to keep me writing more articles like this.

Consulting Services

I also provide a few consulting services on Topmate.io, and you can reach out to me there too. These services include,

  1. Let's Connect
  2. Resume Review
  3. 1:1 Mentorship
  4. Interview Preparation & Tips
  5. Conference Speaking
  6. Take-home Exercise Help (iOS)
  7. Career Guidance
  8. Mock Interview