Writing a Networking Library with Combine, Codable and Swift 5

Most of our apps rely on network calls to work, and thanks to URLSession and Codable, consuming REST APIs has become a lot easier these days. That said, we are still writing quite a bit of code to deal with async calls, JSON encoding and decoding, HTTP error handling, and more.

On the other hand, ready-to-use libs such as Alamofire are great, especially because you can start using them right away, but they also have to be multi-purpose and as generic as possible to be useful to the majority of people and include features that you’ll never use.

With that in mind, let’s write our own simple networking library, specifically designed to consume REST APIs without effort, using a ‘convention over configuration’ approach, alongside Combine, URLSession, and the Codable protocol.

The Request

We will start with a protocol to outline what a network request looks like, and include some of the most common properties we’ll need.

  • A Path or URL to call
  • The HTTP Method (GET, POST, PUT, DELETE)
  • The request Body
  • And optionally, some headers
import Foundation
import Combine
public enum HTTPMethod: String {
    case get     = "GET"
    case post    = "POST"
    case put     = "PUT"
    case delete  = "DELETE"
}
public protocol Request {
    var path: String { get }
    var method: HTTPMethod { get }
    var contentType: String { get }
    var body: [String: Any]? { get }
    var headers: [String: String]? { get }
    associatedtype ReturnType: Codable
}

Besides defining the properties, we are also adding an associatedType. Associated Types are a placeholder for a type we will define when the protocol is adopted.

With the protocol defined, let’s set some default values, via an extension. By default, a request will use the GET method, have application/json content-type, and it’s body, headers and Query Parameters will be empty.

extension Request {
    // Defaults
    var method: String { return .get }
    var contentType: String { return “application/json” }
    var queryParams: [String: String]? { return nil }
    var body: [String: Any]? { return nil }
    var headers: [String: String]? { return nil }
}

Since we will be using URLSession to perform all the networking calls, let’s write a couple of utility methods to transform our custom Request Type into a plain URLRequest Object.

The first of two methods, requestBodyFrom, serializes a dictionary and the second, asURLRequest is used to generate a plain URLRequest object.

extension Request {
    /// Serializes an HTTP dictionary to a JSON Data Object
    /// - Parameter params: HTTP Parameters dictionary
    /// - Returns: Encoded JSON
    private func requestBodyFrom(params: [String: Any]?) -> Data? {
        guard let params = params else { return nil }
        guard let httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) else {
            return nil
        }
        return httpBody
    }
    /// Transforms a Request into a standard URL request
    /// - Parameter baseURL: API Base URL to be used
    /// - Returns: A ready to use URLRequest
    func asURLRequest(baseURL: String) -> URLRequest? {
        guard var urlComponents = URLComponents(string: baseURL) else { return nil }
        urlComponents.path = "\(urlComponents.path)\(path)"
        guard let finalURL = urlComponents.url else { return nil }
        var request = URLRequest(url: finalURL)
        request.httpMethod = method.rawValue
        request.httpBody = requestBodyFrom(params: body)
        request.allHTTPHeaderFields = headers
        return request
    }
}

With this new protocol, defining a network request is as simple as doing:

// Model
struct Todo: Codable {
   var title: String
   var completed: Bool
}
// Request
struct FindTodos: Request {
     typealias ReturnType = [Todo]
     var path: String = "/todos"
}

This will translate into a GET request to the /todo endpoint, that returns a list of ToDo items.

Note As the ReturnType, we can use any Type that conforms to Codable, depending on the information we are fetching.

The Dispatcher

Now that we have our request ready, we need a way to dispatch it over the network, fetch the data, and decode it. For that, we will be using Combine and Codable.

The first step is to define an enum to hold the error codes.

enum NetworkRequestError: LocalizedError, Equatable {
    case invalidRequest
    case badRequest
    case unauthorized
    case forbidden
    case notFound
    case error4xx(_ code: Int)
    case serverError
    case error5xx(_ code: Int)
    case decodingError
    case urlSessionFailed(_ error: URLError)
    case unknownError
}

Now let’s write our dispatch function. By using generics, we can define the ReturnType, and return a Publisher that will pass the requested output to its subscribers.

Our NetworkDispatcher will receive a URLRequest, send it over the network and decode the JSON response for us.

struct NetworkDispatcher {
    let urlSession: URLSession!
    public init(urlSession: URLSession = .shared) {
        self.urlSession = urlSession
    }
    /// Dispatches an URLRequest and returns a publisher
    /// - Parameter request: URLRequest
    /// - Returns: A publisher with the provided decoded data or an error
    func dispatch<ReturnType: Codable>(request: URLRequest) -> AnyPublisher<ReturnType, NetworkRequestError> {
        return urlSession
            .dataTaskPublisher(for: request)
            // Map on Request response
            .tryMap({ data, response in
                // If the response is invalid, throw an error
                if let response = response as? HTTPURLResponse,
                   !(200...299).contains(response.statusCode) {
                    throw httpError(response.statusCode)
                }
                // Return Response data
                return data
            })
            // Decode data using our ReturnType
            .decode(type: ReturnType.self, decoder: JSONDecoder())
            // Handle any decoding errors
            .mapError { error in
                handleError(error)
            }
            // And finally, expose our publisher
            .eraseToAnyPublisher()
    }
}

We are using URLSession’s dataTaskPublisher to perform the request and then mapping on the response, to properly handle errors. If the request completes successfully, we move along and try to decode the received JSON data.

In order to make this testable, we are injecting the URLSession, and defaulting it to URLSession.shared for convenience.

Now let’s define some helper methods to handle errors. The first, httpError, to deal with HTTP errors, and the second handleError, to help with JSON Decoding and general errors.

extension NetworkDispatcher {
/// Parses a HTTP StatusCode and returns a proper error
    /// - Parameter statusCode: HTTP status code
    /// - Returns: Mapped Error
    private func httpError(_ statusCode: Int) -> NetworkRequestError {
        switch statusCode {
        case 400: return .badRequest
        case 401: return .unauthorized
        case 403: return .forbidden
        case 404: return .notFound
        case 402, 405...499: return .error4xx(statusCode)
        case 500: return .serverError
        case 501...599: return .error5xx(statusCode)
        default: return .unknownError
        }
    }
    /// Parses URLSession Publisher errors and return proper ones
    /// - Parameter error: URLSession publisher error
    /// - Returns: Readable NetworkRequestError
    private func handleError(_ error: Error) -> NetworkRequestError {
        switch error {
        case is Swift.DecodingError:
            return .decodingError
        case let urlError as URLError:
            return .urlSessionFailed(urlError)
        case let error as NetworkRequestError:
            return error
        default:
            return .unknownError
        }
    }
}

The APIClient

Now that we have both our Request and Dispatcher, let’s create a type to wrap our API Calls.

Our APIClient will receive a NetworkDispatcher and a BaseUrl and will provide a centralized Dispatch method. That method will receive a Request, convert it to a URLRequest and pass it along to the provided network dispatcher.

struct APIClient {
    var baseURL: String!
    var networkDispatcher: NetworkDispatcher!
    init(baseURL: String,
                networkDispatcher: NetworkDispatcher = NetworkDispatcher()) {
        self.baseURL = baseURL
        self.networkDispatcher = networkDispatcher
    }
    /// Dispatches a Request and returns a publisher
    /// - Parameter request: Request to Dispatch
    /// - Returns: A publisher containing decoded data or an error
    func dispatch<R: Request>(_ request: R) -> AnyPublisher<R.ReturnType, NetworkRequestError> {
        guard let urlRequest = request.asURLRequest(baseURL: baseURL) else {
            return Fail(outputType: R.ReturnType.self, failure: NetworkRequestError.badRequest).eraseToAnyPublisher()
        }
        typealias RequestPublisher = AnyPublisher<R.ReturnType, NetworkRequestError>
        let requestPublisher: RequestPublisher = networkDispatcher.dispatch(request: urlRequest)
        return requestPublisher.eraseToAnyPublisher()
    }
}

We are returning a Publisher with either the response or a NetworkRequest error, and since you can inject your own NetworkDispatcher, testing is super-easy.

Performing a request

That’s it!. Now if we want to perform a network request, we can do as follows:

private var cancellables = [AnyCancellable]()
let dispatcher = NetworkDispatcher()
let apiClient = APIClient(baseURL: "https://jsonplaceholder.typicode.com")
apiClient.dispatch(FindTodos())
    .sink(receiveCompletion: { _ in },
          receiveValue: { value in
            print(value)
        })
    .store(in: &cancellables)

Cool huh?. In this case we are performing a simple GET request, but you can add additional parameters and configure your Request as needed:

For example, if we wanted to Add a Todo we could do something like this:

// Our Add Request
struct AddTodo: Request {
     typealias ReturnType = [Todo]
     var path: String = "/todos"
     var method: HTTPMethod = .post
    var body: [String: Any]
    init(body: [String: Any]) {
        self.body = body
    }
}
let todo: [String: Any] = ["title": "Test Todo", "completed": true]
apiClient.dispatch(AddTodo(body: todo))
    .sink(receiveCompletion: { result in
        // Do something after adding...
        },
        receiveValue: { _ in })
    .store(in: &cancellables)

In this case we are constructing the body from a simple dictionary, but to make things easier, let’s extend Encodable, and add a method to convert a Encodable Type to a Dictionary.

extension Encodable {
    var asDictionary: [String: Any] {
        guard let data = try? JSONEncoder().encode(self) else { return [:] }
        guard let dictionary = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else {
            return [:]
        }
        return dictionary
    }
}

With this, you can write the request as follows:

let otherTodo: Todo = Todo(title: "Test", completed: true)
apiClient.dispatch(AddTodo(body: otherTodo.asDictionary))
    .sink(receiveCompletion: { result in
        // Do something after adding...
        },
        receiveValue: { _ in })
    .store(in: &cancellables)

Conclusion

Thanks to Combine and Codable, we were able to write a very simple Networking client. The Request Type is extensible and easy to maintain, and both our Network Dispatcher and API clients are easy to test and extremely simple to use.

Some of the next steps could include adding authentication, caching, and more detailed logging to facilitate debugging, but that’s something we can do down the road.

Get the Code

  • This Playground includes all the code in this tutorial on a single file.
  • Wirekit, based on this approach is an open-source networking library designed to simplify using REST APIs in your app.

As always, feel free to ping me on Twitter or in the comments below if you have questions or feedback!.

Photo By: JJ Yink

5 thoughts on “Writing a Networking Library with Combine, Codable and Swift 5

  1. Hi Daniel, thank you so much for this great article. In this model, I could not find how to show the error returned when making a post request to the user. I catch the value in the Viewmodel into the array and show it in the View. I can do it easily with URLSession actually, but I was wondering how to do it in this model. Do you have an idea?

  2. Hi Daniel! Great tutorial and overview. I had a question about implementing this using the middleware redux example. For example if I have a service with a function do I still return a future using the above apiclient like the below example function?

    mutating func someFunction() -> AnyPublisher {
    return Future { promise in
    apiClient.dispatch(SomeMethod(method: .GET))
    .sink(receiveCompletion: { _ in },
    receiveValue: {value in
    promise(.success(value))
    })
    }

    or do I just use the apiclient dispatch and map it to AppAction? I’m not sure how to apply it to the middleware.

    1. Hi Daniel.

      So, if I understand it correctly, what you’re doing is fetching some data from the web service and doing something in return. If that’s the case, it is a side effect, and therefore a middleware is the best option.

      What I would do here is exactly that. Your middleware receives a copy of the state and runs the API call as a promise. You could fire up the middleware and then post an action to show a “loader” for example. When your middleware returns the call, you post another action that communicates about UI updates or whatever you want to do next.

      If you don’t care about the API response, you can just dispatch an action with the API call.

    1. Well spotted!. For the request, the contentType is passed as part of the headers prop but is defaulting to nil. If you want to pre-define the contentType you could do something like:

      var headers: [String: String]? { return [
      “Accept”: contentType
      ] }

      Or you can also set some default headers and pass them along the ones set up manually in the request, as I do in Wirekit here: https://github.com/afterxleep/WireKit/blob/8d46abd4f2bb2c7045d4123675c516f58d9447b4/Sources/WireKit/Protocols/WKRequest.swift#L90

Leave a Reply