HTTP in Swift, Part 8: Request Options

Part 8 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

So far we’ve written enough code to describe a chain of HTTPLoader instances that can process an incoming HTTPRequest and eventually produce an HTTPResult.

However, there are situations where we don’t want every request to get loaded the same way. Last time we wrote ApplyEnvironment, an HTTPLoader subclass that will use a pre-defined ServerEnvironment value to fill in any missing values on a request. We’ll use this as our case study.

Let’s imagine that we decide to supplement our StarWarsAPI with additional information from the Star Wars wiki, “Wookieepedia”. Of course we know that we can manually set the host and path and everything on individual requests as they go out, but it would be nice to not do that and You Deserve Nice Things.

// it would be unfortunate to have to repeat this a lot
var request = HTTPRequest()
request.host = "starwars.fandom.com"
request.path = "/api/v1/Search/List"
request.queryItems = [
    URLQueryItem(name: "query", value: "anakin")
]

Instead, let’s add the ability to specify the entire environment on a request before it goes down the chain, and then teach the ApplyEnvironment loader to look for it. Perhaps it might look like this:

var request = HTTPRequest()
request.serverEnvironment = .wookieepedia
request.path = "Search/List"
request.queryItems = [
    URLQueryItem(name: "query", value: "anakin")
]

This looks like almost as much code as before, but I believe it is more expressive. We’re cutting out more magic strings ("starwars.fandom.com" and "/api/v1") and being more descriptive in our intention (“we want the ‘Wookieepedia’ server environment”).

Food for thought: How could you make the syntax to add a query item parameter even more terse?

What we don’t want to do is go back to our HTTPRequest definition and add a new stored property for the server environment. Not every request needs to specify a server environment and it’d be wasteful to make room for one on every single request. Also, that approach does not scale well if we decide there are other per-request options we want to specify. (Hint: there are!)

Instead, we’ll define a private dumping ground in the request to store these options, and create a type-safe interface for them.

Taking Inspiration from SwiftUI

Inside Apple’s SwiftUI framework is a neat little protocol called PreferenceKey. It’s basically a way for a view to communicate a type-safe “preference value” up its superview hierarchy so some ancestor can look for it and read it.

We’re going to use this same sort of thing for our requests. We’ll start with a protocol:

public protocol HTTPRequestOption {
    associatedtype Value

    /// The value to use if a request does not provide a customized value
    static var defaultOptionValue: Value { get }
}

This protocol says that an “option” is simply a type that has a static defaultOptionValue property that we can use if a request doesn’t specify one.

Next, we’ll teach HTTPRequest about options:

public struct HTTPRequest {
    ...
    private var options = [ObjectIdentifier: Any]()

    public subscript<O: HTTPRequestOption>(option type: O.Type) -> O.Value {
        get {
            // create the unique identifier for this type as our lookup key
            let id = ObjectIdentifier(type)

            // pull out any specified value from the options dictionary, if it's the right type
            // if it's missing or the wrong type, return the defaultOptionValue
            guard let value = options[id] as? O.Value else { return type.defaultOptionValue }

            // return the value from the options dictionary
            return value
        }
        set {
            let id = ObjectIdentifier(type)
            // save the specified value into the options dictionary
            options[id] = newValue
        }
    }
}

That’s the infrastructure for holding option values. Now let’s say that requests can specifically hold ServerEnvironment values:

public struct ServerEnvironment: HTTPRequestOption {
    // the associated type is inferred to be "Optional<ServerEnvironment>"
    public static let defaultOptionValue: ServerEnvironment? = nil
    
    ...
}

Our ServerEnvironment struct, which holds the default host, path prefix, etc values is also an HTTPRequestOption. And if we don’t have an explicit ServerEnvironment set on the request, then the value a request “holds” is nil (the defaultOptionValue), meaning “no customized server environment”.

One nice thing we can add is an extension on HTTPRequest to make this a little simpler to use:

extension HTTPRequest {
    
    public var serverEnvironment: ServerEnvironment? {
        get { self[option: ServerEnvironment.self] }
        set { self[option: ServerEnvironment.self] = newValue }
    }
    
}

With this, we now have a way to set any number of custom values on a single HTTPRequest, and retrieve them again in a type-safe way.

Using the option value

The final thing that remains is to teach our ApplyEnvironment loader how to look for an environment to use. If you recall, the class currently looks like this:

public class ApplyEnvironment: HTTPLoader {

    private let environment: ServerEnvironment

    public init(environment: ServerEnvironment) {
        environment = environment
        super.init()
    }

    override public func load(request: HTTPRequest, completion: @escaping (HTTPResult) -> Void) {
        var copy = request

        if copy.host.isEmpty { 
            copy.host = environment.host
        }
        if copy.path.hasPrefix("/") == false {
            // TODO: apply the environment.pathPrefix
        }
        // TODO: apply the query items from the environment
        for (header, value) in environment.headers {
            // TODO: add these header values to the request
        }

        super.load(request: copy, completion: completion)
    }
}

We only need to make one simple adjustment to the load(request:completion:) method:

    override public func load(request: HTTPRequest, completion: @escaping (HTTPResult) -> Void) {
        var copy = request

        // use the environment specified by the request, if it's present
        // if it doesn't have one, use the one passed to the initializer
        let requestEnvironment = request.serverEnvironment ?? environment

        if copy.host.isEmpty { 
            copy.host = requestEnvironment.host
        }
        if copy.path.hasPrefix("/") == false {
            // TODO: apply the requestEnvironment.pathPrefix
        }
        // TODO: apply the query items from the requestEnvironment
        for (header, value) in requestEnvironment.headers {
            // TODO: add these header values to the request
        }

        super.load(request: copy, completion: completion)
    }

And that’s it!


We’ve now added a way for us to customize individual request behavior by declaring options: a type-safe value that gets carried along with the request and examined by various loaders so they can dynamically alter their behavior for that particular request.

In future posts, we’ll use options for customizing many kinds of behavior, including specifying how requests should be retried, what their authentication mechanism is (if any), how responses should be cached (if at all), and so on.

In our next post, we’ll be stepping back from custom loader implementations and take a look at the concept of “resetting” loaders.


Related️️ Posts️

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 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