diff --git a/src/validation.test.ts b/src/validation.test.ts index 1d04a516..23918530 100644 --- a/src/validation.test.ts +++ b/src/validation.test.ts @@ -518,6 +518,30 @@ describe.each([{}, { lazyCompileValidators: true }])('OpenAPIValidator with opts }, }, }, + patch: { + operationId: 'patchPet', + responses: { 200: { description: 'ok' } }, + requestBody: { + required: true, + content: { + 'application/json': { + schema: petSchema, + }, + 'multipart/form-data': { + schema: { + type: 'object', + additionalProperties: false, + properties: { + image: { + type: 'string' + } + }, + required: ['image'] + }, + }, + }, + }, + }, }, '/pets/schedule': { post: { @@ -652,7 +676,36 @@ describe.each([{}, { lazyCompileValidators: true }])('OpenAPIValidator with opts expect(valid.errors).toBeFalsy(); }); - test('passes validation for PUT /pets with multipart/form-data', async () => { + test('fails validation for PATCH form-data to /pets when missing required field', async () => { + const valid = validator.validateRequest({ + path: '/pets', + method: 'patch', + body: {}, + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + expect(valid.errors).toHaveLength(1); + expect(valid.errors && valid.errors[0].keyword).toBe('required'); + }); + + test('passes validation for PATCH form-data to /pets', async () => { + const valid = validator.validateRequest({ + path: '/pets', + method: 'patch', + body: { + image: 'JPEG' + }, + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + expect(valid.errors).toBeFalsy(); + }); + + test('passes validation for POST /pets with multipart/form-data', async () => { const valid = validator.validateRequest({ path: '/pets/schedule', method: 'post', @@ -667,7 +720,7 @@ describe.each([{}, { lazyCompileValidators: true }])('OpenAPIValidator with opts expect(valid.errors).toBeFalsy(); }); - test('fails validation for PUT /pets with multipart/form-data and missing required field', async () => { + test('fails validation for POST /pets with multipart/form-data and missing required field', async () => { const valid = validator.validateRequest({ path: '/pets/schedule', method: 'post', @@ -683,6 +736,22 @@ describe.each([{}, { lazyCompileValidators: true }])('OpenAPIValidator with opts expect(valid.errors?.[0]?.params?.missingProperty).toBe('title'); }); + test('fails validation for PATCH /pets when required body is not provided', async () => { + for (const contentType of ['multipart/form-data', 'application/json']) { + const valid = validator.validateRequest({ + path: '/pets', + method: 'patch', + body: null, + headers: { + 'Content-Type': contentType, + }, + }); + expect(valid.errors).toHaveLength(2); + expect(valid.errors?.[0]?.message).toBe('requestBody is required'); + expect(valid.errors?.[1]?.message).toBe('must be object'); + } + }); + test.each([ ['something'], // string [123], // number diff --git a/src/validation.ts b/src/validation.ts index 88ed6f51..bab91049 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -59,6 +59,22 @@ interface StatusBasedResponseValidatorsFunctionMap { [statusCode: string]: ValidateFunction; } +// TODO: The requestBody can contain custom headers +// https://swagger.io/docs/specification/v3_0/describing-request-body/multipart-requests/#specifying-custom-headers +interface RequestBodyValidator { + requestBody?: ValidateFunction; + // customHeaders?: ValidateFunction; +} + +interface RequestContentTypeValidateFunctionMap { + [contentType: string]: RequestBodyValidator; +} + +interface RequestValidator { + requestBodyValidators: RequestContentTypeValidateFunctionMap; + otherParameters?: ValidateFunction; +} + export enum ValidationContext { RequestBody = 'requestBodyValidator', Params = 'paramsValidator', @@ -128,7 +144,7 @@ export class OpenAPIValidator { public customizeAjv: AjvCustomizer | undefined; public coerceTypes: boolean; - public requestValidators: { [operationId: string]: ValidateFunction[] | null }; + public requestValidators: { [operationId: string]: RequestValidator | null }; public responseValidators: { [operationId: string]: ValidateFunction | null }; public statusBasedResponseValidators: { [operationId: string]: StatusBasedResponseValidatorsFunctionMap | null }; public responseHeadersValidators: { [operationId: string]: ResponseHeadersValidateFunctionMap | null }; @@ -247,7 +263,7 @@ export class OpenAPIValidator { // get pre-compiled ajv schemas for operation const { operationId } = operation; - const validators = this.getRequestValidatorsForOperation(operationId) || []; + const validators = this.getRequestValidatorsForOperation(operationId); // build a parameter object to validate const { params, query, headers, cookies, requestBody } = this.router.parseRequest(req, operation); @@ -296,7 +312,7 @@ export class OpenAPIValidator { keyword: 'parse', instancePath: '', schemaPath: '#/requestBody', - params: [], + params: {}, message: err.message, }); } @@ -304,16 +320,36 @@ export class OpenAPIValidator { } } - if (typeof requestBody === 'object' || headers['content-type'] === 'application/json') { - // include request body in validation if an object is provided - parameters.requestBody = requestBody; + // validate requestBody + const opRequestBody = operation.requestBody as PickVersionElement; + const mediaType = matchMediaType(Object.keys(validators.requestBodyValidators), headers['content-type']); + const isBodyRequired = opRequestBody && opRequestBody.required; + if (isBodyRequired && !requestBody) { + result.errors.push({ + keyword: 'required', + instancePath: '', + schemaPath: '#/required', + params: {}, + message: 'requestBody is required', + }); + } + if (mediaType in validators.requestBodyValidators) { + const validator = validators.requestBodyValidators[mediaType]; + if (validator) { + if (typeof validator.requestBody === 'function') { + validator.requestBody(requestBody); + } + if (validator.requestBody.errors) { + result.errors.push(...validator.requestBody.errors); + } + } } - // validate parameters against each pre-compiled schema - for (const validate of validators) { - validate(parameters); - if (validate.errors) { - result.errors.push(...validate.errors); + // validate path, header, query, cookie parameters + if (validators.otherParameters) { + validators.otherParameters(parameters); + if (validators.otherParameters.errors) { + result.errors.push(...validators.otherParameters.errors); } else if (this.coerceTypes) { result.coerced.query = parameters.query; result.coerced.params = parameters.path; @@ -461,7 +497,7 @@ export class OpenAPIValidator { * * @param {string} operationId * @returns {*} {(ValidateFunction[] | null)} - * @memberof OpenAPIValidator + * @memberof RequestValidator */ public getRequestValidatorsForOperation(operationId: string) { if (this.requestValidators[operationId] === undefined) { @@ -548,49 +584,39 @@ export class OpenAPIValidator { * @returns {*} {(ValidateFunction[] | null)} * @memberof OpenAPIValidator */ - public buildRequestValidatorsForOperation(operation: Operation): ValidateFunction[] | null { + public buildRequestValidatorsForOperation(operation: Operation): RequestValidator { + // validator functions for this operation + const validators: RequestValidator = { + requestBodyValidators: {} + }; + if (!operation?.operationId) { - // no operationId, don't register a validator - return null; + // no operationId + return validators; } - // validator functions for this operation - const validators: ValidateFunction[] = []; - - // schema for operation requestBody if (operation.requestBody) { - const requestBody = operation.requestBody as PickVersionElement< - D, - OpenAPIV3.RequestBodyObject, - OpenAPIV3_1.RequestBodyObject - >; - const jsonbody = - requestBody.content['application/json'] || - requestBody.content['multipart/form-data'] || - requestBody.content['application/x-www-form-urlencoded']; - if (jsonbody && jsonbody.schema) { - const requestBodySchema: InputValidationSchema = { - title: 'Request', - type: 'object', - additionalProperties: true, - properties: { - requestBody: jsonbody.schema as PickVersionElement, - }, - }; - requestBodySchema.required = []; - if (_.keys(requestBody.content).length === 1) { - // if application/json is the only specified format, it's required - requestBodySchema.required.push('requestBody'); - } + const requestBody = operation.requestBody as PickVersionElement; + const required = requestBody.required ? [ 'requestBody' ] : []; + + for (const [contentType, contentBodyVx] of Object.entries(requestBody.content)) { + const contentBody = contentBodyVx as PickVersionElement; + const requestBodySchema = contentBody.schema as PickVersionElement; // add compiled params schema to schemas for this operation id const requestBodyValidator = this.getAjv(ValidationContext.RequestBody); this.removeBinaryPropertiesFromRequired(requestBodySchema); - validators.push(OpenAPIValidator.compileSchema(requestBodyValidator, requestBodySchema)); + + if (!validators.requestBodyValidators) { + validators.requestBodyValidators = {} as RequestContentTypeValidateFunctionMap; + } + validators.requestBodyValidators[contentType] = { + requestBody: OpenAPIValidator.compileSchema(requestBodyValidator, requestBodySchema || {}) + } } } - // schema for operation parameters in: path,query,header,cookie + // generate parameters validator for this operation const paramsSchema: InputValidationSchema = { title: 'Request', type: 'object', @@ -669,7 +695,7 @@ export class OpenAPIValidator { // add compiled params schema to requestValidators for this operation id const paramsValidator = this.getAjv(ValidationContext.Params, { coerceTypes: true }); - validators.push(OpenAPIValidator.compileSchema(paramsValidator, paramsSchema)); + validators.otherParameters = OpenAPIValidator.compileSchema(paramsValidator, paramsSchema); return validators; } @@ -959,3 +985,23 @@ export class OpenAPIValidator { return ajv; } } + +function matchMediaType(mediaTypes: string[], contentType?: string) { + if (!mediaTypes.length) { + return ''; + } + // NOTE: There is nothing in the OpenAPI spec that defaults the media-type + // if one is not provided. Currently, if there is one media type defined + // for the requestBody, then the first is picked. This is for compatibility. + // A strong argument could be made for returning "415 Unsupported Media Type", + // or at least an option to enable strict media type support. + if (!contentType && mediaTypes.length === 1) { + return mediaTypes[0]; + } + // TODO: Implement matching on RFC 7231 media-range, e.g. "image/*", "*/*" + // see: https://swagger.io/specification/#request-body-object + if (mediaTypes.includes(contentType)) { + return contentType; + } + return ''; +}