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
evolved the library slightly, 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 then 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 regarding 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 need all of before we can display a
page. Let's look into this next time.