Building a Modern Networking Layer in Swift

Making the API Client Extensible

Because the application will interface with a number of endpoints of the mock API, we need to make sure the API client is easy to extend. The more we can reduce code duplication, the easier it is to extend and maintain the API client. In this episode, I show you how to use generics to make the API client extensible and easy to maintain.

Defining an API Endpoint

Before we start, we add a bit of structure to the Networking group. Add a group with name Types to the Networking group and move APIError.swift into the Types group. The plan for this episode is to create a type that defines an API endpoint. Add a Swift file to the Types group and name it APIEndpoint.swift. We define an enum with name APIEndpoint.

import Foundation

enum APIEndpoint {
	
}

Each case of the APIEndpoint enum defines an endpoint of the mock API. Let's add a case with name episodes for the endpoint that returns the list of episodes.

import Foundation

enum APIEndpoint {

    // MARK: - Cases

    case episodes

}

The APIEndpoint enum exposes a computed property, request, of type URLRequest. The idea is for the APIEndpoint enum to encapsulate the details that are needed to create and configure the request the API client sends to the mock API.

import Foundation

enum APIEndpoint {

    // MARK: - Cases

    case episodes

    // MARK: - Properties

    var request: URLRequest {

    }

}

To create and configure the URLRequest object, the APIEndpoint enum defines a number of private, computed properties. We start with url of type URL. The URL of the request consists of the base URL of the API and the path of the API endpoint.

private var url: URL {
    Environment.apiBaseURL.appendingPathComponent(path)
}

The next step is implementing the computed path property. It is declared privately and of type String. We use a switch statement to return the path for each API endpoint. A switch statement may seem unnecessary, but remember that we are making the networking layer easy to extend and maintain.

private var path: String {
    switch self {
    case .episodes:
        return "episodes"
    }
}

We also define a computed property that returns the headers for the request. Before we implement the computed property, we define a type alias to make it easier to work with headers. Add a Swift file to the Configuration group and name it TypeAliases.swift. We define a type alias, Headers, for a dictionary with keys of type String and values of type String. Type aliases are helpful to keep the code you write readable and easy to understand.

import Foundation

typealias Headers = [String:String]

Revisit APIEndpoint.swift and declare a computed property, headers, of type Headers. The computed property returns two headers for now. The first header defines the content type of the request. The value of the header is application/json. The second header provides the API token. We discussed this earlier in this series.

private var headers: Headers {
    [
        "Content-Type": "application/json",
        "X-API-TOKEN": Environment.apiToken
    ]
}

To add headers to a request, we implement a convenience method. At the bottom of APIEndpoint.swift, add a fileprivate extension for URLRequest. The extension defines a mutating method, addHeaders(_:), that accepts a Headers object as its only argument. In the body of the addHeaders(_:) method, the request iterates over the headers, adding each header by invoking the addValue(_:forHTTPHeaderField:) method.

extension URLRequest {

    mutating func addHeaders(_ headers: Headers) {
        headers.forEach { header, value in
            addValue(value, forHTTPHeaderField: header)
        }
    }

}

Before we implement the computed request property, we define a computed property for the HTTP method of the request. We first create an enum that defines the HTTP method of a request. The type of the httpMethod property of URLRequest is String and that means it is too easy to introduce a bug by making a typo. This is easy to avoid by using an enum.

Add a Swift file to the Types group and name it HTTPMethod.swift. Define an enum, HTTPMethod, with four cases, get, put, post, and delete. The HTTP specification defines more HTTP methods, but this is fine for now. Notice that the raw value of HTTPMethod is string.

import Foundation

enum HTTPMethod: String {
    case get
    case put
    case post
    case delete
}

Head back to APIEndpoint.swift and define a computed property with name, httpMethod, of type HTTPMethod. We can copy the body of the computed path property. Instead of returning the path of the episodes endpoint, we return the HTTP method, get.

private var httpMethod: HTTPMethod {
    switch self {
    case .episodes:
        return .get
    }
}

It is time to implement the computed request property. We first create a mutable URLRequest object by passing the value of the computed url property to the initializer.

var request: URLRequest {
    var request = URLRequest(url: url)
}

To add the headers to the request, we invoke the addHeaders(_:) method on the request, passing in the value of the computed headers property. Setting the HTTP method is easy. We assign the raw value of the computed httpMethod property to the httpMethod property of the request.

var request: URLRequest {
    var request = URLRequest(url: url)

    request.addHeaders(headers)
    request.httpMethod = httpMethod.rawValue

    return request
}

Adding Generics to the Mix

The APIEndpoint enum is only half the solution I have in mind. Open APIClient.swift. We define a private method, request(_:), that accepts an APIEndpoint object as its only argument and returns a publisher.

private func request(_ endpoint: APIEndpoint) -> AnyPublisher {
    
}

We move the implementation of the episodes() method to the request(_:) method. This drastically simplifies the implementation of the episodes() method. We invoke the request(_:) method, passing in an APIEndpoint object.

func episodes() -> AnyPublisher<[Episode], APIError> {
    request(.episodes)
}

Before we can test the changes, we need to tie up a few loose ends. We need to define the Output and Failure types of the publisher the request(_:) method returns. The Failure type is APIError, but what about the Output type. The Output type is defined at the call site and that allows us to make use of generics. Let me show you how that works.

We define the Output type of the publisher as T, a generic type. This means that the closure of the tryMap operator returns an object of type T. We have one problem, though. The JSON decoder requires that the type of the value to decode conforms to the Decodable protocol. This isn't a problem because Swift allows us to define requirements for the generic type. We do this in a pair of angle brackets following the method name. This is similar to conforming a type declaration to a protocol. This little change gets rid of the last compiler error.

private func request<T: Decodable>(_ endpoint: APIEndpoint) -> AnyPublisher<T, APIError> {
    var request = URLRequest(url: Environment.apiBaseURL.appendingPathComponent("episodes"))

    request.addValue(Environment.apiToken, forHTTPHeaderField: "X-API-TOKEN")

    return URLSession.shared.dataTaskPublisher(for: request)
        .tryMap { data, response -> T in
            guard
                let response = response as? HTTPURLResponse,
                (200..<300).contains(response.statusCode)
            else {
                throw APIError.failedRequest
            }

            do {
                return try JSONDecoder().decode(T.self, from: data)
            } catch {
                print("Unable to Decode Response \(error)")
                throw APIError.invalidResponse
            }
        }
        .mapError { error -> APIError in
            switch error {
            case let apiError as APIError:
                return apiError
            case URLError.notConnectedToInternet:
                return APIError.unreachable
            default:
                return APIError.failedRequest
            }
        }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
}

Build and run the application to make sure we didn't break anything.

What's Next?

Thanks to generics and the APIEndpoint enum, adding support for additional endpoints of the mock API is straightforward. In the next episode, we add the ability for the user to sign in with their email and password.