- 2024, ServerSide Swift, "Introduction to Hummingbird 2 - Joannis Orlandos": https://www.youtube.com/watch?v=FHO_BfidQlQ
- https://hummingbird.codes
- https://docs.hummingbird.codes/2.0/documentation/index/
- https://github.com/hummingbird-project/hummingbird/
- Swift HTTP Types
- https://github.com/apple/swift-nio
- https://github.com/apple/swift-log
- https://github.com/swift-server/swift-service-lifecycle
NOTE: For github links, when a line number is needed a specific hash is used. When linking to a page, main is used.
This write up is going to focus on the trie version of the Router. There is a ResultBuilder version, which interestingly works as a MiddlewareProtocol.
A typical example starter Application provides an App initializer with a Logger, a ApplicationConfiguration and a Router, but the only one of those that has to be there is a is a Router as the others will provide default values.
Also given a default value is a Server as a Service, which wraps code provided by swift-nio in a swift lifecycle. Server's and the Service architecture are beyond the scope of this article. One can see an example of an explicitly added Service in the excellent TO-DO's tutorial.
func buildApplication(configuration: ApplicationConfiguration) -> some ApplicationProtocol {
let router = buildRouter()
let app = Application(
router: router,
configuration: configuration,
logger: Logger(label: "HelloServer")
)
return app
}A Router conforms to ResponderBuilder, so its actual job is to provide the Application with an implementation of a Responder (alias for HTTPResponder).
- Router<Context: RequestContext>: RouterMethods, HTTPResponderBuilder
- var trie: RouterPathTrieBuilder<EndpointResponders>
- public let middlewares: MiddlewareGroup
- let options: RouterOptions
The HTTPResponder protocol only requires ONE thing. A function that takes in a Request and a Context (alias for a RequestContext implementation) and returns a Response.
@Sendable func respond(to request: Request, context: Context) async throws -> ResponseThat one elegant requirement covers a lot of work!
While Request and Response don't have a lot of surprises in them if you've worked with HTTP clients and servers before, a RequestContext needs a bit more explaining. What is it? Where is it going to come from?
A Context has a few required jobs, and can also be customized to help with some other tasks. The Context carries the default encoders and decoders. Any data that the Middlewares needs to move through the process it preserves. It limits the upload size. Some of the work done by a Context is the type thing that a hand-tooled server implementation might shove into its request type. By pulling it out Hummingbird keeps the request type clean and allows for customization app to app, and specialization by route branch if needed.
A Router must receive the TYPE of a concrete implementation of the RequestContext protocol when it is declared.
BasicRequestContext will be the the Router's Context type if nothing is specified.
let router = Router(context: AppRequestContext.self)
//vs
let router = Router() Every time the app receives a request it will spawn an instance of that type, as the Router will have used it to build the Responder.
let context = Self.Responder.Context(
source: .init(
channel: channel,
logger: logger
)
)The initializer for a Router can also take a MiddlewareGroup, although in the intro examples it's more common to see middleware appended after creation:
let router = Router(context: AppRequestContext.self)
router.addMiddleware {
// logging middleware
LogRequestsMiddleware(.info)
}This top level MiddlewareGroup is where the Router catalogs the Middlewares that will apply to ALL the routes. RouteCollections might have their own groups. Middleware gets called in the order it was added to the group, but then backs out in the opposite direction. An example "handle" function tries to demonstrate how that could happen below.
func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response {
//do something solely based on the request: header, body, whatever if wanted
let request = processRequest(request)
// go ahead and go down the middleware chain a level
let response = try await next(request, context)
//backing out, can then inspect the response and make changes.
return processResponse(response)
}The MiddlewareGroup is also what does a final prep of the routes before they're added to the trie which will be discussed in the next section. Even if a developer doesn't add middleware themselves a default group exists.
Although there are various short cuts, nodes are added to Router via the on function. This function is one of the required RouterMethods. RouterMethod is a protocol that groups, collections, and transforming groups also all conform to so they can be folded into the Router's trie builder easily.
@discardableResult public func on<Responder: HTTPResponder>(
_ path: RouterPath,
method: HTTPRequest.Method,
responder: Responder
) -> Self where Responder.Context == Context {
var path = path
if self.options.contains(.caseInsensitive) {
path = path.lowercased()
}
self.trie.addEntry(path, value: EndpointResponders(path: path)) { node in
node.value!.addResponder(for: method, responder: self.middlewares.constructResponder(finalResponder: responder))
}
return self
}The addEntry function breaks the path into node segments and at the leaf node uses addResponder on the private EndpointResponder type to store a dictionary of [HTTPRequest.Method: any HTTPResponder<Context>] for that API endpoint.
mutating func addResponder(for method: HTTPRequest.Method, responder: any HTTPResponder<Context>) {
guard self.methods[method] == nil else {
preconditionFailure("\(method.rawValue) already has a handler")
}
self.methods[method] = responder
}Looking at a one of those route declaration short cuts, how does it map to what's needed in the on function above?
router.get("/ping") { _, _ -> HTTPResponse.Status in
return .ok
}- the
getis theHTTPRequest.Method, - the
"/ping"is theRouterPath (request, context) -> HTTPResponse.Statusis theResponder??? What?! How!?HTTPResponse.Statusisn't aResponse??!!
That closure isn't a Responder at all. But it can get there!
First let's notice that HTTPResponse.Status gets a conformance to a protocol called ResponseGenerator.
A ResponseGenerator is required to provide a single method:
func response(from request: Request, context: some RequestContext) throws -> ResponseSo while a HTTPResponse.Status/ResponseGenerator isn't a HTTPResponder, it has the goods to make one.
Looking at the get, that's exactly what it expects.
@discardableResult public func get(
_ path: RouterPath = "",
use handler: @Sendable @escaping (Request, Context) async throws -> some ResponseGenerator
) -> Self {
self.on(path, method: .get, use: handler)
}The protocol RouterMethods has it's own implementation of the on function, that isn't part of the conformance since it takes different parameters.
@discardableResult public func on(
_ path: RouterPath,
method: HTTPRequest.Method,
use closure: @Sendable @escaping (Request, Context) async throws -> some ResponseGenerator
) -> Self {
let responder = self.constructResponder(use: closure)
self.on(path, method: method, responder: responder)
return self
}It takes a function like the get function and prepares it for the implementation's on function with self.constructResponder.
internal func constructResponder(
use closure: @Sendable @escaping (Request, Context) async throws -> some ResponseGenerator
) -> CallbackResponder<Context> {
CallbackResponder { request, context in
let output = try await closure(request, context)
return try output.response(from: request, context: context)
}
}Handily Response conforms to ResponseGenerator, So something like
let twoBitResponse:Response = MyCannedResponseMaker("twooooooo biiiiiiiits!!!!")
router.get("shave/and/a/haircut") { _, _ in
twoBitResponse
}should work for Responses crafted by hand.
But there is one more step before the route is ready for the trie builder. Let's go back and look at one of the last lines of Router's on function.
node.value!.addResponder(for: method, responder: self.middlewares.constructResponder(finalResponder: responder))The middleware group still has to apply it's changes.
public func constructResponder(finalResponder: any HTTPResponder<Context>) -> any HTTPResponder<Context> {
var currentResponser = finalResponder
for i in (0..<self.middlewares.count).reversed() {
let responder = MiddlewareResponder(middleware: middlewares[i], next: currentResponser.respond(to:context:))
currentResponser = responder
}
return currentResponser
}Letting the Middleware handle that final processing before a path is added to the trie builder is part of what lets Middleware intercede so effectively.
NOTE: It is easy to conform Codable types to the ResponseEncodable protocol, which passes through a conformance to ResponseGenerator
All of this culminates in the Router being able to produce a RouterResponder that the application will call for and store when the app initializes.
- The RouterPathTrieBuilder in the Router becomes RouterTrie<Value: Sendable> in the RouterResponder
So how does a specific request transform into a response?
A lot of the hard work happened at start up, with the Router combining the explicitly defined routes and the middleware defined routes to produce the RouterResponder stored by the Application.
As shown above when discussing how Context works, when a request arrives in the server layer of the application it builds the Request type and asks the RouterResponder what kind of context to spawn.
That request and context are then handed to the respond function of the RouterResponder, which retrieves the relevant responder function from the node chain based on the request's URI. It also retrieves any parameters to update the context stored in that leaf as well. That retrieved responder function is then called with the request and the context.
If there was no responder for the path in the request, the RouterResponder calls the notFoundResponder that was set at its initialization. That responder will either be the default, or one created by middleware.
The resulting response is then handed off to the server layer for processing to be sent out over the network connection.
data from the request
data into a response
light weight on the error handling