A Swift API Client with Associated Types

Posted on Sep 12, 2018

While there are many frameworks out there aimed at making life easier when dealing with your networking layer, it has become less cumbersome to go without them as Swift continues to mature. Let’s take a look at a simple approach to an API client without using any third party frameworks.

Associated Types?

Associated types are a powerful feature in Swift, yet unknown or often overlooked. To put it simply, they allow you to achieve generic type constraints with protocols. Below follows an excerpt from the official documentation regarding them.

When defining a protocol, it’s sometimes useful to declare one or more associated types as part of the protocol’s definition. An associated type gives a placeholder name to a type that’s used as part of the protocol. The actual type to use for that associated type isn’t specified until the protocol is adopted. Associated types are specified with the associatedtype keyword.

On the surface it sounds straight forward, but the consequences of defining protocols with associated types are profound. Before we jump into using associated types in the context of an API client, going forward you should know that protocols with associated types can only be used as generic constraints. That is to say they cannot be used for type definition, as you would normally use a protocol.

API Resource

Here’s where we leverage Swift’s associatedtype keyword, to associate our expected response object from the http request with our request object. I’ve chosen to name it Response accordingly. Adopters of this protocol will be required to provide a concrete typealias named Response. By ensuring Response conforms to Codable we’ll be able to make use of the new JSON decoding introduced in Swift 4.

public protocol APIResource {
    /// The associated response type to use when decoding the response from the API.
    associatedtype Response: Codable

    /// A dictionary of additional headers to send with the request.
    var additionalHeaders: [String: String]? { get }

    /// The data sent as the message body of the request.
    var httpBody: Data? { get }

    /// The HTTP request method.
    var httpRequestMethod: HTTPRequestMethod { get }

    /// The path subcomponent appended to the base URL.
    var path: String { get }

    /// An array of query items for the URL, in the order in which they appear.
    var queryItems: [URLQueryItem]? { get }
}

extension APIResource {

    func buildRequest(withBaseUrl baseUrl: URL?) -> URLRequest? {
        guard let url = buildUrl(withBaseUrl: baseUrl) else {
            NSLog("failed to build request for resource: \(String(describing: self))")
            return nil
        }
        var request = URLRequest(url: url)
        request.httpMethod = httpRequestMethod.rawValue
        request.httpBody = httpBody

        additionalHeaders?.forEach { key, value in
            request.setValue(value, forHTTPHeaderField: key)
        }

        return request
    }

    func buildUrl(withBaseUrl baseUrl: URL?) -> URL? {
        guard
            let baseUrl = baseUrl,
            var components = URLComponents(url: baseUrl, resolvingAgainstBaseURL: true)
        else {
            NSLog("failed to build url for resource: \(String(describing: self))")
            return nil
        }
        components.path = baseUrl.path.appending(path)
        components.queryItems = queryItems

        return components.url
    }
}

API Result

public enum APIResult<T: APIResource> {
    case failure(error: APIError)
    case success(result: T.Response)
}

HTTP Request Method

public enum HTTPRequestMethod: String {
    case delete, get, patch, post, put
}

HTTP Status Code

public enum HTTPStatusCode: Int {
    case informational
    case success
    case redirection
    case clientError
    case serverError

    public init?(rawValue: Int) {
        switch rawValue {
        case 100...199: self = .informational
        case 200...299: self = .success
        case 300...399: self = .redirection
        case 400...499: self = .clientError
        case 500...599: self = .serverError
        default: return nil
        }
    }

    var ok: Bool {
        rawValue == 200
    }
}

API Error

public enum APIError: Error {
    /// For client side errors, such as failing to build a request to the server.
    case client

    /// For decoding errors, such as failing to decode a response from the server.
    case decoding(reason: [String: Any]?)

    /// For network errors, such as 404 not found etc.
    case network(httpStatusCode: HTTPStatusCode)

    /// For when the client receives an unexpected response from the server.
    case unrecognizedFormat

    /// For when the network is unreachable.
    case unreachable
}

API Client

The client should be able to handle any arbitrary request, agnostic to it’s concrete type. This will allow us to do most of the heavy lifting using generics inside an extension, rather than cluttering up our concrete client implementations later on. Reachability is also being leveraged here to determine whether the network is reachable.

public protocol APIClient {
    /// The base URLComponents.
    var baseUrlComponents: URLComponents { get }

    /// The decoder to use for JSON decoding.
    var decoder: JSONDecoder { get }

    /// The session to be used when creating URLSessionDataTasks.
    var session: URLSession { get }

    /// Cancels all URLSessionTasks for the current session.
    func cancelAllTasks()

    /// Starts a URLSessionDataTask using the request for the corresponding APIResource.
    ///
    /// - Parameters:
    ///   - resource: APIResource defining some remote resource.
    ///   - completion: Completion handler.
    /// - Returns: URLSessionDataTask for the corresponding resource, or nil.
    func sendRequest<T: APIResource>(for resource: T, completion: @escaping (APIResult<T>) -> Void) -> URLSessionDataTask?
}

public extension APIClient {

    func cancelAllTasks() {
        session.getAllTasks { tasks in
            tasks.forEach { $0.cancel() }
        }
    }

    func sendRequest<T: APIResource>(for resource: T, completion: @escaping (APIResult<T>) -> Void) -> URLSessionDataTask? {
        if let reachability = Reachability(), reachability.connection == .none {
            completion(.failure(error: APIError.unreachable))
            return nil
        }
        guard
            let request = resource.buildRequest(withBaseUrl: baseUrlComponents.url),
            let url = request.url?.absoluteString
        else {
            completion(.failure(error: APIError.client))
            return nil
        }
        let task = session.dataTask(with: request) { data, response, error in
            guard
                let httpResponse = response as? HTTPURLResponse,
                let httpStatusCode = HTTPStatusCode(rawValue: httpResponse.statusCode)
            else {
                completion(.failure(error: APIError.unrecognizedFormat))
                return
            }
            guard httpStatusCode.ok else {
                completion(.failure(error: APIError.network(httpStatusCode: httpStatusCode)))
                return
            }
            do {
                completion(.success(result: try self.decoder.decode(T.Response.self, from: data!)))
            } catch let error as NSError {
                completion(.failure(error: APIError.decoding(reason: error.userInfo)))
                return
            }
        }
        defer {
            NSLog("starting task with request URL \(url)")
            task.resume()
        }
        return task
    }
}

Piecing It Together

Considering everything thus far as been agnostic to it’s concrete type, we can keep our concrete client thin and extensible. It simply manages an operation queue, while facilitating requests in a consumer friendly way, providing handles to operations so the caller can cancel them if required. Let’s hit an endpoint from Reqres for some mock data.

/*
{
    "data": {
        "id": 2,
        "first_name": "Janet",
        "last_name": "Weaver",
        "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/josephstein/128.jpg"
    }
}
*/

struct User: Codable {
    let id: Double
    let first_name: String
    let last_name: String
    let avatar: String
}

struct UserData: Codable {
    let data: User
}

struct GetUser: APIResource {

    // MARK: - APIResource

    typealias Response = UserData

    var additionalHeaders: [String: String]?

    var httpBody: Data?

    var httpRequestMethod: HTTPRequestMethod = .get

    var path: String {
        return "/api/users/\(id)"
    }

    var queryItems: [URLQueryItem]?

    // MARK: - Parameters

    let id: String

    // MARK: - Init

    init(id: String) {
        self.id = id
    }
}

class ReqresAPIClient: APIClient {

    static let shared = ReqresAPIClient()

    // MARK: - APIClient

    var baseUrlComponents: URLComponents = {
        var components = URLComponents()
        components.host = "reqres.in/api/"
        components.scheme = "https"

        return components
    }()

    var decoder = JSONDecoder()

    var session = URLSession.shared

    // MARK: - Init

    private init() {}
}

class MyViewController: UIViewController {

    // MARK: - Life cycle

    override func viewDidLoad() {
        super.viewDidLoad()

        let resource = GetUser(id: "2")
        _ = ReqresAPIClient.shared.sendRequest(for: resource) { result in
            switch result {
            case .failure(let error):
                print(error)

            case .success(let response):
                print(response)
            }
        }
    }
}