Repository navigation
Route Groups Design #7
Replies: 2 comments
Path joining needs a specThe design says "the group prefix is prepended to the path" but doesn't define joining rules. Simple concatenation breaks:
That last one is particularly relevant — the workaround example before application-level middleware uses Recommend: strip trailing slash from prefix during join (unconditionally — Application.middleware() accumulation semanticsCan it be called multiple times? The chaining pattern ( "What changes" is incompleteThe summary lists |
Uh oh!
There was an error while loading. Please reload this page.
Route groups let you express shared middleware and path prefixes once for a set of routes, rather than attaching them to each route individually. This reduces the chance of accidentally leaving a route unprotected.
The problem
Today, shared middleware requires manual repetition per route:
Miss one
auth_mwand a route is unprotected. The path prefix/apiis also repeated on every route.Implementation strategy: flatten at registration time
All approaches below share the same implementation: group middleware is prepended to per-route middleware, and the group prefix is prepended to the path, when routes are registered. The router, the radix tree,
_ChainRunner, and_Connectionare completely unchanged. Route groups are purely a registration-time convenience — by the timeserve()builds the router, every route is a flat_RouteDefinitionlike it is today.This means the feature has zero runtime cost and zero risk to the routing/execution path.
Three API approaches considered
Approach A: Enter/Exit (stateful stack)
Application maintains a stack of
(prefix, middleware).begin_grouppushes,end_grouppops. Each route method reads the current stack to resolve the full path and middleware chain.Pros: No new types. Clean syntax. Natural with
.>chaining.Cons: Forgetting
end_group()silently applies the group's middleware and prefix to all subsequent routes — exactly the "accidentally leaving a route unprotected" problem, but in reverse (over-protecting/mis-pathing routes).serve()can check for unclosed groups at runtime, but the indentation in the example above is cosmetic — Pony's.>doesn't enforce scoping, so nothing structurally prevents the mistake.Approach B: Lambda/callback
groupcreates a builder internally, passes it to the lambda, then extracts the accumulated routes. The lambda isval(no captures needed), so auto-recovery works on theisoApplication — all arguments (String,Array[...] val, lambdaval) are sendable.Pros: Scoped — forgetting to close is impossible. Familiar pattern from other frameworks.
Cons: The builder type must be public (it appears in the lambda's parameter type, and users outside the package can't reference private types). Nesting means nested lambdas, which gets deeply indented. The lambda syntax is heavier than
.>chaining.Approach C: RouteGroup object (recommended)
RouteGroupisclass isowith the sameget/post/etc. methods as Application. Route methods prepend the group's prefix and middleware at registration time. Application'sgroupmethod takesRouteGroup iso, consumes it, and copies the flattened routes into its own_routesarray.Auto-recovery works the same way it does for Application:
RouteGroupisiso, its route methods arefun refwith all-sendable arguments, so.>chaining works. Application'sgroupmethod takesRouteGroup iso(sendable), so auto-recovery works on theisoApplication too.Nesting:
RouteGroup also has a
groupmethod. When the outer group consumes the inner group, it prepends its own prefix and middleware to each inner route. So/admin/dashboardwith[AdminMW]becomes/api/admin/dashboardwith[AuthMW, AdminMW].Pure prefix grouping (no middleware, just organization):
The middleware parameter defaults to
None.Pros: Uses the exact same
.>chaining pattern users already know. Scoped by construction — no unclosed-group risk. Nesting is natural (samegroupmethod). Composable — groups could be defined in separate functions and passed in. No capability concerns.Cons: Adds one new public type (
RouteGroup). Duplicates the HTTP method registration methods across Application and RouteGroup (can be shared internally via a private trait with default method implementations).Why Approach C
It leverages the same
iso+ auto-recovery +.>chaining mechanism that Application already uses, so it feels like a natural extension rather than a new concept. The scoping is structural (group boundaries are object lifetime) rather than stateful (push/pop stack) or syntactic (lambdas). The enter/exit approach (A) has a silent failure mode that directly contradicts the stated goal. The lambda approach (B) works but is less idiomatic — Pony's.>chaining is the established registration pattern, and the lambda breaks that pattern.Application-level middleware
Route groups address per-group middleware, but there's a related need: middleware that applies to every route in the application (e.g., request logging). Without application-level middleware, you'd need to wrap everything in a root group:
This works but makes Application's own
get/post/etc. methods effectively useless — every route must live inside the outer group to get the global middleware.An application-level
middlewaremethod solves this:Routes registered directly on Application and routes inside groups all get
log_mwprepended. The implementation is the same flatten-at-registration-time approach —serve()prepends the application middleware to every route's middleware chain before building the router.What changes
RouteGroupclass (class iso, public)Application.group(g: RouteGroup iso)methodApplication.middleware(mw: Array[Middleware val] val)method_Router,_RouterBuilder,_BuildNode,_TreeNode,_ChainRunner,_Connection,_Listener,Context,Handler,MiddlewareAll reactions