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..50513c77c 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,349 @@ 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, 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
+ *
+ * @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;
+ var resultHeaders = arguments.result.headers ?: {};
+
+ resultHeaders.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..fe98fcdc0 100644
--- a/system/web/services/RoutingService.cfc
+++ b/system/web/services/RoutingService.cfc
@@ -429,6 +429,18 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" {
verbs : routeResults.route.verbs
}
);
+ } 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 : loggedGateway,
+ verbs : routeResults.route.verbs
+ }
+ );
}
}
renderResponse( routeResults.route, arguments.event );
@@ -899,11 +911,27 @@ 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(),
event.getPrivateCollection()
);
+
+ // The closure rendered the response itself — nothing left to marshall here
+ var closureRenderData = event.getRenderData();
+ if ( !hadRenderData && !closureRenderData.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..328d358aa
--- /dev/null
+++ b/tests/specs/web/routing/RouterGatewayTest.cfc
@@ -0,0 +1,210 @@
+/**
+ * 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" )
+
+ var routes = router.getRoutes()
+ routes.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" )
+
+ var routes = router.getRoutes()
+ routes.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 routes = router.getRoutes()
+ var eventsRoute = routes.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 = {}
+ 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" )
+ 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()
+
+ var routes = router.getRoutes()
+ routes.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()
+
+ var routes = router.getRoutes()
+ routes.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 )
+ } )
+ } )
+ } )
+ } )
+ }
+
+}