Swift Generics: How to build a clean network layer

Codable, URLRequest, Result type. These are the main terms that sounds familiar whenever you deal with network in Swift. Networking has always been an important part of every mobile app, thus a well designed network layer is key to a successful app.

Usually when we refer to modularity, network layer works best when it is implemented as a separated module that behaves as the gate for your app to the outer world.

As a rule of thumb, a network module’s main responsibility is to send a request to an external resource, receive the response and give it back to the caller module.

The network module itself can be break down into several components. In this post, we will walkthrough all the parts that contribute to building a networking layer.

HTTP method

This is what specify the HTTP request method, the most common verbs are GET, POST, PUT, PATCH, and DELETE. We can wrap this up in some enum type object. Something like this:

enum HTTPMethod: String {
    case GET = "GET"
    case POST = "POST"
    case DELETE = "DELETE"
    case PUT = "PUT"
    case PATCH = "PATCH"

    func callAsFunction() -> String {
        rawValue
    }
}

The callAsFunction allows to apply a function call syntax on the HTTPMethod enum type to return the raw value.

Routes

Route is part of the URL and represents the path we use to access the endpoints. As done with HTTP methods, it is a good practise to group all routes in one place.

enum Routes: String {
    case posts = "/posts"
    case comments = "/comments"
    case albums = "/albums"
    case photos = "/photos"

    func callAsFunction() -> String {
        rawValue
    }
}

HTTP errors

As the request may fail, error handling is a crucial part in a networking layer. A simple request that fetch and decode data from the network may fail for the following reasons:

enum HTTPError: Error {
    case failedResponse
    case failedDecoding
    case invalidUrl
    case invalidData 
}

The names above are self explanatory, but they will make more sense when we finish drawing the next parts of the layer 🙂

Scheme and host

The scheme and host are what identify the protocol and the resource name respectively. In the case of https://www.google.com the scheme is the https while the host is what comes after. The host can be longer if it is a subdomain – In general, it is what comes between the scheme and the resource path.

For clarity reasons, we can encapsulate these two informations in a struct type, like below:

struct APIConfig {
    let scheme: String
    let host: String
}

The HTTP Client

The main components of a request are explored above, now is the part where to build a simple client that will take the request, send it over to the network and then send back the received response.

Imagine, we have an app that deals with posts – Few actions we can think about are the following:

  • GET posts list
  • GET authors list
  • GET post details

If we gonna implement each of the following requests, we can think of 3 methods, one for each action:

GET posts list:

func request(request: URLRequest,
                               completion: @escaping (Result<[Post], HTTPError>) -> Void)

GET authors list:

func request(request: URLRequest,
                               completion: @escaping (Result<[Author], HTTPError>) -> Void)

GET post details:

func request(request: URLRequest,
                               completion: @escaping (Result<Post, HTTPError>) -> Void)

All the above methods signature is pretty much the same, except the return type that changes – As you may think, the methods will also have almost the same implementation. As a result we will have duplicated code that does the same think: Send a request and parse a response.

So why not we prevent this code duplication?

Remember in a previous article we used generics to dequeue table view cells – And Generics come to the rescue again. Assuming that all types (Post and Author) conforms to the Decodable protocol. Here is our Post model for instance:

struct Post: Decodable {
    let title: String
    let body: String
}

As a result, all the methods above can be replaced with one single method, its signature can be as follow:

func request<T: Decodable>(request: URLRequest,
                               completion: @escaping (Result<T, HTTPError>) -> Void)

The type placeholder here is a Decodable. The implementation can be unified as follow:

func request<T: Decodable>(request: URLRequest, completion: @escaping (Result<T, HTTPError>) -> Void) {

        URLSession.shared.dataTask(with: request) { data, urlResponse, error in

            // 1 check the response
            guard let urlResponse = urlResponse as? HTTPURLResponse, (200...299).contains(urlResponse.statusCode) else {
                completion(.failure(.failedResponse))
                return
            }

            // 2 check the data
            guard let data = data else {
                completion(.failure(.invalidData))
                return
            }

            // 3 Decode the data
            do {
                let decodedData = try JSONDecoder().decode(T.self, from: data)
                completion(.success(decodedData))
            } catch {
                completion(.failure(.failedDecoding))
            }
        }.resume()
    }

As you can see, we now have a method that sends ALL types of requests and parses ALL types of data as long as this data conforms to the Decodable protocol. Super handy, no code duplication and a straightforward method.

You may also notice that we started using the HTTP errors defined earlier as part of the failure completion callbacks.

The final piece

The network layer is pretty much done, our app needs some sort of interface to interact with it in order to hit the network.

We can define a class that acts as the point of contact between the network layer and the rest of modules in our app – Consider the following:

class API {
    private let apiConfig: APIConfig
    private let apiFetcher: HTTPClient

    init(apiConfig: APIConfig, apiFetcher: HTTPClient) {
        self.apiConfig = apiConfig
        self.apiFetcher = apiFetcher
    }
}

extension API {
    func getPosts(completion: @escaping (Result<[Post],HTTPError>) -> Void) {

        var components = URLComponents()
        components.scheme = apiConfig.scheme
        components.host = apiConfig.host
        components.path = Routes.posts.callAsFunction()

        guard let url = components.url else {
            completion(.failure(.invalidUrl))
            return
        }

        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = HTTPMethod.GET.callAsFunction()

        apiFetcher.request(request: urlRequest) { result in
            completion(result)
        }
    }
}

The class definition is pretty basic – It has two dependencies in order to instantiate an object: APIConfig and HTTPClient types dependencies.

The getPosts(completion:) method will be called to fetch posts from the endpoint and return an array of Post objects. This method is responsible of building up the request. In this example the request is a simple GET request but in some cases it may require some path and/or query parameter(s). In such cases, this method is the right place to append those parts to the request.

Wrap it up

That’s pretty much it. You now have a complete basic functional networking layer that is easily scalable to implement more tasks.

A simple usage example of our layer would be something like this:

let apiConfig = APIConfig(scheme: "https",
                                  host: "jsonplaceholder.typicode.com")
let apiFetcher = APIFetcher()
let api = API(apiConfig: apiConfig, apiFetcher: apiFetcher)

api.getPosts { result in
    switch result {
    case .success(let postsResponse):
        // Display the posts
        self.posts = postsResponse
    case .failure(let error):
        // Handle the error accordingly
        // Display an error alert
        print(error.localizedDescription)
    }
}

The above code can be put in a UIViewController and called in the viewDidLoad.

Always remember to separate your network layer in an isolated component – Whether it is a framework or a simple class. The point is, make it clear to you and your fellow peers what will this layer task be among your app modules with a clear interface and a single responsibility approach.

Here is a link to download the sample project.

What are the patterns that you usually follow when building your networking layer? Let me know on Twitter.

Thanks!

Malek
iOS developer with over than 11 years of extensive experience working on several projects with different sized startups and corporates.