From 204bfc81c162a3666e9be375b481e1ec3b563565 Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Thu, 10 Sep 2026 16:57:18 +0000 Subject: [PATCH 1/2] feat(routing): toAiGateway() terminator for BoxLang AI Gateways Exposes a BoxLang AI Gateway over HTTP natively, alongside toAi() and toMCP(). Until now an application had to hand-write the routes and a passthrough handler for the gateway surface, which is exactly what BX Agents generates per project. route( "/gateways" ).toAiGateway( session: "SupportAgentSession" ) registers: GET/POST {pattern}[/:gateway]/events platform handshake + inbound GET {pattern}/interactions/:requestID poll a pending approval POST {pattern}/interactions/:requestID/decisions submit a human decision GET {pattern}/info what this mount serves GET and POST share the /events path because a platform is given ONE URL and verifies it with a GET before it ever POSTs to it. Pin a mount to one gateway with toAiGateway( "slack" ), or leave the name out and the terminator inserts a :gateway placeholder so one mount serves every registered gateway. A pinned name always wins over the placeholder, so a request cannot redirect a pinned mount. Pass a session (a WireBox ID resolved per request, or a live GatewaySession) and every inbound message is dispatched as an agent turn and acked 202 immediately, without waiting on the turn: a platform webhook times out in seconds while an agent turn does not. Without one, events are verified and parsed only. Also fixes a related routing bug: a response closure that rendered the response itself had its status code and content type flattened back onto the route's static statusCode by the router's own renderData() call. Render data set during the closure is now respected, which is what lets a gateway route answer 401 on a bad signature and text/plain on a handshake. Render data an interceptor set before the route ran is left alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015nRppywTeuVpXgVG24Wm9W --- changelog.md | 20 + system/web/routing/Router.cfc | 358 +++++++++++++++++- system/web/services/RoutingService.cfc | 22 ++ tests/specs/web/routing/RouterGatewayTest.cfc | 204 ++++++++++ 4 files changed, 599 insertions(+), 5 deletions(-) create mode 100644 tests/specs/web/routing/RouterGatewayTest.cfc diff --git a/changelog.md b/changelog.md index d518355f4..bc8b35fa0 100644 --- a/changelog.md +++ b/changelog.md @@ -9,8 +9,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `route( "/gateways" ).toAiGateway()`: a routing DSL terminator exposing a BoxLang AI Gateway over + HTTP, alongside `toAi()` and `toMCP()`. Registers the sub-route family a gateway surface needs: + `GET/POST {pattern}[/:gateway]/events` (the platform's URL-verification handshake and its inbound + events, on one URL because that is what a platform is given), + `GET {pattern}/interactions/:requestID` and `POST {pattern}/interactions/:requestID/decisions` for + human-in-the-loop approvals, and `GET {pattern}/info`. Pin a mount to one gateway with + `toAiGateway( "slack" )`, or leave the name out and one mount serves every gateway registered in + `aiGatewayRegistry()`. Pass a `session` (a WireBox ID or a live `GatewaySession`) and every inbound + message is dispatched as an agent turn and acked `202` immediately, without waiting on the turn: + a platform webhook times out in seconds while an agent turn does not. Without one, inbound events + are verified and parsed only. BoxLang only, and requires the `bxai` module. ([bx-ai#286](https://github.com/ortus-boxlang/bx-ai/pull/286)) + ### Fixed +- A route `response` closure that rendered the response itself (`event.renderData(...)`) had its + status code and content type flattened back onto the route's static `statusCode` by the router's + own `renderData()` call. Render data set during the closure is now left alone, so a closure can + answer with a per-request status. Render data an interceptor set before the route ran is + unaffected. + - `BoxLangProvider` did not convert CacheBox's minute-based timeouts before handing them to BoxLang's cache, which reads a bare number as seconds, so every timeout expired sixty times too soon. A region moved from `CacheBoxProvider` to `BoxLangProvider` kept a 10 minute object for 10 seconds. diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc index 22b4b8384..aee41f7a4 100644 --- a/system/web/routing/Router.cfc +++ b/system/web/routing/Router.cfc @@ -816,6 +816,9 @@ component any aiRunnable = "", boolean mcp = "false", string mcpServer = "", + boolean gateway = "false", + string gatewayName = "", + any gatewaySession = "", array middleware = [], array withoutMiddleware = [], boolean cache = "false", @@ -1273,6 +1276,10 @@ component // MCP Routing "mcp" : false, // Flag indicating this is an MCP server route "mcpServer" : "", // The MCP server name to expose + // AI Gateway Routing + "gateway" : false, // Flag indicating this is an AI gateway route + "gatewayName" : "", // The gateway name this route is pinned to, empty for a :gateway placeholder mount + "gatewaySession" : "", // The GatewaySession WireBox ID or instance inbound messages dispatch into // Route-Level Caching - overrides the handler's own cache="true" annotation when true "cache" : false, // Flag indicating this route caches its output "cacheTimeout" : "", // Cache timeout, in minutes. Blank uses the cache provider's default @@ -2427,8 +2434,8 @@ component /** * Verifies that BoxLang is the active runtime and that the bxai module is installed. - * Used as a guard by toAi() and toMCP() at route-registration time so misconfigurations - * are caught on startup rather than at request time. + * Used as a guard by toAi(), toMCP() and toAiGateway() at route-registration time so + * misconfigurations are caught on startup rather than at request time. * * @throws BoxLangRequiredException If BoxLang is not the active runtime * @throws ModuleNotFoundException If the bxai module is not installed @@ -2437,14 +2444,14 @@ component if ( !server.keyExists( "boxlang" ) ) { throw( type : "BoxLangRequiredException", - message: "BoxLang is required for AI/MCP routing. toAi() and toMCP() are BoxLang-only features." + message: "BoxLang is required for AI routing. toAi(), toMCP() and toAiGateway() are BoxLang-only features." ); } if ( !getModuleList().keyArray().findNoCase( "bxai" ) ) { throw( type : "ModuleNotFoundException", - message: "The BoxLang AI module (bxai) is required for AI/MCP routing. Install it via: box install bxai" + message: "The BoxLang AI module (bxai) is required for AI routing. Install it via: box install bxai" ); } } @@ -2465,7 +2472,7 @@ component * void function stream( function onChunk, any input={}, struct params={}, struct options={} ) * * Any route modifiers already set (withCondition, withDomain, withSSL, etc.) are inherited - * by all five sub-routes. + * by every sub-route. * *
 	 * // Using WireBox ID — resolved lazily at request time
@@ -2837,6 +2844,347 @@ component
 		return this;
 	}
 
+	/**
+	 * Terminates the route by registering the family of sub-routes that expose a BoxLang AI
+	 * Gateway over HTTP: inbound platform events, the URL-verification handshake platforms
+	 * perform before they will POST anywhere, and the human-in-the-loop interaction endpoints.
+	 *
+	 * Given a base pattern (e.g. "/gateways"), the following sub-routes are registered:
+	 *
+	 *   POST {pattern}[/:gateway]/events                 → verify, parse, and dispatch  [202 JSON]
+	 *   GET  {pattern}[/:gateway]/events                 → the platform's URL handshake [gateway's own]
+	 *   GET  {pattern}/interactions/:requestID           → poll a pending interaction   [JSON]
+	 *   POST {pattern}/interactions/:requestID/decisions → submit a human's decision    [JSON]
+	 *   GET  {pattern}/info                              → registered gateways          [JSON]
+	 *
+	 * GET and POST deliberately share the `/events` path — one route answering both verbs — since
+	 * a platform is given ONE URL to store and verifies it with a GET before it ever POSTs to it.
+	 *
+	 * Pass a `gateway` name to pin the mount to one gateway. Leave it out and the terminator
+	 * inserts its own `:gateway` placeholder, so a single mount serves every gateway registered
+	 * in `aiGatewayRegistry()`.
+	 *
+	 * Pass a `session` — a WireBox ID or a live GatewaySession from `aiGatewaySession()` — and
+	 * every inbound message is dispatched as an agent turn and acked `202` immediately, without
+	 * waiting on the turn: a platform webhook times out in seconds while an agent turn does not.
+	 * The response reports which thread each message landed on so the caller can correlate the
+	 * reply that arrives later. Leave it out and inbound events are verified and parsed only,
+	 * returning the normalized messages for the application to dispatch itself.
+	 *
+	 * Any route modifiers already set (withCondition, withDomain, withSSL, etc.) are inherited
+	 * by all five sub-routes.
+	 *
+	 * 
+	 * // One mount serving every registered gateway, dispatching into a session by WireBox ID
+	 * route( "/gateways" ).toAiGateway( session: "SupportAgentSession" );
+	 *
+	 * // Pinned to a single gateway: POST/GET /webhooks/slack/events
+	 * route( "/webhooks/slack" ).toAiGateway( "slack", "SupportAgentSession" );
+	 *
+	 * // Verify and parse only, dispatching nothing
+	 * route( "/gateways" ).toAiGateway();
+	 * 
+ * + * @gateway The registered gateway name to pin this mount to. Defaults to empty, which mounts + * a `:gateway` placeholder serving every gateway in the registry + * @session A WireBox ID string or a live GatewaySession instance to dispatch inbound messages + * into. Defaults to empty, which parses without dispatching + * + * @return Router instance for chaining + * + * @throws BoxLangRequiredException If BoxLang is not the active runtime + * @throws ModuleNotFoundException If the bxai module is not installed + * @throws InvalidArgumentException If session is not a WireBox ID string or an object + */ + function toAiGateway( string gateway = "", any session = "" ){ + // Guard: BoxLang + bxai must be present at route-registration time + ensureBoxLang() + + // Validate argument type + if ( + ( !isSimpleValue( arguments.session ) && !isObject( arguments.session ) ) || + isNumeric( arguments.session ) + ) { + throw( + type : "InvalidArgumentException", + message: "The 'session' argument must be a WireBox ID string or a GatewaySession instance" + ) + } + + // Capture base path and route name from the current fluent state, exactly as toAi() does, + // then build each sub-route as its own explicit routeArgs struct. + var basePath = variables.thisRoute.pattern + var baseName = len( variables.thisRoute.name ) ? variables.thisRoute.name : basePath + + // A pinned mount needs no placeholder; an unpinned one takes the gateway name from the URL. + var gatewaySegment = len( trim( arguments.gateway ) ) ? "" : "/:gateway" + + // Shared modifiers forwarded to every sub-route (mirrors toAi()/resources()). + // `gateway` here is the route-metadata FLAG — the name it was pinned to is `gatewayName`. + var sharedArgs = { + condition : variables.thisRoute.condition, + domain : variables.thisRoute.domain, + ssl : variables.thisRoute.ssl, + headers : variables.thisRoute.headers, + module : variables.thisRoute.module, + namespace : variables.thisRoute.namespace, + meta : variables.thisRoute.meta, + gateway : true, + gatewayName : arguments.gateway, + gatewaySession : arguments.session, + statusCode : 200 + } + + // Captured in local variables so the closures can close over them + var pinnedGateway = arguments.gateway + var capturedSession = arguments.session + + // ===================================================================================== + // EVENTS ROUTE + // ===================================================================================== + + // GET/POST {base}[/:gateway]/events — one route, because a platform is given ONE URL and + // verifies it with a GET before it ever POSTs to it. Two routes sharing a pattern would + // merge into one anyway (see addRoute), so the verb branch lives inside the closure. + var routeArgs = sharedArgs + .copy() + .append( { + "pattern" : "#basePath##gatewaySegment#/events", + "name" : "#baseName#.gateway.events", + "verbs" : "GET,POST", + "response" : ( event, rc, prc ) => { + var gatewayName = resolveGatewayName( pinnedGateway, rc ) + + // The platform's URL-verification handshake, answered by the gateway itself + if ( event.getHTTPMethod() == "GET" ) { + return writeGatewayResult( + event, + bxModules.bxai.models.gateway.http.GatewayRequestProcessor::processHandshake( gatewayName, rc ) + ); + } + + var gatewaySession = resolveGatewaySession( capturedSession ) + var result = ""; + + // Passed positionally rather than as a null-valued named argument, so a mount + // with no session is genuinely a parse-only call. + if ( isNull( gatewaySession ) ) { + result = bxModules.bxai.models.gateway.http.GatewayRequestProcessor::processInbound( + gatewayName, + event.getHTTPContent(), + getGatewayRequestHeaders() + ); + } else { + result = bxModules.bxai.models.gateway.http.GatewayRequestProcessor::processInbound( + gatewayName, + event.getHTTPContent(), + getGatewayRequestHeaders(), + gatewaySession + ); + } + + return writeGatewayResult( event, result ); + } + } ) + + // process a with closure if not empty + if ( !variables.withClosure.isEmpty() ) { + processWith( routeArgs ) + } + // Register the route + addRoute( argumentCollection = routeArgs ) + + // ===================================================================================== + // INTERACTION ROUTE + // ===================================================================================== + + // GET {base}/interactions/:requestID — poll a pending human-in-the-loop interaction + routeArgs = sharedArgs + .copy() + .append( { + "pattern" : "#basePath#/interactions/:requestID", + "name" : "#baseName#.gateway.interaction", + "verbs" : "GET", + "response" : ( event, rc, prc ) => { + var result = bxModules.bxai.models.gateway.http.GatewayRequestProcessor::readInteraction( + rc.requestID ?: "" + ); + return writeGatewayResult( event, result ); + } + } ) + + // process a with closure if not empty + if ( !variables.withClosure.isEmpty() ) { + processWith( routeArgs ) + } + // Register the route + addRoute( argumentCollection = routeArgs ) + + // ===================================================================================== + // DECISION ROUTE + // ===================================================================================== + + // POST {base}/interactions/:requestID/decisions — submit a human's signed decision + routeArgs = sharedArgs + .copy() + .append( { + "pattern" : "#basePath#/interactions/:requestID/decisions", + "name" : "#baseName#.gateway.decision", + "verbs" : "POST", + "response" : ( event, rc, prc ) => { + var result = bxModules.bxai.models.gateway.http.GatewayRequestProcessor::submitDecision( + rc.requestID ?: "", + event.getHTTPContent(), + getGatewayRequestHeaders() + ); + return writeGatewayResult( event, result ); + } + } ) + + // process a with closure if not empty + if ( !variables.withClosure.isEmpty() ) { + processWith( routeArgs ) + } + // Register the route + addRoute( argumentCollection = routeArgs ) + + // ===================================================================================== + // INFO ROUTE + // ===================================================================================== + + // GET {base}/info — what this mount serves, and which gateways are registered behind it + routeArgs = sharedArgs + .copy() + .append( { + "pattern" : "#basePath#/info", + "name" : "#baseName#.gateway.info", + "verbs" : "GET", + "response" : ( event, rc, prc ) => { + var registered = aiGatewayRegistry().listGateways() + if ( len( pinnedGateway ) ) { + registered = registered.filter( ( key, gateway ) => key == pinnedGateway ) + } + + return { + "pattern" : basePath, + "gateway" : pinnedGateway, + "dispatches": !isNull( resolveGatewaySession( capturedSession ) ), + "gateways" : registered, + "endpoints" : [ + { + "verb" : "POST", + "path" : basePath & gatewaySegment & "/events", + "description" : "Inbound platform event" + }, + { + "verb" : "GET", + "path" : basePath & gatewaySegment & "/events", + "description" : "Platform URL verification handshake" + }, + { + "verb" : "GET", + "path" : basePath & "/interactions/:requestID", + "description" : "Poll a pending human interaction" + }, + { + "verb" : "POST", + "path" : basePath & "/interactions/:requestID/decisions", + "description" : "Submit a human decision" + }, + { + "verb" : "GET", + "path" : basePath & "/info", + "description" : "Endpoint metadata" + } + ] + } + } + } ) + + // process a with closure if not empty + if ( !variables.withClosure.isEmpty() ) { + processWith( routeArgs ) + } + // Register the route + addRoute( argumentCollection = routeArgs ) + + // Reset fluent state for the next route definition + variables.thisRoute = initRouteDefinition() + + return this; + } + + /** + * Which gateway a toAiGateway() sub-route is talking to: the name the mount was pinned to + * always wins, so a `:gateway` placeholder (or a stray `gateway` value in the request + * collection) can never redirect a pinned mount at another gateway. + * + * @pinnedGateway The name passed to toAiGateway(), empty for a placeholder mount + * @rc The request collection, carrying the matched `:gateway` placeholder + */ + private string function resolveGatewayName( required string pinnedGateway, required struct rc ){ + return len( arguments.pinnedGateway ) ? arguments.pinnedGateway : ( arguments.rc.gateway ?: "" ); + } + + /** + * Resolve the GatewaySession a toAiGateway() route dispatches into: a live instance is used + * as-is, a WireBox ID is resolved per request (so registering the route never forces the + * session to be constructed), and an empty value means "parse, don't dispatch". + * + * @session The `session` argument toAiGateway() was given + * + * @return The GatewaySession, or null when the route dispatches nothing + */ + private any function resolveGatewaySession( required any session ){ + if ( isObject( arguments.session ) ) { + return arguments.session; + } + if ( !len( trim( arguments.session ) ) ) { + return javacast( "null", "" ); + } + return getInstance( arguments.session ); + } + + /** + * The inbound request headers, for gateway signature verification. Defensive in exactly the + * way RequestContext.getHTTPHeader() is: read from a thread there is no request to read. + */ + private struct function getGatewayRequestHeaders(){ + try { + return getHTTPRequestData( false ).headers; + } catch ( any e ) { + return {}; + } + } + + /** + * Render a bx-ai gateway result as the response. + * + * Both the status code and the content type come back from the gateway per request — a 401 + * on a bad signature, a plain-text challenge echo on a handshake — so this renders directly + * instead of returning a body for the route's static `statusCode` to wrap. + * + * @event The request context + * @result The `{ statusCode, body, contentType, headers }` result from GatewayRequestProcessor + */ + private any function writeGatewayResult( required event, required struct result ){ + var contentType = arguments.result.contentType ?: "application/json"; + var thisEvent = arguments.event; + + ( arguments.result.headers ?: {} ).each( ( key, value ) => { + thisEvent.setHTTPHeader( name = key, value = value ); + } ); + + thisEvent.renderData( + type = findNoCase( "json", contentType ) ? "JSON" : "PLAIN", + data = arguments.result.body ?: {}, + contentType = contentType, + statusCode = arguments.result.statusCode ?: 200 + ); + + return ""; + } + /** * Composes the base URL for the server using the following composition: * - protocol diff --git a/system/web/services/RoutingService.cfc b/system/web/services/RoutingService.cfc index 7450a97f2..6a0ba65ff 100644 --- a/system/web/services/RoutingService.cfc +++ b/system/web/services/RoutingService.cfc @@ -429,6 +429,15 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { verbs : routeResults.route.verbs } ); + } else if ( routeResults.route.gateway ?: false ) { + getLogger().debug( + "Executing AI gateway route: #routeResults.route.pattern#", + { + route : routeResults.route.pattern, + gateway : len( routeResults.route.gatewayName ) ? routeResults.route.gatewayName : ( routeResults.params.gateway ?: "" ), + verbs : routeResults.route.verbs + } + ); } } renderResponse( routeResults.route, arguments.event ); @@ -875,6 +884,13 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { private any function renderResponse( required route, required event ){ var aRoute = arguments.route; var theResponse = ""; + // Tracked so a closure that renders the response ITSELF is not flattened back onto the + // route's static statusCode by the renderData() call at the bottom of this method. An AI + // gateway route is the case in point: its status code and content type come back from the + // gateway per request (a 401 on a bad signature, a plain-text challenge on a handshake), + // not from the route definition. Only renderData set DURING the closure counts — one an + // interceptor set before the route ran is left to whatever set it. + var hadRenderData = !arguments.event.getRenderData().isEmpty(); // standardize status codes if not found. if ( !structKeyExists( aRoute, "statusCode" ) ) { @@ -904,6 +920,12 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { event.getCollection(), event.getPrivateCollection() ); + + // The closure rendered the response itself — nothing left to marshall here + if ( !hadRenderData && !event.getRenderData().isEmpty() ) { + event.noExecution(); + return; + } } // render it out diff --git a/tests/specs/web/routing/RouterGatewayTest.cfc b/tests/specs/web/routing/RouterGatewayTest.cfc new file mode 100644 index 000000000..aa7842cde --- /dev/null +++ b/tests/specs/web/routing/RouterGatewayTest.cfc @@ -0,0 +1,204 @@ +/** + * AI Gateway Routing Tests — covers the toAiGateway() terminator + */ +component extends="coldbox.system.testing.BaseModelTest" skip="notBoxlang" { + + boolean function notBoxlang(){ + return !isBoxLang() + } + + /*********************************** LIFE CYCLE Methods ***********************************/ + + function beforeAll(){ + super.beforeAll() + // Controller mock with bxai module registered so ensureBoxLang() passes + variables.controller = createMock( "coldbox.system.web.Controller" ) + .init( expandPath( "/coldbox/test-harness" ), "cbController" ) + .setSetting( "AppMapping", "" ) + .setSetting( "RoutingAppMapping", "/" ) + } + + /*********************************** BDD SUITES ***********************************/ + + function run( testResults, testBox ){ + if ( notBoxlang() ) { + return; + } + + describe( "AI Gateway Routing — toAiGateway()", function(){ + beforeEach( function(){ + variables.router = createMock( "coldbox.system.web.routing.Router" ) + .init() + .setController( controller ) + .setLogBox( controller.getLogBox() ) + .setLog( controller.getLogBox().getLogger( this ) ) + .setCacheBox( controller.getCacheBox() ) + .setWireBox( controller.getWireBox() ) + } ) + + story( "I want to expose a gateway surface behind a base route pattern", function(){ + given( "no gateway name", function(){ + then( "it should register the sub-route family with a :gateway placeholder", function(){ + router.route( "/gateways" ).toAiGateway() + + var routes = router.getRoutes() + var patterns = routes.map( ( r ) => r.pattern ) + + expect( routes ).toHaveLength( 4 ) + expect( patterns ).toInclude( "gateways/:gateway/events/" ) + expect( patterns ).toInclude( "gateways/interactions/:requestID/" ) + expect( patterns ).toInclude( "gateways/interactions/:requestID/decisions/" ) + expect( patterns ).toInclude( "gateways/info/" ) + } ) + } ) + + given( "a pinned gateway name", function(){ + then( "the events route should carry no placeholder", function(){ + router.route( "/webhooks/slack" ).toAiGateway( "slack" ) + + var patterns = router.getRoutes().map( ( r ) => r.pattern ) + + expect( patterns ).toInclude( "webhooks/slack/events/" ) + expect( patterns ).notToInclude( "webhooks/slack/:gateway/events/" ) + } ) + + then( "every sub-route should carry gateway=true and the gateway name", function(){ + router.route( "/webhooks/slack" ).toAiGateway( "slack" ) + + router.getRoutes().each( ( r ) => { + expect( r.gateway ).toBeTrue() + expect( r.gatewayName ).toBe( "slack" ) + } ) + } ) + } ) + + given( "a session WireBox id", function(){ + then( "every sub-route should carry it as gatewaySession", function(){ + router.route( "/gateways" ).toAiGateway( session = "SupportAgentSession" ) + + router.getRoutes().each( ( r ) => { + expect( r.gatewaySession ).toBe( "SupportAgentSession" ) + } ) + } ) + } ) + + given( "a registered gateway route family", function(){ + then( "the events route should answer both GET and POST", function(){ + router.route( "/gateways" ).toAiGateway() + + var eventsRoute = router + .getRoutes() + .filter( ( r ) => r.pattern == "gateways/:gateway/events/" )[ 1 ] + + expect( eventsRoute.verbs ).toBe( "GET,POST" ) + } ) + + then( "interactions should be GET, decisions POST, and info GET", function(){ + router.route( "/gateways" ).toAiGateway() + + var byPattern = {} + router.getRoutes().each( ( r ) => byPattern[ r.pattern ] = r ) + + expect( byPattern[ "gateways/interactions/:requestID/" ].verbs ).toBe( "GET" ) + expect( byPattern[ "gateways/interactions/:requestID/decisions/" ].verbs ).toBe( "POST" ) + expect( byPattern[ "gateways/info/" ].verbs ).toBe( "GET" ) + } ) + } ) + + given( "a named base route", function(){ + then( "sub-routes should inherit the base name as a prefix", function(){ + router.route( pattern = "/gateways", name = "gw" ).toAiGateway() + + var names = router.getRoutes().map( ( r ) => r.name ) + + expect( names ).toInclude( "gw.gateway.events" ) + expect( names ).toInclude( "gw.gateway.interaction" ) + expect( names ).toInclude( "gw.gateway.decision" ) + expect( names ).toInclude( "gw.gateway.info" ) + } ) + } ) + + given( "a base route with withSSL() set", function(){ + then( "all sub-routes should inherit ssl=true", function(){ + router + .route( "/gateways" ) + .withSSL() + .toAiGateway() + + router.getRoutes().each( ( r ) => { + expect( r.ssl ).toBeTrue() + } ) + } ) + } ) + + given( "a base route with withCondition() set", function(){ + then( "all sub-routes should inherit the condition", function(){ + router + .route( "/gateways" ) + .withCondition( ( route, params, event ) => true ) + .toAiGateway() + + router.getRoutes().each( ( r ) => { + expect( isClosure( r.condition ) || isCustomFunction( r.condition ) ).toBeTrue() + } ) + } ) + } ) + } ) + + story( "I want argument validation on toAiGateway()", function(){ + given( "a numeric value as session", function(){ + then( "it should throw InvalidArgumentException", function(){ + expect( function(){ + router.route( "/gateways" ).toAiGateway( "slack", 123 ) + } ).toThrow( "InvalidArgumentException" ) + } ) + } ) + + given( "an array as session", function(){ + then( "it should throw InvalidArgumentException", function(){ + expect( function(){ + router.route( "/gateways" ).toAiGateway( "slack", [] ) + } ).toThrow( "InvalidArgumentException" ) + } ) + } ) + } ) + + story( "I want the gateway name and session resolved per request", function(){ + beforeEach( function(){ + makePublic( router, "resolveGatewayName" ) + makePublic( router, "resolveGatewaySession" ) + } ) + + given( "a pinned gateway name", function(){ + then( "it wins over anything in the request collection", function(){ + expect( router.resolveGatewayName( "slack", { "gateway" : "spoofed" } ) ).toBe( "slack" ) + } ) + } ) + + given( "no pinned gateway name", function(){ + then( "the matched :gateway placeholder is used", function(){ + expect( router.resolveGatewayName( "", { "gateway" : "telegram" } ) ).toBe( "telegram" ) + } ) + + then( "an absent placeholder resolves to an empty name", function(){ + expect( router.resolveGatewayName( "", {} ) ).toBe( "" ) + } ) + } ) + + given( "no session", function(){ + then( "it resolves to null, meaning parse without dispatching", function(){ + expect( isNull( router.resolveGatewaySession( "" ) ) ).toBeTrue() + } ) + } ) + + given( "a live session instance", function(){ + then( "it is used as-is, with no WireBox lookup", function(){ + var fakeSession = createStub() + expect( router.resolveGatewaySession( fakeSession ) ).toBe( fakeSession ) + } ) + } ) + } ) + } ) + } + +} From e0216e39df296c5e7540923279afdd834748e168 Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Thu, 10 Sep 2026 18:25:21 +0000 Subject: [PATCH 2/2] fix(routing): make the gateway terminator parse on Adobe and pass cfformat Two CI failures on the previous commit: - Adobe 2023/2025 could not compile Router.cfc: a member call on a parenthesized expression, `( arguments.result.headers ?: {} ).each( ... )`, is not valid CFML there. The struct goes into a local first. The router test had the same class of problem in an arrow function whose body was an assignment, so that is now a block body. - cfformat rejected all three files. Formatted with cfformat 0.22.1 against the repo's own .cfformat.json: the over-long handshake call is split, the info payload's struct keys align, and the test drops method chains that the formatter re-wraps. Also narrows the RoutingService change: the render-data tracking now lives inside the closure branch that uses it, so it no longer joins (and re-aligns) an assignment group it has no business touching. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015nRppywTeuVpXgVG24Wm9W --- system/web/routing/Router.cfc | 28 ++++++++++--------- system/web/services/RoutingService.cfc | 24 ++++++++++------ tests/specs/web/routing/RouterGatewayTest.cfc | 22 +++++++++------ 3 files changed, 44 insertions(+), 30 deletions(-) diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc index aee41f7a4..50513c77c 100644 --- a/system/web/routing/Router.cfc +++ b/system/web/routing/Router.cfc @@ -2885,10 +2885,8 @@ component * route( "/gateways" ).toAiGateway(); *
* - * @gateway The registered gateway name to pin this mount to. Defaults to empty, which mounts - * a `:gateway` placeholder serving every gateway in the registry - * @session A WireBox ID string or a live GatewaySession instance to dispatch inbound messages - * into. Defaults to empty, which parses without dispatching + * @gateway The registered gateway name to pin this mount to, or empty for a `:gateway` placeholder mount + * @session A WireBox ID or a live GatewaySession to dispatch into, or empty to parse without dispatching * * @return Router instance for chaining * @@ -2959,7 +2957,10 @@ component if ( event.getHTTPMethod() == "GET" ) { return writeGatewayResult( event, - bxModules.bxai.models.gateway.http.GatewayRequestProcessor::processHandshake( gatewayName, rc ) + bxModules.bxai.models.gateway.http.GatewayRequestProcessor::processHandshake( + gatewayName, + rc + ) ); } @@ -3066,11 +3067,11 @@ component } return { - "pattern" : basePath, - "gateway" : pinnedGateway, - "dispatches": !isNull( resolveGatewaySession( capturedSession ) ), - "gateways" : registered, - "endpoints" : [ + "pattern" : basePath, + "gateway" : pinnedGateway, + "dispatches" : !isNull( resolveGatewaySession( capturedSession ) ), + "gateways" : registered, + "endpoints" : [ { "verb" : "POST", "path" : basePath & gatewaySegment & "/events", @@ -3168,10 +3169,11 @@ component * @result The `{ statusCode, body, contentType, headers }` result from GatewayRequestProcessor */ private any function writeGatewayResult( required event, required struct result ){ - var contentType = arguments.result.contentType ?: "application/json"; - var thisEvent = arguments.event; + var contentType = arguments.result.contentType ?: "application/json"; + var thisEvent = arguments.event; + var resultHeaders = arguments.result.headers ?: {}; - ( arguments.result.headers ?: {} ).each( ( key, value ) => { + resultHeaders.each( ( key, value ) => { thisEvent.setHTTPHeader( name = key, value = value ); } ); diff --git a/system/web/services/RoutingService.cfc b/system/web/services/RoutingService.cfc index 6a0ba65ff..fe98fcdc0 100644 --- a/system/web/services/RoutingService.cfc +++ b/system/web/services/RoutingService.cfc @@ -430,11 +430,14 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { } ); } else if ( routeResults.route.gateway ?: false ) { + var loggedGateway = len( routeResults.route.gatewayName ) ? routeResults.route.gatewayName : ( + routeResults.params.gateway ?: "" + ); getLogger().debug( "Executing AI gateway route: #routeResults.route.pattern#", { route : routeResults.route.pattern, - gateway : len( routeResults.route.gatewayName ) ? routeResults.route.gatewayName : ( routeResults.params.gateway ?: "" ), + gateway : loggedGateway, verbs : routeResults.route.verbs } ); @@ -884,13 +887,6 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { private any function renderResponse( required route, required event ){ var aRoute = arguments.route; var theResponse = ""; - // Tracked so a closure that renders the response ITSELF is not flattened back onto the - // route's static statusCode by the renderData() call at the bottom of this method. An AI - // gateway route is the case in point: its status code and content type come back from the - // gateway per request (a 401 on a bad signature, a plain-text challenge on a handshake), - // not from the route definition. Only renderData set DURING the closure counts — one an - // interceptor set before the route ran is left to whatever set it. - var hadRenderData = !arguments.event.getRenderData().isEmpty(); // standardize status codes if not found. if ( !structKeyExists( aRoute, "statusCode" ) ) { @@ -915,6 +911,15 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { } // Closure/Lambda else { + // Tracked so a closure that renders the response ITSELF is not flattened back onto the + // route's static statusCode by the renderData() call at the bottom of this method. An AI + // gateway route is the case in point: its status code and content type come back from the + // gateway per request (a 401 on a bad signature, a plain-text challenge on a handshake), + // not from the route definition. Only renderData set DURING the closure counts — one an + // interceptor set before the route ran is left to whatever set it. + var priorRenderData = event.getRenderData(); + var hadRenderData = !priorRenderData.isEmpty(); + theResponse = aRoute.response( event, event.getCollection(), @@ -922,7 +927,8 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { ); // The closure rendered the response itself — nothing left to marshall here - if ( !hadRenderData && !event.getRenderData().isEmpty() ) { + var closureRenderData = event.getRenderData(); + if ( !hadRenderData && !closureRenderData.isEmpty() ) { event.noExecution(); return; } diff --git a/tests/specs/web/routing/RouterGatewayTest.cfc b/tests/specs/web/routing/RouterGatewayTest.cfc index aa7842cde..328d358aa 100644 --- a/tests/specs/web/routing/RouterGatewayTest.cfc +++ b/tests/specs/web/routing/RouterGatewayTest.cfc @@ -65,7 +65,8 @@ component extends="coldbox.system.testing.BaseModelTest" skip="notBoxlang" { then( "every sub-route should carry gateway=true and the gateway name", function(){ router.route( "/webhooks/slack" ).toAiGateway( "slack" ) - router.getRoutes().each( ( r ) => { + var routes = router.getRoutes() + routes.each( ( r ) => { expect( r.gateway ).toBeTrue() expect( r.gatewayName ).toBe( "slack" ) } ) @@ -76,7 +77,8 @@ component extends="coldbox.system.testing.BaseModelTest" skip="notBoxlang" { then( "every sub-route should carry it as gatewaySession", function(){ router.route( "/gateways" ).toAiGateway( session = "SupportAgentSession" ) - router.getRoutes().each( ( r ) => { + var routes = router.getRoutes() + routes.each( ( r ) => { expect( r.gatewaySession ).toBe( "SupportAgentSession" ) } ) } ) @@ -86,9 +88,8 @@ component extends="coldbox.system.testing.BaseModelTest" skip="notBoxlang" { then( "the events route should answer both GET and POST", function(){ router.route( "/gateways" ).toAiGateway() - var eventsRoute = router - .getRoutes() - .filter( ( r ) => r.pattern == "gateways/:gateway/events/" )[ 1 ] + var routes = router.getRoutes() + var eventsRoute = routes.filter( ( r ) => r.pattern == "gateways/:gateway/events/" )[ 1 ] expect( eventsRoute.verbs ).toBe( "GET,POST" ) } ) @@ -97,7 +98,10 @@ component extends="coldbox.system.testing.BaseModelTest" skip="notBoxlang" { router.route( "/gateways" ).toAiGateway() var byPattern = {} - router.getRoutes().each( ( r ) => byPattern[ r.pattern ] = r ) + var routes = router.getRoutes() + routes.each( ( r ) => { + byPattern[ r.pattern ] = r; + } ) expect( byPattern[ "gateways/interactions/:requestID/" ].verbs ).toBe( "GET" ) expect( byPattern[ "gateways/interactions/:requestID/decisions/" ].verbs ).toBe( "POST" ) @@ -125,7 +129,8 @@ component extends="coldbox.system.testing.BaseModelTest" skip="notBoxlang" { .withSSL() .toAiGateway() - router.getRoutes().each( ( r ) => { + var routes = router.getRoutes() + routes.each( ( r ) => { expect( r.ssl ).toBeTrue() } ) } ) @@ -138,7 +143,8 @@ component extends="coldbox.system.testing.BaseModelTest" skip="notBoxlang" { .withCondition( ( route, params, event ) => true ) .toAiGateway() - router.getRoutes().each( ( r ) => { + var routes = router.getRoutes() + routes.each( ( r ) => { expect( isClosure( r.condition ) || isCustomFunction( r.condition ) ).toBeTrue() } ) } )