Table of Contents
0. First, let's initiate the API service.
2. Fetching a Single Todo by ID
Extending the APIClient with Middlewares
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!