DEV Community

Sidharth J Dev
Sidharth J Dev

Posted on

Simple Network Layer in Combine

“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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
        }
}
Enter fullscreen mode Exit fullscreen mode

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"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
Enter fullscreen mode Exit fullscreen mode

#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
    }
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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)
            }
.
.
.
.
.
.
}
Enter fullscreen mode Exit fullscreen mode

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()
        }
}
Enter fullscreen mode Exit fullscreen mode
  • 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)