HTTP in Swift, Part 7: Dynamically Modifying Requests
Part 7 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
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: HTTPLoader
public init(loader: HTTPLoader) {
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: HTTPLoader) {
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.