diff --git a/system/Bootstrap.cfc b/system/Bootstrap.cfc
index 4332fdf18..0373dceb3 100644
--- a/system/Bootstrap.cfc
+++ b/system/Bootstrap.cfc
@@ -233,6 +233,8 @@ component serializable="false" accessors="true" {
// ****** PRE PROCESS *******/
interceptorService.announce( "preProcess" );
+ // Route-scoped middleware runs after the global preProcess chain, closest to the handler
+ cbController.getRoutingService().runRouteMiddleware( event, "preProcess" );
if ( len( cbController.getSetting( "RequestStartHandler" ) ) ) {
cbController.runEvent(
event : cbController.getSetting( "RequestStartHandler" ),
@@ -410,6 +412,8 @@ component serializable="false" accessors="true" {
prePostExempt = true
);
}
+ // Route-scoped middleware runs before the global postProcess chain, closest to the handler
+ cbController.getRoutingService().runRouteMiddleware( event, "postProcess" );
interceptorService.announce( "postProcess" );
// ****** FLASH AUTO-SAVE *******/
diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc
index 221ea3c59..65855b7a3 100644
--- a/system/web/routing/Router.cfc
+++ b/system/web/routing/Router.cfc
@@ -145,6 +145,11 @@ component
// Routing pointer
variables.thisRoute = initRouteDefinition();
+ // Stack of group-level middleware arrays, outermost first, so nested groups accumulate in order
+ variables.groupMiddlewareStack = [];
+ // Named, reusable middleware bundles registered via middlewareGroup(), keyed by name
+ variables.middlewareGroups = {};
+
/************************************** CONSTANTS *********************************************/
// STATIC Valid Extensions
@@ -510,7 +515,7 @@ component
* } )
*
*
- * @options The route options that match routing, look at the addRoute() method
+ * @options The route options that match routing, look at the addRoute() method. A `middleware` array (same target values middleware() accepts) applies to every route registered within the body, ahead of any middleware the route registers for itself.
* @body The closure or lambda to contain all the routing methods to be grouped with the options data.
*/
function group( struct options = {}, body ){
@@ -519,12 +524,25 @@ component
// set the withClosure
variables.withClosure.append( arguments.options );
- // Execute the body
- arguments.body( arguments.options );
-
- // Pivot out of the group and do cleanup
- variables.onGroup = false;
- variables.withClosure = {};
+ // Push this group's middleware onto the stack - arrays aren't part of the withClosure
+ // default/prefix merge, so they're inherited via their own stack instead. Pushed even when
+ // empty so the stack depth always matches the current group nesting depth. Entries are
+ // normalized to the same { target, point } shape middleware() produces - options.middleware
+ // may be a single target (not wrapped in an array), a struct missing its own `point`, or the
+ // name of a middlewareGroup() bundle, which normalizeMiddlewareEntries() expands in place.
+ var groupMiddleware = structKeyExists( arguments.options, "middleware" ) ? arguments.options.middleware : [];
+ variables.groupMiddlewareStack.append( normalizeMiddlewareEntries( groupMiddleware ) );
+
+ try {
+ // Execute the body
+ arguments.body( arguments.options );
+ } finally {
+ // Pivot out of the group and do cleanup - always, even if the body threw, so a failed
+ // registration can't leak this group's middleware/options into whatever registers next.
+ variables.groupMiddlewareStack.deleteAt( variables.groupMiddlewareStack.len() );
+ variables.onGroup = false;
+ variables.withClosure = {};
+ }
return this;
}
@@ -797,7 +815,9 @@ component
boolean ai = "false",
any aiRunnable = "",
boolean mcp = "false",
- string mcpServer = ""
+ string mcpServer = "",
+ array middleware = [],
+ array withoutMiddleware = []
){
// The route construct we will save
var thisRoute = {};
@@ -816,6 +836,42 @@ component
// Process all incoming arguments into the route to store
thisRoute.append( arguments );
+ // Inherit group-level middleware (outermost group first), followed by this route's own
+ // entries registered via .middleware(). Arrays don't participate in processWith()'s
+ // default/prefix merge, so group inheritance is tracked explicitly on its own stack.
+ if ( variables.groupMiddlewareStack.len() ) {
+ var inheritedMiddleware = [];
+ for ( var groupEntries in variables.groupMiddlewareStack ) {
+ inheritedMiddleware.append( groupEntries, true );
+ }
+ inheritedMiddleware.append( thisRoute.middleware, true );
+ thisRoute.middleware = inheritedMiddleware;
+ }
+
+ // Strip any middleware this route opted out of via withoutMiddleware() - matched by target
+ // name (a WireBox ID) or by the middlewareGroup() name an entry was expanded from. "*"
+ // strips everything, inherited or this route's own. Closures/objects have no name to match,
+ // so they can only be excluded by not attaching them in the first place.
+ if ( thisRoute.withoutMiddleware.len() ) {
+ if ( thisRoute.withoutMiddleware.findNoCase( "*" ) ) {
+ thisRoute.middleware = [];
+ } else {
+ var filteredMiddleware = [];
+ for ( var mwEntry in thisRoute.middleware ) {
+ var excludedByTarget = isSimpleValue( mwEntry.target ) && thisRoute.withoutMiddleware.findNoCase(
+ mwEntry.target
+ );
+ var excludedByGroup = mwEntry.keyExists( "group" ) && thisRoute.withoutMiddleware.findNoCase(
+ mwEntry.group
+ );
+ if ( !excludedByTarget && !excludedByGroup ) {
+ filteredMiddleware.append( mwEntry );
+ }
+ }
+ thisRoute.middleware = filteredMiddleware;
+ }
+ }
+
// Cleanup Route: Add trailing / to make it easier to parse
if ( right( thisRoute.pattern, 1 ) IS NOT "/" ) {
thisRoute.pattern = thisRoute.pattern & "/";
@@ -1176,6 +1232,7 @@ component
"layout" : "", // The layout to proxy to
"layoutModule" : "", // If the layout comes from a module
"meta" : {}, // Route metadata if any
+ "middleware" : [], // Route-scoped middleware entries: [ { target, point } ]
"module" : "", // The module event we must execute
"moduleRouting" : "", // This routes to a module
"name" : "", // The named route
@@ -1197,6 +1254,7 @@ component
"view" : "", // The view to proxy to
"viewModule" : "", // If the view comes from a module
"viewNoLayout" : false, // If we use a layout or not
+ "withoutMiddleware" : [], // Middleware target/group names excluded from this route
// AI Routing
"ai" : false, // Flag indicating this is an AI runnable route
"aiRunnable" : "", // The AI runnable WireBox ID or instance
@@ -1378,6 +1436,178 @@ component
/* MODIFIERS */
/****************************************************************************************************************************/
+ /**
+ * Attach route-scoped middleware. Middleware runs at a ColdBox interception point (`preProcess`
+ * by default, or `postProcess`), but only for requests that matched this route - it is
+ * InterceptorState's point-based dispatch, scoped to one route instead of the whole app.
+ *
+ * A target can be:
+ * - A closure/lambda: `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, that has a method named after the point (`preProcess()`/
+ * `postProcess()`) - the same duck-typed convention ColdBox interceptors themselves already use.
+ * No base class or interface is required.
+ *
+ * Returning `true` from a target short-circuits the remaining middleware for this route at this
+ * point - it does not, by itself, skip the handler or the render. To actually stop the request,
+ * call `event.relocate()`, `event.renderData().noExecution()`, `event.etag()`, etc, exactly as you
+ * would from any other preProcess/postProcess interceptor.
+ *
+ * A target may also be the name of a bundle registered via `middlewareGroup()` - it expands to
+ * that bundle's own targets in place, at this call's point unless a member declares its own.
+ * The group must already be registered when this runs, since expansion happens immediately -
+ * referencing one too early silently treats the name as a literal target instead of expanding it.
+ *
+ *
+ * // inline closure
+ * route( "/admin/:action" ).middleware( function( event, rc, prc ){
+ * if ( !auth.isLoggedIn() ) {
+ * event.relocate( "login" );
+ * return true;
+ * }
+ * } ).toHandler( "admin" );
+ *
+ * // a WireBox ID, or any class with a preProcess()/postProcess() method
+ * 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" );
+ *
+ * // a name registered via middlewareGroup() - expands to that bundle's targets
+ * route( "/api/orders" ).middleware( "api" ).toHandler( "orders" );
+ *
+ *
+ * @target A closure/lambda, a WireBox ID, an object instance, a `middlewareGroup()` name, or an array of any mix of those.
+ * @point The interception point to run this middleware at. Defaults to `preProcess`.
+ */
+ function middleware( required any target, string point = "preProcess" ){
+ // process a with closure if not empty
+ if ( !variables.withClosure.isEmpty() ) {
+ processWith( arguments );
+ }
+
+ variables.thisRoute.middleware.append(
+ normalizeMiddlewareEntries( arguments.target, arguments.point ),
+ true
+ );
+
+ return this;
+ }
+
+ /**
+ * Register a named, reusable bundle of middleware that can be referenced by name from
+ * `.middleware()` or a `group( { middleware : [ ... ] } )` call, instead of repeating the same
+ * target list at every call site - the same role Laravel's `$middlewareGroups` plays.
+ *
+ * Groups are flat: an entry may not itself be the name of another group - a bundle is always a
+ * concrete list of closures/WireBox IDs/objects, never a pointer to another bundle.
+ *
+ * Register a group before any `.middleware()`/`group()` call that references it by name - name
+ * resolution happens immediately, at registration time, not lazily at request time. A name that
+ * doesn't match a registered group yet is silently treated as a literal target (e.g. a WireBox ID)
+ * instead of being expanded, so referencing a group too early fails quietly rather than throwing.
+ *
+ *
+ * middlewareGroup( "api", [ "RequireApiKey", "RateLimiter" ] );
+ *
+ * route( "/orders" ).middleware( "api" ).toHandler( "orders" );
+ *
+ * group( { pattern : "/api", middleware : [ "api" ] }, function(){
+ * route( "/users" ).toHandler( "users" );
+ * } );
+ *
+ *
+ * @name The group name, referenced later as a middleware target.
+ * @middleware The middleware targets in this group - the same values `.middleware()` accepts.
+ * @point The interception point for any member that doesn't declare its own via `{ target, point }`.
+ */
+ function middlewareGroup(
+ required string name,
+ required any middleware,
+ string point = "preProcess"
+ ){
+ variables.middlewareGroups[ arguments.name ] = normalizeMiddlewareEntries(
+ arguments.middleware,
+ arguments.point
+ );
+ return this;
+ }
+
+ /**
+ * Exclude middleware this route would otherwise inherit - most commonly from an enclosing
+ * `group()` - from running for this specific route. Mirrors Laravel's `Route::withoutMiddleware()`.
+ *
+ * Matches by the same name used to attach the middleware: a WireBox ID, or the name of a
+ * `middlewareGroup()` - excluding a group name drops every member it expanded to, not just a
+ * same-named single target. Pass `"*"` to strip all middleware, inherited or this route's own.
+ *
+ * Closures and object instances have no name to match, so they can only be kept off a route by
+ * not attaching them in the first place - the same limitation Laravel has for anonymous middleware.
+ *
+ *
+ * middlewareGroup( "api", [ "RequireApiKey", "RateLimiter" ] );
+ *
+ * group( { pattern : "/api", middleware : [ "api" ] }, function(){
+ * route( "/users" ).toHandler( "users" ); // runs "api"
+ * route( "/health" ).withoutMiddleware( "api" ).toHandler( "health" ); // opts out
+ * } );
+ *
+ *
+ * @target A middleware target name, a `middlewareGroup()` name, `"*"` for all, or an array of any mix.
+ */
+ function withoutMiddleware( required any target ){
+ var targets = isArray( arguments.target ) ? arguments.target : [ arguments.target ];
+ variables.thisRoute.withoutMiddleware.append( targets, true );
+ return this;
+ }
+
+ /**
+ * Normalize a mixed set of middleware targets - closures, WireBox IDs, object instances,
+ * `{ target, point }` structs, or the name of a previously registered `middlewareGroup()` - into
+ * the canonical `{ target, point, group }` entry shape `addRoute()` and `runRouteMiddleware()`
+ * expect. A name that resolves to a registered group expands to that group's own entries, each
+ * tagged with the group name it came from so `withoutMiddleware()` can exclude the whole bundle
+ * later without knowing its individual members.
+ *
+ * Groups are flat - a group's own members are never expanded again here - so there's no risk of
+ * a group indirectly referencing itself.
+ *
+ * @entries A single target, or an array of any mix of the above.
+ * @defaultPoint The interception point to use for any entry that doesn't declare its own.
+ */
+ private array function normalizeMiddlewareEntries( required any entries, string defaultPoint = "preProcess" ){
+ var rawEntries = isArray( arguments.entries ) ? arguments.entries : [ arguments.entries ];
+ var normalized = [];
+
+ for ( var entry in rawEntries ) {
+ var target = entry;
+ var point = arguments.defaultPoint;
+
+ if ( isStruct( entry ) && entry.keyExists( "target" ) ) {
+ target = entry.target;
+ point = entry.keyExists( "point" ) ? entry.point : arguments.defaultPoint;
+ }
+
+ // A name that matches a registered middleware group expands to that group's members,
+ // each tagged with the group name so withoutMiddleware() can exclude it as a whole.
+ if ( isSimpleValue( target ) && variables.middlewareGroups.keyExists( target ) ) {
+ for ( var groupEntry in variables.middlewareGroups[ target ] ) {
+ normalized.append( {
+ "target" : groupEntry.target,
+ "point" : groupEntry.point,
+ "group" : target
+ } );
+ }
+ continue;
+ }
+
+ normalized.append( { "target" : target, "point" : point } );
+ }
+
+ return normalized;
+ }
+
/**
* Add a header to a route
*
diff --git a/system/web/services/RoutingService.cfc b/system/web/services/RoutingService.cfc
index cb7c66974..8b37821f9 100644
--- a/system/web/services/RoutingService.cfc
+++ b/system/web/services/RoutingService.cfc
@@ -440,6 +440,72 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" {
return discoveredEvent;
}
+ /**
+ * Run the currently matched route's middleware (`Router.middleware()`) for the given interception
+ * point. A no-op if no route matched, or the matched route registered no middleware for this point.
+ *
+ * Mirrors `InterceptorState.processSync()`'s short-circuit contract: a target returning `true`
+ * stops the remaining middleware at this point for this route. It does not, by itself, skip the
+ * handler or the render - the target must do that explicitly (`event.relocate()`,
+ * `event.renderData().noExecution()`, etc), exactly like any other preProcess/postProcess interceptor.
+ *
+ * @event The ColdBox Request context
+ * @point The interception point to run middleware for, e.g. `preProcess` or `postProcess`
+ *
+ * @return True if a middleware target short-circuited the chain by returning true; false otherwise
+ */
+ boolean function runRouteMiddleware( required event, required string point ){
+ var routeRecord = arguments.event.getCurrentRouteRecord();
+
+ if ( !structKeyExists( routeRecord, "middleware" ) || !routeRecord.middleware.len() ) {
+ return false;
+ }
+
+ var invocationArgs = {
+ "event" : arguments.event,
+ "rc" : arguments.event.getCollection(),
+ "prc" : arguments.event.getPrivateCollection()
+ };
+
+ for ( var entry in routeRecord.middleware ) {
+ if ( entry.point != arguments.point ) {
+ continue;
+ }
+
+ var target = resolveMiddlewareTarget( entry.target );
+ var results = "";
+
+ if ( isClosure( target ) || isCustomFunction( target ) ) {
+ results = target( argumentCollection = invocationArgs );
+ } else if ( structKeyExists( target, arguments.point ) ) {
+ results = invoke( target, arguments.point, invocationArgs );
+ } else {
+ // No method matching this point on the target - nothing to run
+ continue;
+ }
+
+ if ( !isNull( local.results ) && isBoolean( results ) && results ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Resolve a route middleware target: a WireBox ID string is resolved via `getInstance()` on every
+ * call so it respects the mapping's own declared scope; anything else (a closure or an already
+ * built object instance) is returned as-is.
+ *
+ * @target The middleware target to resolve
+ */
+ private function resolveMiddlewareTarget( required target ){
+ if ( isSimpleValue( arguments.target ) ) {
+ return variables.wirebox.getInstance( arguments.target );
+ }
+ return arguments.target;
+ }
+
/****************************************************************************************************************************/
/* ROUTE DISPATCHING METHODS */
/****************************************************************************************************************************/
diff --git a/tests/resources/routing/SampleMiddleware.cfc b/tests/resources/routing/SampleMiddleware.cfc
new file mode 100644
index 000000000..35b54c32c
--- /dev/null
+++ b/tests/resources/routing/SampleMiddleware.cfc
@@ -0,0 +1,28 @@
+/**
+ * A plain object with no base class or interface - used to prove that route-scoped middleware
+ * (Router.middleware()) works by duck-typed method name, the same convention ColdBox interceptors
+ * already use, not by inheritance.
+ */
+component accessors="true" {
+
+ property name="wasCalled";
+ property name="shortCircuit";
+
+ function init(){
+ variables.wasCalled = false;
+ variables.shortCircuit = false;
+ return this;
+ }
+
+ function preProcess( event, rc, prc ){
+ variables.wasCalled = true;
+ if ( variables.shortCircuit ) {
+ return true;
+ }
+ }
+
+ function postProcess( event, rc, prc ){
+ variables.wasCalled = true;
+ }
+
+}
diff --git a/tests/specs/web/routing/RouterTest.cfc b/tests/specs/web/routing/RouterTest.cfc
index df96adc4a..8162aebff 100644
--- a/tests/specs/web/routing/RouterTest.cfc
+++ b/tests/specs/web/routing/RouterTest.cfc
@@ -356,6 +356,279 @@ component extends="coldbox.system.testing.BaseModelTest" {
} );
} );
+ story( "I want to attach route-scoped middleware", function(){
+ given( "a single middleware target with no explicit point", function(){
+ then( "it defaults to preProcess and accumulates in order", function(){
+ var authCheck = function( event, rc, prc ){
+ };
+ router
+ .route( "/admin" )
+ .middleware( authCheck )
+ .middleware( "AuditLog", "postProcess" )
+ .toHandler( "admin" );
+
+ var middleware = router.getRoutes()[ 1 ].middleware;
+ expect( middleware ).toHaveLength( 2 );
+ expect( middleware[ 1 ].target ).toBe( authCheck );
+ expect( middleware[ 1 ].point ).toBe( "preProcess" );
+ expect( middleware[ 2 ].target ).toBe( "AuditLog" );
+ expect( middleware[ 2 ].point ).toBe( "postProcess" );
+ } );
+ } );
+
+ given( "an array of targets in a single call", function(){
+ then( "each target is registered individually on the same point", function(){
+ router
+ .route( "/api/orders" )
+ .middleware( [ "RateLimiter", "RequireApiKey" ] )
+ .toHandler( "orders" );
+
+ var middleware = router.getRoutes()[ 1 ].middleware;
+ expect( middleware ).toHaveLength( 2 );
+ expect( middleware[ 1 ].target ).toBe( "RateLimiter" );
+ expect( middleware[ 1 ].point ).toBe( "preProcess" );
+ expect( middleware[ 2 ].target ).toBe( "RequireApiKey" );
+ expect( middleware[ 2 ].point ).toBe( "preProcess" );
+ } );
+ } );
+
+ given( "a route with no middleware() calls", function(){
+ then( "it still carries the defaulted empty middleware array", function(){
+ router.route( "/plain" );
+ expect( router.getThisRoute().middleware ).toBeArray().toBeEmpty();
+ } );
+ } );
+
+ given( "a group with middleware options", function(){
+ then( "every route inside inherits it ahead of its own middleware", function(){
+ router.group( { pattern : "/api", middleware : [ "RequireApiKey" ] }, function( options ){
+ router
+ .route( "/users" )
+ .middleware( "RateLimiter" )
+ .toHandler( "users" );
+ router.route( "/products" ).toHandler( "products" );
+ } );
+
+ var routes = router.getRoutes();
+ expect( routes ).toHaveLength( 2 );
+
+ expect( routes[ 1 ].middleware ).toHaveLength( 2 );
+ expect( routes[ 1 ].middleware[ 1 ].target ).toBe( "RequireApiKey" );
+ expect( routes[ 1 ].middleware[ 2 ].target ).toBe( "RateLimiter" );
+
+ expect( routes[ 2 ].middleware ).toHaveLength( 1 );
+ expect( routes[ 2 ].middleware[ 1 ].target ).toBe( "RequireApiKey" );
+ } );
+ } );
+
+ given( "a route registered outside any group", function(){
+ then( "it does not inherit a previously-run group's middleware", function(){
+ router.group( { pattern : "/api", middleware : [ "RequireApiKey" ] }, function( options ){
+ router.route( "/users" ).toHandler( "users" );
+ } );
+ router.route( "/public" ).toHandler( "public" );
+
+ var routes = router.getRoutes();
+ expect( routes[ 2 ].middleware ).toBeArray().toBeEmpty();
+ } );
+ } );
+
+ given( "nested groups each contributing middleware", function(){
+ then( "the outer group's middleware runs before the inner group's", function(){
+ router.group( { pattern : "/api", middleware : [ "RequireApiKey" ] }, function( options ){
+ router.group( { pattern : "/admin", middleware : [ "RequireAdmin" ] }, function( innerOptions ){
+ router.route( "/users" ).toHandler( "users" );
+ } );
+ } );
+
+ var middleware = router.getRoutes()[ 1 ].middleware;
+ expect( middleware ).toHaveLength( 2 );
+ expect( middleware[ 1 ].target ).toBe( "RequireApiKey" );
+ expect( middleware[ 2 ].target ).toBe( "RequireAdmin" );
+ } );
+ } );
+
+ given( "a group middleware option that is a single target, not wrapped in an array", function(){
+ then( "it is normalized to a one-entry list rather than iterated as a collection", function(){
+ router.group( { pattern : "/api", middleware : "RequireApiKey" }, function( options ){
+ router.route( "/users" ).toHandler( "users" );
+ } );
+
+ var middleware = router.getRoutes()[ 1 ].middleware;
+ expect( middleware ).toHaveLength( 1 );
+ expect( middleware[ 1 ].target ).toBe( "RequireApiKey" );
+ expect( middleware[ 1 ].point ).toBe( "preProcess" );
+ } );
+ } );
+
+ given( "a group middleware entry given as a struct with no point key", function(){
+ then( "point defaults to preProcess instead of throwing later", function(){
+ router.group(
+ {
+ pattern : "/api",
+ middleware : [ { target : "RequireApiKey" } ]
+ },
+ function( options ){
+ router.route( "/users" ).toHandler( "users" );
+ }
+ );
+
+ var middleware = router.getRoutes()[ 1 ].middleware;
+ expect( middleware[ 1 ].target ).toBe( "RequireApiKey" );
+ expect( middleware[ 1 ].point ).toBe( "preProcess" );
+ } );
+ } );
+
+ given( "a group body that throws", function(){
+ then( "group state is still cleaned up so it does not leak into later routes", function(){
+ expect( function(){
+ router.group( { pattern : "/api", middleware : [ "RequireApiKey" ] }, function( options ){
+ throw( message = "boom", type = "TestBoom" );
+ } );
+ } ).toThrow( type = "TestBoom" );
+
+ router.route( "/public" ).toHandler( "public" );
+
+ var routes = router.getRoutes();
+ expect( routes[ routes.len() ].middleware ).toBeArray().toBeEmpty();
+ expect( routes[ routes.len() ].pattern ).toBe( "public/" );
+ } );
+ } );
+
+ given( "a middlewareGroup() referenced from .middleware()", function(){
+ then( "it expands to the group's members, tagged with the group name", function(){
+ router.middlewareGroup( "api", [ "RequireApiKey", "RateLimiter" ] );
+ router
+ .route( "/orders" )
+ .middleware( "api" )
+ .toHandler( "orders" );
+
+ var middleware = router.getRoutes()[ 1 ].middleware;
+ expect( middleware ).toHaveLength( 2 );
+ expect( middleware[ 1 ].target ).toBe( "RequireApiKey" );
+ expect( middleware[ 1 ].point ).toBe( "preProcess" );
+ expect( middleware[ 1 ].group ).toBe( "api" );
+ expect( middleware[ 2 ].target ).toBe( "RateLimiter" );
+ expect( middleware[ 2 ].group ).toBe( "api" );
+ } );
+ } );
+
+ given( "a middlewareGroup() referenced from a group()'s middleware option", function(){
+ then( "every route in the body inherits the expanded group members", function(){
+ router.middlewareGroup( "api", [ "RequireApiKey", "RateLimiter" ] );
+ router.group( { pattern : "/api", middleware : [ "api" ] }, function( options ){
+ router.route( "/users" ).toHandler( "users" );
+ } );
+
+ var middleware = router.getRoutes()[ 1 ].middleware;
+ expect( middleware ).toHaveLength( 2 );
+ expect( middleware[ 1 ].target ).toBe( "RequireApiKey" );
+ expect( middleware[ 2 ].target ).toBe( "RateLimiter" );
+ } );
+ } );
+
+ given( "a middlewareGroup() entry given its own point", function(){
+ then( "that member keeps its own point instead of the group's default", function(){
+ router.middlewareGroup(
+ "audited",
+ [
+ "RequireApiKey",
+ { target : "AuditLog", point : "postProcess" }
+ ]
+ );
+ router
+ .route( "/orders" )
+ .middleware( "audited" )
+ .toHandler( "orders" );
+
+ var middleware = router.getRoutes()[ 1 ].middleware;
+ expect( middleware[ 1 ].point ).toBe( "preProcess" );
+ expect( middleware[ 2 ].target ).toBe( "AuditLog" );
+ expect( middleware[ 2 ].point ).toBe( "postProcess" );
+ } );
+ } );
+
+ given( "withoutMiddleware() naming a single WireBox ID target", function(){
+ then( "only that target is stripped from the merged middleware list", function(){
+ router.group(
+ {
+ pattern : "/api",
+ middleware : [ "RequireApiKey", "RateLimiter" ]
+ },
+ function( options ){
+ router
+ .route( "/health" )
+ .withoutMiddleware( "RateLimiter" )
+ .toHandler( "health" );
+ }
+ );
+
+ var middleware = router.getRoutes()[ 1 ].middleware;
+ expect( middleware ).toHaveLength( 1 );
+ expect( middleware[ 1 ].target ).toBe( "RequireApiKey" );
+ } );
+ } );
+
+ given( "withoutMiddleware() naming a middlewareGroup()", function(){
+ then( "every member that group expanded to is stripped", function(){
+ router.middlewareGroup( "api", [ "RequireApiKey", "RateLimiter" ] );
+ router.group( { pattern : "/api", middleware : [ "api" ] }, function( options ){
+ router.route( "/users" ).toHandler( "users" );
+ router
+ .route( "/health" )
+ .withoutMiddleware( "api" )
+ .toHandler( "health" );
+ } );
+
+ var routes = router.getRoutes();
+ expect( routes[ 1 ].middleware ).toHaveLength( 2 );
+ expect( routes[ 2 ].middleware ).toBeArray().toBeEmpty();
+ } );
+ } );
+
+ given( "withoutMiddleware( '*' )", function(){
+ then( "every middleware for that route is stripped, inherited or its own", function(){
+ router.group( { pattern : "/api", middleware : [ "RequireApiKey" ] }, function( options ){
+ router
+ .route( "/health" )
+ .middleware( "RateLimiter" )
+ .withoutMiddleware( "*" )
+ .toHandler( "health" );
+ } );
+
+ expect( router.getRoutes()[ 1 ].middleware ).toBeArray().toBeEmpty();
+ } );
+ } );
+
+ given( "a route with no withoutMiddleware() calls", function(){
+ then( "its middleware is unaffected", function(){
+ router.group( { pattern : "/api", middleware : [ "RequireApiKey" ] }, function( options ){
+ router.route( "/users" ).toHandler( "users" );
+ } );
+
+ expect( router.getRoutes()[ 1 ].middleware ).toHaveLength( 1 );
+ } );
+ } );
+
+ given( "a middlewareGroup() name referenced before the group is registered", function(){
+ then( "it is treated as a literal target instead of being expanded", function(){
+ // Documents a known ordering requirement: expansion happens immediately at
+ // registration time, not lazily at request time, so a group must be
+ // registered before anything references it by name.
+ router
+ .route( "/z" )
+ .middleware( "lateGroup" )
+ .toHandler( "z" );
+ router.middlewareGroup( "lateGroup", [ "RequireApiKey" ] );
+
+ var middleware = router.getRoutes()[ 1 ].middleware;
+ expect( middleware ).toHaveLength( 1 );
+ expect( middleware[ 1 ].target ).toBe( "lateGroup" );
+ expect( middleware[ 1 ] ).notToHaveKey( "group" );
+ } );
+ } );
+ } );
+
story( "Router will throw exception if a non-closure or string is passed to the body of a toResponse()", function(){
given( "Anything but a closure or string to the toResponse() body", function(){
then( "an InvalidArgumentException will be thrown", function(){
diff --git a/tests/specs/web/routing/RoutingServiceTest.cfc b/tests/specs/web/routing/RoutingServiceTest.cfc
index 547b662dc..eaf516794 100755
--- a/tests/specs/web/routing/RoutingServiceTest.cfc
+++ b/tests/specs/web/routing/RoutingServiceTest.cfc
@@ -282,6 +282,158 @@
expect( discoveredEventPOST ).toBe( "api-v1:MyOtherHandler.create" );
} );
} );
+
+ describe( "route-scoped middleware (runRouteMiddleware())", function(){
+ beforeEach( function(){
+ mockEvent = createMock( "coldbox.system.web.context.RequestContext" ).init(
+ controller = getController(),
+ properties = {
+ defaultLayout : "Main.cfm",
+ defaultView : "",
+ eventName : "event",
+ modules : {}
+ }
+ );
+ } );
+
+ it( "no-ops when no route matched", function(){
+ mockEvent.$( "getCurrentRouteRecord", {} );
+ expect( routingService.runRouteMiddleware( mockEvent, "preProcess" ) ).toBeFalse();
+ } );
+
+ it( "no-ops when the matched route has no middleware", function(){
+ mockEvent.$( "getCurrentRouteRecord", { middleware : [] } );
+ expect( routingService.runRouteMiddleware( mockEvent, "preProcess" ) ).toBeFalse();
+ } );
+
+ it( "invokes an inline closure target", function(){
+ var called = false;
+ var target = function( event, rc, prc ){
+ called = true;
+ };
+ mockEvent.$(
+ "getCurrentRouteRecord",
+ { middleware : [ { target : target, point : "preProcess" } ] }
+ );
+
+ routingService.runRouteMiddleware( mockEvent, "preProcess" );
+
+ expect( called ).toBeTrue();
+ } );
+
+ it( "only runs middleware registered for the requested point", function(){
+ var preCalls = 0;
+ var postCalls = 0;
+ mockEvent.$(
+ "getCurrentRouteRecord",
+ {
+ middleware : [
+ {
+ target : function( event, rc, prc ){
+ preCalls++;
+ },
+ point : "preProcess"
+ },
+ {
+ target : function( event, rc, prc ){
+ postCalls++;
+ },
+ point : "postProcess"
+ }
+ ]
+ }
+ );
+
+ routingService.runRouteMiddleware( mockEvent, "preProcess" );
+
+ expect( preCalls ).toBe( 1 );
+ expect( postCalls ).toBe( 0 );
+ } );
+
+ it( "short-circuits the remaining middleware when a target returns true", function(){
+ var secondCalled = false;
+ mockEvent.$(
+ "getCurrentRouteRecord",
+ {
+ middleware : [
+ {
+ target : function( event, rc, prc ){
+ return true;
+ },
+ point : "preProcess"
+ },
+ {
+ target : function( event, rc, prc ){
+ secondCalled = true;
+ },
+ point : "preProcess"
+ }
+ ]
+ }
+ );
+
+ var result = routingService.runRouteMiddleware( mockEvent, "preProcess" );
+
+ expect( result ).toBeTrue();
+ expect( secondCalled ).toBeFalse();
+ } );
+
+ it( "invokes a duck-typed object target with no base class by its point-named method", function(){
+ var target = new tests.resources.routing.SampleMiddleware();
+ mockEvent.$(
+ "getCurrentRouteRecord",
+ { middleware : [ { target : target, point : "preProcess" } ] }
+ );
+
+ routingService.runRouteMiddleware( mockEvent, "preProcess" );
+
+ expect( target.getWasCalled() ).toBeTrue();
+ } );
+
+ it( "skips a target with no method matching the requested point", function(){
+ var target = new tests.resources.routing.SampleMiddleware();
+ mockEvent.$(
+ "getCurrentRouteRecord",
+ { middleware : [ { target : target, point : "someOtherPoint" } ] }
+ );
+
+ expect( function(){
+ routingService.runRouteMiddleware( mockEvent, "someOtherPoint" );
+ } ).notToThrow();
+ expect( target.getWasCalled() ).toBeFalse();
+ } );
+
+ it( "resolves a string target as a WireBox ID on every call", function(){
+ var wirebox = getController().getWireBox();
+ wirebox
+ .registerNewInstance(
+ name = "RouteMiddlewareTestTarget",
+ instancePath = "tests.resources.routing.SampleMiddleware"
+ )
+ .setScope( wirebox.getBinder().SCOPES.SINGLETON );
+
+ mockEvent.$(
+ "getCurrentRouteRecord",
+ {
+ middleware : [
+ {
+ target : "RouteMiddlewareTestTarget",
+ point : "preProcess"
+ }
+ ]
+ }
+ );
+
+ routingService.runRouteMiddleware( mockEvent, "preProcess" );
+
+ expect(
+ getController()
+ .getWireBox()
+ .getInstance( "RouteMiddlewareTestTarget" )
+ .getWasCalled()
+ ).toBeTrue();
+ } );
+ } );
}
/**