This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We revisit the networking library we built in the first episode and discuss improvements we've made over the years.

00:06 Today we'll follow up on the very first episode we did, in which we built a tiny networking library. Its key feature is the Resource struct, which combines a network request with a parse function for the response.

00:39 We've been using this pattern again and again β€” for example, in the new Swift backend we recently built for this website. And over the years, we've slightly evolved the library, so the time has come to look at some of our updates. But first, let's go back to the original version β€” converted to Swift 4.

01:23 The Resource struct contains a URL, a method, and a parse method:

struct Resource<A> {
    let url: URL
    let method: HttpMethod<Data>
    let parse: (Data) -> A?
}

01:28 The HttpMethod enum is generic over Data so that the .post case can hold data for the request's body:

enum HttpMethod<Body> {
    case get
    case post(Body)
}

01:52 We wrote a Webservice class that can load any Resource by constructing a request from the resource's URL, performing the request using URLSession, and parsing the response. The parsed, typed result is then passed to the completion handler:

final class Webservice {
    func load<A>(_ resource: Resource<A>, completion: @escaping (A?) -> ()) {
        let request = URLRequest(resource: resource)
        URLSession.shared.dataTask(with: request) { data, _, _ in
            completion(data.flatMap(resource.parse))
        }.resume()
    }
}

02:20 We defined an example resource to load an array of episodes from our server. The resource's parse method feels very outdated with its manual conversion of JSON dictionaries into Episode structs:

let resource = Resource<[Episode]>(url: URL(string: "https://talk.objc.io/episodes.json")!) { jsonArr in
    (jsonArr as? [[String:Any]])?.compactMap { json in
        guard
            let number = json["number"] as? Int,
            let title = json["title"] as? String
            else { return nil }
        return Episode(number: number, title: title)
    }
}

Using Codable

02:48 Our first improvement is letting Codable decode the JSON for us. Since the parse method takes an optional result value β€” without handling errors β€” we use try? to fall back to nil if the decoding fails:

let decoder = JSONDecoder()
let resource = Resource<[Episode]>(url: URL(string: "https://talk.objc.io/episodes.json")!, method: .get, parse: { data in
    try? decoder.decode([Episode].self, from: data)
})

04:40 In order to use a decoder, we have to make Episode conform to Codable. This is a trivial change because the conformance can be automatically generated:

struct Episode: Codable {
    var number: Int
    var title: String
}

04:50 Loading and parsing the list of episodes works, and our parsing code is much shorter. But it would be good to move the parsing logic out of the episodes resource and into the library so that we can rely on Codable for all JSON endpoints.

05:16 We add a new initializer to Resource in an extension that's constrained to Decodable result types. This ensures that we're constructing a resource for a type that can be decoded from the response:

extension Resource where A: Decodable {
    // ...
}

05:48 We write the initializer with a generic Body type for POST requests, and this type needs to be Encodable. The only parameters are the URL and the method, because we'll implement the parse method ourselves using Codable:

extension Resource where A: Decodable {
    init<Body: Encodable>(url: URL, method: HttpMethod<Body>) {
        self.url = url
        // ...
    }
}

06:30 Where we map over the method parameter to convert it into an HttpMethod<Data>, we can now use a JSONEncoder:

extension Resource where A: Decodable {
    init<Body: Encodable>(url: URL, method: HttpMethod<Body>) {
        self.url = url
        self.method = method.map { value in
            try! JSONEncoder().encode(value)
        }
        // ...
    }
}

07:30 In the parse closure, we use a JSONDecoder to decode the generic result type A from the response data:

extension Resource where A: Decodable {
    init<Body: Encodable>(url: URL, method: HttpMethod<Body>) {
        self.url = url
        self.method = method.map { value in
            try! JSONEncoder().encode(value)
        }
        self.parse = { data in
            try? JSONDecoder().decode(A.self, from: data)
        }
    }
}

08:00 This initializer β€” one that's generic over a body type β€” feels a bit awkward when we're using it to create a resource with a GET request, which doesn't have a body.

So we write an additional initializer that's specific for GET requests, but without the generic parameter. We add a label that makes it explicit at the call site that this initializer creates a GET request:

extension Resource where A: Decodable {
    init(get url: URL) {
        self.url = url
        self.method = .get
        self.parse = { data in
            try? JSONDecoder().decode(A.self, from: data)
        }
    }

    // ...
}

09:12 The definition of the episodes resource becomes much shorter:

let resource = Resource<[Episode]>(get: URL(string: "https://talk.objc.io/episodes.json")!)

09:35 It's very easy to create a resource for another endpoint. We basically just define both the struct we want to load and the URL to load it from:

struct Collection: Codable {
    var title: String
}

let collections = Resource<[Collection]>(get: URL(string: "https://talk.objc.io/collections.json")!)

10:23 Relying on Codable has many benefits for our networking library: it lets us work with type-safe results, including properties for dates and URLs, without having to manually convert to and from strings. We can also use nested structs to define complex response structures. And once we've defined a data structure, then an array of that structure automatically works as well.

Loading from URLSession

10:49 We can simplify things even more. The Webservice class is nothing more than its load method. We can eliminate the class if we move the method to an extension of URLSession:

extension URLSession {
    func load<A>(_ resource: Resource<A>, completion: @escaping (A?) -> ()) {
        let request = URLRequest(resource: resource)
        dataTask(with: request) { data, _, _ in
            completion(data.flatMap(resource.parse))
        }.resume()
    }
}

11:16 And then we call load on the shared session:

URLSession.shared.load(collections) { print($0) }

Storing URLRequest

11:31 Another change we want to make is the information we store in Resource. Instead of storing the URL and the HTTP method, we can store a URLRequest, which contains both of these properties. This will allow us to remove the map function for HttpMethod completely:

struct Resource<A> {
    var urlRequest: URLRequest
    let parse: (Data) -> A?
}

13:08 In the initializer for GET requests, we simply create a URLRequest from the passed-in URL. Since GET is the default, we don't have to set the request's method:

extension Resource where A: Decodable {
    init(get url: URL) {
        self.urlRequest = URLRequest(url: url)
        self.parse = { data in
            try? JSONDecoder().decode(A.self, from: data)
        }
    }
    
    // ...
}

13:23 In the POST initializer, we also create a URLRequest from the URL parameter, and we set the request's method to the passed-in method's string value. We then switch over method to set the request's body β€” this replaces the map call we had before:

extension Resource where A: Decodable {
    // ...
    
    init<Body: Encodable>(url: URL, method: HttpMethod<Body>) {
        urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = method.method
        switch method {
        case .get: ()
        case .post(let body):
            self.urlRequest.httpBody = try! JSONEncoder().encode(body)
        }
        self.parse = { data in
            try? JSONDecoder().decode(A.self, from: data)
        }
    }
}

We like the use of a switch statement here, because if we ever decide to add .put and .delete cases to the HttpMethod enum, the compiler will force us to consider these cases by requiring the switch to be exhaustive.

14:34 Before, we used the HttpMethod enum to enforce the correctness of our requests: for a GET request, we just needed a URL, but for a POST request we need a body. This difference was encoded in our types. Now that we're storing a URLRequest in Resource, we have to use dedicated initializers to make the distinction between GET and POST.

On the plus side, we don't have to add separate properties for the URL, the method, and other information we potentially need in the future, like request headers.

15:25 In the load method, we no longer have to create a request. Instead, we simply take the URLRequest from the resource:

extension URLSession {
    func load<A>(_ resource: Resource<A>, completion: @escaping (A?) -> ()) {
        dataTask(with: resource.urlRequest) { data, _, _ in
            completion(data.flatMap(resource.parse))
            }.resume()
    }
}

Mapping Resource

16:09 One more thing we want to add is the ability to map a Resource. This proves to be very useful when we want to transform a resource's result into something else. For example, we could map over the episodes resource to transform the result into an array of titles. Or we could just pick the first episode from the loaded result.

16:37 We write an extension of Resource and implement map with its standard signature. The function returns a new resource with the same URLRequest, but with an altered parse function. In it, we first call the original parse and then map the optional result to the new type using the passed-in transform function:

extension Resource {
    func map<B>(_ transform: @escaping (A) -> B) -> Resource<B> {
        return Resource<B>(urlRequest: urlRequest) { self.parse($0).map(transform) }
    }
}

18:21 We try using map to convert our collections resource to a resource for the latest collection:

let latestCollection = collections.map { $0.first }

URLSession.shared.load(latestCollection) { print($0) }

18:53 When we run this, a single collection is printed to the console, so it works well. Since our transform function returns an optional, the result is a doubly nested optional. We can avoid this by also writing compactMap and using that instead of map.

Discussion

19:06 So far, we've made a few small improvements: using Codable and URLRequest internally makes our library simpler. And we moved the loading method onto the URLSession directly, which makes the library more flexible.

19:25 The cool thing is that we can also use the library for non-JSON endpoints. In our backend, we load a few resources that are encoded as XML. Because we have an XMLDecoder and an XMLEncoder, we just have to write a struct with the correctly specified properties and supply the URL, and it works.

19:54 Using this networking library, we don't need to import a large framework, or have a lot of boilerplate code, or rely on any platform-specific client libraries (which rarely exist for Swift anyway). We simply use the JSON or XML APIs offered by providers and let Codable do the heavy lifting.

Upcoming Features

20:36 Something we would still like to add is the ability to express combined requests. Often, we need to load one resource and use its result to create a second request. For example, we could fetch the latest collection and then load all episodes from that collection.

Currently, we have to do this on the request level, but we'd like to move this to the resource level. For this, we need a way to express a resource's dependence on another resource.

21:23 Likewise, it would be handy if we could define parallel requests and combine their results. This is useful if we have to load various resources that don't depend on each other but that we all need before we can display a page. Let's look into all of this next time.

Resources

  • Playground

    Written in Swift 4.2

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • Invite Your Team

    Sign up additional team members at 30% discount

  • Support Us

    Ensure the continuous production of new episodes