Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,21 @@ box run-script start:lucee # Lucee CFML engine
box run-script start:2023 # Adobe ColdFusion 2023
```

### Local Setup (BoxLang + CommandBox)

Install BoxLang with the quick installer, which also gives you the `box` CommandBox launcher. Add BoxLang modules with `install-bx-module`:

```bash
curl -fsSL https://install.boxlang.io | bash -s -- --yes
install-bx-module bx-ai # needed by the toAi()/toMCP() router specs
box install # TestBox and the formatter
```

- If `install-bx-module` reports "Helper scripts not found", run `/usr/local/boxlang/scripts/install-bx-module.sh <module>` directly.
- If `box install` fails with a PKIX or certificate error behind a TLS-intercepting proxy, import the proxy CA into CommandBox's own truststore (`~/.CommandBox/engine/cfml/cli/lucee-server/context/security/cacerts`). `JAVA_TOOL_OPTIONS` is not enough.
- Run the router specs without the full test app by serving a small runner with `boxlang-miniserver` that maps `/coldbox`, `/testbox` and `/tests`. Restart it after installing a module.
- CI fails on formatting that differs from `box run-script format:check`. Format only the files you changed and do not accept reflows of lines outside your diff.

### Building & Formatting

```bash
Expand Down
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `COLDBOX-1468` The `toAi()` routes (`/invoke`, `/stream`, `/batch`) answered HTTP 500 when the request had no body or the body was not a JSON object. They now answer 400 with `{ "error": true, "message": "The request body must be a JSON object" }` for a missing body, invalid JSON, a JSON array or a JSON string.
- `COLDBOX-1466` Event caching ignored `noExecution()`. When an interceptor, route middleware or the request start handler denied a request (for example `renderData( statusCode = 429 ).noExecution()`), a warm cached event was still served with its cached 200, and a denied response for a cold event was cached and then served to allowed requests. Denied requests now skip event cache delivery and are never cached.
- The development error page (`BugReport.cfm`) failed with an `encodeForHTML` error when an exception's `extendedInfo` was a struct, as BoxLang modules such as bx-orm set it, and hid the real error. `ExceptionBean.getExtendedInfo()` now always returns a string: complex values come back as JSON.

Expand Down
63 changes: 53 additions & 10 deletions system/web/routing/Router.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -2724,9 +2724,12 @@ component
"verbs" : "POST",
"response" : ( event, rc, prc ) => {
var runnableInstance = isSimpleValue( capturedRunnable ) ? getInstance( capturedRunnable ) : capturedRunnable
var body = event.getHTTPContent( json: true )
var aiContext = resolveAiContext( body )
var options = body.options ?: {}
var body = resolveAiBody( event )
if ( isNull( body ) ) {
return rejectAiBody( event )
}
var aiContext = resolveAiContext( body )
var options = body.options ?: {}
options.append( aiContext, true )
var result = runnableInstance.run( body.input ?: {}, body.params ?: {}, options )
event.setHTTPHeader( name = "X-Thread-Id", value = aiContext.threadId )
Expand Down Expand Up @@ -2758,9 +2761,12 @@ component
"verbs" : "POST",
"response" : ( event, rc, prc ) => {
var runnableInstance = isSimpleValue( capturedRunnable ) ? getInstance( capturedRunnable ) : capturedRunnable;
var body = event.getHTTPContent( json: true );
var aiContext = resolveAiContext( body );
var options = body.options ?: {};
var body = resolveAiBody( event );
if ( isNull( body ) ) {
return rejectAiBody( event );
}
var aiContext = resolveAiContext( body );
var options = body.options ?: {};
options.append( aiContext, true );

// Headers must go out before the stream opens
Expand Down Expand Up @@ -2814,10 +2820,13 @@ component
"verbs" : "POST",
"response" : ( event, rc, prc ) => {
var runnableInstance = isSimpleValue( capturedRunnable ) ? getInstance( capturedRunnable ) : capturedRunnable
var body = event.getHTTPContent( json: true )
var params = body.params ?: {}
var aiContext = resolveAiContext( body )
var options = body.options ?: {}
var body = resolveAiBody( event )
if ( isNull( body ) ) {
return rejectAiBody( event )
}
var params = body.params ?: {}
var aiContext = resolveAiContext( body )
var options = body.options ?: {}
options.append( aiContext, true )
var inputs = body.inputs ?: []

Expand Down Expand Up @@ -2900,6 +2909,40 @@ component
return this;
}

/**
* Read the JSON request body for a toAi() sub-route - shared by invoke/stream/batch.
*
* `getHTTPContent( json: true )` yields an empty string when there is no body or the JSON is
* invalid, and an array or string when the JSON is not an object. None of those can carry
* `input`, `params` or `options`, so they are all reported as "no usable body".
*
* @event The request context
*
* @return The parsed body struct, or null when the body is missing, invalid or not a JSON object
*/
private any function resolveAiBody( required event ){
var body = arguments.event.getHTTPContent( json: true )
return isStruct( body ) ? body : javacast( "null", "" )
}

/**
* Render the 400 answer for a toAi() request whose body is not a JSON object.
*
* @event The request context
*/
private string function rejectAiBody( required event ){
var payload = {
"error" : true,
"message" : "The request body must be a JSON object"
}
arguments.event.renderData(
type = "json",
data = payload,
statusCode = 400
)
return ""
}

/**
* Resolve the conversational identity/thread context for an AI request - shared by the
* invoke/stream/batch sub-routes toAi() registers.
Expand Down
72 changes: 72 additions & 0 deletions tests/specs/web/routing/RouterAITest.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,78 @@ component extends="coldbox.system.testing.BaseModelTest" skip="notBoxlang" {
} )
} )

story( "I want toAi() invoke/stream/batch to reject a request body that is not a JSON object", function(){
beforeEach( function(){
variables.runnable = createStub().$( "run", "stub-output" )
router.route( "/api/chat" ).toAi( runnable )
} )

// Run a sub-route's response closure against a stub event whose body is `body`
function callRoute( required string path, required any body ){
var route = router.getRoutes().filter( ( r ) => r.pattern == "api/chat/#path#/" )[ 1 ]
var event = createStub()
.$( "getHTTPContent", body )
.$( "renderData" )
.$( "setHTTPHeader" )
return { event : event, result : route.response( event, {}, {} ) }
}

function assertRejected( required struct called ){
expect( called.event.$once( "renderData" ) ).toBeTrue()
var args = called.event.$callLog().renderData[ 1 ]
expect( args.statusCode ).toBe( 400 )
expect( args.type ).toBe( "json" )
expect( args.data.error ).toBeTrue()
expect( args.data.message ).toBe( "The request body must be a JSON object" )
expect( runnable.$never( "run" ) ).toBeTrue()
}

var routePaths = [ "invoke", "stream", "batch" ]
var badBodies = {
"no body" : "",
"invalid JSON" : "",
"a JSON array" : [ 1, 2 ],
"a JSON string" : "hello"
}

routePaths.each( ( path ) => {
badBodies.each( ( label, badBody ) => {
given( "a POST to #path# with #label#", () => {
then( "it answers 400 with a JSON error", () => {
assertRejected( callRoute( path, badBody ) )
} )
} )
} )
} )

given( "a valid JSON object body on invoke", function(){
then( "it runs the runnable and answers as before", function(){
var called = callRoute( "invoke", { "input" : "hi", "userId" : "u1", "threadId" : "t1" } )
expect( called.event.$never( "renderData" ) ).toBeTrue()
expect( called.result.success ).toBeTrue()
expect( called.result.output ).toBe( "stub-output" )
expect( called.result.threadId ).toBe( "t1" )
} )
} )

given( "a valid JSON object body on batch", function(){
then( "it runs the runnable for each input and answers as before", function(){
var called = callRoute(
"batch",
{
"inputs" : [ "a", "b" ],
"userId" : "u1",
"threadId" : "t1"
}
)
expect( called.event.$never( "renderData" ) ).toBeTrue()
expect( called.result.outputs ).toHaveLength( 2 )
expect( called.result.outputs[ 1 ].success ).toBeTrue()
expect( called.result.threadId ).toBe( "t1" )
} )
} )
} )

story( "I want argument validation on toAi()", function(){
given( "a numeric value as runnable", function(){
then( "it should throw InvalidArgumentException", function(){
Expand Down
Loading