diff --git a/README.md b/README.md index eb103f1f..1347397c 100644 --- a/README.md +++ b/README.md @@ -538,58 +538,162 @@ Example Groups are built from Parameter objects in both the Path Item Object and -## Example Request Body +## Example Request Bodies -An Example Request Body is built from Property objects in the Request Body's Content schema. -All properties listed as required will be included in the example request body. -Properties that are not required will not be included, even if a property example is provided. +If an Operation requires a request body, LOAST will build a default ExampleRequestBody using an OAS Operation source in order of precedence: -
Sample JSON +- MediaTypeObject.example +- The "example" field on each property in MediaTypeObject.schema.properties + +The default ExampleRequestBody will include every field included in MediaTypeObject.example or every property in MediaTypeObject.schema.properties that has its "example" field set. + +If the default ExampleRequestBody contains optional fields, then an additional required-fields-only ExampleRequestBody will be constructed. This request body will only contain fields that are marked as required in MediaTypeObject.schema. + +
Sample JSON - MediaTypeObject.example ```json - "schemas": { - "VeteranStatusRequest": { - "type": "object", - "required": [ - "ssn", - "first_name", - "last_name", - "birth_date" - ], - "properties": { - "ssn": { - "type": "string", - "example": "555-55-5555" - }, - "first_name": { - "type": "string", - "example": "John" - }, - "last_name": { - "type": "string", - "example": "Doe" - }, - "birth_date": { - "type": "string", - "example": "1965-01-01" - }, - "middle_name": { - "type": "string", - "example": "Theodore" - }, - "gender": { - "type": "string", - "enum": [ - "M", - "F" - ], - "example": "M" + "paths": { + "/status": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ssn", + "first_name", + "last_name", + "birth_date" + ], + "properties": { + "ssn": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "birth_date": { + "type": "string" + }, + "middle_name": { + "type": "string" + }, + "gender": { + "type": "string", + "enum": [ + "M", + "F" + ] + } + } + }, + "example": { + "ssn": "555-55-5555", + "first_name": "John", + "last_name": "Doe", + "birth_date": "1965-01-01", + "middle_name": "Theodore", + "gender": "M" + } + } + } + } + } + } + } + + //Example Request Body - default + { + "ssn": "555-55-5555", + "first_name": "John", + "last_name": "Doe", + "birth_date": "1965-01-01", + "middle_name": "Theodore", + "gender": "M" + } + + //Example Request Body - required fields only + { + "ssn": "555-55-5555", + "first_name": "John", + "last_name": "Doe", + "birth_date": "1965-01-01" + } +``` + +
+
+ +
Sample JSON - schema.properties "example" fields + +```json + "paths": { + "/status": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ssn", + "first_name", + "last_name", + "birth_date" + ], + "properties": { + "ssn": { + "type": "string", + "example": "555-55-5555" + }, + "first_name": { + "type": "string", + "example": "John" + }, + "last_name": { + "type": "string", + "example": "Doe" + }, + "birth_date": { + "type": "string", + "example": "1965-01-01" + }, + "middle_name": { + "type": "string", + "example": "Theodore" + }, + "gender": { + "type": "string", + "enum": [ + "M", + "F" + ], + "example": "M" + } + } + } + } + } } } } } - //Example Request Body + //Example Request Body - default + { + "ssn": "555-55-5555", + "first_name": "John", + "last_name": "Doe", + "birth_date": "1965-01-01", + "middle_name": "Theodore", + "gender": "M" + } + + //Example Request Body - required fields only { "ssn": "555-55-5555", "first_name": "John", @@ -599,6 +703,7 @@ Properties that are not required will not be included, even if a property exampl ```
+
# Validation diff --git a/package-lock.json b/package-lock.json index 1e2b4ec2..3ea7da35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5095,7 +5095,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "devOptional": true + "optional": true }, "node_modules/ci-info": { "version": "3.8.0", @@ -24878,7 +24878,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "devOptional": true + "optional": true }, "ci-info": { "version": "3.8.0", diff --git a/src/oas-parsing/operation-example/operation-example.factory.ts b/src/oas-parsing/operation-example/operation-example.factory.ts index 75afaff7..2b0e0f31 100644 --- a/src/oas-parsing/operation-example/operation-example.factory.ts +++ b/src/oas-parsing/operation-example/operation-example.factory.ts @@ -1,3 +1,12 @@ +/** + * The OperationExampleFactory class is responsible for building OperationExamples for all Operations that are part of the OAS under test. + * An Operation could potentially have both multiple ExampleGroups (query, path, or header parameters) + * and multiple ExampleRequestBodies (a request body with only required fields and a request body with required and optional fields). + * OperationExamples will be created for each permutation of ExampleGroups and ExampleRequestBodies + * (if an Operation has 2 ExampleGroups and 2 ExampleRequestBodies, then 4 OperationExamples will be created for that Operation). + */ + +import ExampleGroup from '../example-group/example-group'; import OASOperation from '../operation/oas-operation'; import OperationExample from './operation-example'; @@ -5,20 +14,26 @@ export default class OperationExampleFactory { public static buildFromOperations( operations: OASOperation[], ): OperationExample[] { - const operationExamples: OperationExample[] = []; - - for (const operation of operations) { - const exampleGroups = operation.exampleGroups; + return operations.flatMap((operation) => + this.buildFromOperation(operation), + ); + } - for (const exampleGroup of exampleGroups) { - operationExamples.push({ - operation, - exampleGroup, - requestBody: operation.exampleRequestBody, - }); - } - } + private static buildFromOperation( + operation: OASOperation, + ): OperationExample[] { + return operation.exampleGroups.flatMap((exampleGroup) => + this.buildFromOperationAndExampleGroup(operation, exampleGroup), + ); + } - return operationExamples; + private static buildFromOperationAndExampleGroup( + operation: OASOperation, + exampleGroup: ExampleGroup, + ): OperationExample[] { + return operation.exampleRequestBodies.map( + (exampleRequestBody) => + new OperationExample(operation, exampleGroup, exampleRequestBody), + ); } } diff --git a/src/oas-parsing/operation-example/operation-example.ts b/src/oas-parsing/operation-example/operation-example.ts index d16b30cf..b4fb3d2c 100644 --- a/src/oas-parsing/operation-example/operation-example.ts +++ b/src/oas-parsing/operation-example/operation-example.ts @@ -1,21 +1,29 @@ -import { RequestBody } from 'swagger-client'; +import { NO_REQUEST_BODY } from '../../utilities/constants'; import ExampleGroup from '../example-group/example-group'; import OASOperation from '../operation/oas-operation'; +import ExampleRequestBody from '../request-body/example-request-body'; export default class OperationExample { readonly operation: OASOperation; readonly exampleGroup: ExampleGroup; - readonly requestBody: RequestBody; + readonly exampleRequestBody: ExampleRequestBody; + + readonly name: string; constructor( operation: OASOperation, exampleGroup: ExampleGroup, - requestBody: RequestBody, + exampleRequestBody: ExampleRequestBody, ) { this.operation = operation; this.exampleGroup = exampleGroup; - this.requestBody = requestBody; + this.exampleRequestBody = exampleRequestBody; + + this.name = + exampleRequestBody.name === NO_REQUEST_BODY + ? exampleGroup.name + : `${exampleGroup.name} - ${exampleRequestBody.name}`; } } diff --git a/src/oas-parsing/operation/oas-operation.ts b/src/oas-parsing/operation/oas-operation.ts index 94c1300f..58ec2dfe 100644 --- a/src/oas-parsing/operation/oas-operation.ts +++ b/src/oas-parsing/operation/oas-operation.ts @@ -1,4 +1,4 @@ -import { OperationObject, ParameterObject, RequestBody } from 'swagger-client'; +import { OperationObject, ParameterObject } from 'swagger-client'; import { RequestBodyObject, ResponseObject, @@ -8,6 +8,7 @@ import ExampleGroup, { ExampleGroupFactory } from '../example-group'; import OASSecurity from '../security'; import OASSecurityFactory from '../security/oas-security.factory'; import RequestBodyFactory from '../request-body/request-body.factory'; +import ExampleRequestBody from '../request-body/example-request-body'; class OASOperation { readonly operation: OperationObject; @@ -22,7 +23,7 @@ class OASOperation { private _exampleGroups: ExampleGroup[]; - private _exampleRequestBody: RequestBody; + private _exampleRequestBodies: ExampleRequestBody[]; constructor( operation: OperationObject, @@ -33,7 +34,7 @@ class OASOperation { this.parameters = operation.parameters; this.requestBody = operation.requestBody; this._exampleGroups = ExampleGroupFactory.buildFromOperation(this); - this._exampleRequestBody = RequestBodyFactory.buildFromOperation(this); + this._exampleRequestBodies = RequestBodyFactory.buildFromOperation(this); this.security = OASSecurityFactory.getSecurities( operation.security ?? securities, ); @@ -43,8 +44,8 @@ class OASOperation { return [...this._exampleGroups]; } - get exampleRequestBody(): RequestBody { - return { ...this._exampleRequestBody }; + get exampleRequestBodies(): ExampleRequestBody[] { + return [...this._exampleRequestBodies]; } get requiredParameterNames(): string[] { diff --git a/src/oas-parsing/request-body/example-request-body.ts b/src/oas-parsing/request-body/example-request-body.ts new file mode 100644 index 00000000..6f902b75 --- /dev/null +++ b/src/oas-parsing/request-body/example-request-body.ts @@ -0,0 +1,13 @@ +import { RequestBody } from 'swagger-client'; + +class ExampleRequestBody { + readonly name: string; + readonly requestBody: RequestBody; + + constructor(name: string, requestBody: RequestBody) { + this.name = name; + this.requestBody = requestBody; + } +} + +export default ExampleRequestBody; diff --git a/src/oas-parsing/request-body/request-body.factory.ts b/src/oas-parsing/request-body/request-body.factory.ts index 2f6140b1..927d1640 100644 --- a/src/oas-parsing/request-body/request-body.factory.ts +++ b/src/oas-parsing/request-body/request-body.factory.ts @@ -1,35 +1,139 @@ -import { RequestBody } from 'swagger-client'; +/** + * The RequestBodyFactory class is responsible for creating ExampleRequestBodies for an OASOperation. + * RequestBody OAS sources in order of precedence: MediaTypeObject.example => each MediaTypeObject.schema.properties "example" field + */ + import OASOperation from '../operation'; +import ExampleRequestBody from './example-request-body'; +import { + DEFAULT_REQUEST_BODY, + NO_REQUEST_BODY, + REQUIRED_FIELDS_REQUEST_BODY, +} from '../../utilities/constants'; +import { Json, SchemaObject } from 'swagger-client'; class RequestBodyFactory { - static buildFromOperation(operation: OASOperation): RequestBody { - const requestBodyExample = {}; - + /** + * If the OASOperation does not require a request body or fields necessary to build a request body are missing, then only an empty ExampleRequestBody is returned. + * The default ExampleRequestBody is built using the an OAS source in order of precedence: + * MediaTypeObject.example => each MediaTypeObject.schema.properties "example" field. + * The default ExampleRequestBody is then used to build an additional ExampleRequestBody with only required fields, but if the required-fields-only + * ExampleRequestBody is not necessary (i.e. the required-fields-only ExampleRequestBody is the same as the default ExampleRequestBody), then only the + * default ExampleRequestBody is returned. + * @param operation + * @returns ExampleRequestBody[] + */ + static buildFromOperation(operation: OASOperation): ExampleRequestBody[] { const requestBody = operation.requestBody; + const emptyRequestBody = new ExampleRequestBody(NO_REQUEST_BODY, {}); - if (!requestBody || !requestBody.required) return {}; + if (!requestBody || !requestBody.required) return [emptyRequestBody]; const content = requestBody.content; // get first key in content map const [key] = Object.keys(content); - const schema = content[key].schema; + const mediaTypeObject = content[key]; + const schema = mediaTypeObject.schema; + const mediaTypeObjectExample = mediaTypeObject.example; + + let defaultRequestBody: ExampleRequestBody | undefined = undefined; - if (!schema) { - return {}; + if (mediaTypeObjectExample) { + // create default request body - example field as is + defaultRequestBody = new ExampleRequestBody( + DEFAULT_REQUEST_BODY, + mediaTypeObjectExample, + ); + } else if (schema) { + // create default request body from schema + defaultRequestBody = this.buildFromSchemaExamples(schema); } - const requiredProperties = schema.required; + // create request body with only required fields if necessary + if (defaultRequestBody !== undefined) { + const requiredFieldsRequestBody = this.buildRequiredFieldsOnlyRequestBody( + defaultRequestBody.requestBody, + schema.required, + ); - if (requiredProperties && schema.properties) { - for (const requiredProperty of requiredProperties) { - if (schema.properties[requiredProperty]) { - const example = schema.properties[requiredProperty].example; - requestBodyExample[requiredProperty] = example; - } + if (requiredFieldsRequestBody !== undefined) { + return [defaultRequestBody, requiredFieldsRequestBody]; } + + return [defaultRequestBody]; + } + + return [emptyRequestBody]; + } + + /** + * Builds an ExampleRequestBody using the "example" field of each property present in the SchemaObject's "properties" field. + * @param schema + * @returns ExampleRequestBody | undefined + */ + private static buildFromSchemaExamples( + schema: SchemaObject, + ): ExampleRequestBody | undefined { + const schemaProperties = schema.properties; + + if (schemaProperties) { + const requestBody = {}; + Object.entries(schemaProperties).forEach(([key, value]) => { + if (value.example) { + requestBody[key] = value.example; + } + }); + + const exampleRequestBody = new ExampleRequestBody( + DEFAULT_REQUEST_BODY, + requestBody, + ); + + return exampleRequestBody; + } + + return undefined; + } + + /** + * Builds an ExampleRequestBody with only required fields, using a default requestBody as the source. + * @param defaultExample + * @param requiredProperties + * @returns ExampleRequestBody | undefined + */ + private static buildRequiredFieldsOnlyRequestBody( + defaultExample: Json, + requiredProperties: string[] | undefined, + ): ExampleRequestBody | undefined { + /** + * Check if the defaultExample contains optional parameters by comparing the number of keys in the defaultExample to the number of requiredProperties. + * If there are more keys in the defaultExample than requiredProperties, then the defaultExample must contain optional properties that can be filtered out, and + * a required-properties-only request body can be built. + * If there are the same number of keys in the defaultExample as requiredProperites, then the defaultExample must be the same as + * the required-parameters-only, so undefined is returned. + * If there are fewer keys in the defaultExample than requiredProperties, then a requiredProperty must be missing - no need to build a second + * request body, and undefined is returned. + */ + if ( + requiredProperties && + Object.keys(defaultExample).length > requiredProperties.length + ) { + const requiredPropertiesOnlyRequestBody = {}; + Object.entries(defaultExample).forEach(([key, value]) => { + if (requiredProperties.includes(key)) { + requiredPropertiesOnlyRequestBody[key] = value; + } + }); + + const requiredPropertiesExampleRequestBody = new ExampleRequestBody( + REQUIRED_FIELDS_REQUEST_BODY, + requiredPropertiesOnlyRequestBody, + ); + + return requiredPropertiesExampleRequestBody; } - return requestBodyExample; + return undefined; } } diff --git a/src/suites/positive/conductors/request-validation-conductor.ts b/src/suites/positive/conductors/request-validation-conductor.ts index a9f2cfa3..600af697 100644 --- a/src/suites/positive/conductors/request-validation-conductor.ts +++ b/src/suites/positive/conductors/request-validation-conductor.ts @@ -6,15 +6,23 @@ import { RequestBodyValidator, } from '../validation'; import Message from '../../../validation/message'; +import ExampleRequestBody from '../../../oas-parsing/request-body/example-request-body'; export default class RequestValidationConductor { private operation: OASOperation; private exampleGroup: ExampleGroup; - constructor(operation: OASOperation, exampleGroup: ExampleGroup) { + private exampleRequestBody: ExampleRequestBody; + + constructor( + operation: OASOperation, + exampleGroup: ExampleGroup, + exampleRequestBody: ExampleRequestBody, + ) { this.operation = operation; this.exampleGroup = exampleGroup; + this.exampleRequestBody = exampleRequestBody; } async validate(): Promise[]> { @@ -28,7 +36,10 @@ export default class RequestValidationConductor { failures = new Map([...failures, ...parameterSchemaValidator.failures]); warnings = new Map([...warnings, ...parameterSchemaValidator.warnings]); - const requestBodyValidator = new RequestBodyValidator(this.operation); + const requestBodyValidator = new RequestBodyValidator( + this.operation, + this.exampleRequestBody.requestBody, + ); await requestBodyValidator.validate(); failures = new Map([...failures, ...requestBodyValidator.failures]); warnings = new Map([...warnings, ...requestBodyValidator.warnings]); diff --git a/src/suites/positive/conductors/validation-conductor.ts b/src/suites/positive/conductors/validation-conductor.ts index 590867dc..b2fb1494 100644 --- a/src/suites/positive/conductors/validation-conductor.ts +++ b/src/suites/positive/conductors/validation-conductor.ts @@ -3,9 +3,10 @@ import RequestValidationConductor from './request-validation-conductor'; import OASOperation from '../../../oas-parsing/operation'; import ExampleGroup from '../../../oas-parsing/example-group'; import OASSchema from '../../../oas-parsing/schema'; -import { RequestBody, SecurityValues } from 'swagger-client'; +import { SecurityValues } from 'swagger-client'; import ResponseValidationConductor from './response-validation-conductor'; import { OperationExample } from '../../../oas-parsing/operation-example'; +import ExampleRequestBody from '../../../oas-parsing/request-body/example-request-body'; export default class ValidationConductor { private schema: OASSchema; @@ -14,7 +15,9 @@ export default class ValidationConductor { private exampleGroup: ExampleGroup; - private requestBody: RequestBody; + private exampleRequestBody: ExampleRequestBody; + + private operationExampleName: string; private securityValues: SecurityValues; @@ -22,14 +25,20 @@ export default class ValidationConductor { constructor( schema: OASSchema, - { operation, exampleGroup, requestBody }: OperationExample, + { + operation, + exampleGroup, + exampleRequestBody, + name: operationExampleName, + }: OperationExample, securityValues: SecurityValues, server: string | undefined, ) { this.schema = schema; this.operation = operation; this.exampleGroup = exampleGroup; - this.requestBody = requestBody; + this.exampleRequestBody = exampleRequestBody; + this.operationExampleName = operationExampleName; this.securityValues = securityValues; this.server = server; } @@ -38,6 +47,7 @@ export default class ValidationConductor { const requestValidationConductor = new RequestValidationConductor( this.operation, this.exampleGroup, + this.exampleRequestBody, ); let [failures, warnings] = await requestValidationConductor.validate(); @@ -47,7 +57,7 @@ export default class ValidationConductor { this.operation, this.exampleGroup, this.securityValues, - this.requestBody, + this.exampleRequestBody.requestBody, this.server, ); @@ -70,7 +80,7 @@ export default class ValidationConductor { return new OperationResult( this.operation.operationId, originalOperationId, - this.exampleGroup.name, + this.operationExampleName, failures, warnings, ); diff --git a/src/suites/positive/validation/request-body-validator.ts b/src/suites/positive/validation/request-body-validator.ts index a596f3a7..9d7e5305 100644 --- a/src/suites/positive/validation/request-body-validator.ts +++ b/src/suites/positive/validation/request-body-validator.ts @@ -3,13 +3,16 @@ import OASOperation from '../../../oas-parsing/operation'; import PositiveValidator from './positive-validator'; import { Type } from './positive-message'; import { REQUEST_BODY_PATH } from '../utilities/constants'; +import { RequestBody } from 'swagger-client'; class RequestBodyValidator extends PositiveValidator { private operation: OASOperation; + private requestBody: RequestBody; - constructor(operation: OASOperation) { + constructor(operation: OASOperation, requestBody: RequestBody) { super(); this.operation = operation; + this.requestBody = requestBody; } performValidation = async (): Promise => { @@ -41,8 +44,7 @@ class RequestBodyValidator extends PositiveValidator { const [key] = Object.keys(content); const schema = content[key].schema; if (schema) { - const example = this.operation.exampleRequestBody; - await this.validateObjectAgainstSchema(example, schema, [ + await this.validateObjectAgainstSchema(this.requestBody, schema, [ ...path, 'example', ]); diff --git a/src/utilities/constants.ts b/src/utilities/constants.ts index 4ee22e94..5fa12e2f 100644 --- a/src/utilities/constants.ts +++ b/src/utilities/constants.ts @@ -1,4 +1,7 @@ export const DEFAULT_PARAMETER_GROUP = 'default'; +export const NO_REQUEST_BODY = 'noRequestBody'; +export const DEFAULT_REQUEST_BODY = 'defaultRequestBody'; +export const REQUIRED_FIELDS_REQUEST_BODY = 'requiredPropertiesRequestBody'; export const DEFAULT_SUITE_ID = 'suite'; export const DEFAULT_TEST_NAME = 'test'; diff --git a/test/fixtures/utilities/example-request-bodies.ts b/test/fixtures/utilities/example-request-bodies.ts new file mode 100644 index 00000000..ff0c31f1 --- /dev/null +++ b/test/fixtures/utilities/example-request-bodies.ts @@ -0,0 +1,28 @@ +import ExampleRequestBody from '../../../src/oas-parsing/request-body/example-request-body'; +import { + DEFAULT_REQUEST_BODY, + NO_REQUEST_BODY, + REQUIRED_FIELDS_REQUEST_BODY, +} from '../../../src/utilities/constants'; + +export const exampleRequestBodyEmpty = new ExampleRequestBody( + NO_REQUEST_BODY, + {}, +); + +export const exampleRequestBodyDefault = new ExampleRequestBody( + DEFAULT_REQUEST_BODY, + { + age: 'eleventy one', + home: 'The Shire', + hobby: 'eating', + }, +); + +export const exampleRequestBodyRequiredOnly = new ExampleRequestBody( + REQUIRED_FIELDS_REQUEST_BODY, + { + age: 'eleventy one', + home: 'The Shire', + }, +); diff --git a/test/fixtures/utilities/oas-operations.ts b/test/fixtures/utilities/oas-operations.ts index df39d107..ec089057 100644 --- a/test/fixtures/utilities/oas-operations.ts +++ b/test/fixtures/utilities/oas-operations.ts @@ -3,7 +3,8 @@ import { requestBodyMissingSchema, requestBodyWithFailures, requestBodyValid, -} from './request-bodies'; + requestBodyOptionalPropertiesMediaTypeExample, +} from './oas-request-bodies'; export const operationSimpleGet = new OASOperation({ operationId: 'GET:/harryPotter', @@ -26,20 +27,23 @@ export const operationGetWithExampleGroups = new OASOperation({ ], }); -export const operationPutStudentValidRequestBody = new OASOperation({ - operationId: 'PUT:/student/{id}', - responses: {}, - parameters: [ - { - name: 'id', - in: 'path', - schema: { type: 'string' }, - example: '123456', - required: true, - }, - ], - requestBody: requestBodyValid, -}); +export const operationWithExampleGroupsAndExampleRequestBodies = + new OASOperation({ + operationId: 'POST:/hobbit', + responses: {}, + parameters: [ + { + name: 'name', + in: 'query', + schema: { type: 'string' }, + examples: { + default: { value: 'bilbo' }, + frodo: { value: 'frodo' }, + }, + }, + ], + requestBody: requestBodyOptionalPropertiesMediaTypeExample, + }); export const operationPutStudentRequestBodyMissingSchema = new OASOperation({ operationId: 'PUT:/student/{id}', diff --git a/test/fixtures/utilities/request-bodies.ts b/test/fixtures/utilities/oas-request-bodies.ts similarity index 76% rename from test/fixtures/utilities/request-bodies.ts rename to test/fixtures/utilities/oas-request-bodies.ts index d15ed674..d9de2cd2 100644 --- a/test/fixtures/utilities/request-bodies.ts +++ b/test/fixtures/utilities/oas-request-bodies.ts @@ -31,6 +31,35 @@ export const requestBodyValid: RequestBodyObject = { }, }; +export const requestBodyOptionalPropertiesMediaTypeExample: RequestBodyObject = + { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['age', 'home'], + properties: { + age: { + type: 'string', + }, + home: { + type: 'string', + }, + hobby: { + type: 'string', + }, + }, + }, + example: { + age: 'eleventy one', + home: 'The Shire', + hobby: 'eating', + }, + }, + }, + }; + export const requestBodyMissingSchema: RequestBodyObject = { required: true, content: { diff --git a/test/fixtures/utilities/operation-examples.ts b/test/fixtures/utilities/operation-examples.ts index 4a15dc9d..cd33d417 100644 --- a/test/fixtures/utilities/operation-examples.ts +++ b/test/fixtures/utilities/operation-examples.ts @@ -8,19 +8,20 @@ import { exampleGroupEmptyDefault, exampleGroupTomRiddle, } from './example-groups'; +import { exampleRequestBodyEmpty } from './example-request-bodies'; export const operationExampleSimpleGetDefault = new OperationExample( operationSimpleGet, exampleGroupEmptyDefault, - {}, + exampleRequestBodyEmpty, ); export const operationExampleTomRiddleExGroup = new OperationExample( operationGetWithExampleGroups, exampleGroupTomRiddle, - {}, + exampleRequestBodyEmpty, ); export const operationExampleDefaultExGroup = new OperationExample( operationGetWithExampleGroups, exampleGroupDefault, - {}, + exampleRequestBodyEmpty, ); diff --git a/test/oas-parsing/operation-example/operation-example.factory.test.ts b/test/oas-parsing/operation-example/operation-example.factory.test.ts index d4cc23d5..06dd306c 100644 --- a/test/oas-parsing/operation-example/operation-example.factory.test.ts +++ b/test/oas-parsing/operation-example/operation-example.factory.test.ts @@ -1,7 +1,16 @@ -import { OperationExampleFactory } from '../../../src/oas-parsing/operation-example'; +import ExampleGroup from '../../../src/oas-parsing/example-group/example-group'; +import { + OperationExample, + OperationExampleFactory, +} from '../../../src/oas-parsing/operation-example'; +import { + exampleRequestBodyDefault, + exampleRequestBodyRequiredOnly, +} from '../../fixtures/utilities/example-request-bodies'; import { operationSimpleGet, operationGetWithExampleGroups, + operationWithExampleGroupsAndExampleRequestBodies, } from '../../fixtures/utilities/oas-operations'; import { operationExampleSimpleGetDefault, @@ -12,11 +21,48 @@ import { describe('OperationExampleFactory', () => { describe('buildFromOperations', () => { it('returns the expected OperationExamples', () => { + const exampleGroupFrodo = new ExampleGroup('frodo', { + name: 'frodo', + }); + + const exampleGroupBilboDefault = new ExampleGroup('default', { + name: 'bilbo', + }); + + const operationExampleFrodoExGroupDefaultRequestBody = + new OperationExample( + operationWithExampleGroupsAndExampleRequestBodies, + exampleGroupFrodo, + exampleRequestBodyDefault, + ); + + const operationExampleFrodoExGroupRequiredOnlyRequestBody = + new OperationExample( + operationWithExampleGroupsAndExampleRequestBodies, + exampleGroupFrodo, + exampleRequestBodyRequiredOnly, + ); + + const operationExampleDefaultExGroupDefaultRequestBody = + new OperationExample( + operationWithExampleGroupsAndExampleRequestBodies, + exampleGroupBilboDefault, + exampleRequestBodyDefault, + ); + + const operationExampleDefaultExGroupRequiredOnlyRequestBody = + new OperationExample( + operationWithExampleGroupsAndExampleRequestBodies, + exampleGroupBilboDefault, + exampleRequestBodyRequiredOnly, + ); + const operationExamples = OperationExampleFactory.buildFromOperations([ operationSimpleGet, operationGetWithExampleGroups, + operationWithExampleGroupsAndExampleRequestBodies, ]); - expect(operationExamples).toHaveLength(3); + expect(operationExamples).toHaveLength(7); expect(operationExamples).toContainEqual( operationExampleSimpleGetDefault, ); @@ -24,6 +70,18 @@ describe('OperationExampleFactory', () => { expect(operationExamples).toContainEqual( operationExampleTomRiddleExGroup, ); + expect(operationExamples).toContainEqual( + operationExampleFrodoExGroupDefaultRequestBody, + ); + expect(operationExamples).toContainEqual( + operationExampleFrodoExGroupRequiredOnlyRequestBody, + ); + expect(operationExamples).toContainEqual( + operationExampleDefaultExGroupDefaultRequestBody, + ); + expect(operationExamples).toContainEqual( + operationExampleDefaultExGroupRequiredOnlyRequestBody, + ); }); }); }); diff --git a/test/oas-parsing/operation/oas-operation.test.ts b/test/oas-parsing/operation/oas-operation.test.ts index 304bc7bb..39578ed2 100644 --- a/test/oas-parsing/operation/oas-operation.test.ts +++ b/test/oas-parsing/operation/oas-operation.test.ts @@ -1,6 +1,10 @@ import OASOperation from '../../../src/oas-parsing/operation'; import { OperationObject } from 'swagger-client'; import { operationSimpleGet } from '../../fixtures/utilities/oas-operations'; +import { + DEFAULT_REQUEST_BODY, + REQUIRED_FIELDS_REQUEST_BODY, +} from '../../../src/utilities/constants'; describe('OASOperation', () => { const baseOperation = { @@ -95,12 +99,40 @@ describe('OASOperation', () => { }); }); - describe('getExampleRequestBody', () => { - it('returns the example request body', () => { - const exampleRequestBody = operation.exampleRequestBody; - expect(Object.keys(exampleRequestBody)).toHaveLength(2); - expect(exampleRequestBody.age).toEqual('eleventy one'); - expect(exampleRequestBody.home).toEqual('The Shire'); + describe('getExampleRequestBodies', () => { + it('returns the example request bodies', () => { + const exampleRequestBodies = operation.exampleRequestBodies; + expect(exampleRequestBodies).toHaveLength(2); + + const defaultExampleRequestBody = exampleRequestBodies.find( + (exampleRequestBody) => + exampleRequestBody.name === DEFAULT_REQUEST_BODY, + ); + expect(defaultExampleRequestBody).toBeDefined(); + + const defaultRequestBody = defaultExampleRequestBody?.requestBody; + expect(defaultRequestBody).toBeDefined(); + if (defaultRequestBody !== undefined) { + expect(Object.keys(defaultRequestBody)).toHaveLength(3); + expect(defaultRequestBody.age).toEqual('eleventy one'); + expect(defaultRequestBody.home).toEqual('The Shire'); + expect(defaultRequestBody.hobby).toEqual('eating'); + } + + const requiredFieldsExampleRequestBody = exampleRequestBodies.find( + (exampleRequestBody) => + exampleRequestBody.name === REQUIRED_FIELDS_REQUEST_BODY, + ); + expect(requiredFieldsExampleRequestBody).toBeDefined(); + + const requiredFieldsRequestBody = + requiredFieldsExampleRequestBody?.requestBody; + expect(requiredFieldsRequestBody).toBeDefined(); + if (requiredFieldsRequestBody !== undefined) { + expect(Object.keys(requiredFieldsRequestBody)).toHaveLength(2); + expect(requiredFieldsRequestBody.age).toEqual('eleventy one'); + expect(requiredFieldsRequestBody.home).toEqual('The Shire'); + } }); }); diff --git a/test/oas-parsing/request-body/request-body.factory.test.ts b/test/oas-parsing/request-body/request-body.factory.test.ts new file mode 100644 index 00000000..d2a5dcce --- /dev/null +++ b/test/oas-parsing/request-body/request-body.factory.test.ts @@ -0,0 +1,143 @@ +import { OperationObject } from 'swagger-client'; +import OASOperation from '../../../src/oas-parsing/operation/oas-operation'; +import RequestBodyFactory from '../../../src/oas-parsing/request-body/request-body.factory'; +import { + exampleRequestBodyEmpty, + exampleRequestBodyDefault, + exampleRequestBodyRequiredOnly, +} from '../../fixtures/utilities/example-request-bodies'; +import { requestBodyOptionalPropertiesMediaTypeExample } from '../../fixtures/utilities/oas-request-bodies'; + +const oasOperationObject: OperationObject = { + operationId: 'postHobbit', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + description: '', + type: 'string', + }, + }, + }, + }, + }, +}; + +it('RequestBodyFactory.buildFromOperation() returns ExampleRequestBodies from MediaTypeObject example', () => { + oasOperationObject.requestBody = + requestBodyOptionalPropertiesMediaTypeExample; + + const oasOperation = new OASOperation(oasOperationObject); + const exampleRequestBodies = + RequestBodyFactory.buildFromOperation(oasOperation); + + expect(exampleRequestBodies).toHaveLength(2); + expect(exampleRequestBodies).toContainEqual(exampleRequestBodyDefault); + expect(exampleRequestBodies).toContainEqual(exampleRequestBodyRequiredOnly); +}); + +it('RequestBodyFactory.buildFromOperation() returns one ExampleRequestBody when default and required fields request bodies are the same', () => { + oasOperationObject.requestBody = { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['age', 'home', 'hobby'], + properties: { + age: { + type: 'string', + }, + home: { + type: 'string', + }, + hobby: { + type: 'string', + }, + }, + }, + example: { + age: 'eleventy one', + home: 'The Shire', + hobby: 'eating', + }, + }, + }, + }; + + const oasOperation = new OASOperation(oasOperationObject); + const exampleRequestBodies = + RequestBodyFactory.buildFromOperation(oasOperation); + + expect(exampleRequestBodies).toHaveLength(1); + expect(exampleRequestBodies).toContainEqual(exampleRequestBodyDefault); +}); + +it('RequestBodyFactory.buildFromOperation() returns ExampleRequestBodies from MediaTypeObject schema examples', () => { + oasOperationObject.requestBody = { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['age', 'home'], + properties: { + age: { + type: 'string', + example: 'eleventy one', + }, + home: { + type: 'string', + example: 'The Shire', + }, + hobby: { + type: 'string', + example: 'eating', + }, + }, + }, + }, + }, + }; + + const oasOperation = new OASOperation(oasOperationObject); + const exampleRequestBodies = + RequestBodyFactory.buildFromOperation(oasOperation); + + expect(exampleRequestBodies).toHaveLength(2); + expect(exampleRequestBodies).toContainEqual(exampleRequestBodyDefault); + expect(exampleRequestBodies).toContainEqual(exampleRequestBodyRequiredOnly); +}); + +it('RequestBodyFactory.buildFromOperation() returns empty ExampleRequestBody when operation does not require a request body', () => { + oasOperationObject.requestBody = undefined; + const oasOperation = new OASOperation(oasOperationObject); + const exampleRequestBodies = + RequestBodyFactory.buildFromOperation(oasOperation); + + expect(exampleRequestBodies).toHaveLength(1); + expect(exampleRequestBodies).toContainEqual(exampleRequestBodyEmpty); +}); + +it('RequestBodyFactory.buildFromOperation() returns empty ExampleRequestBody when schema properties are not set', () => { + oasOperationObject.requestBody = { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['age', 'home', 'hobby'], + }, + }, + }, + }; + + const oasOperation = new OASOperation(oasOperationObject); + const exampleRequestBodies = + RequestBodyFactory.buildFromOperation(oasOperation); + + expect(exampleRequestBodies).toHaveLength(1); + expect(exampleRequestBodies).toContainEqual(exampleRequestBodyEmpty); +}); diff --git a/test/oas-parsing/schema/oas-schema.test.ts b/test/oas-parsing/schema/oas-schema.test.ts index a0510a9b..e930bb5b 100644 --- a/test/oas-parsing/schema/oas-schema.test.ts +++ b/test/oas-parsing/schema/oas-schema.test.ts @@ -7,6 +7,7 @@ import { securitySchemeAPIKey, securitySchemeTeacherAPIKey, } from '../../fixtures/utilities/oas-security-schemes'; +import { DEFAULT_REQUEST_BODY } from '../../../src/utilities/constants'; describe('OASSchema', () => { const generateSchema = async (filePath: string): Promise => { @@ -105,7 +106,7 @@ describe('OASSchema', () => { }; }); - it('calls the provided operation with the provided parameters and request body without apiKey', async () => { + it('calls the provided operation with the provided parameters and request bodies without apiKey', async () => { const operation = new OASOperation(operationObject); const executeMock = jest.fn( (_arg) => new Promise((resolve) => resolve(_arg)), @@ -119,17 +120,24 @@ describe('OASSchema', () => { const [exampleGroup] = operation.exampleGroups; const securities: SecurityValues = {}; - const requestBody = operation.exampleRequestBody; const server = 'https://sandbox-api.va.gov/services/va_facilities/{version}'; - await schema.execute( - operation, - exampleGroup, - securities, - requestBody, - server, - ); + const requiredFieldsRequestBody = operation.exampleRequestBodies.find( + (exampleRequestBody) => + exampleRequestBody.name === DEFAULT_REQUEST_BODY, + )?.requestBody; + expect(requiredFieldsRequestBody).toBeDefined; + + if (requiredFieldsRequestBody !== undefined) { + await schema.execute( + operation, + exampleGroup, + securities, + requiredFieldsRequestBody, + server, + ); + } expect(executeMock).toHaveBeenCalledWith({ operationId: 'getFacilityById', @@ -139,6 +147,7 @@ describe('OASSchema', () => { }, requestBody: { id: 'secondTestId', + zip: '00000', }, securities: { authorized: {}, diff --git a/test/suites/positive/conductors/request-validation-conductor.test.ts b/test/suites/positive/conductors/request-validation-conductor.test.ts index 84a40904..ba61872d 100644 --- a/test/suites/positive/conductors/request-validation-conductor.test.ts +++ b/test/suites/positive/conductors/request-validation-conductor.test.ts @@ -8,6 +8,7 @@ import { import { emptyWarningMap, requestBodyWarningMap } from '../fixtures/warnings'; import { exampleGroupEmptyDefault } from '../../../fixtures/utilities/example-groups'; import { operationSimpleGet } from '../../../fixtures/utilities/oas-operations'; +import { exampleRequestBodyEmpty } from '../../../fixtures/utilities/example-request-bodies'; const mockParameterValidate = jest.fn(); const mockRequestBodyValidate = jest.fn(); @@ -79,6 +80,7 @@ describe('RequestValidationConductor', () => { const requestValidationConductor = new RequestValidationConductor( operationSimpleGet, exampleGroupEmptyDefault, + exampleRequestBodyEmpty, ); const [failures, warnings] = await requestValidationConductor.validate(); diff --git a/test/suites/positive/validation/request-body-validator.test.ts b/test/suites/positive/validation/request-body-validator.test.ts index a21cf78a..0ac89f2d 100644 --- a/test/suites/positive/validation/request-body-validator.test.ts +++ b/test/suites/positive/validation/request-body-validator.test.ts @@ -1,14 +1,18 @@ import { RequestBodyValidator } from '../../../../src/suites/positive/validation'; +import { REQUIRED_FIELDS_REQUEST_BODY } from '../../../../src/utilities/constants'; import { operationSimpleGet, operationPutStudentRequestBodyMissingSchema, operationPutStudentRequestBodyFailures, - operationPutStudentValidRequestBody, + operationWithExampleGroupsAndExampleRequestBodies, } from '../../../fixtures/utilities/oas-operations'; describe('RequestBodyValidator', () => { it('contains no failures or warnings when the operation does not have a request body', async () => { - const validator = new RequestBodyValidator(operationSimpleGet); + const validator = new RequestBodyValidator( + operationSimpleGet, + operationSimpleGet.exampleRequestBodies[0].requestBody, + ); await validator.validate(); expect(validator.failures.size).toEqual(0); @@ -17,7 +21,8 @@ describe('RequestBodyValidator', () => { it('contains no failures or warnings for a valid request body', async () => { const validator = new RequestBodyValidator( - operationPutStudentValidRequestBody, + operationWithExampleGroupsAndExampleRequestBodies, + operationWithExampleGroupsAndExampleRequestBodies.exampleRequestBodies[0].requestBody, ); await validator.validate(); @@ -28,6 +33,7 @@ describe('RequestBodyValidator', () => { it('adds a validation failure if content does not contain a schema object', async () => { const validator = new RequestBodyValidator( operationPutStudentRequestBodyMissingSchema, + operationPutStudentRequestBodyMissingSchema.exampleRequestBodies[0].requestBody, ); await validator.validate(); @@ -42,31 +48,41 @@ describe('RequestBodyValidator', () => { }); it('adds validation failures and warnings from validateObjectAgainstSchema', async () => { - const validator = new RequestBodyValidator( - operationPutStudentRequestBodyFailures, - ); - await validator.validate(); + const requiredPropertiesRequestBody = + operationPutStudentRequestBodyFailures.exampleRequestBodies.find( + (exampleRequestBody) => + exampleRequestBody.name === REQUIRED_FIELDS_REQUEST_BODY, + )?.requestBody; + expect(requiredPropertiesRequestBody).toBeDefined(); - const failures = validator.failures; - const warnings = validator.warnings; + if (requiredPropertiesRequestBody !== undefined) { + const validator = new RequestBodyValidator( + operationPutStudentRequestBodyFailures, + requiredPropertiesRequestBody, + ); + await validator.validate(); - expect(failures.size).toEqual(3); - expect(failures).toContainValidationFailure( - 'Actual type did not match schema. Schema type: integer. Actual type: string. Path: requestBody -> example -> year', - ); - expect(failures).toContainValidationFailure( - 'Schema enum contains duplicate values. Enum values: ["Gryffindor","Hufflepuff","Hufflepuff","Slytherin","Ravenclaw"]. Path: requestBody -> example -> house', - ); - expect(failures).toContainValidationFailure( - 'Actual value does not match schema enum. Enum values: ["Gryffindor","Hufflepuff","Hufflepuff","Slytherin","Ravenclaw"]. Actual value: "NotAHouse". Path: requestBody -> example -> house', - ); + const failures = validator.failures; + const warnings = validator.warnings; - expect(warnings.size).toEqual(2); - expect(warnings).toContainValidationWarning( - 'Warning: This array was found to be empty and therefore could not be validated. Path: requestBody -> example -> classes', - ); - expect(warnings).toContainValidationWarning( - 'Warning: Request body is missing non-required properties that were unable to be validated, including hobby. Path: requestBody -> example', - ); + expect(failures.size).toEqual(3); + expect(failures).toContainValidationFailure( + 'Actual type did not match schema. Schema type: integer. Actual type: string. Path: requestBody -> example -> year', + ); + expect(failures).toContainValidationFailure( + 'Schema enum contains duplicate values. Enum values: ["Gryffindor","Hufflepuff","Hufflepuff","Slytherin","Ravenclaw"]. Path: requestBody -> example -> house', + ); + expect(failures).toContainValidationFailure( + 'Actual value does not match schema enum. Enum values: ["Gryffindor","Hufflepuff","Hufflepuff","Slytherin","Ravenclaw"]. Actual value: "NotAHouse". Path: requestBody -> example -> house', + ); + + expect(warnings.size).toEqual(2); + expect(warnings).toContainValidationWarning( + 'Warning: This array was found to be empty and therefore could not be validated. Path: requestBody -> example -> classes', + ); + expect(warnings).toContainValidationWarning( + 'Warning: Request body is missing non-required properties that were unable to be validated, including hobby. Path: requestBody -> example', + ); + } }); });