Improved Swift Macro based routing for Hummingbird controllers.
import Hummingbird
import HummingbirdMacroRouting
@MacroRouting
struct AuthController<Context: RequestContext> {
@GET("/login")
@Sendable func logIn(request: Request, context: Context) async throws -> Response {
return templatedResponse("login.html")
}
@POST("/login")
@Sendable func logInHandler(request: Request, context: Context) async throws -> Response {
if checkLogin(request) {
session.userLoggedIn(getUsername(from: request))
return redirectResponse(to: UserController.$routes.dashboard.path)
} else {
session.flash(message: Localization.logInFailed)
return redirectResponse(to: $Routes.login.path)
}
}
@POST("/logout")
@Sendable func logOut(request: Request, context: Context) async throws -> Response {
session.clear()
return redirectResponse(to: HomeController.$Routes.root.path)
}
}
Here's how you'd do the same thing without MacroRouting
:
import Hummingbird
struct AuthController<Context: RequestContext> {
var routes: RouteCollection<Context> {
let routes = RouteCollection()
routes.get("/login", use: logIn)
routes.post("/login", use: logInHandler)
// …
routes.post("/logout", use: logOutHandler)
return routes
}
@Sendable func logIn(request: Request, context: Context) async throws -> Response {
return templatedResponse("login.html")
}
@Sendable func logInHandler(request: Request, context: Context) async throws -> Response {
if checkLogin(request) {
session.userLoggedIn(getUsername(from: request))
return redirectResponse(to: "/dashboard")
} else {
session.flash(message: Localization.logInFailed)
return redirectResponse(to: "/login")
}
}
@Sendable func logOut(request: Request, context: Context) async throws -> Response {
session.clear()
return redirectResponse(to: "/")
}
}
Both approaches get added to your router in a similar way.
MacroRouting
method:
router.addRoutes(AuthController().$routes)
Traditional method:
router.addRoutes(AuthController().routes)
The main benefits to this approach are:
- less boilerplate (no need to compose a bespoke
var routes: RouteCollection<Context>
) - a direct relationship/link between your route functions and the
@VERB("/path")
annotations (no need to look elsewhere in the file to track down the logic in.routes
) - route lookup with
Controller.$Routing.routeName
whererouteName
is the function name (or declared route name)- If you have a
@MacroRouting
controller, a$Routing
property is synthesized (this is a controller-specific struct, which includes routing info for each of your declared routes), so you can look up routes progrmamatically, and at compile time (so you also get code completion, and you can change route paths by changing the value in@GET("/login")
, seamlessly, if you don't change the name ofAuthController.logIn
, and you'll get help from the compiler if you do rename thelogIn
function.
- If you have a
- you can still use the normal routing methods, including the documented
RouteCollection
+addRoutes(…)
based approachhummingbird-macroroutes
provides aRouteCollectionContainer
that wraps these to help hint that you shouldn't use theatPath
signature (see below)- a
.$routes
var is synthesized on the controller to contain thisRouteCollectionContainer
- you can construct route paths based on path arguments, all statically, so if anything changes, the compiler will warn you
In your Package.swift
, put this into your .dependencies
:
.package(url: "https://github.com/sloatescoan/hummingbird-macrorouting.git", from: "0.2.1")
…and in your .target
/.executableTarget
:
.product(name: "HummingbirdMacroRouting", package: "hummingbird-macrorouting")
To use the macros in a controller, you need to import HummingbirdMacroRouting
; this provides the needed types and the macros themselves.
You can no longer use atPath
with addRoutes(…)
. Technically you can (if you dig into RouteCollectionContainer
's .routeCollection
property), but you'll lose the ability to look up definitive route paths in UserController.$Routing
.
You can, however, set a prefix
in the @MacroRouting
call, so your routes automatically get a prefix. This is not a complete replacement for atPath
—with atPath
you can attach the same routes in multiple places, under multiple prefixes—but this approach allows you to avoid repeating the /users/
part of your UserController routes:
(this code is adapted from the test suite)
@MacroRouting(prefix: "/api")
struct ApiController {
typealias Context = AppRequestContext
@GET("/auth") // actually /api/auth
@Sendable func auth(request: Request, context: Context) async throws -> Response {
…
}
@GET("/charge/card") // actually /api/charge/card
@Sendable func chargeCard(request: Request, context: Context) async throws -> Response {
…
}
}
You can attach more than one @VERB
declaration to each handler. Consider the above ApiController
, but you'd want to allow the client to use the /api/auth
route as both GET
and POST
:
@GET("/auth") // actually /api/auth
@POST("/auth", name: "postAuth")
@Sendable func auth(request: Request, context: Context) async throws -> Response {
…
}
(Note: you need to give additional routes (or all routes) name
s, so the $Routing
resolution has a structural name.)
In this example, you can use GET
and POST
to /api/auth
to hit the same handler. You could also do something like @GET("/login", name: "authAsLogin")
to make this handler answer on /api/login
. This is especially useful for making APIs backward compatible.
HummingbirdMacroRouting synthesizes a $Routing
structure in each @MacroRouting
controller.
In the above API example, you might want to do something like this:
let authPath = ApiController.$Routing.auth.path
This value is available at compile time (which is development time if your IDE builds macros with the Swift language server or similar), so you get the safety of the compiler, and the convenience of code completion.
Additionally, route paths with arguments can be resolved through the synthesized methods. Consider this code in ApiController
:
@GET("/logs/{userId}/{timing}")
@Sendable func logs(request: Request, context: Context) async throws -> Response {
…
}
Where you might normally get the logs path with ApiController.$Routing.logs.path
, here, the path has arguments. .path
would return /api/logs/{userId}/{timing}
, which isn't exactly useful for passing to a client if you want them to fetch "my logs for today", for example.
This is where .resolvedPath
comes in:
let logsPath = ApiController.$Routing.logs.resolvedPath(userId: "123", timing: "2025-05-27")
This will return: /api/logs/123/2025-05-27
.
The argument names are synthesized by the HummingbirdMacroRouting, so they're available to well-behaving editors/IDEs:
There's some useful reference code available in the test suite.
MacroRouting runs—as the name implies—as a Swift macro, which means that it is part of the compile phase. This means that it can't "know" about routing that is applied at runtime.
It operates statically, to synthesize the $Routing
struct.
You can still apply routes with atPath
, or as RouterGroup
methods, but you won't benefit from MacroRouting's synthesis.