From 9f480af473f98f9f5da4fe8db6e3ec8fb1df997f Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 11 Feb 2025 13:34:47 -0700 Subject: [PATCH 1/2] Generate cbValidation constraints from Swagger docs --- models/OpenAPIConstraintsGenerator.cfc | 226 ++++++++++++++++++ readme.md | 42 ++++ server-adobe@2018.json | 10 +- .../resources/users.add.requestBody.json | 49 ++++ .../specs/OpenAPIConstraintsGeneratorTest.cfc | 39 +++ 5 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 models/OpenAPIConstraintsGenerator.cfc create mode 100644 test-harness/includes/resources/users.add.requestBody.json create mode 100644 test-harness/tests/specs/OpenAPIConstraintsGeneratorTest.cfc diff --git a/models/OpenAPIConstraintsGenerator.cfc b/models/OpenAPIConstraintsGenerator.cfc new file mode 100644 index 0000000..8435f8f --- /dev/null +++ b/models/OpenAPIConstraintsGenerator.cfc @@ -0,0 +1,226 @@ +component name="OpenAPIConstraintsGenerator" { + + property name="cache" inject="cachebox:template"; + + public struct function generateConstraintsFromOpenAPISchema( + string parametersPath = "", + string requestBodyPath = "", + boolean discoverPaths = true, + string callingFunctionName + ){ + var paths = _discoverPaths( argumentCollection = arguments ); + + var constraints = {}; + + if ( paths.parametersPath != "" ) { + var parametersJSON = variables.cache.getOrSet( + paths.parametersPath, + () => { + return deserializeJSON( fileRead( expandPath( paths.parametersPath ) ) ); + }, + createTimespan( 1, 0, 0, 0 ) + ); + structAppend( constraints, generateConstraintsFromParameters( parametersJSON[ paths.parametersKey ] ) ) + } + + if ( paths.requestBodyPath != "" ) { + var requestBodyJSON = variables.cache.getOrSet( + paths.requestBodyPath, + () => { + return deserializeJSON( fileRead( expandPath( paths.requestBodyPath ) ) ); + }, + createTimespan( 1, 0, 0, 0 ) + ); + var schema = requestBodyJSON[ "content" ][ "application/json" ][ "schema" ]; + structAppend( + constraints, + generateConstraintsFromRequestBodyProperties( schema.properties, schema.required ) + ); + } + + return constraints; + } + + private struct function generateConstraintsFromParameters( required array parameters ){ + return arguments.parameters.reduce( ( allConstraints, parameter ) => { + allConstraints[ parameter.name ] = generateConstraint( + schema = ( parameter.schema ?: {} ), + isRequired = ( parameter.required ?: false ) + ); + return allConstraints; + }, {} ); + } + + private struct function generateConstraintsFromRequestBodyProperties( + required struct properties, + required array requiredFields + ){ + return arguments.properties.map( ( fieldName, schema ) => { + return generateConstraint( + schema = schema, + isRequired = arrayContainsNoCase( requiredFields, fieldName ) > 0 + ); + } ); + } + + private struct function generateConstraint( required struct schema, required boolean isRequired ){ + var constraints = {}; + constraints[ "required" ] = arguments.isRequired; + addValidationType( constraints, schema ); + if ( constraints[ "type" ] == "struct" ) { + constraints[ "constraints" ] = generateConstraintsFromRequestBodyProperties( + schema.properties, + schema.required ?: [] + ); + } + if ( constraints[ "type" ] == "array" ) { + constraints[ "items" ] = generateConstraint( schema.items, arguments.isRequired ); + } + if ( schema.keyExists( "enum" ) ) { + constraints[ "inList" ] = arrayToList( schema[ "enum" ] ); + } + if ( schema.keyExists( "minimum" ) ) { + constraints[ "min" ] = schema[ "minimum" ]; + } + if ( schema.keyExists( "maximum" ) ) { + constraints[ "max" ] = schema[ "maximum" ]; + } + if ( schema.keyExists( "default" ) ) { + constraints[ "defaultValue" ] = schema[ "default" ]; + } + if ( schema.keyExists( "minLength" ) || schema.keyExists( "maxLength" ) ) { + param schema.minLength = ""; + param schema.maxLength = ""; + constraints[ "size" ] = "#schema.minLength#..#schema.maxLength#"; + } + if ( schema.keyExists( "x-coldbox-additional-validation" ) ) { + structAppend( constraints, schema[ "x-coldbox-additional-validation" ] ); + } + for ( + var c in [ + "after", + "afterOrEqual", + "before", + "beforeOrEqual", + "dateEquals" + ] + ) { + if ( constraints.keyExists( c ) && constraints[ c ] == "now" ) { + constraints[ c ] = now(); + } + } + return constraints; + } + + private string function addValidationType( required struct constraints, required struct metadata ){ + param arguments.metadata.type = ""; + param arguments.metadata.format = ""; + switch ( arguments.metadata.type ) { + case "integer": + arguments.constraints[ "type" ] = "integer"; + break; + case "number": + switch ( arguments.metadata.format ) { + case "double": + case "float": + arguments.constraints[ "type" ] = "float"; + break; + default: + arguments.constraints[ "type" ] = "numeric"; + break; + } + break; + case "boolean": + arguments.constraints[ "type" ] = "boolean"; + break; + case "array": + arguments.constraints[ "type" ] = "array"; + break; + case "object": + arguments.constraints[ "type" ] = "struct"; + break; + case "string": + switch ( arguments.metadata.format ) { + case "date-time-without-timezone": + arguments.constraints[ "type" ] = "date"; + break; + default: + arguments.constraints[ "type" ] = "string"; + break; + } + break; + } + } + + private struct function _discoverPaths( + string parametersPath = "", + string requestBodyPath = "", + boolean discoverPaths = true, + string callingComponent, + string callingFunctionName + ){ + var parametersKey = "parameters"; + if ( arguments.parametersPath != "" ) { + parametersKey = listLast( arguments.parametersPath, "####" ); + arguments.parametersPath = reReplaceNoCase( + listFirst( arguments.parametersPath, "####" ), + "^~", + "/resources/apidocs/" + ); + if ( parametersKey == "" ) { + parametersKey = "parameters"; + } + } + + if ( arguments.discoverPaths ) { + if ( arguments.parametersPath == "" || arguments.requestBodyPath == "" ) { + param variables.localActions = getMetadata( variables.$parent ).functions; + if ( isNull( arguments.callingFunctionName ) ) { + var stackFrames = callStackGet(); + for ( var stackFrame in stackFrames ) { + if ( + !arrayContains( + [ + "_discoverPaths", + "generateConstraintsFromOpenAPISchema", + "getByDelegate" + ], + stackFrame[ "function" ] + ) + ) { + arguments.callingFunctionName = stackFrame[ "function" ]; + break; + } + } + } + var callingFunction = variables.localActions.filter( ( action ) => { + return action.name == callingFunctionName; + } ); + if ( !callingFunction.isEmpty() ) { + if ( arguments.parametersPath == "" && callingFunction[ 1 ].keyExists( "x-parameters" ) ) { + parametersKey = listLast( callingFunction[ 1 ][ "x-parameters" ], "####" ); + arguments.parametersPath = reReplaceNoCase( + listFirst( callingFunction[ 1 ][ "x-parameters" ], "####" ), + "^~", + "/resources/apidocs/" + ); + } + if ( arguments.requestBodyPath == "" && callingFunction[ 1 ].keyExists( "requestBody" ) ) { + arguments.requestBodyPath = reReplaceNoCase( + listFirst( callingFunction[ 1 ][ "requestBody" ], "####" ), + "^~", + "/resources/apidocs/" + ); + } + } + } + } + + return { + "parametersPath" : arguments.parametersPath, + "parametersKey" : parametersKey, + "requestBodyPath" : arguments.requestBodyPath + }; + } + +} diff --git a/readme.md b/readme.md index 5abfdab..24f60e0 100644 --- a/readme.md +++ b/readme.md @@ -295,6 +295,48 @@ component displayName="API.v1.Users"{ } ``` +## Integration with cbValidation + +You can utilize your Swagger docs to generate constraints to use with cbValidation. You can do so by utilizing +`OpenAPIConstraintsGenerator@cbSwagger` as a delegate. Doing so exposes a `generateConstraintsFromOpenAPISchema` function +that will generate the constraints. + +``` +component delegates="OpenAPIConstraintsGenerator@cbSwagger" { + + /** + * @route (GET) /api/v1/jokes + * + * Returns a random dad joke + * + * @x-parameters ~api/v1/Jokes/index/parameters.json##parameters + * @requestBody ~api/v1/Jokes/index/requestBody.json + * @response-200 ~api/v1/Jokes/index/responses.200.json + * @response-403 ~api/v1/errors/example.403.json + */ + function index( event, rc, prc ) { + var validated = validateOrFail( target = rc, constraints = generateConstraintsFromOpenAPISchema() ); + // ... + } + +} +``` + +Using `OpenAPIConstraintsGenerator@cbSwagger` as a delegate allows it to auto discover the parameters and +request body for the action using the annotations on your action. + +If you prefer to manually pass in the parameters and/or requestBody path or use the `OpenAPIConstraintsGenerator@cbSwagger` +as an injection, you can pass in the paths as arguments to the `generateConstraintsFromOpenAPISchema` function. + +``` +public struct function generateConstraintsFromOpenAPISchema( + string parametersPath = "", + string requestBodyPath = "", + boolean discoverPaths = true, // this only discovers paths not explicitly passed in + string callingFunctionName // this will look itself up using the current call stack +) +``` + ******************************************************************************** Copyright Since 2016 Ortus Solutions, Corp www.ortussolutions.com diff --git a/server-adobe@2018.json b/server-adobe@2018.json index 0479108..3d1d728 100644 --- a/server-adobe@2018.json +++ b/server-adobe@2018.json @@ -11,13 +11,13 @@ "rewrites":{ "enable":"true" }, - "webroot": "test-harness", - "aliases":{ + "webroot":"test-harness", + "aliases":{ "/moduleroot/cbswagger":"../" } }, "openBrowser":"false", - "cfconfig": { - "file" : ".cfconfig.json" - } + "cfconfig":{ + "file":".cfconfig.json" + } } diff --git a/test-harness/includes/resources/users.add.requestBody.json b/test-harness/includes/resources/users.add.requestBody.json new file mode 100644 index 0000000..ddb676a --- /dev/null +++ b/test-harness/includes/resources/users.add.requestBody.json @@ -0,0 +1,49 @@ +{ + "description": "Required parameters for user address", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "address1", + "city", + "state", + "zipCode" + ], + "properties": { + "address1": { + "description": "The first line of the address.", + "type": "string" + }, + "address2": { + "description": "The second line of the address.", + "type": "string" + }, + "city": { + "description": "The city of the address.", + "type": "string" + }, + "state": { + "description": "The state of the address.", + "type": "string" + }, + "country": { + "description": "The country of the address.", + "type": "string" + }, + "zipCode": { + "description": "The postal code of the address.", + "type": "string" + } + }, + "example": { + "address1" : "123 Elm Street", + "city" : "Beverly Hills", + "state" : "CA", + "postalCode" : "90210" + } + } + } + } +} diff --git a/test-harness/tests/specs/OpenAPIConstraintsGeneratorTest.cfc b/test-harness/tests/specs/OpenAPIConstraintsGeneratorTest.cfc new file mode 100644 index 0000000..9f0e2bb --- /dev/null +++ b/test-harness/tests/specs/OpenAPIConstraintsGeneratorTest.cfc @@ -0,0 +1,39 @@ +component + extends ="coldbox.system.testing.BaseTestCase" + appMapping="/" + accessors =true +{ + + function run(){ + describe( "OpenAPI Constraints Generator", () => { + it( "can generate constraints using parameters.json and requestBody.json", () => { + var generator = getInstance( "OpenAPIConstraintsGenerator@cbSwagger" ); + var constraints = generator.generateConstraintsFromOpenAPISchema( + parametersPath = "/includes/resources/users.add.parameters.json##user", + requestBodyPath = "/includes/resources/users.add.requestBody.json", + autoDiscover = false + ); + + expect( constraints ).toBeStruct(); + expect( constraints ).toBe( { + "country" : { "required" : false, "type" : "string" }, + "address2" : { "required" : false, "type" : "string" }, + "zipCode" : { "required" : true, "type" : "string" }, + "address1" : { "required" : true, "type" : "string" }, + "state" : { "required" : true, "type" : "string" }, + "city" : { "required" : true, "type" : "string" }, + "user" : { + "required" : true, + "constraints" : { + "firstname" : { "required" : true, "type" : "string" }, + "lastname" : { "required" : true, "type" : "string" }, + "email" : { "required" : true, "type" : "string" } + }, + "type" : "struct" + } + } ); + } ); + } ); + } + +} From f2531dbddab49c421f1e6fcf65f5561dc386501b Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 13 Feb 2025 13:58:02 -0700 Subject: [PATCH 2/2] BoxLang compatibility --- models/OpenAPIConstraintsGenerator.cfc | 8 ++--- models/RoutesParser.cfc | 49 ++++++++++++++------------ test-harness/box.json | 2 +- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/models/OpenAPIConstraintsGenerator.cfc b/models/OpenAPIConstraintsGenerator.cfc index 8435f8f..d54d1dd 100644 --- a/models/OpenAPIConstraintsGenerator.cfc +++ b/models/OpenAPIConstraintsGenerator.cfc @@ -18,7 +18,7 @@ component name="OpenAPIConstraintsGenerator" { () => { return deserializeJSON( fileRead( expandPath( paths.parametersPath ) ) ); }, - createTimespan( 1, 0, 0, 0 ) + 1 // 1 day ); structAppend( constraints, generateConstraintsFromParameters( parametersJSON[ paths.parametersKey ] ) ) } @@ -29,7 +29,7 @@ component name="OpenAPIConstraintsGenerator" { () => { return deserializeJSON( fileRead( expandPath( paths.requestBodyPath ) ) ); }, - createTimespan( 1, 0, 0, 0 ) + 1 // 1 day ); var schema = requestBodyJSON[ "content" ][ "application/json" ][ "schema" ]; structAppend( @@ -67,13 +67,13 @@ component name="OpenAPIConstraintsGenerator" { var constraints = {}; constraints[ "required" ] = arguments.isRequired; addValidationType( constraints, schema ); - if ( constraints[ "type" ] == "struct" ) { + if ( constraints[ "type" ] == "struct" && schema.keyExists( "properties" ) ) { constraints[ "constraints" ] = generateConstraintsFromRequestBodyProperties( schema.properties, schema.required ?: [] ); } - if ( constraints[ "type" ] == "array" ) { + if ( constraints[ "type" ] == "array" && schema.keyExists( "items" ) ) { constraints[ "items" ] = generateConstraint( schema.items, arguments.isRequired ); } if ( schema.keyExists( "enum" ) ) { diff --git a/models/RoutesParser.cfc b/models/RoutesParser.cfc index b9b73ce..d78267c 100644 --- a/models/RoutesParser.cfc +++ b/models/RoutesParser.cfc @@ -64,7 +64,11 @@ component accessors="true" threadsafe singleton { // Incorporate our API routes into the document filterDesignatedRoutes().each( function( key, value ){ - template[ "paths" ].putAll( createPathsFromRouteConfig( value ) ); + structAppend( + template[ "paths" ], + createPathsFromRouteConfig( value ), + true + ); } ); // Build out the Open API Document object @@ -192,7 +196,7 @@ component accessors="true" threadsafe singleton { for ( var pathEntry in entrySet ) { for ( var routeKey in designatedRoutes ) { if ( replaceNoCase( routeKey, "/", "", "ALL" ) == pathEntry ) { - sortedRoutes.put( routeKey, designatedRoutes[ routeKey ] ); + sortedRoutes[ routeKey ] = designatedRoutes[ routeKey ]; } } } @@ -287,7 +291,7 @@ component accessors="true" threadsafe singleton { // method not in error methods if ( !arrayFindNoCase( errorMethods, actions[ methodList ] ) ) { // Create new path template - path.put( lCase( methodName ), getOpenAPIUtil().newMethod() ); + path[ lCase( methodName ) ] = getOpenAPIUtil().newMethod(); // Append Params appendPathParams( pathKey = arguments.pathKey, method = path[ lCase( methodName ) ] ); // Append Function metadata @@ -309,7 +313,7 @@ component accessors="true" threadsafe singleton { } else { for ( var methodName in getOpenAPIUtil().defaultMethods() ) { // Insert path template for default method - path.put( lCase( methodName ), getOpenAPIUtil().newMethod() ); + path[ lCase( methodName ) ] = getOpenAPIUtil().newMethod(); // Append Params appendPathParams( pathKey = arguments.pathKey, method = path[ lCase( methodName ) ] ); // Append metadata @@ -346,7 +350,7 @@ component accessors="true" threadsafe singleton { } } - arguments.existingPaths.put( "/" & arrayToList( pathSegments, "/" ), path ); + arguments.existingPaths[ "/" & arrayToList( pathSegments, "/" ) ] = path; } /** @@ -358,7 +362,7 @@ component accessors="true" threadsafe singleton { private void function appendPathParams( required string pathKey, required struct method ){ // Verify parameters array in the method definition if ( !structKeyExists( arguments.method, "parameters" ) ) { - arguments.method.put( "parameters", [] ); + arguments.method[ "parameters" ] = []; } // handle any parameters in the url now @@ -508,44 +512,41 @@ component accessors="true" threadsafe singleton { // hint/description if ( infoKey == "hint" ) { - if ( !method.containsKey( "description" ) || method[ "description" ] == "" ) { - method.put( "description", infoMetadata ); + if ( !structKeyExists( method, "description" ) || method[ "description" ] == "" ) { + method[ "description" ] = infoMetadata; } - if ( !functionMetadata.containsKey( "summary" ) ) { - method.put( "summary", infoMetadata ); + if ( !structKeyExists( functionMetadata, "summary" ) ) { + method[ "summary" ] = infoMetadata; } continue; } if ( infoKey == "description" && infoMetadata != "" ) { - method.put( "description", infoMetadata ); + method[ "description" ] = infoMetadata; continue; } if ( infoKey == "summary" ) { - method.put( "summary", infoMetadata ); + method[ "summary" ] = infoMetadata; continue; } // Operation Tags if ( infoKey == "tags" ) { - method.put( - "tags", - ( isSimpleValue( infoMetadata ) ? listToArray( infoMetadata ) : infoMetadata ) - ); + method[ "tags" ] = isSimpleValue( infoMetadata ) ? listToArray( infoMetadata ) : infoMetadata; continue; } // Request body: { description, required, content : {} } if simple, we just add it as required, with listed as content if ( left( infoKey, 12 ) == "requestBody" ) { - method.put( "requestBody", structNew( "ordered" ) ); + method[ "requestBody" ] = structNew( "ordered" ); if ( isSimpleValue( infoMetadata ) ) { method[ "requestBody" ][ "description" ] = infoMetadata; method[ "requestBody" ][ "required" ] = true; method[ "requestBody" ][ "content" ] = { "#infoMetadata#" : {} }; } else { - method[ "requestBody" ].putAll( infoMetadata ); + structAppend( method[ "requestBody" ], infoMetadata, true ); } continue; } @@ -635,13 +636,13 @@ component accessors="true" threadsafe singleton { var filterString = arrayToList( [ moduleName, - listLast( handlerMetadata.name, "." ), + listLast( handlerMetadata.fullname, "." ), methodName ], "." ); } else { - var filterString = arrayToList( [ handlerMetadata.name, methodName ], "." ); + var filterString = arrayToList( [ handlerMetadata.fullname, methodName ], "." ); } availableFiles @@ -754,14 +755,18 @@ component accessors="true" threadsafe singleton { // get reponse name var responseName = right( infoKey, len( infoKey ) - 9 ); - method[ "responses" ].put( responseName, structNew( "ordered" ) ); + method[ "responses" ][ responseName ] = structNew( "ordered" ); // Use simple value for description and content type if ( isSimpleValue( infoMetadata ) ) { method[ "responses" ][ responseName ][ "description" ] = infoMetadata; method[ "responses" ][ responseName ][ "content" ] = { "#infoMetadata#" : {} }; } else { - method[ "responses" ][ responseName ].putAll( infoMetadata ); + structAppend( + method[ "responses" ][ responseName ], + infoMetadata, + true + ); } } ); diff --git a/test-harness/box.json b/test-harness/box.json index 434a081..bf7a30f 100644 --- a/test-harness/box.json +++ b/test-harness/box.json @@ -10,7 +10,7 @@ "coldbox":"be" }, "devDependencies":{ - "testbox":"^4.0.0", + "testbox":"be", "route-visualizer":"^2.0.0+6" }, "installPaths":{