HTTP in Swift, Part 18: Wrapping Up
Part 18 in a series on building a Swift HTTP framework:
- HTTP in Swift, Part 1: An Intro to HTTP
- HTTP in Swift, Part 2: Basic Structures
- HTTP in Swift, Part 3: Request Bodies
- HTTP in Swift, Part 4: Loading Requests
- HTTP in Swift, Part 5: Testing and Mocking
- HTTP in Swift, Part 6: Chaining Loaders
- HTTP in Swift, Part 7: Dynamically Modifying Requests
- HTTP in Swift, Part 8: Request Options
- HTTP in Swift, Part 9: Resetting
- HTTP in Swift, Part 10: Cancellation
- HTTP in Swift, Part 11: Throttling
- HTTP in Swift, Part 12: Retrying
- HTTP in Swift, Part 13: Basic Authentication
- HTTP in Swift, Part 14: OAuth Setup
- HTTP in Swift, Part 15: OAuth
- HTTP in Swift, Part 16: Composite Loaders
- HTTP in Swift, Part 17: Brain Dump
- HTTP in Swift, Part 18: Wrapping Up
Over the course of this series, we’ve started with a simple idea and taken it to some pretty fascinating places. The idea we started with is that a network layer can be abstracted out to the idea of “I send this request, and eventually I get a response”.
I started working on this approach after reading Rob Napier’s blog post on protocols on protocols. In it, he makes the point that we seem to misunderstand the seminal “Protocol Oriented Programming” idea introduced by Dave Abrahams Crusty at WWDC 2015. We especially miss the point when it comes to networking, and Rob’s subsequent posts go in to this idea further.
One of the things I hope you’ve realized throughout this blog post series is that nowhere in this series did I ever talk about Codable
. Nothing in this series is generic (with the minor exception of making it easy to specify a request body). There is no mention of deserialization or JSON or decoding responses or anything. This is extremely deliberate.
The point of HTTP is simple: You send an HTTP request (which we saw has a very well-defined structure) and you get back an HTTP response (which has a similarly well-defined structure). There’s no opportunity to introduce generics, because we’re not dealing with a general algorithm.
So this begs the question: where do generics come in? How do I use my awesome Codable
type with this framework? The answer is: the next layer of abstraction.
Hello, Codable!
Our HTTP stack deals with a concrete input type (HTTPRequest
) and a concrete output type (HTTPResponse
). There’s no place to put something generic there. We want generics at some point, because we want to use our nice Codable
structs, but they don’t belong in the HTTP communication layer.
So, we’ll wrap up our HTTPLoader
chain in a new layer that can handle generics. I call this the “Connection” layer, and it looks like this:
public class Connection {
private let loader: HTTPLoader
public init() {
self.loader = ...
}
public func request(_ request: ..., completion: ...) {
// TODO: create an HTTPRequest
// TODO: interpret the HTTPResponse
}
}
In order to interpret a response in a generic way, this is where we’ll need generics, because this is the algorithm we need to make applicable to many different types. So, we’ll define a type that generically wraps an HTTPRequest
and can interpret an HTTPResponse
:
public struct Request<Response> {
public let underlyingRequest: HTTPRequest
public let decode: (HTTPResponse) throws -> Response
public init(underlyingRequest: HTTPRequest, decode: @escaping (HTTPResponse) throws -> Response) {
self.underlyingRequest = underlyingRequest
self.decode = decode
}
}
We can also provide some convenience methods for when we know the Response
is Decodable
:
extension Request where Response: Decodable {
// request a value that's decoded using a JSON decoder
public init(underlyingRequest: HTTPRequest) {
self.init(underlyingRequest: underlyingRequest, decoder: JSONDecoder())
}
// request a value that's decoded using the specified decoder
// requires: import Combine
public init<D: TopLevelDecoder>(underlyingRequest: HTTPRequest, decoder: D) where D.Input == Data {
self.init(underlyingRequest: underlyingRequest,
decode: { try decoder.decode(Response.self, from: $0.body) })
}
}
With this, we have a way to encapsulate the idea of “sending this HTTPRequest
should result in a value I can decode using this closure”. We can now implement that request
method we stubbed out earlier:
public class Connection {
...
public func request<ResponseType>(_ request: Request<ResponseType>, completion: @escaping (Result<ResponseType, Error>) -> Void) {
let task = HTTPTask(request: request.underlyingRequest, completion: { result in
switch result {
case .success(let response):
do {
let response = try request.decode(httpResponse: response)
completion(.success(response))
} catch {
// something when wrong while deserializing
completion(.failure(error))
}
case .failure(let error):
// something went wrong during transmission (couldn't connect, dropped connection, etc)
completion(.failure(error))
}
})
loader.load(task)
}
}
And using conditionalized extensions, we can make Request
construction simple:
extension Request where Response == Person {
static func person(_ id: Int) -> Request<Response> {
return Request(personID: id)
}
init(personID: Int) {
let request = HTTPRequest(path: "/api/person/\(personID)/")
// because Person: Decodable, this will use the initializer that automatically provides a JSONDecoder to interpret the response
self.init(underlyingRequest: request)
}
}
// usage:
// automatically infers `Request<Person>` based on the initializer/static method
connection.request(Request(personID: 1)) { ... }
// or:
connection.request(.person(1)) { ... }
There are some important things at work here:
- Remember that even a
404 Not Found
response is a successful response. It’s a response we got back from the server! Interpreting that response is a client-side problem. So by default, we can blindly attempt to deserialize any response, because everyHTTPResponse
is a “successful” response. That means dealing with a404 Not Found
or304 Not Modified
response is up to the client. - By making each
Request
decode the response, we provide the opportunity for individualized/request-specific deserialization logic. One request might look for errors encoded in a JSON response if decoding fails, while another might just be satisfied with throwing aDecodingError
. - Since each
Request
uses a closure for decoding, we can capture domain- and contextually-specific values in the closure to aid in the decoding process for that particular request! - We’re not limited to only JSON deserialization. Some requests might deserialize as JSON; others might deserialize using an
XMLDecoder
or something custom. Each request has the opportunity to decode a response however it wishes. - Conditional extensions to
Request
mean we have a nice and expressive API ofconnection.request(.person(42)) { ... }
Hello, Combine!
This Connection
layer also makes it easy to integrate with Combine. We can provide a method on Connection
to expose sending a request and provide back a Publisher
-conforming type to use in a publisher chain or as part of an ObservableObject
or even with a .onReceive()
modifier in SwiftUI:
import Combine
extension Connection {
// Future<...> is a Combine-provided type that conforms to the Publisher protocol
public func publisher<ResponseType>(for request: Request<ResponseType>) -> Future<ResponseType, Error> {
return Future { promise in
self.request(request, completion: promise)
}
}
// This provides a "materialized" publisher, needed by SwiftUI's View.onReceive(...) modifier
public func publisher<ResponseType>(for request: Request<ResponseType>) -> Future<Result<ResponseType, Error>, Never> {
return Future { promise in
self.request(request, completion: { promise(.success($0)) }
}
}
}
Conclusion
We’ve finally reached the end! I hope you’ve enjoyed this series and that it’s opened your mind to new possibilities. Some things I hope you take away from this:
- HTTP is not a scary, complex thing. At it’s core, it’s really really simple. It’s a simple text-based format for sending a request, and a simple format for getting a response. We can easily model that in Swift.
- Abstracting HTTP out to a high-level “request/response” model allows us to do some really cool things that would be really difficult to implement if we get stuck looking at all the
URLSession
-specific trees in the HTTP forest. - We can have our cake and eat it too! This model of networking works great whether you’re using UIKit/AppKit or SwiftUI or whatever.
- By recognizing we didn’t need generics nor protocols, we avoided overly-complicating our code. Each part of the loader chain is discrete, composeable, and easily tested in isolation. We’ll never have to deal with those dreaded “associated type or self” errors while working in it.
- The principles of this approach work regardless of your programming language and platform. This series has been about “how to think about a problem”.
Thanks for reading!
Do you have thoughts on the content of this series? Maybe you’ve found that some things work well with this approach, or things that don’t? I’d love to hear about your experience! Feel free to contact me via this site or on Twitter.