Building a Reliable and Testable Networking Stack on iOS
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
- 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)
}
The APIRoute
will also encode additional information about the endpoint and associated parameters as follow,
- Base URL
- Full URL
- URL Query parameters
- HTTP Method
- HTTP body
- Creating an
URLRequest
- 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
}
}
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,
RequestHandler
implements theRequestHandling
which has arequest
method- The
request
method works with a genericDecodable
object. It takes in theAPIRoute
parameter, constructs an API request, decodes the object and if everything goes right, returns theResult
enum withsuccess
case and decoded object - In case there is an error (Captured by multiple checks along the way), it returns the
Result
enum withfailure
case and theDataLoadError
error case - We inject
URLSession
andJSONDecoder
objects while instantiatingRequestHandler
. 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
}
Environment
object to consider the API versioning or add tweaks based on the passed feature flagsSummary
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,