Simple Network Layer in Combine
Tags: swift, ios
Published: 2023-05-10 16:50:48

> “Any fool can write code that a computer can understand. Good programmers write code that humans can understand.” — Martin Fowler

In my iOS projects, I’ve come to realize that crafting a functional and efficient Network Layer is an ongoing adventure. It’s like a constantly evolving puzzle that keeps me on my toes. With each new project, I strive to make improvements to the Network Layer, always aiming for a smoother experience than before. However, I’ve also learned that sometimes, in the pursuit of perfection, I can get caught up in overthinking and unnecessary complexity.
Recently, I took a step back and approached things differently. I decided to focus on creating a clean and straightforward class to handle network calls in my iOS app. By simplifying and streamlining the process, I hope to achieve a more sensible and enjoyable development experience. Join me on this journey as we unravel the mysteries of the Network Layer and discover the beauty of simplicity in handling our network requests.

## Let’s Start Then!

Before we get to the Network Layer, I think we can create a simple **ResponseWrapper**. Usually, an API response will have the following structure

```
{
  "message": "success",
  "status": 200,
  "data": {
    "id": 123,
    "name": "John Doe",
    "email": "johndoe@example.com",
    "age": 30,
    "city": "New York"
  }
}
```
Bearing this format in mind, let’s proceed to make a Class to handle the response.

```
struct ResponseWrapper<T: Decodable>: Decodable {
    let status: Int?
    let message: String?
    let data: T?
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        status = try container.decodeIfPresent(Int.self, forKey: .status)
        message = try container.decodeIfPresent(String.self, forKey: .message)
        data = try container.decodeIfPresent(T.self, forKey: .data)
    }
    
    private enum CodingKeys: String, CodingKey {
        case status
        case message
        case data
    }
}
```

Having the `status`, `message`, and `data` as optional parameters provides me with peace of mind when deploying the code to production. I prefer handling guarded unwrapping over encountering a `fatalError` .
Enabling the `decoder` through `CodingKeys` enhances the flexibility and reusability of the Wrapper, allowing it to adapt to new developer and backend naming conventions.


## The Network Layer

- **Error Handling**

I decided to stick to an `enum` based approach, here. So let’s start with some simple error handling-

```
enum APIError: Error {
    case invalidURL
    case requestFailed(String)
    case decodingFailed
}

enum HttpMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
}
```

This part is fairly self-explanatory, I am sure. With `APIError` we can handle the obvious and expected error responses that we get from the API. Same simplicity was used to create a set of `enums` for handling `HTTPMethods`.

- **Endpoints**

Now we shall proceed to create the endpoints required in our app. This too, shall follow the enum pattern we have created for the Error extension-

```
enum Endpoint {
    case examplePostOne
    case examplePutOne
    case exampleDelete
    case examplePostTwo
    case exampleGet
    case examplePutTwo
    
    var path: String {
        switch self {
        case .examplePostOne:
            return "/api/postOne"
        case .examplePutOne:
            return "/api/putOne"
        case .exampleDelete:
            return "/api/delete"
        case .examplePostTwo:
            return "/api/postTwo"
        case .exampleGet:
            return "/api/get"
        case .examplePutTwo:
            return "/api/puTwo"
        }
    }
    
    var httpMethod: HttpMethod {
        switch self {
        case .exampleGet:
            return .get
        case .examplePostOne, .examplePostTwo:
            return .post
        case .examplePutOne, .examplePutTwo:
            return .put
        case .exampleDelete:
            return .delete
        }
    }
}
```

This approach enables us to access the URLEndpoints and HTTPMethods easily, using the following methods-

```
let endPoint: Endpoint = Endpoint.examplePostOne
print(endPoint.path) // prints "/api/postOne"
print(endPoint.httpMethod) // .post
```
As the complexity of the app increases and more API endpoints are to be added, we can do it here inside the `enum` `Endpoint` , and add the `HTTPMethod`, with it. This will be a single Source Of Truth for our API endpoints.

- **Network Request**

Next we make a very basic `NetworkManager` class to hold our `request` function. We shall use the Combine Framework to implement this functionality-

```
protocol NetworkService {
    func request<T: Decodable>(_ endpoint: Endpoint, parameters: Encodable?) -> AnyPublisher<T, APIError>
}

class NetworkManager: NetworkService {
    private let baseURL: String
    
    init(baseURL: String = "") {
        self.baseURL = environment.baseURL
    }
    
    func request<T: Decodable>(_ endpoint: Endpoint, parameters: Encodable? = nil) -> AnyPublisher<T, APIError> {
            guard let url = URL(string: baseURL + endpoint.path) else {
                return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
            }
            var urlRequest = URLRequest(url: url)
            
            if let parameters = parameters {
                urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
                do {
                    let jsonData = try JSONEncoder().encode(parameters)
                    urlRequest.httpBody = jsonData
                } catch {
                    return Fail(error: APIError.requestFailed("Encoding parameters failed.")).eraseToAnyPublisher()
                }
            }
            return URLSession.shared.dataTaskPublisher(for: urlRequest)
                .tryMap { (data, response) -> Data in
                    if let httpResponse = response as? HTTPURLResponse,
                       (200..<300).contains(httpResponse.statusCode) {
                        return data
                    } else {
                        let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
                        throw APIError.requestFailed("Request failed with status code: \(statusCode)")
                    }
                }
                .decode(type: ResponseWrapper<T>.self, decoder: JSONDecoder())
                .tryMap { (responseWrapper) -> T in
                    guard let status = responseWrapper.status else {
                        throw APIError.requestFailed("Missing status.")
                    }
                    switch status {
                    case 200:
                        guard let data = responseWrapper.data else {
                            throw APIError.requestFailed("Missing data.")
                        }
                        return data
                    default:
                        let message = responseWrapper.message ?? "An error occurred."
                        throw APIError.requestFailed(message)
                    }
                }
                .mapError { error -> APIError in
                    if error is DecodingError {
                        return APIError.decodingFailed
                    } else if let apiError = error as? APIError {
                        return apiError
                    } else {
                        return APIError.requestFailed("An unknown error occurred.")
                    }
                }
                .eraseToAnyPublisher()
        }
}
```

Let us set the `baseURL` as an empty string to start off, here. We can modify that later. One thing to notice here is the generic `T` we are using to refer to the `decodable`. What we are trying to do here, is to let the function know the data type to which we would like to `decode` our response to. We have created the `ResponseWrapper` earlier but that is not where should map the final result to. We need to decode the value that we expect to receive inside `data` key.

So, we use `.decode` on `ResponseWrapper` which tries to match its data to the very data type that we pass in (don’t worry, this will be clearer, once we try to fetch data). Once the decoding is done successfully on `ResponseWrapper` we look for `status`, `message` and `data` inside it. If the decoding fails, we try to connect the reason of failure, to appropriate APIError .

- **BaseURL**

We could’ve passed in a simple string for baseURL, but it is a common and neat practise to have a `development`, `staging` and `production` environment, while working as part of a team and when working for an outside client. So to have a clean release cycle, we can classify the baseURL as so-

```
enum APIEnvironment {
    case development
    case staging
    case production
    
    var baseURL: String {
        switch self {
        case .development:
            return "development.example.com"
        case .staging:
            return "staging.example.com"
        case .production:
            return "production.example.com"
        }
    }
}
```

Let us now modify the initialisation for `NetworkManager` -

```
init(environment: APIEnvironment = NetworkManager.defaultEnvironment()) {
        self.baseURL = environment.baseURL
    }
    
    static func defaultEnvironment() -> APIEnvironment {
#if DEBUG
        return .development
#elseif STAGING
        return .staging
#else
        return .production
#endif
    }
```
`#if DEBUG` is provided by the compiler. You can define your own custom flags or build configurations, such as `STAGING`, to differentiate between different environments during the build process. This is a cleaner way to initialise the class from a `ViewModel` where we might try to access the `NetworkManager` from.

- **Headers**

We can add custom `header` elements as well, as they tend to come in handy.

```
private func defaultHeaders() -> [String: String] {
        var headers: [String: String] = [
            "Platform": "iOS",
            "User-Token": "your_user_token",
            "uid": "user-id"
        ]
        
        if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
            headers["App-Version"] = appVersion
        }
        
        return headers
    }
```

We can add elements like `auth-token`, `refresh-token` etc in our `headers`. Of course, now that we have created the headers, we need to include them in the API call, as well.

First, let’s change the function call-

```
protocol NetworkService {
    func request<T: Decodable>(_ endpoint: Endpoint, headers: [String: String]?, parameters: Encodable?) -> AnyPublisher<T, APIError>
}
```
And then, we add the httpMethod inclusion in the function definition-

```
func request<T: Decodable>(_ endpoint: Endpoint, headers: [String: String]? = nil, parameters: Encodable? = nil) -> AnyPublisher<T, APIError> {
            guard let url = URL(string: baseURL + endpoint.path) else {
                return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
            }
            var urlRequest = URLRequest(url: url)
            urlRequest.httpMethod = endpoint.httpMethod.rawValue
            let allHeaders = defaultHeaders().merging(headers ?? [:], uniquingKeysWith: { (_, new) in new })
                for (key, value) in allHeaders {
                    urlRequest.setValue(value, forHTTPHeaderField: key)
            }
.
.
.
.
.
.
}
```

With this, we have the function definition, sorted out. When we combine everything, we get this-

```
import Foundation
import Combine

enum APIError: Error {
    case invalidURL
    case requestFailed(String)
    case decodingFailed
}

enum HttpMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
}

enum Endpoint {
    case examplePostOne
    case examplePutOne
    case exampleDelete
    case examplePostTwo
    case exampleGet
    case examplePutTwo
    
    var path: String {
        switch self {
        case .examplePostOne:
            return "/api/postOne"
        case .examplePutOne:
            return "/api/putOne"
        case .exampleDelete:
            return "/api/delete"
        case .examplePostTwo:
            return "/api/postTwo"
        case .exampleGet:
            return "/api/get"
        case .examplePutTwo:
            return "/api/puTwo"
        }
    }
    
    var httpMethod: HttpMethod {
        switch self {
        case .exampleGet:
            return .get
        case .examplePostOne, .examplePostTwo:
            return .post
        case .examplePutOne, .examplePutTwo:
            return .put
        case .exampleDelete:
            return .delete
        }
    }
}

enum APIEnvironment {
    case development
    case staging
    case production
    
    var baseURL: String {
        switch self {
        case .development:
            return "development.example.com"
        case .staging:
            return "staging.example.com"
        case .production:
            return "production.example.com"
        }
    }
}

protocol NetworkService {
    func request<T: Decodable>(_ endpoint: Endpoint, headers: [String: String]?, parameters: Encodable?) -> AnyPublisher<T, APIError>
}



class NetworkManager: NetworkService {
    private let baseURL: String
    
    init(environment: APIEnvironment = NetworkManager.defaultEnvironment()) {
        self.baseURL = environment.baseURL
    }
    
    static func defaultEnvironment() -> APIEnvironment {
#if DEBUG
        return .development
#elseif STAGING
        return .staging
#else
        return .production
#endif
    }
    
    private func defaultHeaders() -> [String: String] {
        var headers: [String: String] = [
            "Platform": "iOS",
            "User-Token": "your_user_token",
            "uid": "user-id"
        ]
        
        if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
            headers["App-Version"] = appVersion
        }
        
        return headers
    }
    
    func request<T: Decodable>(_ endpoint: Endpoint, headers: [String: String]? = nil, parameters: Encodable? = nil) -> AnyPublisher<T, APIError> {
            guard let url = URL(string: baseURL + endpoint.path) else {
                return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
            }
            var urlRequest = URLRequest(url: url)
            urlRequest.httpMethod = endpoint.httpMethod.rawValue
            let allHeaders = defaultHeaders().merging(headers ?? [:], uniquingKeysWith: { (_, new) in new })
                for (key, value) in allHeaders {
                    urlRequest.setValue(value, forHTTPHeaderField: key)
            }
            if let parameters = parameters {
                urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
                do {
                    let jsonData = try JSONEncoder().encode(parameters)
                    urlRequest.httpBody = jsonData
                } catch {
                    return Fail(error: APIError.requestFailed("Encoding parameters failed.")).eraseToAnyPublisher()
                }
            }
            return URLSession.shared.dataTaskPublisher(for: urlRequest)
                .tryMap { (data, response) -> Data in
                    if let httpResponse = response as? HTTPURLResponse,
                       (200..<300).contains(httpResponse.statusCode) {
                        return data
                    } else {
                        let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
                        throw APIError.requestFailed("Request failed with status code: \(statusCode)")
                    }
                }
                .decode(type: ResponseWrapper<T>.self, decoder: JSONDecoder())
                .tryMap { (responseWrapper) -> T in
                    guard let status = responseWrapper.status else {
                        throw APIError.requestFailed("Missing status.")
                    }
                    switch status {
                    case 200:
                        guard let data = responseWrapper.data else {
                            throw APIError.requestFailed("Missing data.")
                        }
                        return data
                    default:
                        let message = responseWrapper.message ?? "An error occurred."
                        throw APIError.requestFailed(message)
                    }
                }
                .mapError { error -> APIError in
                    if error is DecodingError {
                        return APIError.decodingFailed
                    } else if let apiError = error as? APIError {
                        return apiError
                    } else {
                        return APIError.requestFailed("An unknown error occurred.")
                    }
                }
                .eraseToAnyPublisher()
        }
}
```

- **Fetch Request**

Let us assume that we are expecting a response for the API .exampleGet in the following format-

```
{
  "message": "success",
  "status": 200,
  "data": {
    "id": 123,
    "name": "John Doe",
    "email": "johndoe@example.com",
    "age": 30,
    "city": "New York"
  }
}
```

We will start off by creating a new struct- `ResponseModel` that conforms to `Codable` protocol.

```
struct ResponseModel: Codable {
  var id: Int
  var name: String
  var email: String
  var age: Int
  var city: String
}
```
As you may notice, we don’t have to worry about `message` and `status` keys since that is taken care of, by the `ResponseWrapper` . We are just providing for the data key in `ResponseWrapper` .
Next, we can create a `ViewModel` and call the request function-


```
import Foundation
import Combine

class ResponseViewModel: ObservableObject {
    
    private let networkService: NetworkService
    private var cancellables: Set<AnyCancellable> = []
    
    init(networkService: NetworkService = NetworkManager()) {
        self.networkService = networkService
    }
    
    func signUp(onCompletion: @escaping (Bool) -> ())  {
        let response: AnyPublisher<ResponseModel, APIError> = networkService.request(.exampleGet, headers: nil)
        response
            .sink { completion in
                switch completion {
                    case .finished:
                        break
                    case .failure(let error):
                        print("Error: \(error)")
                        onCompletion(false)
                }
            }
            receiveValue: { response in
                print(response)
                
                onCompletion(true)
            }
            .store(in: &cancellables)
    }
}
```

When we pass in `ResponseModel` for the request function, we are effectively doing a double decoding. First we the function maps the response to `ResponseWrapper` , and once that is done, it tries to unwrap the data by matching it to `ResponseModel` . This sets up a clean structure for API response and API Parsing, which would be integral for setting up clean code.

And there you have it. A reusable Network Layer that is flexible and scalable.

Feel free to make it better and make it yours.

Cheers. Happy Coding.