Protocol oriented networking using URLSession with Swift

Protocol oriented networking using URLSession with Swift

Posted on by Ciprian Redinciuc Ciprian Redinciuc · 21 min read

Back to the basics

Nowadays most iOS developers will just import a full networking library into their mobile application to perform a couple of trivial requests without even bothering to give URLSession a try.

While there's nothing wrong with frameworks like Alamofire or Moya and the support for them is quite amazing, it's worhtwhile to ask yourself if you actually need that full blown library for the small number of requests your application makes.

Nonetheless, it is always worth investing time in understanding how iOS APIs work and you can also look at how these frameworks make use of them. I can guarantee you will at least learn something in the process.

In the following article I will try to build a solution that is somewhat flexible but is by no means a replacement for Alamofire. It will deal with making HTTP REST requests to a JSON API. Don't try this in your project if you heavily rely on more than a dozen of API requests, with various forms of data, retries, etc.

The URL loading system

URLSession and URLSessionTask.

Before we dive into writing any code we must first take a closer look at how the URL loading system works on iOS.

There are two big pieces to this puzzle:

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

If we want to do more fancy stuff such as providing an upload/download progress feedback to the end-user, we will need to take advantage of the delegate methods.

URLSession delegates.

We will be focusing on leveraging data, upload and download URLSessionTasks to create a network layer that we fully own and can customize according to our needs. Our example will be based on an HTTP RESTful API.

The anatomy of an HTTP request

In order to create a scalable networking layer using protocols, we have to define how the bare bones of our utility classes will look like.

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 or PATCH to replace/update a resource
    • DELETE to delete a resource
  • An optional set of parameters that we send to the server
  • Some 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

Pretty straigth forward, isn't it? Now that we've done this. Let's see how we could 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.

Working with multiple environments

In most cases, you and your team will work with different servers that expose the same API at different URLs and every commit will have to be integrated and tested against these environments to make sure everything works fine.

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

As I previously mentioned, URLSession is responsible for creating URLSessionTaks for us, we don't create them ourselves. We are responsible for passing a URL or URLRequest to the specialized instance methods that URLSession provides to be able to create a task instance. Once created, the caller is responsible for calling resume() on the task instance to start the actual 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?
}

As the inline documentation states, our class/struct conforming to this protocol will have three methods that are responsible for creating URLSessionDataTask, URLSessionDownloadTask and URLSessionUploadTask instances.

Our implementation of a wrapper class on top of URLSession should look something 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 take a second an go through it and the decisions behind the implementation. Some questions you might ask would be:

Why did we pass a URLSessionConfiguration to the designated initializer?

We want to write code that is flexible, has a single, well-defined purpose and is easy to test. 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 been easily left out if we just passed completion handlers to the URLSessionTaks but because we want to sprinkle 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

Let's go on and 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 call them later from the URLSessionDelegate and URLSessionDownloadDelegate protocols.

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

When conforming to URLSessionDownloadDelegate we are obligated to conform to 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)
        }
    }
}

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

Wrapping tasks into operations

To make our lives 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.

Want products news and updates?

Sign up for our newsletter to stay up to date.

We care about the protection of your data. Read our Privacy Policy.

© 2020 Applicodo SRL. All rights reserved.