diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1635707..76b81f6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -18,7 +18,7 @@ jobs: formatCheck: name: Checks Source Code Formatting - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Checkout Repository uses: actions/checkout@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8166c2b..b7017cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ on: type: boolean env: - MODULE_ID: cbSwagger + MODULE_ID: cbswagger SNAPSHOT: ${{ inputs.snapshot || false }} jobs: @@ -25,7 +25,7 @@ jobs: ########################################################################################## build: name: Build & Publish - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Checkout Repository uses: actions/checkout@v3 @@ -133,7 +133,7 @@ jobs: prep_next_release: name: Prep Next Release if: github.ref == 'refs/heads/main' - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 needs: [build] steps: # Checkout development diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 45d7dd1..387ea5f 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -4,6 +4,7 @@ on: push: branches: - 'development' + workflow_dispatch: jobs: ########################################################################################## @@ -18,7 +19,7 @@ jobs: ########################################################################################## format: name: Code Auto-Formatting - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fa20851..f67eec3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,15 +10,18 @@ on: jobs: tests: name: Tests - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: ${{ github.workspace }}/cbswagger env: DB_USER: root DB_PASSWORD: root strategy: fail-fast: false matrix: - cfengine: [ "lucee@5", "adobe@2018", "adobe@2021" ] - coldboxVersion: [ "^6.0.0" ] + cfengine: [ "lucee@5", "lucee@6" ,"adobe@2018", "adobe@2021" ] + coldboxVersion: [ "^6.0.0", "^7.0.0" ] experimental: [ false ] include: - cfengine: "adobe@2023" @@ -31,11 +34,16 @@ jobs: cfengine: "adobe@2018" experimental: true - coldboxVersion: "be" - cfengine: "adobe@2021" + cfengine: "adobe@2023" + experimental: true + - coldboxVersion: "be" + cfengine: "boxlang@1" experimental: true steps: - name: Checkout Repository uses: actions/checkout@v3 + with: + path: cbswagger # - name: Setup Database and Fixtures # run: | @@ -69,6 +77,7 @@ jobs: - name: Install Test Harness with ColdBox ${{ matrix.coldboxVersion }} run: | + box install commandbox-boxlang --force box install cd test-harness box package set dependencies.coldbox=${{ matrix.coldboxVersion }} @@ -88,16 +97,16 @@ jobs: uses: EnricoMi/publish-unit-test-result-action@v2 if: always() with: - junit_files: test-harness/tests/results/**/*.xml + junit_files: ${{ github.workspace }}/cbswagger/test-harness/tests/results/**/*.xml check_name: "${{ matrix.cfengine }} ColdBox ${{ matrix.coldboxVersion }} Test Results" - name: Upload Test Results to Artifacts if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: test-results-${{ matrix.cfengine }}-${{ matrix.coldboxVersion }} path: | - test-harness/tests/results/**/* + ${{ github.workspace }}/cbswagger/test-harness/tests/results/**/* - name: Show Server Log On Failures if: ${{ failure() }} @@ -106,12 +115,12 @@ jobs: - name: Upload Debug Logs To Artifacts if: ${{ failure() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Failure Debugging Info - ${{ matrix.cfengine }} - ${{ matrix.coldboxVersion }} path: | - .engine/**/logs/* - .engine/**/WEB-INF/cfusion/logs/* + ${{ github.workspace }}/cbswagger/.engine/**/logs/* + ${{ github.workspace }}/cbswagger/.engine/**/WEB-INF/cfusion/logs/* - name: Slack Notifications # Only on failures and NOT in pull requests diff --git a/.vscode/settings.json b/.vscode/settings.json index 49c30ae..8f01489 100755 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,7 +11,7 @@ "isPhysicalDirectoryPath": false }, { - "logicalPath": "/cbSwagger", + "logicalPath": "/cbswagger", "directoryPath": "./", "isPhysicalDirectoryPath": false } diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index 2aa16bf..0f11d70 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -7,14 +7,14 @@ component { // Module Properties - this.title = "cbSwagger"; + this.title = "cbswagger"; this.author = "Jon Clausen "; - this.webURL = "https://github.com/coldbox-modules/cbSwagger"; + this.webURL = "https://github.com/coldbox-modules/cbswagger"; this.version = "@version.number@+@build.number@"; this.description = "A coldbox module to auto-generate Swagger API documentation from your configured routes"; - this.entryPoint = "cbSwagger"; - this.modelNamespace = "cbSwagger"; - this.cfmapping = "cbSwagger"; + this.entryPoint = "cbswagger"; + this.modelNamespace = "cbswagger"; + this.cfmapping = "cbswagger"; this.autoMapModels = true; this.dependencies = [ "swagger-sdk" ]; @@ -75,7 +75,7 @@ component { // A list of tags used by the specification with additional metadata. // https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#tagObject "tags" : [], - // Whether to enable endpoint and parsed doc caching by cbSwagger + // Whether to enable endpoint and parsed doc caching by cbswagger "cacheEnabled" : true }; diff --git a/box.json b/box.json index af45a54..8e6845d 100644 --- a/box.json +++ b/box.json @@ -1,18 +1,18 @@ { - "name":"cbSwagger", - "version":"3.1.2", + "name":"cbswagger", + "version":"3.1.3", "location":"https://downloads.ortussolutions.com/ortussolutions/coldbox-modules/cbswagger/@build.version@/cbswagger-@build.version@.zip", "author":"Ortus Solutions, Corp", - "slug":"cbSwagger", + "slug":"cbswagger", "type":"modules", "keywords":"Swagger,OpenAPI,API Docs", - "homepage":"https://github.com/coldbox-modules/cbSwagger", - "documentation":"https://github.com/coldbox-modules/cbSwagger/wiki", + "homepage":"https://github.com/coldbox-modules/cbswagger", + "documentation":"https://github.com/coldbox-modules/cbswagger/wiki", "repository":{ "type":"git", - "url":"https://github.com/coldbox-modules/cbSwagger" + "url":"https://github.com/coldbox-modules/cbswagger" }, - "bugs":"https://github.com/coldbox-modules/cbSwagger/issues", + "bugs":"https://github.com/coldbox-modules/cbswagger/issues", "shortDescription":"A Coldbox Module which automatically generates api documentation from your configured SES routes", "license":[ { diff --git a/build/.travis.yml b/build/.travis.yml index 8fa34f3..1bf6a8f 100644 --- a/build/.travis.yml +++ b/build/.travis.yml @@ -5,7 +5,7 @@ notifications: env: # Fill out these global variables for build process global: - - MODULE_ID=cbSwagger + - MODULE_ID=cbswagger matrix: - ENGINE=lucee@5 - ENGINE=adobe@2016 diff --git a/changelog.md b/changelog.md index db9dd6c..73df674 100644 --- a/changelog.md +++ b/changelog.md @@ -25,19 +25,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Interaction to WireBox via base test case and not application scope -- Removed support for Adobe Coldfusion 2016 -- Removes `default` key from response object if empty and other status code keys are present [Issue #28](https://github.com/coldbox-modules/cbSwagger/issues/28) +* Interaction to WireBox via base test case and not application scope +* Removed support for Adobe Coldfusion 2016 +* Removes `default` key from response object if empty and other status code keys are present [Issue #28](https://github.com/coldbox-modules/cbswagger/issues/28) ### Added -- Virtual application testing -- Updated github actions -- Updated module template provisions -- Added support for samples path shortcut annotation (`~`) in annotation JSON `$ref` pointers -- Added support for using globbing patterns in `excludeRoutes` array. [Issue #37](https://github.com/coldbox-modules/cbSwagger/issues/37) -- Added `cbswagger` endpoint caching ( defaults to `true` ) and `cacheEnabled` setting. -- Added URL convention for clearing/bypassing endpoint cache ( `swaggerCache=false` ) +* Virtual application testing +* Updated github actions +* Updated module template provisions +* Added support for samples path shortcut annotation (`~`) in annotation JSON `$ref` pointers +* Added support for using globbing patterns in `excludeRoutes` array. [Issue #37](https://github.com/coldbox-modules/cbswagger/issues/37) +* Added `cbswagger` endpoint caching ( defaults to `true` ) and `cacheEnabled` setting. +* Added URL convention for clearing/bypassing endpoint cache ( `swaggerCache=false` ) ## [2.7.0] => 2021-OCT-1 @@ -113,6 +113,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `feature`: Upgraded to swagger-sdk 2.0.0 to support OpenAPI 3.0.x. A great guide on migrating is here: + - Migrated `cbSwagger` settings to the `moduleSettings` struct instead of top-level in the `config/ColdBox.cfc`. Make sure you move your settings. - `feature` : You can now pass a `format` to the `/cbSwagger` endpoint to either get the OpenAPI doc as `json` or `yml`. Eg: `/cbswagger?format=yml` @@ -200,4 +201,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [3.1.1]: https://github.com/coldbox-modules/cbSwagger/compare/v3.1.0...v3.1.1 -[3.0.0]: https://github.com/coldbox-modules/cbSwagger/compare/5be045e7bd456304f3e338fd5e4f5ca94342dad0...v3.0.0 +[3.0.0]: https://github.com/coldbox-modules/cbswagger/compare/5be045e7bd456304f3e338fd5e4f5ca94342dad0...v3.0.0 diff --git a/models/OpenAPIConstraintsGenerator.cfc b/models/OpenAPIConstraintsGenerator.cfc new file mode 100644 index 0000000..d54d1dd --- /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 ) ) ); + }, + 1 // 1 day + ); + structAppend( constraints, generateConstraintsFromParameters( parametersJSON[ paths.parametersKey ] ) ) + } + + if ( paths.requestBodyPath != "" ) { + var requestBodyJSON = variables.cache.getOrSet( + paths.requestBodyPath, + () => { + return deserializeJSON( fileRead( expandPath( paths.requestBodyPath ) ) ); + }, + 1 // 1 day + ); + 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" && schema.keyExists( "properties" ) ) { + constraints[ "constraints" ] = generateConstraintsFromRequestBodyProperties( + schema.properties, + schema.required ?: [] + ); + } + if ( constraints[ "type" ] == "array" && schema.keyExists( "items" ) ) { + 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/models/RoutesParser.cfc b/models/RoutesParser.cfc index b13cd14..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 @@ -72,7 +76,7 @@ component accessors="true" threadsafe singleton { } /** - * Filters the designated routes as provided in the cbSwagger configuration + * Filters the designated routes as provided in the cbswagger configuration */ private any function filterDesignatedRoutes(){ // make a copy of our routes array so we can append it @@ -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 @@ -421,7 +425,7 @@ component accessors="true" threadsafe singleton { * * @return struct of handlerMetadata * - * @throws cbSwagger.RoutesParse.handlerSyntaxException + * @throws cbswagger.RoutesParse.handlerSyntaxException */ private any function getHandlerMetadata( required any route ){ var module = ( isNull( arguments.route.module ) ? "" : arguments.route.module ); @@ -452,7 +456,7 @@ component accessors="true" threadsafe singleton { return util.getInheritedMetadata( invocationPath ); } catch ( any e ) { throw( - type = "cbSwagger.RoutesParse.handlerSyntaxException", + type = "cbswagger.RoutesParse.handlerSyntaxException", message = "The handler at #invocationPath# could not be parsed. The error that occurred: #e.message#", extendedInfo = e.detail ); @@ -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/readme.md b/readme.md index 64b4f51..24f60e0 100644 --- a/readme.md +++ b/readme.md @@ -25,7 +25,7 @@ Apache License, Version 2.0. ## Important links -- https://github.com/coldbox-modules/cbSwagger +- https://github.com/coldbox-modules/cbswagger ## Resources @@ -45,13 +45,13 @@ To operate, the module requires that SES routing be enabled in your application. ## Install cbSWagger ( via Commandbox ) -`box install cbSwagger` +`box install cbswagger` > Note: Omit the `box` from your command, if you are already in the Commandbox interactive shell -## Configure cbSwagger to auto-detect your API Routes +## Configure cbswagger to auto-detect your API Routes -By default, cbSwagger looks for routes beginning with `/api/*` prefix. By adding a `cbSwagger` configuration key to your Coldbox configuration, you can add additional metadata to the OpenAPI JSON produced by the module entry point and configure this module for operation. +By default, cbswagger looks for routes beginning with `/api/*` prefix. By adding a `cbswagger` configuration key to your Coldbox configuration, you can add additional metadata to the OpenAPI JSON produced by the module entry point and configure this module for operation. * `routes:array` : An array of route prefixes to search for and add to the resulting documentation. * `defaultFormat:string` : The default output format of the documentation. Valid options are `json` and `yml`. @@ -64,7 +64,7 @@ cbswagger = { "routes" : [ "api" ], // The default output format: json or yml // Routes to exclude by prefix. Routes beginning with this prefix will be excluded - "excludeRoutesPrefix" : [ "cbSwagger", "relax" ], + "excludeRoutesPrefix" : [ "cbswagger", "relax" ], // Any routes to exclude - may use exact matches or globbing patterns e.g `[ "api/v1/mysecret" ]` or `[ "**/secret", "**/undocumented" ]` (no initial `/`, trailing `/` optional for routes) "excludeRoutes" : [], // Routes to exclude based on event @@ -159,22 +159,22 @@ cbswagger = { ## Outputting Documentation (json|yml) -You can visit the API documentation by hitting the `/cbSwagger` route. This will trigger the default format (json) to be sent to the output. +You can visit the API documentation by hitting the `/cbswagger` route. This will trigger the default format (json) to be sent to the output. ### Format Parameter You can force the format by using the `?format={format}` in the URI. The valid options are `json` and `yml` ``` -http://localhost/cbSwagger?format=yml -http://localhost/cbSwagger?format=json -http://localhost/cbSwagger/json -http://localhost/cbSwagger/yml +http://localhost/cbswagger?format=yml +http://localhost/cbswagger?format=json +http://localhost/cbswagger/json +http://localhost/cbswagger/yml ``` ## Handler Introspection & Documentation attributes -`cbSwagger` will automatically introspect your API handlers provided by your routing configuration. You may provide additional function attributes (**metadata**), which will be picked up and included in your documentation. +`cbswagger` will automatically introspect your API handlers provided by your routing configuration. You may provide additional function attributes (**metadata**), which will be picked up and included in your documentation. The content body of these function metadata/attributes may be provided as: @@ -187,7 +187,7 @@ Here are some additional pointers for you: - Metadata attributes using a `response-` prefix in the annotation will be parsed as responses. For example `@response-200 { "description" : "User successfully updated", "schema" : "/resources/apidocs/schema.json##user" }` would populate the `200` responses node for the given method ( in this case, `PUT /api/v1/users/:id` ). If the annotation text is not valid JSON or a file pointer, this will be provided as the response description. - Metadata attributes prefixed with `param-` will be included as parameters to the method/action. Example: `@param-firstname { "type": "string", "required" : "false", "in" : "query" }` If the annotation text is not valid JSON or a file pointer, this will be provided as the parameter description and the parameter requirement will be set to `false`. - Parameters provided via the route ( e.g. the `id` in `/api/v1/users/:id` ) will always be included in the array of parameters as required for the method. Annotations on those parameters may be used to provide additional documentation. -- [Security Requirement Objects](https://swagger.io/specification/#securityRequirementObject) defined in the cbSwagger config will be displayed on every API method, except methods that override the default with `@security`. You may use the name of a security scheme, a JSON array of Security Requirement Objects, or a file pointer. Security Requirement Objects must have the same name as a Security Scheme Object defined under components in `cbSwagger` settings. +- [Security Requirement Objects](https://swagger.io/specification/#securityRequirementObject) defined in the cbswagger config will be displayed on every API method, except methods that override the default with `@security`. You may use the name of a security scheme, a JSON array of Security Requirement Objects, or a file pointer. Security Requirement Objects must have the same name as a Security Scheme Object defined under components in `cbswagger` settings. - You may also provide paths to JSON files which describe complex objects which may not be expressed within the attributes themselves. This is ideal to provide an endpoint for [parameters](https://swagger.io/specification/#parameterObject) and [responses](https://swagger.io/specification/#responsesObject) If the atttribute ends with `.json`, this will be included in the generated OpenAPI document as a [$ref include](https://swagger.io/specification/#pathItemObject). - Attributes which are not part of the swagger path specification should be prefixed with an `x-`, [x-attributes](https://swagger.io/specification/#specificationExtensions) are an official part of the OpenAPI Specification and may be used to provide additional information for your developers and consumers - `hint` attributes, provided as either comment `@` annotations or as function body attributes will be treated as the description for the method @@ -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 1473e84..3d1d728 100644 --- a/server-adobe@2018.json +++ b/server-adobe@2018.json @@ -11,13 +11,13 @@ "rewrites":{ "enable":"true" }, - "webroot": "test-harness", - "aliases":{ - "/moduleroot/cbSwagger":"../" + "webroot":"test-harness", + "aliases":{ + "/moduleroot/cbswagger":"../" } }, "openBrowser":"false", - "cfconfig": { - "file" : ".cfconfig.json" - } + "cfconfig":{ + "file":".cfconfig.json" + } } diff --git a/server-adobe@2021.json b/server-adobe@2021.json index f10ef13..1e11647 100644 --- a/server-adobe@2021.json +++ b/server-adobe@2021.json @@ -13,7 +13,7 @@ }, "webroot": "test-harness", "aliases":{ - "/moduleroot/cbSwagger":"../" + "/moduleroot/cbswagger":"../" } }, "openBrowser":"false", diff --git a/server-adobe@2023.json b/server-adobe@2023.json index 8499be2..e889825 100644 --- a/server-adobe@2023.json +++ b/server-adobe@2023.json @@ -13,7 +13,7 @@ }, "webroot":"test-harness", "aliases":{ - "/moduleroot/cbSwagger":"../" + "/moduleroot/cbswagger":"../" } }, "openBrowser":"false", diff --git a/server-boxlang@1.json b/server-boxlang@1.json new file mode 100644 index 0000000..923c647 --- /dev/null +++ b/server-boxlang@1.json @@ -0,0 +1,36 @@ +{ + "app":{ + "cfengine":"boxlang@be", + "serverHomeDirectory":".engine/boxlang" + }, + "name":"cbSwagger-boxlang@1", + "force":true, + "openBrowser":false, + "web":{ + "directoryBrowsing":true, + "http":{ + "port":"60299" + }, + "rewrites":{ + "enable":"true" + }, + "webroot":"test-harness", + "aliases":{ + "/moduleroot/cbSwagger":"./" + } + }, + "JVM":{ + "heapSize":"1024", + "javaVersion":"openjdk21_jdk", + "args":"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=9999" + }, + "cfconfig":{ + "file":".cfconfig.json" + }, + "env":{ + "BOXLANG_DEBUG":true + }, + "scripts":{ + "onServerInitialInstall":"install bx-mail,bx-mysql,bx-derby,bx-compat-cfml@be,bx-unsafe-evaluate,bx-esapi --noSave" + } +} diff --git a/server-lucee@5.json b/server-lucee@5.json index b046de2..7c3211f 100644 --- a/server-lucee@5.json +++ b/server-lucee@5.json @@ -13,7 +13,7 @@ }, "webroot":"test-harness", "aliases":{ - "/moduleroot/cbSwagger":"../" + "/moduleroot/cbswagger":"../" } }, "openBrowser":"false", diff --git a/server-lucee@6.json b/server-lucee@6.json new file mode 100644 index 0000000..706e573 --- /dev/null +++ b/server-lucee@6.json @@ -0,0 +1,23 @@ +{ + "name":"cbswagger-lucee@6", + "app":{ + "serverHomeDirectory":".engine/lucee5", + "cfengine":"lucee@6" + }, + "web":{ + "http":{ + "port":"60299" + }, + "rewrites":{ + "enable":"true" + }, + "webroot":"test-harness", + "aliases":{ + "/moduleroot/cbswagger":"../" + } + }, + "openBrowser":"false", + "cfconfig":{ + "file":".cfconfig.json" + } +} diff --git a/test-harness/Application.cfc b/test-harness/Application.cfc index 07a2275..d687f2a 100644 --- a/test-harness/Application.cfc +++ b/test-harness/Application.cfc @@ -7,7 +7,7 @@ www.ortussolutions.com component{ // UPDATE THE NAME OF THE MODULE IN TESTING BELOW - request.MODULE_NAME = "cbSwagger"; + request.MODULE_NAME = "cbswagger"; // Application properties this.name = hash( getCurrentTemplatePath() ); 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":{ diff --git a/test-harness/handlers/api/v1/Users.cfc b/test-harness/handlers/api/v1/Users.cfc index 164225b..48cfa79 100755 --- a/test-harness/handlers/api/v1/Users.cfc +++ b/test-harness/handlers/api/v1/Users.cfc @@ -1,7 +1,7 @@ /** * * @name User API Controller - * @package cbSwagger-shell + * @package cbswagger-shell * @description This is the User API Controller * @author Jon Clausen * 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/modules_app/test-api-module/ModuleConfig.cfc b/test-harness/modules_app/test-api-module/ModuleConfig.cfc index 232db8d..113da84 100644 --- a/test-harness/modules_app/test-api-module/ModuleConfig.cfc +++ b/test-harness/modules_app/test-api-module/ModuleConfig.cfc @@ -9,7 +9,7 @@ component { this.title = "test-api-module"; this.author = "Jon Clausen "; this.webURL = "N/A"; - this.description = "A test api module for cbSwagger"; + this.description = "A test api module for cbswagger"; this.version = "0.0.1"; // If true, looks for views in the parent first, if not found, then in the module. Else vice-versa this.viewParentLookup = true; diff --git a/test-harness/modules_app/test-api-module/handlers/UserPosts.cfc b/test-harness/modules_app/test-api-module/handlers/UserPosts.cfc index cbde5ea..fd27ba5 100644 --- a/test-harness/modules_app/test-api-module/handlers/UserPosts.cfc +++ b/test-harness/modules_app/test-api-module/handlers/UserPosts.cfc @@ -1,7 +1,7 @@ /** * * @name User API Controller - * @package cbSwagger-shell + * @package cbswagger-shell * @description This is the User Posts API Controller * @author Jon Clausen * diff --git a/test-harness/modules_app/test-api-module/handlers/UserSettings.cfc b/test-harness/modules_app/test-api-module/handlers/UserSettings.cfc index 8544a80..8efeb75 100644 --- a/test-harness/modules_app/test-api-module/handlers/UserSettings.cfc +++ b/test-harness/modules_app/test-api-module/handlers/UserSettings.cfc @@ -1,7 +1,7 @@ /** * * @name UserSettings API Controller - * @package cbSwagger-shell + * @package cbswagger-shell * @description This is the User Settings API Controller * @author Jon Clausen * diff --git a/test-harness/modules_app/test-api-module/handlers/Users.cfc b/test-harness/modules_app/test-api-module/handlers/Users.cfc index 8c4d8bc..7a504a8 100755 --- a/test-harness/modules_app/test-api-module/handlers/Users.cfc +++ b/test-harness/modules_app/test-api-module/handlers/Users.cfc @@ -1,7 +1,7 @@ /** * * @name User API Controller - * @package cbSwagger-shell + * @package cbswagger-shell * @description This is the User API Controller * @author Jon Clausen * diff --git a/test-harness/tests/Application.cfc b/test-harness/tests/Application.cfc index 21fca6f..49097dd 100644 --- a/test-harness/tests/Application.cfc +++ b/test-harness/tests/Application.cfc @@ -6,9 +6,9 @@ component { // The name of the module used in cfmappings ,etc - request.MODULE_NAME = "cbSwagger"; + request.MODULE_NAME = "cbswagger"; // The directory name of the module on disk. Usually, it's the same as the module name - request.MODULE_PATH = "cbSwagger"; + request.MODULE_PATH = "cbswagger"; // APPLICATION CFC PROPERTIES this.name = "#request.MODULE_NAME# Testing Suite"; 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" + } + } ); + } ); + } ); + } + +} diff --git a/test-harness/tests/specs/RoutesParserTest.cfc b/test-harness/tests/specs/RoutesParserTest.cfc index 2c1e3cd..b3a9f02 100644 --- a/test-harness/tests/specs/RoutesParserTest.cfc +++ b/test-harness/tests/specs/RoutesParserTest.cfc @@ -15,9 +15,9 @@ component // Wire up this object getWireBox().autowire( this ); variables.testHandlerMetadata = getMetadata( createObject( "component", "handlers.api.v1.Users" ) ); - variables.cbSwaggerSettings = controller.getSetting( "modules" ).cbSwagger.settings; + variables.cbswaggerSettings = controller.getSetting( "modules" ).cbswagger.settings; - variables.model = prepareMock( new cbSwagger.models.RoutesParser() ); + variables.model = prepareMock( new cbswagger.models.RoutesParser() ); getWireBox().autowire( variables.model ); variables.samplesPath = controller.getAppRootPath() & variables.model.getModuleSettings().samplesPath; @@ -115,7 +115,7 @@ component var APIPaths = normalizedDoc[ "paths" ]; // pull our routing configuration - var apiPrefixes = cbSwaggerSettings.routes; + var apiPrefixes = cbswaggerSettings.routes; expect( apiPrefixes ).toBeArray(); var CBRoutes = getController() @@ -150,7 +150,7 @@ component var APIPaths = normalizedDoc[ "paths" ]; // pull our routing configuration - var apiPrefixes = cbSwaggerSettings.routes; + var apiPrefixes = cbswaggerSettings.routes; expect( apiPrefixes ).toBeArray(); var TLRoutes = getController().getRoutingService().getRoutes(); diff --git a/test-harness/views/main/index.cfm b/test-harness/views/main/index.cfm index 2c3ee59..44bcdd4 100644 --- a/test-harness/views/main/index.cfm +++ b/test-harness/views/main/index.cfm @@ -1,4 +1,4 @@ -

cbSwagger is Up and Running!

-

Go visit it at /cbSwagger

+

cbswagger is Up and Running!

+

Go visit it at /cbswagger