When implementing a typical mobile app, one of the first things I think about is the networking layer.
What is the best way to start? How can I make it as testable as possible?

In this blog post, I am exploring the way I start designing it, midlewares, my inspirations, and some ideas for improvements.


Designing the APIClient

The ideal networking layer is one that:
• is easy to extend
• is testable
• is easy to mock
• makes it easy to stub responses 

Let’s start simple and call it: APIClient.

import Foundation

struct APIClient {
    private var baseURL: URL
    private var urlSession: URLSession    init(
        baseURL: URL,
        urlSession: URLSession = URLSession.shared
    ) {
        self.baseURL = baseURL
        self.urlSession = urlSession
    }
}

I said in the beginning that I want it to be extensible. That means that we need to abstract the API client configuration and how we define and interact with our API specifications.

By defining a protocol, APISpec, we can ensure that every API request conforms to a standard structure. This not only makes our client more adaptable to changes but also simplifies the process of adding new endpoints.

extension APIClient {
    protocol APISpec {
        var endpoint: String { get }
        var method: HttpMethod { get }
        var returnType: DecodableType.Type { get }
        var body: Data? { get }
    }

    enum HttpMethod: String, CaseIterable {
        case get = "GET"
        case post = "POST"
        case patch = "PATCH"
        case put = "PUT"
        case delete = "DELETE"
        case head = "HEAD"
        case options = "OPTIONS"
    }
}

protocol DecodableType: Decodable { }    
// Make Array of DecodableType also conform to DecodableType if needed.
// For cases where the API returns an array of entities.
extension Array: DecodableType where Element: DecodableType 

The APISpec protocol serves as a blueprint for our network requests. It demands that each request specifies its endpoint, HTTP method, the type it returns (conforming to DecodableType), and any body data.

Making the request


With our APISpec in place, let’s extend APIClient to include a method for making requests:

func sendRequest(_ apiSpec: APISpec) async throws -> DecodableType {
        guard let url = URL(string: baseURL.absoluteString + apiSpec.endpoint) else {
            throw NetworkError.invalidURL
        }
        var request = URLRequest(
            url: url,
            cachePolicy: .useProtocolCachePolicy,
            timeoutInterval: TimeInterval(floatLiteral: 30.0)
        )
        request.httpMethod = apiSpec.method.rawValue
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = apiSpec.body        
        
        var responseData: Data? = nil
        do {
            let (data, response) = try await urlSession.data(for: request)
            try handleResponse(data: data, response: response)
            responseData = data
        } catch {
            throw error
        }
        
        let decoder = JSONDecoder()
        do {
            let decodedData = try decoder.decode(
                apiSpec.returnType,
                from: responseData!
            )
            return decodedData
        } catch let error as DecodingError {
            throw error
        } catch {
            throw NetworkError.dataConversionFailure
        }
}


private func handleResponse(data: Data, response: URLResponse) throws {
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }

        guard (200...299).contains(httpResponse.statusCode) else {
            throw NetworkError.requestFailed(statusCode: httpResponse.statusCode)
        }
}

Adding a real use-case

For testing purposes, I am using https://jsonplaceholder.typicode.com . It is a free fake API for testing and prototyping.


I want to add support for three use cases:
• getting and listing all the todo items - GET https://jsonplaceholder.typicode.com/todos
• getting a single todo item - GET https://jsonplaceholder.typicode.com/todos/1
• creating a todo item - POST https://jsonplaceholder.typicode.com/todos


To do this, we need to implement the APISpec protocol. In this case, I chose an enum for its expressivity.

enum TodosAPISpec: APIClient.APISpec {
    case getTodos
    case getTodo(id: Int)
    case create(todo: TodoDTO)

    var endpoint: String {
        switch self {
        case .getTodos:
            return "/todos"
        case .getTodo(id: let id):
            return "/todos/\(id)"
        case .create(_):
            return "/todos"
        }
    }

    var method: APIClient.HttpMethod {
        switch self {
        case .getTodos:
            return .get
        case .getTodo(id: _):
            return .get
        case .create(_):
            return .post
        }
    }

    var returnType: DecodableType.Type {
        switch self {
        case .getTodos:
            return [TodoDTO].self
        case .getTodo:
            return TodoDTO.self
        case .create(_):
            return TodoDTO.self
        }
    }

    var body: Data? {
        switch self {
        case .getTodos:
            return nil
        case .getTodo(_):
            return nil
        case .create(let todo):
            return try? JSONEncoder().encode(todo)
        }
    }
}

TodoDTO looks like this:

struct TodoDTO: Sendable, Equatable, Codable, DecodableType {
    var id: Int?
    var userId: Int
    var title: String
    var completed: Bool

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        guard !container.allKeys.isEmpty else {
            throw DecodingError.dataCorrupted(
                DecodingError.Context(
                    codingPath: decoder.codingPath,
                    debugDescription: "Empty object: \(container.allKeys)"
                )
            )
        }

        title = try container.decode(String.self, forKey: .title)
        completed = try container.decode(Bool.self, forKey: .completed)
        id = try container.decode(Int.self, forKey: .id)
        userId = try container.decode(Int.self, forKey: .userId)
    }

    init(id: Int? = nil, userId: Int, title: String, completed: Bool = false) {
        self.id = id
        self.userId = userId
        self.title = title
        self.completed = completed
    }
}

Now, tying up what we have until now, we can use the APIClient to create a service that handles the todo items requests:

class APIService {
    private(set) var apiClient: APIClient?
    init(apiClient: APIClient?) {
        self.apiClient = apiClient
    }
}

import Foundation

enum TodosAPIServiceError: Error {
    case noUserIdFound
}

class TodosAPIService: APIService {
    func getTodo(withId id: Int) async throws -> TodoDTO? {
        let apiSpec: TodosAPISpec = .getTodo(id: id)
        do {
            let todo = try await apiClient?.sendRequest(apiSpec)
            return todo as? TodoDTO
        } catch {
            print(error)
            return nil
        }
    }

    func getTodos() async throws -> [TodoDTO] {
        let apiSpec: TodosAPISpec = .getTodos
        let todos = try await apiClient?.sendRequest(apiSpec)
        return todos as? [TodoDTO] ?? []
    }

    func createTodo(userId: Int, title: String) async throws -> TodoDTO {
        let apiSpec: TodosAPISpec = .create(
            todo: TodoDTO(userId: userId, title: title)
        )
        let todo = try await apiClient?.sendRequest(apiSpec)
        return todo as! TodoDTO
    }
}


Usage examples

Now with the TodoAPIService in place,  let’s look at how to use its methods to interact with the todos API. We will cover how to fetch all todos, retrieve a specific todo by ID, and create a new todo.

0. First, let's initiate the API service.

let baseURL = URL(string: "https://jsonplaceholder.typicode.com")
let apiClient = APIClient(baseURL: baseURL!)

let todosService = TodosAPIService(apiClient: apiClient)

1. Fetching All Todos

Important: since it’s an async method, you’ll need to call it from within an async context.

do {
    let todo = try await todosService.getTodo(withId: "1")
     print(#function, todo ?? "nil todo")
} catch {
    print(#function, error)
}

2. Fetching a Single Todo by ID

To get a specific todo by its id, you can use the getTodo(withId:) method. This method returns a single TodoDTO?, which represents a single todo item.

Task {
    do {
        if let todo = try await todosService.getTodo(withId: 1) {
            print("Fetched Todo: \(todo.title) - Completed: \(todo.completed)")
        } else {
            print("Todo not found")
        }
    } catch {
        print("Failed to fetch todo: \(error)")
    }
}

3. Creating a New Todo

To create a new todo item, use the createTodo(userId:title:) method. You’ll pass in the userId and title to create the todo, and it will return the newly created TodoDTO.

Task {
    do {
        let newTodo = try await todosService.createTodo(userId: 1, title: "Buy groceries")
        print("Created Todo: \(newTodo.title) - ID: \(String(describing: newTodo.id))")
    } catch {
        print("Failed to create todo: \(error)")
    }
}


Extending the APIClient with Middlewares

To enhance the flexibility of our APIClient, I added middlewares.

Middlewares allow us to intercept and modify network requests before they are sent out, enabling us to inject custom behavior such as adding headers, logging requests, or handling tokens.

Note: of course, we can implement middlewares that can modify a request/response after the API call is sent, but in our case it's not necessary.

Middleware Protocol

First, let's define the Middleware protocol:

extension APIClient {
    protocol Middleware {
        func intercept(_ request: URLRequest) async throws -> (URLRequest)
    }
}

Each middleware must conform to this protocol by implementing the intercept(_:) method. This method takes in a URLRequest and asynchronously returns a modified version of it. The flexibility of this approach allows us to chain multiple middlewares to enhance the request.

Implement Middleware Support inside the APIClient

First, we will extend our APIClient to support middlewares, by allowing the API client to accept a list of middlewares in its initializer.

struct APIClient {
    private var baseURL: URL
    private var urlSession: URLSession
    private(set) var middlewares: [any APIClient.Middleware]
    
    init(
        baseURL: URL,
        middlewares: [any APIClient.Middleware] = [],
        urlSession: URLSession = URLSession.shared
    ) {
        self.baseURL = baseURL
        self.urlSession = urlSession
        self.middlewares = middlewares
    }

/// rest of the struct

}

In this approach, each API request goes through every middleware, allowing each to modify the request as needed.

In the sendRequest(_:) method, we iterate through the middlewares, updating the request at each step before sending it to the server:

func sendRequest(_ apiSpec: APISpec) async throws -> DecodableType {
    guard let url = URL(string: baseURL.absoluteString + apiSpec.endpoint) else {
        throw NetworkError.invalidURL
    }
    var request = URLRequest(
        url: url,
        cachePolicy: .useProtocolCachePolicy,
        timeoutInterval: TimeInterval(floatLiteral: 30.0)
    )
    request.httpMethod = apiSpec.method.rawValue
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = apiSpec.body
    
    // apply the middlewares
    var updatedRequest = request
    for middleware in middlewares {
        let tempRequest = updatedRequest
        updatedRequest = try await wrapCatchingErrors {
            try await middleware.intercept(
                tempRequest
            )
        }
    }
    
    var responseData: Data? = nil
    do {
        let (data, response) = try await urlSession.data(for: updatedRequest)
        try handleResponse(data: data, response: response)
        responseData = data
    } catch {
        throw error
    }

    // decode the response data
}

This implementation includes a helper method to handle errors during middleware execution.

func wrapCatchingErrors<R>(work: () async throws -> R) async throws -> R {
    do {
        return try await work()
    } catch {
        throw error
    }
}

This method wraps any asynchronous work, catching and rethrowing errors to ensure that exceptions are handled properly without disrupting the middleware flow.

Middleware Examples

To better understand the middlewares, let’s look at two practical examples: Authorization and Logging.

Authorization Middleware

This middleware adds an authorization token to the request’s headers, ensuring that every API request includes the correct authentication details. In this example, AuthorizationMiddleware inserts a Bearer token into the Authorization header of every request.

class AuthorizationMiddleware: APIClient.Middleware {
    var token: String?
    
    init(token: String? = nil) {
        self.token = token
    }
    
    func intercept(_ request: URLRequest) async throws -> (URLRequest) {
        var requestCopy = request
        requestCopy.addValue("Bearer \(token ?? "")", forHTTPHeaderField: "Authorization")
        return requestCopy
    }
}

Logging Middleware

This middleware logs the details of each request before it is sent. It could help by tracking requests and debugging issues by logging relevant information. In this middleware, each request’s description is printed and logged before it’s sent.

public struct LoggingMiddleware: APIClient.Middleware {
    private var logger: Logging
    
    init(logger: Logging) {
        self.logger = logger
    }
    
    func intercept(_ request: URLRequest) async throws -> (URLRequest) {
        print(request.customDescription)
        logger.log(message: request.customDescription)
        return request
    }
}


// Logging
protocol Logging {
    func log(message: String)
}
struct Logger: Logging {
    func log(message: String) {
        print(#function, message)
    }
}

Note - for pretty logging of the URLRequest object, I have this helper extension:

extension URLRequest {
    public var customDescription: String {
        var printableDescription = ""
        
        if let method = self.httpMethod {
            printableDescription += method
        }
        if let urlString = self.url?.absoluteString {
            printableDescription += " " + urlString
        }
        if let headers = self.allHTTPHeaderFields, !headers.isEmpty {
            printableDescription += "\\nHeaders: \(headers)"
        }
        if let bodyData = self.httpBody,
            let body = String(data: bodyData, encoding: .utf8) {
            printableDescription += "\\nBody: \(body)"
        }
        
        return printableDescription.replacingOccurrences(of: "\\n", with: "\n")
    }
}

Putting It All Together

To utilize the middleware system in APIClient, simply initialize the client with the desired middlewares:

let baseURL = URL(string: "https://jsonplaceholder.typicode.com")

let loggingMiddleware = LoggingMiddleware(logger: Logger())
let authorizationMiddleware = AuthorizationMiddleware(token: "auth-token")

let apiClient = APIClient(
    baseURL: baseURL!,
    middlewares: [loggingMiddleware, authorizationMiddleware]
)
let todosService = TodosAPIService(apiClient: apiClient)


// Usage
print("Creating a todo")
let todosService = TodosAPIService(apiClient: apiClient)
do {
    let todo = try await todosService.createTodo(userId: 1, title: "Shop bananas")
    print(#function, todo)
} catch {
    print(#function, error)
}
print("Done!")

When looking in console, we see the middlewares at work:

Creating a todo
POST https://jsonplaceholder.typicode.com/todos
Headers: ["Content-Type": "application/json", "Authorization": "Bearer auth-token"]
Body: {"completed":false,"userId":2,"title":"Shop bio bananas"}

getTodosService() TodoDTO(id: Optional(201), userId: 2, title: "Shop bio bananas", completed: false)
Done!

Conclusions

In this first part of the series, we've built a flexible and extensible APIClient for iOS, with support for various API specifications and middlewares.

But what good is a networking layer without being able to reliably test it? In Part 2, we will explore how to write comprehensive unit and integration tests for our APIClient, Middleware or APIService. 


Thank you for reading! If you enjoyed this post and want to explore topics like this, don’t forget to subscribe to the newsletter. You’ll get the latest blog posts delivered directly to your inbox. Follow me on LinkedIn and Twitter/X. Your journey doesn’t have to end here. Subscribe, follow, and let’s take continue the conversation. This is the way!