HTTP in Swift, Part 16: Composite Loaders
Part 16 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
So far we’ve built two different loaders that handle authentication, and it’s conceivable we’d want to build more to support others. Wouldn’t it be nice if we could encapsulate all of the “authentication” logic into a single loader?
We’re going to do that by creating a composite loader.
The Setup
This loader will be similar in structure to other loaders we’ve made: the bulk of the work will happen in the load(task:)
method, and we’ll need to implement reset(with:)
at some point too.
public class Auth: HTTPLoader {
private let basic: BasicAuth = ...
private let oauth: OAuth = ...
public override func load(task: HTTPTask) {
if /* should load with basic auth */ {
basic.load(task: task)
} else if /* should load with oauth */ {
oauth.load(task: task)
} else {
super.load(task: task)
}
}
public override func reset(with group: DispatchGroup) {
basic.reset(with: group)
oauth.reset(with: group)
super.reset(with: group)
}
}
This looks pretty good, right? Well, it’s not 😅. There are a couple of problems with this approach that we’ll need to fix:
-
We need a way to determine which loader should load a particular request.
-
There’s a severe logic flaw with the
reset(with:)
method.
Choosing Loaders for Requests
Given an incoming HTTPTask
, we need a way to determine how it should be authenticated. Or in other words, each request needs to tell us what it wants. Fortunately, we already have a way to specify per-request options!
public struct AuthenticationMethod: Hashable, HTTPRequestOption {
// by default, requests are not authenticated
public static let defaultOptionValue: AuthenticationMethod? = nil
public static let basic = AuthenticationMethod(rawValue: "basic")
public static let oauth = AuthenticationMethod(rawValue: "oauth")
public let rawValue: String
public init(rawValue: String) { self.rawValue = rawValue }
}
extension HTTPRequest {
public var authenticationMethod: AuthenticationMethod? {
get { self[option: AuthenticationMethod.self] }
set { self[option: AuthenticationMethod.self] = newValue }
}
}
I’ve chosen to use a struct
for the AuthenticationMethod
type to make it so that clients can create their own AuthenticationMethods
to correspond to their own custom authentication loaders; an enum
would make that very difficult. We can also update the loader to allow clients to pass in the kinds of authenticating loaders they want to use, by creating a Dictionary<AuthenticationMethod, HTTPLoader>
to associate an authentication method to a particular loader:
public class Auth: HTTPLoader {
private let subloaders: [AuthenticationMethod: HTTPLoader]
public init(loaders: [AuthenticationMethod: HTTPLoader]) {
self.subloaders = loaders
super.init()
}
public override func load(task: HTTPTask) {
if let method = task.request.authenticationMethod {
// the request wants authentication
if let loader = subloaders[method] {
// we know which loader to use
loader.load(task: task)
} else {
// we don't know which loader to use
task.fail(.cannotAuthenticate)
}
} else {
// no authentication; immediately pass it on
super.load(task: task)
}
}
public override func reset(with group: DispatchGroup) {
subloaders.values.forEach { $0.reset(with: group) }
super.reset(with: group)
}
}
Food for thought: This implementation will fail a task if it can’t determine how to authenticate it. Is that right decision to make? Why or why not?
This way our Auth
loader not only supports built-in authentication methods (BasicAuth
and OAuth
); it also supports any kind of AuthenticationMethod
and custom authenticating loader that a client of the framework makes. Another huge advantage of this approach is that the Auth
loader does not need to intercept any delegate methods of the underlying BasicAuth
or OAuth
loaders. Since those loaders are now created externally, the creator can set the delegate (for reading/writing credentials, etc) to whatever object it wants. If the Auth
loader had created those values itself, we would’ve needed a way to either inject a delegate or intercept the delegate method calls before forwarding them on via a different delegate protocol.
The Reset Race
There’s an interesting problem with the reset(with:)
method. Let’s take a look at how the nextLoader
values are configured with this kind of composite loader:
Our inner loaders need a .nextLoader
value, because they need a way to pass their modified tasks down the chain for eventual transmission over the network. However, if we send them straight on to the Auth
loader’s .nextLoader
, then we end up with a situation where that next
loader will have its reset(with:)
method invoked multiple times during a single reset attempt. We can visualize this happening:
- the loader before
Auth
gets a reset call and begins resetting. It instructsAuth
to reset Auth
instructs every child loader to reset, and also instructs its next loader to reset- Each child loader begins resetting and each instructs its next loader to reset, but
Auth
already told that loader to reset
Therefore, we need to intercept all of these reset calls from the inner loader and stop them from propagating down the chain. Fortunately, we already have a loader that does that! Back in part 9, we built a ResetGuard
loader that does exactly that.
Broadly speaking, the Auth
loader needs to make sure that every child loader’s .nextLoader
points to a private ResetGuard
loader. Then it needs to intercept the call to set its own nextLoader
and instead make that the next loader of the ResetGuard
. In this manner, the Auth
loader is injecting a new ResetGuard
loader instance after itself in the overall loading chain.
I’ll leave this up to you to implement.
Conclusion
Overall, building a composite loader is a conceptually straight-forward task. There’s the interesting edge case about the nextLoader
we need to handle, but once we understand the problem, the solution presents itself with components we’ve already built.
In the next post, we’ll be looking at a different kind of composite loader to support a variant on OAuth: OpenID.