Network client using Combine, Generics and Codables

2020-05-20

Network client using Combine, Generics and Codables
  1. Introduction
  2. Environment
  3. The network client
  4. Codable models
  5. Creating and canceling HTTP request
  6. Request Chaining
  7. Conclusions
  8. References

Introduction

Making HTTP requests is one of the first things you should learn when starting iOS development. Whether you build the client from scratch or use Alamofire or another library, you often end up with complex code. Especially when it comes to chaining requests, executing them in parallel or canceling them. The Combine framework already provides us with all the tools we need to write a concise network layer. In this article we will implement a promise-based web client using the Swift 5 APIs: Codables, URLSession, and the Combine framework. To test our network layer, we'll practice with several real-world examples that query the Marvel REST API and synchronize HTTP requests on-chain and in parallel.

Environment

This tutorial is written using the following environment:

  • Hardware: MacBook Pro 15' (2.5 GHz Intel Core i7, 16GB DDR3, mid-2015)
  • Operating system: macOS Catalina 10.15
  • Software versions:
    • Xcode: 11
    • iOS SDK: 13.4

It is important to know that to try SwiftUI you need to install Mac OS Catalina 10.15 and Xcode 11.

The network client

# The traditional way

Making an HTTP request in Swift is quite easy, you can use the built-in URLSession with a simple data task. Of course, you may want to check the status code and if everything is fine, you can parse its JSON response using the Foundation's JSONDecoder object.

let task = URLSession.shared.dataTask(with: url) { data, response, error in
    if let error = error {
        fatalError("Error: \(error.localizedDescription)")
    }
    guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
        fatalError("Error: invalid HTTP response code")
    }
    guard let data = data else {
        fatalError("Error: missing response data")
    }

    do {
        let decoder = JSONDecoder()
        let posts = try decoder.decode([Post].self, from: data)
        print(posts.map { $0.title })
    }
    catch {
        print("Error: \(error.localizedDescription)")
    }
}
task.resume()

# Data tasks and Combine framework

Now, as we can see, the traditional “block-based” approach is good, but can we do something better here? Do you know how to describe everything as a string, like we used to do this with promises in Javascript? Starting with iOS13 with the help of the amazing Combine framework, you can go much further! ?

private var cancellable: AnyCancellable?
//1
self.cancellable = URLSession.shared.dataTaskPublisher(for: url) //2
.map { $0.data } //3
.decode(type: [Post].self, decoder: JSONDecoder()) //4
.replaceError(with: []) //5
.eraseToAnyPublisher()  //6
.sink(receiveValue: { posts in  //7
    print(posts.count)
})
//...
self.cancellable?.cancel() //8

I love how the code "explains" itself:

  1. First we save the publisher in the cancellable variable
  2. Then we create a new publisher object
  3. We map the response, we only care about the data part (we ignore errors)
  4. We decode the data content using a JSONDecoder
  5. If something goes wrong, we create our error (or an empty array)
  6. We erase the complexity of publisher types to a simple AnyPublisher
  7. We use the sink operator to display information about the final value
  8. Optional: you can cancel your network request at any time

Combine is the Swift declarative framework for processing values over time. It enforces the reactive functional paradigm of programming, which is different from the object-oriented one prevalent in the iOS development community.

With everything we already know, we create an HTTP client based on promises. Fulfills and configures requests by passing a single URLRequest object. The client automatically transforms the JSON data into an encodable value and returns an AnyPublisher instance:

public enum APIError: Error {
    case internalError
    case decodingError
    case serverError(code: Int, message: String)
}

func send<T: Decodable>(_ request: URLRequest, _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher<T, APIError> {
        return URLSession.shared
         .dataTaskPublisher(for: request) //1
         .mapError{ APIError.serverError(code: $0.errorCode,
                    message: $0.localizedDescription) } //2
         .map { $0.data } //3
         .decode(type: T.self, decoder: decoder) //4
         .print() //5
         .mapError { _ in APIError.decodingError } //6
         .receive(on: DispatchQueue.main) //7
         .eraseToAnyPublisher() //8
    }
  1. We use the URLSession API to return the publisher, which in turn issues the response in the form of (data: Data, response: URLResponse).
  2. Convert any previous publisher bug into a new bug.
  3. Transform all elements of the previous publisher with a provided closure. In our case we extract the property data that contains the response data.
  4. Decodes the upstream output using a specified TopLevelDecoder. For example, use JSONDecoder. In our case we use JSONDecoder by default.
  5. Print log messages for all publisher events.
  6. If an error occurs with decoding, we create our specific error.
  7. Specify the scheduler in which we receive the elements from the publisher. In our case we use main queue.
  8. We delete the publisher type and return an AnyPublisher instance.

Codable models

Many programming tasks involve sending data over a network connection, saving data to disk, or sending data to APIs and services. These tasks often require data to be encoded and decoded to and from an intermediate format while the data is transferred. The Swift standard library has a standardized way for data encoding and decoding. You can adopt it by implementing the Codable and Decodable protocols in your types. Adopting these protocols allows implementations of the Encoder and Decoder protocols to take your data and encode or decode it to and from an external representation such as JSON or the property list. To support both encoding and decoding, you must declare compliance with Codable, which combines the Encodable and Decodable protocols.

The simplest way to make a type encodable is to declare its properties using types that are already encodable. These types include standard library types such as String, Int, and Double; and Foundation types such as Date, Data and URL. Any type whose properties are codable automatically conforms to Codable simply by declaring that conformance. We consider a Landmark structure that stores the name and the year of founding:

struct Landmark {
    var name: String
    var foundingYear: Int
}

Adding Codable to the inheritance list for Landmark triggers automatic compliance that satisfies all Encodable and Decodable protocol requirements:

struct Landmark: Codable {
    var name: String
    var foundingYear: Int

    // Landmark now supports Codable's init(from:) and encode(to:) methods,
    // even though they are not written explicitly
}

Adopting Codable into your own types allows you to serialize them to and from any of the built-in data formats, and any formats provided by custom encoders and decoders. For example, the Landmark structure can be coded using the PropertyListEncoder and JSONEncoder classes, although Landmark itself does not contain code to specifically handle property lists or JSON.

In the case of Marvel API we have a very complex API with a lot of fields and it takes a lot of time to describe all the fields of the API. To simplify the process we can use services like Quicktype. This service configures Codable structures or classes for us. And this way we can save a lot of time.

// MARK: - CharacterWrapper
struct CharacterResponse: Codable {
    let code: Int?
    let status: String?
    let copyright: String?
    let attributionText: String?
    let attributionHTML: String?
    let etag: String?
    var data: DataClass?
}

// MARK: - DataClass
struct DataClass: Codable {
    let offset, limit, total, count: Int?
    var results: [Character]?
}
...

Sometimes the output of this service is not entirely accurate and we have to correct it, but in any case it helps us a lot.

Creating and canceling HTTP request

Throughout the article, we will work with the Marvel REST API. Let's start by declaring a namespace for it:

extension URL{

    static private let baseURL = "https://gateway.marvel.com/"

    private enum Endpoint: String {
        case characters = "v1/public/characters"
        case comics = "v1/public/comics"
    }

}

Then we will implement the endpoints to request the character list and the comic list.

static func characters(_ characterId: String? = nil, limit: Int, offset: Int) -> URL {
        var endPoint = Endpoint.characters.rawValue
        var pageParams = ""
        if let _ = characterId {
            endPoint = endPoint + "/\(characterId!)"
        } else {
            pageParams = "&limit=\(limit)&offset=\(offset)"
        }
        return URL(string: "\(baseURL)\(endPoint)?apikey=\(Secret.publicKey)&hash=\(Secret.md5)&ts=\(Secret.ts)\(pageParams)")!
    }

    static func comics(_ comicId: String? = nil) -> URL{
        var endPoint = Endpoint.comics.rawValue
        if let _ = comicId {
            endPoint = endPoint + "/\(comicId!)"
        }
        return URL(string: "\(baseURL)\(endPoint)?apikey=\(Secret.publicKey)&hash=\(Secret.md5)&ts=\(Secret.ts)")!

    }

And finally we define the method to send the requests through our client. We have to use the dateDecodingStrategy property of JSONDecoder in our case, because the date format in the Marvel API is different.

static func buildRequest(for url: URL, method: HTTPMethod) -> URLRequest {

        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
        request.allHTTPHeaderFields = ["Content-Type": "application/json"
        return request
    }

static func send<T: Decodable>(_ url: URL, method: HTTPMethod) -> AnyPublisher<T, APIError> {

        let request = buildRequest(for: url, method: method)

        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
            let container = try decoder.singleValueContainer()
            let dateStr = try container.decode(String.self)

            let dateFormatter = DateFormatter()
            dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
            dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"

            if let date = dateFormatter.date(from: dateStr) {
                return date
            } else {
                return Date.distantPast
            }
        })

        return networkService.send(request, decoder)
            .eraseToAnyPublisher()
    }

Request chaining

Another common task is to execute the requests one by one. For example, let's search for characters from comics and then extract the information about the first comic.

//1
let characters = MarvelAPI.characters(page: 0).map{ $0.data?.results}
//2
let firstComic = characters.compactMap {$0.first?.comics?.items?.first}
//3
let loadFirstComic = firstComic.flatMap { comic in
    MarvelAPI.comics(comicId: comic.id)
}
//4
let token = loadFirstComic.sink(receiveCompletion: { _ in },
                        receiveValue: { print($0) })
  1. We ask for the list of all the characters.
  2. We get the first comic on the list.
  3. We chain two requests with the help of Combine. The flatMap operator transforms a publisher, which returns the first comic, into a new one, which returns all the information for this comic.
  4. We subscribe to the chain of requests. This is where the petitions are actually launched.

If you are going to run this code, you will see the information in the debug console. Let's take a moment to appreciate how easy it was. The code is very easy to read. Additionally, it scales well if we are going to add more requests to the chain.

When HTTP requests are independent of each other, we can execute them in parallel and combine their results. This speeds up the process, compared to chaining, since the total loading time is equal to that of the slowest request.

In this section, we will list Marvel characters and comics side by side. But before we do that, let's do a little refactoring.

let characters = MarvelAPI.characters() //1
let comics = MarvelAPI.comics() //2
//3
let token = Publishers.Zip(characters,comics).sink(
            receiveCompletion: {_ in }, receiveValue: { (characters, comics) in
                print(characters, comics) })
  1. The request of the characters.
  2. The comics request.
  3. We create and launch the combined petition. We're using Combine's Zip publisher, which waits until both requests complete and then returns their results as a tuple.

Conclusions

Combine is an amazing framework, it can do a lot, but it definitely has a bit of a learning curve. Unfortunately, you can only use it if you're targeting iOS13 or higher (this means you have at least a full year to learn every bit of the framework), so don't think twice before starting to learn this new technology.

You should also note that there is currently no publisher for upload and download tasks, but you can make your own solution until Apple officially releases something. ?

I really love how Apple implemented some reactive programming concepts and I hope that over time reactive programming will be the main way to make iOS apps.

References

  1. Introducing Combine
  2. Combine in practice
  3. Modern Networking in Swift 5 with URLSession, Combine and Codable