Swift protocol oriented networking using URLSession

Swift protocol oriented networking using URLSession

Back to the basics

Many iOS developers today prefer to use a networking library for their mobile apps, even for simple requests, rather than giving URLSession a try.

Don't get me wrong, frameworks like Alamofire or Moya are amazing and the support for them is incredible, it's worhtwhile to ask yourself if you actually need that full blown library for the small number of requests your application makes.

Even though it may take some effort, understanding how iOS APIs work is always beneficial. Additionally, examining how different frameworks utilize these APIs can also be informative and I assure you that you will gain knowledge through this process.

The following article aims to present a solution that offers some level of detail, but it is not intended as a substitute for Alamofire. The focus will be on constructing HTTP REST requests to a JSON API. It is not recommended to use this solution for projects that heavily rely on a large number of API requests with various forms of data and retries.

The URL loading system

URLSession and URLSessionTask

Before proceeding with any coding, we must first take a closer look at how the URL loading system works on iOS.

There are two main actors:

  • The URLSession class provides an API for performing downloading and uploading requests to an endpoint
  • The URLSesssionTask class performs the heavy-lifting and specializes in data, upload, download and stream tasks and return the HTTP response

If we want to provide upload/download progress feedback to the end-user, we will need to take advantage of the URLSession delegate methods.

URLSession delegates

Our primary focus will be utilizing data, uploading and downloading URLSessionTasks to develop a network layer that we have complete control over and can tailor to meet our specific requirements. Our demonstration will utilize an HTTP RESTful API as a base.

The anatomy of an HTTP request

To establish a networking layer that can expand and adapt, we must establish the basic structure of our utility classes.

Let's start by defining how we make an HTTP request to a RESTful API. An HTTP request will usually have:

  • A URL that is composed of a host, path and query
  • An HTTP method that sane people use them like this:
    • GET to retrieve a resource
    • POST to create a resource
    • PUT to replace a resource
    • PATCH to update a resource
    • DELETE to delete a resource
  • An optional set of parameters that we send to the server
  • A set of headers that can help the API with authenticating us, responding in a specific content-type, etc.

From the other side, the API will respond with some of the following:

  • Some data in the form of a JSON payload or file
  • An HTTP status code that we can interpret to determine if the request was successful or not
  • A set of headers that we can later use

Now that we've done this, let's translate this into a set of Swift protocols:

/// The request type that matches the URLSessionTask types.
enum RequestType {
    /// Will translate to a URLSessionDataTask.
    case data
    /// Will translate to a URLSessionDownloadTask.
    case download
    /// Will translate to a URLSessionUploadTask.
    case upload
}

/// The expected remote response type.
enum ResponseType {
    /// Used when the expected response is a JSON payload.
    case json
    /// Used when the expected response is a file.
    case file
}

/// HTTP request methods.
enum RequestMethod: String {
    /// HTTP GET
    case get = "GET"
    /// HTTP POST
    case post = "POST"
    /// HTTP PUT
    case put = "PUT"
    /// HTTP PATCH
    case patch = "PATCH"
    /// HTTP DELETE
    case delete = "DELETE"
}
/// Type alias used for HTTP request headers.
typealias ReaquestHeaders = [String: String]
/// Type alias used for HTTP request parameters. Used for query parameters for GET requests and in the HTTP body for POST, PUT and PATCH requests.
typealias RequestParameters = [String : Any?]
/// Type alias used for the HTTP request download/upload progress.
typealias ProgressHandler = (Float) -> Void

/// Protocol to which the HTTP requests must conform.
protocol RequestProtocol {

    /// The path that will be appended to API's base URL.
    var path: String { get }

    /// The HTTP method.
    var method: RequestMethod { get }

    /// The HTTP headers/
    var headers: ReaquestHeaders? { get }

    /// The request parameters used for query parameters for GET requests and in the HTTP body for POST, PUT and PATCH requests.
    var parameters: RequestParameters? { get }

    /// The request type.
    var requestType: RequestType { get }

    /// The expected response type.
    var responseType: ResponseType { get }

    /// Upload/download progress handler.
    var progressHandler: ProgressHandler? { get set }
}

I know it's quite a lot to digest in one single swoop but this covers the bare minimum we need to encapsulate an HTTP request, except for the progress handler. I've added that to the example to be able to provide a more practical and in-depth example.

In the following part, let's see how we can transform instances conforming to the RequestProtocol to concrete URLRequests that point to an API.

Handling multiple environments

It's common to have different servers that expose the same API at different URLs. To ensure that every commit integrates and works correctly with these environments, we can use an Enum to define and implement multiple environment configurations.

Atlassian Workflow

At a first glance, an environment can be defined like this:

/// Protocol to which environments must conform.
protocol EnvironmentProtocol {
    /// The default HTTP request headers for the environment.
    var headers: ReaquestHeaders? { get }

    /// The base URL of the environment.
    var baseURL: String { get }
}

A simple yet efficient way to implement multiple environment configurations is with an Enum:

/// Environments enum.
enum APIEnvironment: EnvironmentProtocol {
    /// The development environment.
    case development
    /// The production environment.
    case production

    /// The default HTTP request headers for the given environment.
    var headers: ReaquestHeaders? {
        switch self {
        case .development:
            return [
                "Content-Type" : "application/json",
                "Authorization" : "Bearer yourBearerToken"
            ]
        case .production:
            return [:]
        }
    }

    /// The base URL of the given environment.
    var baseURL: String {
        switch self {
        case .development:
            return "http://api.localhost:3000/v1/"
        case .production:
            return "https://api.yourapp.com/v1/"
        }
    }
}

Working with URLSession

URLSession is responsible for creating URLSessionTaks for us, Instead of creating tasks ourselves, we pass a URL or URLRequest to the specialized instance methods provided by URLSession to create a task instance. Once created, we call resume() on the task instance to initiate the request.

Our URLSession wrapper protocol could look something like this:

/// Protocol to which network session handling classes must conform to.
protocol NetworkSessionProtocol {
    /// Create  a URLSessionDataTask. The caller is responsible for calling resume().
    /// - Parameters:
    ///   - request: `URLRequest` object.
    ///   - completionHandler: The completion handler for the data task.
    func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask?

    /// Create  a URLSessionDownloadTask. The caller is responsible for calling resume().
    /// - Parameters:
    ///   - request: `URLRequest` object.
    ///   - progressHandler: Optional `ProgressHandler` callback.
    ///   - completionHandler: The completion handler for the download task.
    func downloadTask(request: URLRequest, progressHandler: ProgressHandler?, completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTask?

    /// Create  a URLSessionUploadTask. The caller is responsible for calling resume().
    /// - Parameters:
    ///   - request: `URLRequest` object.
    ///   - fileURL: The source file `URL`.
    ///   - progressHandler: Optional `ProgressHandler` callback.
    ///   - completion: he completion handler for the upload task.
    func uploadTask(with request: URLRequest, from fileURL: URL, progressHandler: ProgressHandler?, completion: @escaping (Data?, URLResponse?, Error?)-> Void) -> URLSessionUploadTask?
}

To simplify the process, we can create a wrapper protocol for URLSession. Our protocol should have three methods for creating URLSessionDataTask, URLSessionDownloadTask, and URLSessionUploadTask instances.

An example implementation of a wrapper class for URLSession could look like this:

/// Class handling the creation of URLSessionTaks and responding to URSessionDelegate callbacks.
class APINetworkSession: NSObject {

    /// The URLSession handing the URLSessionTaks.
    var session: URLSession!

    /// A typealias describing a progress and completion handle tuple.
    private typealias ProgressAndCompletionHandlers = (progress: ProgressHandler?, completion: ((URL?, URLResponse?, Error?) -> Void)?)

    /// Dictionary containing associations of `ProgressAndCompletionHandlers` to `URLSessionTask` instances.
    private var taskToHandlersMap: [URLSessionTask : ProgressAndCompletionHandlers] = [:]

    /// Convenience initializer.
    public override convenience init() {
        // Configure the default URLSessionConfiguration.
        let sessionConfiguration = URLSessionConfiguration.default
        sessionConfiguration.timeoutIntervalForResource = 30
        if #available(iOS 11, *) {
            sessionConfiguration.waitsForConnectivity = true
        }

        // Create a `OperationQueue` instance for scheduling the delegate calls and completion handlers.
        let queue = OperationQueue()
        queue.maxConcurrentOperationCount = 3
        queue.qualityOfService = .userInitiated

        // Call the designated initializer
        self.init(configuration: sessionConfiguration, delegateQueue: queue)
    }

    /// Designated initializer.
    /// - Parameters:
    ///   - configuration: `URLSessionConfiguration` instance.
    ///   - delegateQueue: `OperationQueue` instance for scheduling the delegate calls and completion handlers.
    public init(configuration: URLSessionConfiguration, delegateQueue: OperationQueue) {
        super.init()
        self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: delegateQueue)
    }


    /// Associates a `URLSessionTask` instance with its `ProgressAndCompletionHandlers`
    /// - Parameters:
    ///   - handlers: `ProgressAndCompletionHandlers` tuple.
    ///   - task: `URLSessionTask` instance.
    private func set(handlers: ProgressAndCompletionHandlers?, for task: URLSessionTask) {
        taskToHandlersMap[task] = handlers
    }

    /// Fetches the `ProgressAndCompletionHandlers` for a given `URLSessionTask`.
    /// - Parameter task: `URLSessionTask` instance.
    /// - Returns: `ProgressAndCompletionHandlers` optional instance.
    private func getHandlers(for task: URLSessionTask) -> ProgressAndCompletionHandlers? {
        return taskToHandlersMap[task]
    }

    deinit {
        // We have to invalidate the session becasue URLSession strongly retains its delegate. https://developer.apple.com/documentation/foundation/urlsession/1411538-invalidateandcancel
        session.invalidateAndCancel()
        session = nil
    }
}

Let's go through it and the decisions behind the implementation. We will also address some common questions that may arise during this process.

Why did we pass a URLSessionConfiguration to the designated initializer?

In order to write code that is adaptable, has a clear and specific objective, and can be easily tested. To achieve this, we will need a URLSessionConfiguration when initializing our URLSession instance.

By doing this we can take advantage or iOS ephemeral configuration when testing against a mock server for example or to create a session that can easily handle background requests through:

URLSessionConfiguration.background(withIdentifier: "id.download.background-job")

Why did we need a (URLSessionTaks, ProgressAndCompletionHandlers) tuple?

This could have omitted if we just passed completion handlers to the URLSessionTaks but because we want to add a progress handler for our upload and download tasks while being able to handle multiple downloads and upload tasks in the process, we will need to implement a couple of methods to conform with URLSessionDelegate and URLSessionDownloadDelegate.

One thing that was pointed out to me on reddit by a user, (thanks again, mate!) is that URLSession retains its delegate, so you have to call invalidateAndCancel before setting the session to nil. Once a session is invalidated, you can no longer use it. This is probably the only situation where Cocoa breaks the "a delegate should always be weak" rule and I wasn't aware of it. This comes to prove that there's always something new to learn.

Conforming to NetworkSessionProtocol, URLSessionDelegate and URLSessionDownloadDelegate protocols

We now need to conform our APINetworkSession class to the NetworkSessionProtocol, URLSessionDelegate, and URLSessionDownloadDelegate protocols.

As a good practice, we will split each protocol implementation into an extension making it easier to separate our logic and improve readability.

Let's start with the NetworkSessionProtocol:

extension APINetworkSession: NetworkSessionProtocol {

    func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask? {
        let dataTask = session.dataTask(with: request) { (data, response, error) in
            completionHandler(data, response, error)
        }
        return dataTask
    }

    func downloadTask(request: URLRequest, progressHandler: ProgressHandler? = nil, completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTask? {
        let downloadTask = session.downloadTask(with: request)
        // Set the associated progress and completion handlers for this task.
        set(handlers: (progressHandler, completionHandler), for: downloadTask)
        return downloadTask
    }

    func uploadTask(with request: URLRequest, from fileURL: URL, progressHandler: ProgressHandler? = nil, completion: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionUploadTask? {
        let uploadTask = session.uploadTask(with: request, fromFile: fileURL, completionHandler: { (data, urlResponse, error) in
            completion(data, urlResponse, error)
        })
        // Set the associated progress handler for this task.
        set(handlers: (progressHandler, nil), for: uploadTask)
        return uploadTask
    }
}

For our upload and download tasks, we set the progress handlers associated with the created tasks for us to be able to invoke them later from the URLSessionDelegate and URLSessionDownloadDelegate protocols.

One thing to be mindful about is that if we pass a completion handler to either downloadTask(with: ) or uploadTask(with: ) and implement the corresponding completion delegate methods, both the delegate completion method and the completion closures will be called by iOS.

When conforming to the URLSessionDownloadDelegate protocol we must implement the completion delegate method and that's why I left out the completion handler for the URLSessionDownloadTask as you can see below.

extension APINetworkSession: URLSessionDelegate {

    func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
        guard let handlers = getHandlers(for: task) else {
            return
        }

        let progress = Float(totalBytesSent) / Float(totalBytesExpectedToSend)
        DispatchQueue.main.async {
            handlers.progress?(progress)
        }
        //  Remove the associated handlers.
        set(handlers: nil, for: task)
    }

    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        guard let downloadTask = task as? URLSessionDownloadTask,
            let handlers = getHandlers(for: downloadTask) else {
            return
        }

        DispatchQueue.main.async {
            handlers.completion?(nil, downloadTask.response, downloadTask.error)
        }

        //  Remove the associated handlers.
        set(handlers: nil, for: task)
    }
}



extension APINetworkSession: URLSessionDownloadDelegate {

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        guard let handlers = getHandlers(for: downloadTask) else {
            return
        }

        DispatchQueue.main.async {
            handlers.completion?(location, downloadTask.response, downloadTask.error)
        }

        //  Remove the associated handlers.
        set(handlers: nil, for: downloadTask)
    }

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        guard let handlers = getHandlers(for: downloadTask) else {
            return
        }

        let progress = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)
        DispatchQueue.main.async {
            handlers.progress?(progress)
        }
    }
}

Don't forget to queue the progress and completion callbacks on the main thread to avoid issues with updating your UI and remove the completion handlers once the task has completed.

Wrapping tasks into operations

To make matters easier, we want to be able to cancel URLSession tasks when required and to specify that is the expected outcome of a request. Here is where protocols with associated types come into play.

Let's go on and define an operation interface that will take care of that for us.

/// The type to which all operations must conform in order to execute and cancel a request.
protocol OperationProtocol {
    associatedtype Output

    /// The request to be executed.
    var request: RequestProtocol { get }

    /// Execute a request using a request dispatcher.
    /// - Parameters:
    ///   - requestDispatcher: `RequestDispatcherProtocol` object that will execute the request.
    ///   - completion: Completion block.
    func execute(in requestDispatcher: RequestDispatcherProtocol, completion: @escaping (Output) -> Void) ->  Void

    /// Cancel the operation.
    func cancel() -> Void
}

You might be wondering what that RequestDispatcherProtocol represents. I use this as a way of describing a utility that dispatches requests on a given environment and with a specific network configuration.

Let's say you might want to execute a test suite with a ephemeral configuration against a server running on your local machine, this should allow you to do just so.

On the other hand, we want a way to elevate the concrete type of the Operation's associated type. This is highlighted through the OperationResult enum that defines the expected output as either a JSON response, a downloaded file or an error.

/// The expected result of an API Operation.
enum OperationResult {
    /// JSON reponse.
    case json(_ : Any?, _ : HTTPURLResponse?)
    /// A downloaded file with an URL.
    case file(_ : URL?, _ : HTTPURLResponse?)
    /// An error.
    case error(_ : Error?, _ : HTTPURLResponse?)
}

/// Protocol to which a request dispatcher must conform to.
protocol RequestDispatcherProtocol {

    /// Required initializer.
    /// - Parameters:
    ///   - environment: Instance conforming to `EnvironmentProtocol` used to determine on which environment the requests will be executed.
    ///   - networkSession: Instance conforming to `NetworkSessionProtocol` used for executing requests with a specific configuration.
    init(environment: EnvironmentProtocol, networkSession: NetworkSessionProtocol)

    /// Executes a request.
    /// - Parameters:
    ///   - request: Instance conforming to `RequestProtocol`
    ///   - completion: Completion handler.
    func execute(request: RequestProtocol, completion: @escaping (OperationResult) -> Void) -> URLSessionTask?
}

Let's go on and define a concrete implementation of the OperationProtocol that will be passed to a similarly concrete implementation of the RequestDispatcherProtocol:

/// API Operation class that can  execute and cancel a request.
class APIOperation: OperationProtocol {
    typealias Output = OperationResult

    /// The `URLSessionTask` to be executed/
    private var task: URLSessionTask?

    /// Instance conforming to the `RequestProtocol`.
    internal var request: RequestProtocol

    /// Designated initializer.
    /// - Parameter request: Instance conforming to the `RequestProtocol`.
    init(_ request: RequestProtocol) {
        self.request = request
    }

    /// Cancels the operation and the encapsulated task.
    func cancel() {
        task?.cancel()
    }

    /// Execute a request using a request dispatcher.
    /// - Parameters:
    ///   - requestDispatcher: `RequestDispatcherProtocol` object that will execute the request.
    ///   - completion: Completion block.
    func execute(in requestDispatcher: RequestDispatcherProtocol, completion: @escaping (OperationResult) -> Void) {
        task = requestDispatcher.execute(request: request, completion: { result in
            completion(result)
        })
    }
}

Providing a default implementation to the Request protocol

Wouldn't it be nice if all data structures that conform to the RequestProtocol would inherit a generic implementation that composes a URLRequest? It definitely would so we will go on and do that by creating a protocol extension that provides a default implementation.

extension RequestProtocol {

    /// Creates a URLRequest from this instance.
    /// - Parameter environment: The environment against which the `URLRequest` must be constructed.
    /// - Returns: An optional `URLRequest`.
    public func urlRequest(with environment: EnvironmentProtocol) -> URLRequest? {
        // Create the base URL.
        guard let url = url(with: environment.baseURL) else {
            return nil
        }
        // Create a request with that URL.
        var request = URLRequest(url: url)

        // Append all related properties.
        request.httpMethod = method.rawValue
        request.allHTTPHeaderFields = headers
        request.httpBody = jsonBody

        return request
    }

    /// Creates a URL with the given base URL.
    /// - Parameter baseURL: The base URL string.
    /// - Returns: An optional `URL`.
    private func url(with baseURL: String) -> URL? {
        // Create a URLComponents instance to compose the url.
        guard var urlComponents = URLComponents(string: baseURL) else {
            return nil
        }
        // Add the request path to the existing base URL path
        urlComponents.path = urlComponents.path + path
        // Add query items to the request URL
        urlComponents.queryItems = queryItems

        return urlComponents.url
    }

    /// Returns the URLRequest `URLQueryItem`
    private var queryItems: [URLQueryItem]? {
        // Chek if it is a GET method.
        guard method == .get, let parameters = parameters else {
            return nil
        }
        // Convert parameters to query items.
        return parameters.map { (key: String, value: Any?) -> URLQueryItem in
            let valueString = String(describing: value)
            return URLQueryItem(name: key, value: valueString)
        }
    }

    /// Returns the URLRequest body `Data`
    private var jsonBody: Data? {
        // The body data should be used for POST, PUT and PATCH only
        guard [.post, .put, .patch].contains(method), let parameters = parameters else {
            return nil
        }
        // Convert parameters to JSON data
        var jsonBody: Data?
        do {
            jsonBody = try JSONSerialization.data(withJSONObject: parameters,
                                                  options: .prettyPrinted)
        } catch {
            print(error)
        }
        return jsonBody
    }
}

These methods will help us transform a Request item into a concrete URLRequest instance.

Creating a request dispatcher class

Last but not least, we must create a concrete implementation of our RequestDispatcher protocol.

/// Enum of API Errors
enum APIError: Error {
    /// No data received from the server.
    case noData
    /// The server response was invalid (unexpected format).
    case invalidResponse
    /// The request was rejected: 400-499
    case badRequest(String?)
    /// Encoutered a server error.
    case serverError(String?)
    /// There was an error parsing the data.
    case parseError(String?)
    /// Unknown error.
    case unknown
}


/// Class that handles the dispatch of requests to an environment with a given configuration.
class APIRequestDispatcher: RequestDispatcherProtocol {

    /// The environment configuration.
    private var environment: EnvironmentProtocol

    /// The network session configuration.
    private var networkSession: NetworkSessionProtocol

    /// Required initializer.
    /// - Parameters:
    ///   - environment: Instance conforming to `EnvironmentProtocol` used to determine on which environment the requests will be executed.
    ///   - networkSession: Instance conforming to `NetworkSessionProtocol` used for executing requests with a specific configuration.
    required init(environment: EnvironmentProtocol, networkSession: NetworkSessionProtocol) {
        self.environment = environment
        self.networkSession = networkSession
    }

    /// Executes a request.
    /// - Parameters:
    ///   - request: Instance conforming to `RequestProtocol`
    ///   - completion: Completion handler.
    func execute(request: RequestProtocol, completion: @escaping (OperationResult) -> Void) -> URLSessionTask? {
        // Create a URL request.
        guard var urlRequest = request.urlRequest(with: environment) else {
            completion(.error(APIError.badRequest("Invalid URL for: \(request)"), nil))
            return nil
        }
        // Add the environment specific headers.
        environment.headers?.forEach({ (key: String, value: String) in
            urlRequest.addValue(value, forHTTPHeaderField: key)
        })

        // Create a URLSessionTask to execute the URLRequest.
        var task: URLSessionTask?
        switch request.requestType {
        case .data:
            task = networkSession.dataTask(with: urlRequest, completionHandler: { [weak self] (data, urlResponse, error) in
                self?.handleJsonTaskResponse(data: data, urlResponse: urlResponse, error: error, completion: completion)
            })
        case .download:
            task = networkSession.downloadTask(request: urlRequest, progressHandler: request.progressHandler, completionHandler: { [weak self] (fileUrl, urlResponse, error) in
                self?.handleFileTaskResponse(fileUrl: fileUrl, urlResponse: urlResponse, error: error, completion: completion)
            })
            break
        case .upload:
            task = networkSession.uploadTask(with: urlRequest, from: URL(fileURLWithPath: ""), progressHandler: request.progressHandler, completion: { [weak self] (data, urlResponse, error) in
                self?.handleJsonTaskResponse(data: data, urlResponse: urlResponse, error: error, completion: completion)
            })
            break
        }
        // Start the task.
        task?.resume()

        return task
    }

    /// Handles the data response that is expected as a JSON object output.
    /// - Parameters:
    ///   - data: The `Data` instance to be serialized into a JSON object.
    ///   - urlResponse: The received  optional `URLResponse` instance.
    ///   - error: The received  optional `Error` instance.
    ///   - completion: Completion handler.
    private func handleJsonTaskResponse(data: Data?, urlResponse: URLResponse?, error: Error?, completion: @escaping (OperationResult) -> Void) {
        // Check if the response is valid.
        guard let urlResponse = urlResponse as? HTTPURLResponse else {
            completion(OperationResult.error(APIError.invalidResponse, nil))
            return
        }
        // Verify the HTTP status code.
        let result = verify(data: data, urlResponse: urlResponse, error: error)
        switch result {
        case .success(let data):
            // Parse the JSON data
            let parseResult = parse(data: data as? Data)
            switch parseResult {
            case .success(let json):
                DispatchQueue.main.async {
                    completion(OperationResult.json(json, urlResponse))
                }
            case .failure(let error):
                DispatchQueue.main.async {
                    completion(OperationResult.error(error, urlResponse))
                }
            }
        case .failure(let error):
            DispatchQueue.main.async {
                completion(OperationResult.error(error, urlResponse))
            }
        }
    }

    /// Handles the url response that is expected as a file saved ad the given URL.
    /// - Parameters:
    ///   - fileUrl: The `URL` where the file has been downloaded.
    ///   - urlResponse: The received  optional `URLResponse` instance.
    ///   - error: The received  optional `Error` instance.
    ///   - completion: Completion handler.
    private func handleFileTaskResponse(fileUrl: URL?, urlResponse: URLResponse?, error: Error?, completion: @escaping (OperationResult) -> Void) {
        guard let urlResponse = urlResponse as? HTTPURLResponse else {
            completion(OperationResult.error(APIError.invalidResponse, nil))
            return
        }

        let result = verify(data: fileUrl, urlResponse: urlResponse, error: error)
        switch result {
        case .success(let url):
            DispatchQueue.main.async {
                completion(OperationResult.file(url as? URL, urlResponse))
            }

        case .failure(let error):
            DispatchQueue.main.async {
                completion(OperationResult.error(error, urlResponse))
            }
        }
    }

    /// Parses a `Data` object into a JSON object.
    /// - Parameter data: `Data` instance to be parsed.
    /// - Returns: A `Result` instance.
    private func parse(data: Data?) -> Result<Any, Error> {
        guard let data = data else {
            return .failure(APIError.invalidResponse)
        }

        do {
            let json = try JSONSerialization.jsonObject(with: data, options: .mutableContainers)
            return .success(json)
        } catch (let exception) {
            return .failure(APIError.parseError(exception.localizedDescription))
        }
    }

    /// Checks if the HTTP status code is valid and returns an error otherwise.
    /// - Parameters:
    ///   - data: The data or file  URL .
    ///   - urlResponse: The received  optional `URLResponse` instance.
    ///   - error: The received  optional `Error` instance.
    /// - Returns: A `Result` instance.
    private func verify(data: Any?, urlResponse: HTTPURLResponse, error: Error?) -> Result<Any, Error> {
        switch urlResponse.statusCode {
        case 200...299:
            if let data = data {
                return .success(data)
            } else {
                return .failure(APIError.noData)
            }
        case 400...499:
            return .failure(APIError.badRequest(error?.localizedDescription))
        case 500...599:
            return .failure(APIError.serverError(error?.localizedDescription))
        default:
            return .failure(APIError.unknown)
        }
    }
}

This is a lot to take in but let's take it step by step. As previously stated, the request dispatcher is responsible for creating and starting a URLRequest and handling the URLSessionTasks responses: checking if the response HTTP status code is valid and parsing the data into JSON objects of passing along the downloaded file URL.

Using the instance conforming to EnvironmentProtocol, the request dispatcher creates a URLRequest by passing along the environment configuration to the Request.

After that, the instance conforming to NetworkSessionProtocol will create a URLSessionTask that the dispatcher will start.

Once the request finishes, it checks if the response HTTP status code is valid using the verify method that returns a Result that's either the received data or an APIError.

Then it tries to convert the received data into the expected Output. That's it. Now we can focus on using our network layer code.

Putting it all together

If you've followed the full article, now you should have a network layer that is flexible and you can start performing API requests on top of it.

Let's start by defining our first API endpoint that can handle different types of requests - let's imagine we have a Books endpoint that we want to use in our application:

The implementation could look something like this:

/// Books endpoint
enum BooksEndpoint {
    /// Lists all the books.
    case index
    /// Fetches a book with a given identifier.
    case get(identifier: String)
    /// Creates a book with the given parameters.
    case create(parameters: [String: Any?])
}

extension BooksEndpoint: RequestProtocol {
    var path: String {
        switch self {
        case .index:
            return "/books"
        case .get(let identifier):
            return "/books/\(identifier)"
        case .create(_):
            return "/books"
        }
    }

    var method: RequestMethod {
        switch self {
        case .index:
            return .get
        case .get(_):
            return .get
        case .create(_):
            return .post
        }
    }

    var headers: ReaquestHeaders? {
        return nil
    }

    var parameters: RequestParameters? {
        switch self {
        case .index:
            return nil
        case .get(_):
            return nil
        case .create(let parameters):
            return parameters
        }
    }

    var requestType: RequestType {
        return .data
    }

    var responseType: ResponseType {
        return .json
    }

    var progressHandler: ProgressHandler? {
        get { nil }
        set { }
    }
}

The BooksEndpoint can handle multiple requests and by conforming to the RequestProtocol it makes it easy to specify the different request properties for each request the endpoint supports.

Lastly, let's go on and call this endpoint to create a new book in our collection.

let requestDispatcher = APIRequestDispatcher(environment: APIEnvironment.development, networkSession: APINetworkSession())
let params: [String : Any] = [
   "name": "Gone with the wind",
   "author": "Margaret Mitchell"
]

let bookCreationRequest = BooksEndpoint.create(parameters: params)

let bookOperation = APIOperation(bookCreationRequest)
bookOperation?.execute(in: requestDispatcher) { result in
  // Handle result
}

That's all it takes. By using an Enum to define our endpoint we can make our code easier to understand. Keep in mind that you can always cancel the book operation if you change your mind.

Conclusion

First of all, thanks for taking the time to walk through this example with me and I hope that I provided you with the proper tools to build your own network layer.

Tools like Alamofire and Moya are great and you can also build a solid networking layer on top of them but if you want to cater for some particular scenarios such as switching all requests to a background operation, these frameworks can make it harder for you to do so.

Nonetheless, we are lucky to have access to such great open source projects that can speed up our development time but we must not overlook the underlying technologies that these tools leverage so that we can become better developers ourselves.