Sitemap

Modularizing the Network Layer in Swift with Combine

--

Introduction

In a world where software development is increasingly dynamic, code clarity and maintainability become essential. The network layer, in particular, can become a maze in complex applications. In this article, we will unravel this maze and present a modular approach using Swift and Combine.

The Problem

Imagine an app that starts with a single network call using URLSession directly in the ViewController. It seems simple, right? But as the app evolves, this simplicity can turn into a monster of duplicated code, coupling, and nearly impossible testing.

The Solution: Modular Network Layer

APIRequest and HTTPMethod

Before delving into network calls, it’s crucial to establish a solid foundation. This foundation is the structure of our requests.

import Foundation

public protocol APIRequest {
associatedtype ResponseType: Decodable
var baseURL: URL { get }
var path: String { get }
var method: HTTPMethod { get }
var body: Data? { get }
var customHeaderFields: [String: String]? { get }
}

public extension APIRequest {
var url: URL? {
return baseURL.appendingPathComponent(path)
}
}

public enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case patch = "PATCH"
case delete = "DELETE"
}

Here, APIRequest serves as a contract that all our requests will follow, while HTTPMethod enumerates the HTTPMethod we can use.

NetworkSession and URLSession

With the foundation established, it’s time to think about how we will load our data. Creating an abstraction here not only makes testing easier but also lays the groundwork for future extensions.

public protocol NetworkSession: AnyObject {
func loadData(from url: URLRequest, completionHandler: @escaping (Result<(Data, URLResponse), Error>) -> Void)
}

extension URLSession: NetworkSession {
public func loadData(from url: URLRequest, completionHandler: @escaping (Result<(Data, URLResponse), Error>) -> Void) {
let task = dataTask(with: url, completionHandler: { data, response, error in
if let data = data, let response = response {
completionHandler(.success((data, response)))
} else if let error = error {
completionHandler(.failure(error))
}
})
task.resume()
}
}

APIServiceManager, ResponseDecoder, and APIError

Now, we enter the heart of our network layer. But before diving into the code, let’s understand Combine’s role here. Combine is a framework that allows us to work with asynchronous operations and events using functional types, making our code cleaner and more understandable.

APIError helps us deal with the inevitable errors we will encounter. ResponseDecoder, on the other hand, is an abstraction that allows us to decode responses in any way we want. Finally, DefaultAPIServiceManager is where the magic happens, integrating everything and using Combine to handle asynchronous operations.

import Foundation
import Combine

public enum APIError: Error {
case invalidURL
case apiError(cause: Error, statusCode: Int?)
case invalidResponse
case notFound
case serverError
case decodingError(Error)
}

public protocol APIServiceManager {
func fetch<T: APIRequest>(request: T) -> AnyPublisher<T.ResponseType, APIError>
}

public protocol ResponseDecoder {
func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T
}

public struct DefaultResponseDecoder: ResponseDecoder {
public init() {}

public func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
let decoder = JSONDecoder()
return try decoder.decode(type, from: data)
}
}

public class DefaultAPIServiceManager: APIServiceManager {
private let session: NetworkSession
private let decoder: ResponseDecoder

public init(session: NetworkSession, decoder: ResponseDecoder = DefaultResponseDecoder()) {
self.session = session
self.decoder = decoder
}

public convenience init() {
self.init(session: URLSession.shared, decoder: DefaultResponseDecoder())
}

public func fetch<T: APIRequest>(request: T) -> AnyPublisher<T.ResponseType, APIError> {
return Future { [weak self] promise in
self?.performRequest(request: request, completion: { result in
switch result {
case .success(let model):
promise(.success(model))
case .failure(let error):
promise(.failure(error))
}
})
}
.eraseToAnyPublisher()
}

private func performRequest<T: APIRequest>(request: T, completion: @escaping (Result<T.ResponseType, APIError>) -> Void) {
guard let url = request.url else {
return completion(.failure(.invalidURL))
}

var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method.rawValue
urlRequest.allHTTPHeaderFields = request.customHeaderFields
urlRequest.httpBody = request.body

session.loadData(from: urlRequest) { result in
switch result {
case .success(let (data, response)):
self.handleResponse(response, data, nil, request: request, completion: completion)
case .failure(let error):
self.handleResponse(nil, nil, error, request: request, completion: completion)
}
}
}

private func handleResponse<T: APIRequest>(_ response: URLResponse?, _ data: Data?, _ error: Error?, request: T, completion: @escaping (Result<T.ResponseType, APIError>) -> Void) {
if let error = error {
completion(.failure(.apiError(cause: error, statusCode: nil)))
return
}

guard let httpResponse = response as? HTTPURLResponse else {
completion(.failure(.invalidResponse))
return
}

switch httpResponse.statusCode {
case 200..<300:
// Success range
break
case 404:
completion(.failure(.notFound))
return
case 500..<600:
completion(.failure(.serverError))
return
default:
completion(.failure(.apiError(cause: NSError(domain: "unknownError", code: httpResponse.statusCode, userInfo: nil), statusCode: httpResponse.statusCode)))
return
}

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

do {
let model = try self.decoder.decode(T.ResponseType.self, from: data)
completion(.success(model))
} catch {
completion(.failure(.decodingError(error)))
}
}
}

Practical Example: Using the CoinGecko API

To demonstrate our network layer in action, let’s use the CoinGecko API, a popular platform that provides information about cryptocurrencies. We will focus on obtaining a list of available coins.

Coin Structure

First, let’s define the structure of a coin we expect to receive from the API:

struct Coin: Decodable {
let id: String
let symbol: String
let name: String
}

Coin List Request

Now, let’s create a specific request to get the list of coins:

struct CoinListRequest: APIRequest {
typealias ResponseType = [Coin]

var baseURL: URL {
return URL(string: "https://api.coingecko.com")!
}

var path: String {
return "/api/v3/coins/list"
}

var method: HTTPMethod {
return .get
}

var body: Data? {
return nil
}

var customHeaderFields: [String : String]? {
return nil
}
}

You may have noticed that we are returning nil for body and customHeaderFields. This is because, for this specific example, we don’t need to send a request body or custom header fields. However, in a real scenario, these fields can be crucial.

For example, if we were making a POST call to create a new coin, the request body could contain the details of that coin:

var body: Data? {
let coinDetails = [
"id": "newcoin",
"symbol": "nc",
"name": "New Coin"
]
return try? JSONSerialization.data(withJSONObject: coinDetails, options: [])
}

And if the API required authentication, the custom header fields could contain an authentication token:

var customHeaderFields: [String : String]? {
return ["Authorization": "Bearer YOUR_TOKEN_HERE"]
}

These are just simple examples, but they illustrate how flexible and adaptable the APIRequest structure is to different network call needs.

Testability with CoreNetworkTests

What is code without tests? Incomplete. Thanks to our modular approach, writing unit tests becomes much simpler. Let’s use the above structures to perform tests on our network layer.

import XCTest
import Combine
@testable import CoreNetwork

final class CoreNetworkTests: XCTestCase {

var cancellables: Set<AnyCancellable> = []

func testCoinListAPI() throws {
let expectation = self.expectation(description: "Fetching coin list")

let apiManager = DefaultAPIServiceManager()
let request = CoinListRequest()

apiManager.fetch(request: request)
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure(leterror):
XCTFail("API call failed with error: (error)")
}
}, receiveValue: { coins in
XCTAssert(!coins.isEmpty, "Coin list is empty")
expectation.fulfill()
})
.store(in: &cancellables)
waitForExpectations(timeout: 10, handler: nil)
}
}

Conclusion

Modularization, as we have seen, is not just a technique to organize code. It is a strategy that, when correctly implemented, can bring numerous benefits to software development. In large teams, modularization facilitates collaboration, allowing different developers to work on distinct parts of the code without interfering with each other. Furthermore, modular code is more sustainable in the long run. It is easier to maintain, test, and scale. With Swift and Combine as allies, we have a solid and powerful foundation on which an application can grow and evolve, confidently and effectively tackling the challenges of modern development.

Links and References

While this article was written based on experiences and best practices, it is always helpful to consult official documentation and other resources to delve deeper into the topic. Here are some links I used and that may be useful:

- Official Swift Documentation
- Combine Framework
- URLSession

Now it’s your turn!

I encourage you to test this approach in your own app. How are you implementing this modular network layer? Is there anything you think we could improve or optimize? I would love to hear your comments and feedback. The community benefits greatly when we share our experiences and learnings. So, try it out, adapt it to your needs, and share your discoveries!

--

--

Marcelo Henrique
Marcelo Henrique

Written by Marcelo Henrique

Software Engineer with a passion for iOS

No responses yet