HTTP in Swift, Part 7: Dynamically Modifying Requests

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

In this post, we’ll be creating an HTTPLoader subclass that will allow us to dynamically modify requests.

We’ve seen that the HTTPLoader interface has very loose requirements of “a request comes in, and a completion block gets executed”. We’ve also seen how we can delegate portions of that API responsibility to other loaders, allowing us to create “chains” of loaders.

Let’s take a closer look at that PrintLoader we declared last time:

public class PrintLoader: HTTPLoader {

    override func load(request: HTTPRequest, completion: @escaping (HTTPResult) -> Void) {
        print("Loading \(request)")
        super.load(request: request, completion: { result in
            print("Got result: \(result)")
            completion(result)
        })
    }

}

The astute observer will see that we modified the completion block before sending it on to the next loader. Yes, we are still calling the original completion handler, but the closure sent on is not technically the same closure as the one we received. Instead, we created a new closure and used that one instead.

This same idea can be applied to the request as well. The HTTPLoader interface doesn’t have any requirements about enforcing that the request we receive has to be the same request we send on. We are completely free to modify (or even replace) the request before sending it further down the chain. Let’s run with this idea and see where it gets us.

Modifying a request can be thought of as a specialized “map” function, where the input and output types are both HTTPRequest values. Modeling this as a closure that gets applied to every request seems like a natural way to go, and a loader that uses this might look like this:

public class ModifyRequest: HTTPLoader {

    private let modifier: (HTTPRequest) -> HTTPRequest

    public init(modifier: @escaping (HTTPRequest) -> HTTPRequest) {
        self.modifier = modifier
        super.init()
    }

    override public func load(request: HTTPRequest, completion: @escaping (HTTPResult) -> Void) {
        let modifiedRequest = modifier(request)
        super.load(request: modifiedRequest, completion: completion)
    }
}

It may not be immediately obvious why this is useful, so let’s think back to our StarWarsAPI class:

public class StarWarsAPI {
    private let loader: HTTPLoading

    public init(loader: HTTPLoading = URLSession.shared) {
        self.loader = loader
    }

    public func requestPeople(completion: @escaping (...) -> Void) {
        var r = HTTPRequest()
        r.host = "swapi.dev"
        r.path = "/api/people"

        loader.load(request: r) { result in
            // TODO: interpret the result
            completion(...)
        }
    }
}

This is pretty simple as it currently stands, but you can imagine that if we added more methods to request ships or planets or droids (especially the ones we’re looking for) or weapons, then we’d end up with a lot of duplicated code. Specifically, we’d see this chunk of code over and over:

var r = HTTPRequest()
r.host = "swapi.dev"
r.path = "/api/..."

Since we now have a way to modify in-flight requests, we don’t need to repeat this code everywhere. Let’s modify the initializer to build a loading chain:

    public init(loader: HTTPLoading = URLSession.shared) {

        let modifier = ModifyRequest { request in
            var copy = request
            if copy.host.isEmpty { 
                copy.host = "swapi.dev"
            }
            if copy.path.hasPrefix("/") == false {
                copy.path = "/api/" + copy.path
            }
            return copy
        }

        self.loader = modifier --> loader
    }

Our ModifyRequest loader will look at incoming requests and fill out missing information. If a request doesn’t have a host, it’ll supply one. If a request’s .path is not an absolute path, it’ll prepend a default path for us.

Now, our requestPeople method can get a whole lot simpler:

    public func requestPeople(completion: @escaping (...) -> Void) {
        loader.load(request: HTTPRequest(path: "people")) { result in
            // TODO: interpret the result
            completion(...)
        }
    }

As we add more request... methods, each one’s implementation will only include the pieces that are unique to that implementation. All of the common boilerplate needed to fill out the request will be handled by the ModifyRequest loader.

Server Environments

We can take this a little bit further by formalizing certain kinds of request modification. The one we’ve shown above is a general “change anything” modification. One of the most common kinds of modifications we tend to do to requests centers on targeting different backends. We’ll have one set of servers we use for development, another set used while testing, another set for staging-to-go-live, and then the production set of servers.

Being able to specify the various details of these “environments” without having to directly hard-code the transformation is really useful. A ServerEnvironment struct might look something like this:

public struct ServerEnvironment {
    public var host: String
    public var pathPrefix: String
    public var headers: [String: String]
    public var query: [URLQueryItem]

    public init(host: String, pathPrefix: String = "/", headers: [String: String] = [:], query: [URLQueryItem] = []) {
        // make sure the pathPrefix starts with a /
        let prefix = pathPrefix.hasPrefix("/") ? "" : "/"
        
        self.host = host
        self.pathPrefix = prefix + pathPrefix
        self.headers = headers
        self.query = query
    }
}

With a structure like this, we can pre-define well-known server environments, or use a dynamically-retrieved list of environments, or both.

extension ServerEnvironment {

    public static let development = ServerEnvironment(host: "development.example.com", pathPrefix: "/api-dev")
    public static let qa = ServerEnvironment(host: "qa-1.example.com", pathPrefix: "/api")
    public static let staging = ServerEnvironment(host: "api-staging.example.com", pathPrefix: "/api")
    public static let production = ServerEnvironment(host: "api.example.com", pathPrefix: "/api")

}

Applying this ServerEnvironment value to an HTTPRequest is an expansion of the closure shown above. We’ll test the various portions of an incoming HTTPRequest and fill out the parts that are missing. Our loader to use this would look something 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)
    }
}

Modifying requests as they pass through the chain is an extremely powerful tool. We can use it for filling out headers (User-Agent? A per-request identifier?), examining and updating bodies, redirecting requests to other places, and so on. We’ll see more of this in future posts.

In our next post, we’ll build a feature that will allow individual requests to provide custom per-request “options” to alter their loading behavior away from the default behavior of the chain.


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 8: Request Options
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