HTTP in Swift, Part 18: Wrapping Up

Part 18 in a series on building a Swift HTTP framework:

  1. HTTP in Swift, Part 1: An Intro to HTTP
  2. HTTP in Swift, Part 2: Basic Structures
  3. HTTP in Swift, Part 3: Request Bodies
  4. HTTP in Swift, Part 4: Loading Requests
  5. HTTP in Swift, Part 5: Testing and Mocking
  6. HTTP in Swift, Part 6: Chaining Loaders
  7. HTTP in Swift, Part 7: Dynamically Modifying Requests
  8. HTTP in Swift, Part 8: Request Options
  9. HTTP in Swift, Part 9: Resetting
  10. HTTP in Swift, Part 10: Cancellation
  11. HTTP in Swift, Part 11: Throttling
  12. HTTP in Swift, Part 12: Retrying
  13. HTTP in Swift, Part 13: Basic Authentication
  14. HTTP in Swift, Part 14: OAuth Setup
  15. HTTP in Swift, Part 15: OAuth
  16. HTTP in Swift, Part 16: Composite Loaders
  17. HTTP in Swift, Part 17: Brain Dump
  18. 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 every HTTPResponse is a “successful” response. That means dealing with a 404 Not Found or 304 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 a DecodingError.
  • 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 of connection.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.


Related️️ Posts️

HTTP in Swift, Part 17: Brain Dump
HTTP in Swift, Part 16: Composite Loaders
HTTP in Swift, Part 15: OAuth
HTTP in Swift, Part 14: OAuth Setup
HTTP in Swift, Part 13: Basic Authentication
HTTP in Swift, Part 12: Retrying
HTTP in Swift, Part 11: Throttling
HTTP in Swift, Part 10: Cancellation
HTTP in Swift, Part 9: Resetting
HTTP in Swift, Part 8: Request Options
HTTP in Swift, Part 7: Dynamically Modifying Requests
HTTP in Swift, Part 6: Chaining Loaders
HTTP in Swift, Part 5: Testing and Mocking
HTTP in Swift, Part 4: Loading Requests
HTTP in Swift, Part 3: Request Bodies
HTTP in Swift, Part 2: Basic Structures
HTTP in Swift, Part 1: An Intro to HTTP