Skip to content

feat: route-scoped middleware via existing interceptor points - #679

Merged
lmajano merged 7 commits into
developmentfrom
claude/route-scoped-middleware
Aug 16, 2026
Merged

feat: route-scoped middleware via existing interceptor points#679
lmajano merged 7 commits into
developmentfrom
claude/route-scoped-middleware

Conversation

@lmajano

@lmajano lmajano commented Aug 16, 2026

Copy link
Copy Markdown
Member

Description

Route-scoped middleware: attach middleware to a route at an existing ColdBox interception point (preProcess by default, or postProcess) instead of inventing a parallel middleware subsystem. See the full write-up and examples below.

Jira Issues

COLDBOX-1416

Type of change

  • Bug Fix
  • Improvement
  • New Feature
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Checklist

  • My code follows the style guidelines of this project cfformat
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Adds Router.middleware(): attach middleware to a route at an existing ColdBox
interception point (preProcess by default, or postProcess) instead of
inventing a parallel middleware subsystem. It reuses the same
dynamic method dispatch ColdBox
interceptors already use (invoke(target, point, args)), just scoped to one
route instead of the whole app.

Evaluated this design against Laravel's middleware pipeline ($next-closure
onion composition, $middlewareGroups, withoutMiddleware()). Laravel's true
wrapping composition doesn't fit here - ColdBox's chain is flat before/after
dispatch by design (aroundHandler already covers genuine wrapping at the
handler level) - but two of its ideas were worth adopting directly: named,
reusable middleware groups, and excluding inherited middleware per route.

A target can be

  • An inline closure - function( event, rc, prc ){ ... }
  • A WireBox ID - resolved via getInstance() on every request, so it
    respects whatever scope (singleton, prototype, etc) the mapping was
    registered with
  • Any object, WireBox-managed or not - as long as it has a method named
    after the point (preProcess()/postProcess()). No base class or
    interface required - the same duck-typed convention ColdBox interceptors
    themselves already use.
  • The name of a middlewareGroup() bundle - expands in place to that
    bundle's own targets.

Examples

// a concrete middleware class, resolved by WireBox ID (any object with a
// preProcess()/postProcess() method works - no base class needed)
component singleton {
    property name="auth" inject="AuthService";

    function preProcess( event, rc, prc ){
        if ( !auth.isLoggedIn() ) {
            event.relocate( "login" );
            return true; // short-circuits the rest of this route's middleware
        }
    }
}
// registered in WireBox as "RequireLogin", then:
route( "/admin/:action" ).middleware( "RequireLogin" ).toHandler( "admin" );

// inline closure, short-circuits with a redirect
route( "/admin/:action" ).middleware( function( event, rc, prc ){
    if ( !auth.isLoggedIn() ) {
        event.relocate( "login" );
        return true; // stop the remaining middleware for this route
    }
} ).toHandler( "admin" );

// a WireBox ID - resolved and invoked by its preProcess()/postProcess() method
route( "/api/orders" ).middleware( "RateLimiter" ).toHandler( "orders" );

// postProcess: shape the response after the handler runs
route( "/api/reports" ).middleware( "AuditLog", "postProcess" ).to( "reports.index" );

// multiple targets in one call, all on the same point
route( "/api/orders" ).middleware( [ "RateLimiter", "RequireApiKey" ] ).toHandler( "orders" );

// any plain class with a preProcess()/postProcess() method - no interface needed
route( "/webhooks/stripe" ).middleware( new app.middleware.VerifyStripeSignature() ).toHandler( "webhooks" );

// group-level middleware, shared across every route in the body,
// ahead of each route's own middleware() calls
group( { pattern : "/api", middleware : [ "RequireApiKey" ] }, function(){
    route( "/users" ).middleware( "RateLimiter" ).toHandler( "users" ); // RequireApiKey, then RateLimiter
    route( "/products" ).toHandler( "products" );                      // RequireApiKey only
} );

// nested groups compose in order - outer first, then inner
group( { pattern : "/api", middleware : [ "RequireApiKey" ] }, function(){
    group( { pattern : "/admin", middleware : [ "RequireAdmin" ] }, function(){
        route( "/users" ).toHandler( "users" ); // RequireApiKey, then RequireAdmin
    } );
} );

// named, reusable middleware groups - like Laravel's $middlewareGroups -
// referenced by name from .middleware() or a group()'s middleware option,
// instead of repeating the same target list at every call site
middlewareGroup( "api", [ "RequireApiKey", "RateLimiter" ] );
route( "/orders" ).middleware( "api" ).toHandler( "orders" );
group( { pattern : "/api", middleware : [ "api" ] }, function(){
    route( "/users" ).toHandler( "users" );
} );

// withoutMiddleware() - opt a route out of middleware it would otherwise
// inherit, by target name or by the middlewareGroup() name it expanded from
group( { pattern : "/api", middleware : [ "api" ] }, function(){
    route( "/users" ).toHandler( "users" );                              // runs "api"
    route( "/health" ).withoutMiddleware( "api" ).toHandler( "health" ); // opts out
} );
// "*" strips everything for that route, inherited or its own
route( "/public" ).middleware( "RateLimiter" ).withoutMiddleware( "*" ).toHandler( "public" );

Semantics

  • Returning true from a target short-circuits the remaining middleware for
    that route at that point - the same contract
    InterceptorState.processSync() already uses for the global chain. It does
    not, by itself, skip the handler or the render; call
    event.relocate(), event.renderData().noExecution(), event.etag(),
    etc, exactly as you would from any other preProcess/postProcess
    interceptor.
  • Ordering: route-scoped middleware runs after the global preProcess
    announce and before the global postProcess announce - route-specific
    work happens closest to the handler, global interceptors stay the
    outermost layer.
  • group({ middleware: [...] }) is tracked on its own stack, independent of
    group()'s existing withClosure/onGroup state, so nested groups
    compose correctly.
  • middlewareGroup() bundles are flat - a member can't itself be another
    group's name - so there's no cycle to guard against.
  • withoutMiddleware() matches by name: a WireBox ID, or a middlewareGroup()
    name (which drops every member that group expanded to, not just a
    same-named single target). Closures and object instances have no name to
    match, so they can only be kept off a route by not attaching them.

Files touched

  • system/web/routing/Router.cfc - middleware()/middlewareGroup()/
    withoutMiddleware() fluent modifiers, middleware/withoutMiddleware
    route-struct keys, group-level middleware inheritance via
    groupMiddlewareStack, shared expansion via normalizeMiddlewareEntries()
  • system/web/services/RoutingService.cfc - runRouteMiddleware() executes
    the matched route's middleware for a given point; resolveMiddlewareTarget()
    resolves WireBox ID strings
  • system/Bootstrap.cfc - wires runRouteMiddleware() in right after the
    preProcess announce and right before the postProcess announce
  • tests/specs/web/routing/RouterTest.cfc /
    tests/specs/web/routing/RoutingServiceTest.cfc - fluent API, group/nested
    group inheritance, closure/WireBox-ID/duck-typed-object dispatch,
    short-circuit, point filtering, named group expansion (including per-member
    point overrides), and withoutMiddleware() by target name, by group name,
    and via "*"
  • tests/resources/routing/SampleMiddleware.cfc - a plain class fixture with
    no base class, proving middleware works by method-name convention alone

Testing notes

RouterTest.cfc extends BaseModelTest and exercises Router.cfc directly
via createMock(), with no servlet/database dependency, so it's runnable in
CI as-is. The sandbox this PR was authored in has no reachable test database,
so the full TestBox HTTP runner (tests/runner.cfm) couldn't be exercised
locally regardless of engine; instead every new registration-time behavior
(group expansion, per-member point overrides, withoutMiddleware() by target/
group/"*") was verified with a standalone script instantiating Router.cfc
directly and asserting against its real methods, matching the same assertions
now encoded in RouterTest.cfc.

claude added 4 commits August 16, 2026 16:48
Router.middleware() attaches middleware to a route at a ColdBox
interception point (preProcess by default, or postProcess) instead of
introducing a parallel middleware subsystem. A target can be an inline
closure, a WireBox ID (resolved via getInstance() on every call, so it
respects the mapping's own declared scope), or any object - WireBox
managed or not - that exposes a method named after the point, the same
duck-typed convention ColdBox interceptors already use.

group({ middleware: [...] }, body) shares a chain across every route
registered in the body, ahead of each route's own middleware(), tracked
on its own stack so nested groups compose correctly independent of
group()'s existing withClosure/onGroup nesting limitation.

RoutingService.runRouteMiddleware() executes the current route's
middleware for a given point, wired into Bootstrap.cfc right after the
global preProcess announce and right before postProcess - route-scoped
middleware runs closest to the handler, global interceptors stay the
outermost layer. A target returning true short-circuits the remaining
middleware at that point for that route, mirroring
InterceptorState.processSync()'s existing short-circuit contract; it
does not by itself skip the handler or render, matching how a normal
preProcess/postProcess interceptor works today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
The WireBox-ID resolution test registered its mapping with no explicit
scope, so getInstance() returned a fresh instance per call under the
default (non-singleton) scope - the test's post-hoc assertion never saw
the state the production code had mutated. Registers it as a singleton
explicitly, matching what the docstring already promised: resolution
respects the mapping's own declared scope.

Also replaces group()'s arrow-function/map() middleware normalization
with a plain for-loop, matching the rest of the file's established style
more closely and avoiding CI's older cfformat disagreeing with a newer
local run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
…t block

The new variables.groupMiddlewareStack line sat inside a comment-interrupted
block of variable initializers that cfformat column-aligns across all
lines. Its longer name pushed every other line's = column out, and local
(newer) cfformat and CI's (older) cfformat apparently disagree on how far
that realignment should propagate - CI kept flagging Router.cfc without
saying why. Moving the new line after a blank line keeps the original
four-line block byte-for-byte as it was before this feature, sidestepping
the disagreement entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
…ame line

A background agent pulled the exact CI container image (cfml-ci-tools
1.0.12, CommandBox 5.8.0) and ran cfformat check --verbose against it,
producing the real diff: the file's earlier session had already padded
this line to satisfy CI's alignment.consecutive.assignments rule inside
the mcpResponseClosure arrow function, but a later `cfformat run
--overwrite` pass in this session (using a newer local cfformat that
disagrees on whether a nested var inside an arrow-function body joins the
outer assignment's alignment group) silently stripped that padding back
out. Restoring it - unrelated to this session's actual feature work,
same as the earlier documented instance of this exact drift.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown

Test Results

0 tests  ±0   0 ✅ ±0   0s ⏱️ ±0s
0 suites ±0   0 💤 ±0 
0 files   ±0   0 ❌ ±0 

Results for commit 4aef45a. ± Comparison against base commit 57205b2.

♻️ This comment has been updated with latest results.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces route-scoped middleware by reusing existing ColdBox interception points (preProcess by default, optionally postProcess) and executing middleware only for the currently matched route (plus group-level inheritance).

Changes:

  • Adds Router.middleware() and a middleware route key, including group-level middleware inheritance via a new groupMiddlewareStack.
  • Adds RoutingService.runRouteMiddleware() with target resolution (closures, WireBox IDs, duck-typed objects) and short-circuit semantics via true return.
  • Wires route middleware execution into the request lifecycle in Bootstrap.cfc, with accompanying unit tests and fixtures.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
system/web/routing/Router.cfc Adds middleware() modifier, route middleware storage, and group-level middleware inheritance via groupMiddlewareStack.
system/web/services/RoutingService.cfc Implements runRouteMiddleware() and WireBox-ID resolution for route middleware targets.
system/Bootstrap.cfc Executes route middleware after global preProcess and before global postProcess.
tests/specs/web/routing/RouterTest.cfc Verifies middleware registration, accumulation, and group/nested-group inheritance order.
tests/specs/web/routing/RoutingServiceTest.cfc Verifies execution semantics: point filtering, closures, WireBox IDs, duck-typed objects, and short-circuiting.
tests/resources/routing/SampleMiddleware.cfc Adds a plain CFC fixture to validate duck-typed preProcess/postProcess dispatch.
Suppressed comments (1)

system/web/routing/Router.cfc:543

  • If the group() body throws, the current implementation will skip the cleanup that pops groupMiddlewareStack and resets onGroup/withClosure, which can leak group-level middleware/options into subsequent route registrations. Wrap the body execution in a try/finally (or try/catch/finally) so cleanup always runs.
		// Execute the body
		arguments.body( arguments.options );

		// Pivot out of the group and do cleanup
		variables.groupMiddlewareStack.deleteAt( variables.groupMiddlewareStack.len() );

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread system/web/routing/Router.cfc Outdated
Comment on lines +529 to +537
var groupMiddleware = structKeyExists( arguments.options, "middleware" ) ? arguments.options.middleware : [];
var normalizedGroupMW = [];
for ( var entry in groupMiddleware ) {
if ( isStruct( entry ) && entry.keyExists( "target" ) ) {
normalizedGroupMW.append( entry );
} else {
normalizedGroupMW.append( { "target" : entry, "point" : "preProcess" } );
}
}
- group()'s options.middleware is now normalized to an array before
  iterating - a single non-array target (e.g. a bare closure or WireBox
  ID string, not wrapped in []) would otherwise iterate its characters
  (if a string) or fail outright, instead of being treated as one entry.
- A struct entry in options.middleware that omits its own `point` key no
  longer throws later in RoutingService.runRouteMiddleware() when that
  key is read - it now defaults to "preProcess", same as every other
  middleware()-registered entry.
- group() now wraps body execution in try/finally: if the body throws,
  groupMiddlewareStack/onGroup/withClosure cleanup still runs, so a
  failed group registration can't leak its middleware/options into
  whatever gets registered next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR

lmajano commented Aug 16, 2026

Copy link
Copy Markdown
Member Author

Both findings were real, fixed in 7274750:

  • group() leaking state on a throw: body execution is now wrapped in try/finally, so groupMiddlewareStack/onGroup/withClosure cleanup always runs, even if the group body throws.
  • options.middleware normalization: a single non-array target (bare closure/WireBox ID) is now wrapped into a one-entry array before iterating instead of behaving unpredictably, and a struct entry that omits its own point key defaults to preProcess instead of throwing later when RoutingService.runRouteMiddleware() reads it.

New test coverage for all three in the same commit.


Generated by Claude Code

claude added 2 commits August 16, 2026 21:35
Route-scoped middleware currently only inherits through literal group()
nesting, with no way to share a bundle across unrelated routes or opt a
single route out of an inherited one. Adds two Laravel-inspired pieces on
top of the existing flat preProcess/postProcess dispatch:

- middlewareGroup(name, [...]) registers a named, reusable bundle,
  referenced by name from either .middleware() or group({ middleware }).
  Groups are flat - a member can't itself be another group's name - so
  there's no cycle risk.
- withoutMiddleware(target) excludes middleware a route would otherwise
  inherit, matched by WireBox ID or by the middlewareGroup() name an
  entry was expanded from (dropping the whole bundle), or "*" for
  everything.

normalizeMiddlewareEntries() is the shared expansion point used by
.middleware(), group()'s middleware option, and middlewareGroup() itself,
tagging group-expanded entries with their source group name so
withoutMiddleware() can match on it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
…rement

Final review of the named-groups/withoutMiddleware() work surfaced one real
gap: group expansion happens immediately at registration time, so a name
referenced before its middlewareGroup() call is silently treated as a
literal target instead of being expanded - no error, just quietly wrong.
Documents the requirement on both middleware() and middlewareGroup(), and
adds a regression test pinning the current (silent-fallback) behavior so
it stays visible rather than being an undiscovered trap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
@lmajano
lmajano merged commit a85f7a5 into development Aug 16, 2026
27 of 28 checks passed
@lmajano
lmajano deleted the claude/route-scoped-middleware branch August 16, 2026 23:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants