diff --git a/examples/2-standard-multiple-api-specs/README.md b/examples/2-standard-multiple-api-specs/README.md index 6333cb71..e995641d 100644 --- a/examples/2-standard-multiple-api-specs/README.md +++ b/examples/2-standard-multiple-api-specs/README.md @@ -30,31 +30,31 @@ curl 'localhost:3000/v2/pets?pet_type=kitty' |jq ] ## invoke GET /v2/pets using `type` as specified in v1, but not v2 -curl 'localhost:3000/v2/pets?type=cat' |jq +curl 'localhost:3000/v2/pets?type=cat' |jq { "message": "Unknown query parameter 'type'", "errors": [ { - "path": ".query.type", + "path": "/query/type", "message": "Unknown query parameter 'type'" } ] } -## invoke GET /v1/pets using type='kitty'. kitty is not a valid v1 value. +## invoke GET /v1/pets using type='kitty'. kitty is not a valid v1 value. ## also limit is required in GET /v1/pets curl 'localhost:3000/v1/pets?type=kitty' |jq { - "message": "request.query.type should be equal to one of the allowed values: dog, cat, request.query should have required property 'limit'", + "message": "request/query/type must be equal to one of the allowed values: dog, cat, request.query must have required property 'limit'", "errors": [ { - "path": ".query.type", - "message": "should be equal to one of the allowed values: dog, cat", + "path": "/query.type", + "message": "must be equal to one of the allowed values: dog, cat", "errorCode": "enum.openapi.validation" }, { - "path": ".query.limit", - "message": "should have required property 'limit'", + "path": "/query.limit", + "message": "must have required property 'limit'", "errorCode": "required.openapi.validation" } ] diff --git a/examples/9-nestjs/src/modules/ping/ping.controller.spec.ts b/examples/9-nestjs/src/modules/ping/ping.controller.spec.ts index d2e3aa80..b9d2e617 100644 --- a/examples/9-nestjs/src/modules/ping/ping.controller.spec.ts +++ b/examples/9-nestjs/src/modules/ping/ping.controller.spec.ts @@ -64,8 +64,8 @@ describe('PingController', () => { path: '/', errors: [ { - path: '.body.ping', - message: "should have required property 'ping'", + path: '/body/ping', + message: "must have required property 'ping'", errorCode: 'required.openapi.validation', }, ], diff --git a/package-lock.json b/package-lock.json index 386715d7..d5760716 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "express-openapi-validator", - "version": "4.13.7", + "version": "4.14.0-beta.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -833,16 +833,29 @@ } }, "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", "requires": { "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, + "ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==" + }, + "ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "requires": { + "ajv": "^8.0.0" + } + }, "ansi-align": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", @@ -2086,7 +2099,8 @@ "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true }, "fast-safe-stringify": { "version": "2.1.1", @@ -2449,6 +2463,26 @@ "requires": { "ajv": "^6.12.3", "har-schema": "^2.0.0" + }, + "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + } } }, "has": { @@ -3102,9 +3136,9 @@ } }, "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "json-stringify-safe": { "version": "5.0.1", @@ -4399,6 +4433,11 @@ "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", "dev": true }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" + }, "require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", diff --git a/package.json b/package.json index dd83cd2c..cd12c4a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-openapi-validator", - "version": "4.13.7", + "version": "4.14.0-beta.1", "description": "Automatically validate API requests and responses with OpenAPI 3 and Express.", "main": "dist/index.js", "scripts": { @@ -33,7 +33,9 @@ "license": "MIT", "dependencies": { "@types/multer": "^1.4.7", - "ajv": "^6.12.6", + "ajv": "^8.6.2", + "ajv-draft-04": "^1.0.0", + "ajv-formats": "^2.1.1", "content-type": "^1.0.4", "json-schema-ref-parser": "^9.0.9", "lodash.clonedeep": "^4.5.0", diff --git a/src/framework/ajv/formats.ts b/src/framework/ajv/formats.ts index 83d7c7bf..4ee0711a 100644 --- a/src/framework/ajv/formats.ts +++ b/src/framework/ajv/formats.ts @@ -14,22 +14,28 @@ const base64regExp = /^[A-Za-z0-9+/]*(=|==)?$/; export const formats = { int32: { - validate: i => Number.isInteger(i) && i <= maxInt32 && i >= minInt32, + validate: (i: number) => + Number.isInteger(i) && i <= maxInt32 && i >= minInt32, type: 'number', }, int64: { - validate: i => Number.isInteger(i) && i <= maxInt64 && i >= minInt64, + validate: (i: number) => + Number.isInteger(i) && i <= maxInt64 && i >= minInt64, type: 'number', }, float: { - validate: i => typeof i === 'number' && (i === 0 || (i <= maxFloat && i >= minPosFloat) || (i >= minFloat && i <= maxNegFloat)), + validate: (i: number) => + typeof i === 'number' && + (i === 0 || + (i <= maxFloat && i >= minPosFloat) || + (i >= minFloat && i <= maxNegFloat)), type: 'number', }, double: { - validate: i => typeof i === 'number', + validate: (i: number) => typeof i === 'number', type: 'number', }, - byte: b => b.length % 4 === 0 && base64regExp.test(b), + byte: (b: string) => b.length % 4 === 0 && base64regExp.test(b), binary: alwaysTrue, password: alwaysTrue, -}; +} as const; diff --git a/src/framework/ajv/index.ts b/src/framework/ajv/index.ts index ca879d40..58683cd7 100644 --- a/src/framework/ajv/index.ts +++ b/src/framework/ajv/index.ts @@ -1,20 +1,25 @@ -import * as Ajv from 'ajv'; -import * as draftSchema from 'ajv/lib/refs/json-schema-draft-04.json'; +import AjvDraft4 from 'ajv-draft-04'; +import { DataValidateFunction } from 'ajv/dist/types'; +import ajvType from 'ajv/dist/vocabularies/jtd/type'; +import addFormats from 'ajv-formats'; import { formats } from './formats'; -import { OpenAPIV3, Options } from '../types'; -import ajv = require('ajv'); +import { OpenAPIV3, Options, SerDes } from '../types'; + +interface SerDesSchema extends Partial { + kind?: 'req' | 'res'; +} export function createRequestAjv( openApiSpec: OpenAPIV3.Document, options: Options = {}, -): Ajv.Ajv { +): AjvDraft4 { return createAjv(openApiSpec, options); } export function createResponseAjv( openApiSpec: OpenAPIV3.Document, options: Options = {}, -): Ajv.Ajv { +): AjvDraft4 { return createAjv(openApiSpec, options, false); } @@ -22,82 +27,102 @@ function createAjv( openApiSpec: OpenAPIV3.Document, options: Options = {}, request = true, -): Ajv.Ajv { - const ajv = new Ajv({ - ...options, - schemaId: 'auto', +): AjvDraft4 { + const { ajvFormats, ...ajvOptions } = options; + const ajv = new AjvDraft4({ + ...ajvOptions, allErrors: true, - meta: draftSchema, - formats: { ...formats, ...options.formats }, - unknownFormats: options.unknownFormats, + formats: formats, }); + // Formats will overwrite existing validation, + // so set in order of least->most important. + if (options.serDesMap) { + for (const serDesFormat of Object.keys(options.serDesMap)) { + ajv.addFormat(serDesFormat, true); + } + } + for (const [formatName, formatValidation] of Object.entries(formats)) { + ajv.addFormat(formatName, formatValidation); + } + if (ajvFormats) { + addFormats(ajv, ajvFormats); + } + for (let [formatName, formatDefinition] of Object.entries(options.formats)) { + ajv.addFormat(formatName, formatDefinition); + } ajv.removeKeyword('propertyNames'); ajv.removeKeyword('contains'); ajv.removeKeyword('const'); + if (options.serDesMap) { + // Alias for `type` that can execute AFTER x-eov-res-serdes + // There is a `type` keyword which this is positioned "next to", + // as well as high-level type assertion that runs before any keywords. + ajv.addKeyword({ + ...ajvType, + keyword: 'x-eov-type', + before: 'type', + }); + } + if (request) { if (options.serDesMap) { - ajv.addKeyword('x-eov-serdes', { + ajv.addKeyword({ + keyword: 'x-eov-req-serdes', modifying: true, - compile: (sch) => { - if (sch) { - return function validate(data, path, obj, propName) { - if (!!sch.deserialize) { - if (typeof data !== 'string') { - (validate).errors = [ - { - keyword: 'serdes', - schemaPath: data, - dataPath: path, - message: `must be a string`, - params: { 'x-eov-serdes': propName }, - }, - ]; - return false; - } - try { - obj[propName] = sch.deserialize(data); - } - catch(e) { - (validate).errors = [ - { - keyword: 'serdes', - schemaPath: data, - dataPath: path, - message: `format is invalid`, - params: { 'x-eov-serdes': propName }, - }, - ]; - return false; - } - } + errors: true, + // Deserialization occurs AFTER all string validations + post: true, + compile: (sch: SerDesSchema, p, it) => { + const validate: DataValidateFunction = (data, ctx) => { + if (typeof data !== 'string') { + // Either null (possibly allowed, defer to nullable validation) + // or already failed string validation (no need to throw additional internal errors). return true; - }; - } - return () => true; + } + try { + ctx.parentData[ctx.parentDataProperty] = sch.deserialize(data); + } catch (e) { + validate.errors = [ + { + keyword: 'serdes', + instancePath: ctx.instancePath, + schemaPath: it.schemaPath.str, + message: `format is invalid`, + params: { 'x-eov-req-serdes': ctx.parentDataProperty }, + }, + ]; + return false; + } + + return true; + }; + return validate; }, - // errors: 'full', }); } ajv.removeKeyword('readOnly'); - ajv.addKeyword('readOnly', { - modifying: true, - compile: (sch) => { + ajv.addKeyword({ + keyword: 'readOnly', + errors: true, + compile: (sch, p, it) => { if (sch) { - return function validate(data, path, obj, propName) { - const isValid = !(sch === true && data != null); - delete obj[propName]; - (validate).errors = [ - { - keyword: 'readOnly', - schemaPath: data, - dataPath: path, - message: `is read-only`, - params: { readOnly: propName }, - }, - ]; - return isValid; + const validate: DataValidateFunction = (data, ctx) => { + const isValid = data == null; + if (!isValid) { + validate.errors = [ + { + keyword: 'readOnly', + instancePath: ctx.instancePath, + schemaPath: it.schemaPath.str, + message: `is read-only`, + params: { writeOnly: ctx.parentDataProperty }, + }, + ]; + } + return false; }; + return validate; } return () => true; @@ -106,54 +131,59 @@ function createAjv( } else { // response if (options.serDesMap) { - ajv.addKeyword('x-eov-serdes', { + ajv.addKeyword({ + keyword: 'x-eov-res-serdes', modifying: true, - compile: (sch) => { - if (sch) { - return function validate(data, path, obj, propName) { - if (typeof data === 'string') return true; - if (!!sch.serialize) { - try { - obj[propName] = sch.serialize(data); - } - catch(e) { - (validate).errors = [ - { - keyword: 'serdes', - schemaPath: data, - dataPath: path, - message: `format is invalid`, - params: { 'x-eov-serdes': propName }, - }, - ]; - return false; - } - } - return true; - }; - } - return () => true; + errors: true, + // Serialization occurs BEFORE type validations + before: 'x-eov-type', + compile: (sch: SerDesSchema, p, it) => { + const validate: DataValidateFunction = (data, ctx) => { + if (typeof data === 'string') return true; + try { + ctx.parentData[ctx.parentDataProperty] = sch.serialize(data); + } catch (e) { + validate.errors = [ + { + keyword: 'serdes', + instancePath: ctx.instancePath, + schemaPath: it.schemaPath.str, + message: `format is invalid`, + params: { 'x-eov-res-serdes': ctx.parentDataProperty }, + }, + ]; + return false; + } + + return true; + }; + return validate; }, }); } ajv.removeKeyword('writeOnly'); - ajv.addKeyword('writeOnly', { - modifying: true, - compile: (sch) => { + ajv.addKeyword({ + keyword: 'writeOnly', + schemaType: 'boolean', + errors: true, + compile: (sch, p, it) => { if (sch) { - return function validate(data, path, obj, propName) { - const isValid = !(sch === true && data != null); - (validate).errors = [ - { - keyword: 'writeOnly', - dataPath: path, - schemaPath: path, - message: `is write-only`, - params: { writeOnly: propName }, - }, - ]; - return isValid; + const validate: DataValidateFunction = (data, ctx) => { + const isValid = data == null; + if (!isValid) { + validate.errors = [ + { + keyword: 'writeOnly', + instancePath: ctx.instancePath, + schemaPath: it.schemaPath.str, + message: `is write-only`, + params: { writeOnly: ctx.parentDataProperty }, + }, + ]; + } + return false; }; + return validate; } return () => true; diff --git a/src/framework/ajv/options.ts b/src/framework/ajv/options.ts index 2549d812..5bf32b09 100644 --- a/src/framework/ajv/options.ts +++ b/src/framework/ajv/options.ts @@ -1,6 +1,5 @@ -import ajv = require('ajv'); import { - OpenApiValidatorOpts, + NormalizedOpenApiValidatorOpts, Options, RequestValidatorOptions, ValidateRequestOpts, @@ -8,15 +7,15 @@ import { } from '../types'; export class AjvOptions { - private options: OpenApiValidatorOpts; - constructor(options: OpenApiValidatorOpts) { + private options: NormalizedOpenApiValidatorOpts; + constructor(options: NormalizedOpenApiValidatorOpts) { this.options = options; } - get preprocessor(): ajv.Options { + get preprocessor(): Options { return this.baseOptions(); } - get response(): ajv.Options { + get response(): Options { const { coerceTypes, removeAdditional } = ( this.options.validateResponses ); @@ -45,12 +44,8 @@ export class AjvOptions { } private baseOptions(): Options { - const { - coerceTypes, - unknownFormats, - validateFormats, - serDes, - } = this.options; + const { coerceTypes, formats, validateFormats, serDes, ajvFormats } = + this.options; const serDesMap = {}; for (const serDesObject of serDes) { if (!serDesMap[serDesObject.format]) { @@ -65,22 +60,21 @@ export class AjvOptions { } } - return { - validateSchema: false, // this is true for statup validation, thus it can be bypassed here - nullable: true, + const options: Options = { + strict: false, + strictNumbers: true, + strictTuples: true, + allowUnionTypes: false, + validateSchema: false, // this is true for startup validation, thus it can be bypassed here coerceTypes, useDefaults: true, removeAdditional: false, - unknownFormats, - format: validateFormats, - formats: this.options.formats.reduce((acc, f) => { - acc[f.name] = { - type: f.type, - validate: f.validate, - }; - return acc; - }, {}), - serDesMap: serDesMap, + validateFormats: validateFormats, + formats, + serDesMap, + ajvFormats, }; + + return options; } } diff --git a/src/framework/openapi.schema.validator.ts b/src/framework/openapi.schema.validator.ts index 1e6034b4..03da0129 100644 --- a/src/framework/openapi.schema.validator.ts +++ b/src/framework/openapi.schema.validator.ts @@ -1,5 +1,9 @@ -import * as Ajv from 'ajv'; -import * as draftSchema from 'ajv/lib/refs/json-schema-draft-04.json'; +import AjvDraft4, { + ErrorObject, + Options, + ValidateFunction, +} from 'ajv-draft-04'; +import addFormats from 'ajv-formats'; // https://github.com/OAI/OpenAPI-Specification/blob/master/schemas/v3.0/schema.json import * as openapi3Schema from './openapi.v3.schema.json'; import { OpenAPIV3 } from './types.js'; @@ -10,19 +14,23 @@ export interface OpenAPISchemaValidatorOpts { extensions?: object; } export class OpenAPISchemaValidator { - private validator: Ajv.ValidateFunction; + private validator: ValidateFunction; constructor(opts: OpenAPISchemaValidatorOpts) { - const options: any = { - schemaId: 'auto', + const options: Options = { + schemaId: 'id', allErrors: true, + validateFormats: true, + coerceTypes: false, + useDefaults: false, + // Strict enforcement is nice, but schema is controlled by this library and known to be valid + strict: false, }; - if (!opts.validateApiSpec) { options.validateSchema = false; } - const v = new Ajv(options); - v.addMetaSchema(draftSchema); + const v = new AjvDraft4(options); + addFormats(v, ['email', 'regex', 'uri', 'uri-reference']); const ver = opts.version && parseInt(String(opts.version), 10); if (!ver) throw Error('version missing from OpenAPI specification'); @@ -32,9 +40,9 @@ export class OpenAPISchemaValidator { this.validator = v.compile(openapi3Schema); } - public validate( - openapiDoc: OpenAPIV3.Document, - ): { errors: Array | null } { + public validate(openapiDoc: OpenAPIV3.Document): { + errors: Array | null; + } { const valid = this.validator(openapiDoc); if (!valid) { return { errors: this.validator.errors }; diff --git a/src/framework/openapi.spec.loader.ts b/src/framework/openapi.spec.loader.ts index 6460fe4c..8f53d8e3 100644 --- a/src/framework/openapi.spec.loader.ts +++ b/src/framework/openapi.spec.loader.ts @@ -104,7 +104,7 @@ export class OpenApiSpecLoader { // instead create our own syntax that is compatible with express' pathToRegex // /{path}* => /:path*) - // /{path}(*) => /:path*) + // /{path}(*) => /:path*) const pass1 = part.replace(/\/{([^\*]+)}\({0,1}(\*)\){0,1}/g, '/:$1$2'); // substitute params with express equivalent // /path/{id} => /path/:id diff --git a/src/framework/types.ts b/src/framework/types.ts index cc16a520..41733a9c 100644 --- a/src/framework/types.ts +++ b/src/framework/types.ts @@ -1,5 +1,6 @@ import * as ajv from 'ajv'; import * as multer from 'multer'; +import { FormatsPluginOptions, FormatOptions } from 'ajv-formats'; import { Request, Response, NextFunction } from 'express'; export { OpenAPIFrameworkArgs }; @@ -39,6 +40,7 @@ export interface MultipartOpts { export interface Options extends ajv.Options { // Specific options serDesMap?: SerDesMap; + ajvFormats?: FormatsPluginOptions; } export interface RequestValidatorOptions extends Options, ValidateRequestOpts {} @@ -72,7 +74,6 @@ export type Format = { export type SerDes = { format: string; - jsonType?: string; serialize?: (o: unknown) => string; deserialize?: (s: string) => unknown; }; @@ -81,30 +82,25 @@ export class SerDesSingleton implements SerDes { serializer: SerDes; deserializer: SerDes; format: string; - jsonType: string; serialize?: (o: unknown) => string; deserialize?: (s: string) => unknown; constructor(param: { format: string; - jsonType?: string; serialize: (o: unknown) => string; deserialize: (s: string) => unknown; }) { this.format = param.format; - this.jsonType = param.jsonType || 'object'; this.serialize = param.serialize; this.deserialize = param.deserialize; this.deserializer = { format: param.format, - jsonType: param.jsonType || 'object', - deserialize: param.deserialize - } + deserialize: param.deserialize, + }; this.serializer = { format: param.format, - jsonType: param.jsonType || 'object', - serialize: param.serialize - } + serialize: param.serialize, + }; } }; @@ -122,16 +118,36 @@ export interface OpenApiValidatorOpts { ignoreUndocumented?: boolean; securityHandlers?: SecurityHandlers; coerceTypes?: boolean | 'array'; + /** + * @deprecated + * Use `formats` + `validateFormats` to ignore specified formats + */ unknownFormats?: true | string[] | 'ignore'; serDes?: SerDes[]; - formats?: Format[]; + formats?: Format[] | Record; + ajvFormats?: FormatsPluginOptions; fileUploader?: boolean | multer.Options; multerOpts?: multer.Options; $refParser?: { mode: 'bundle' | 'dereference'; }; operationHandlers?: false | string | OperationHandlerOptions; - validateFormats?: false | 'fast' | 'full'; + validateFormats?: boolean | 'fast' | 'full'; +} + +export interface NormalizedOpenApiValidatorOpts extends OpenApiValidatorOpts { + validateApiSpec: boolean; + validateResponses: false | ValidateResponseOpts; + validateRequests: false | ValidateRequestOpts; + validateSecurity: false | ValidateSecurityOpts; + fileUploader: boolean | multer.Options; + $refParser: { + mode: 'bundle' | 'dereference'; + }; + operationHandlers: false | OperationHandlerOptions; + formats: Record; + validateFormats: boolean; + unknownFormats?: never; } export namespace OpenAPIV3 { diff --git a/src/middlewares/openapi.request.validator.ts b/src/middlewares/openapi.request.validator.ts index 5ce1c29c..ee2fd094 100644 --- a/src/middlewares/openapi.request.validator.ts +++ b/src/middlewares/openapi.request.validator.ts @@ -1,4 +1,4 @@ -import { Ajv, ValidateFunction } from 'ajv'; +import Ajv, { ValidateFunction } from 'ajv'; import { createRequestAjv } from '../framework/ajv'; import { ContentType, @@ -76,6 +76,20 @@ export class RequestValidator { return this.middlewareCache[key](req, res, next); } + private warnUnknownQueryParametersKeyword( + reqSchema: OperationObject, + ): boolean { + if (typeof reqSchema['x-allow-unknown-query-parameters'] === 'boolean') { + console.warn( + '"x-allow-unknown-query-parameters" is deprecated. Use "x-eov-allow-unknown-query-parameters"', + ); + } + return ( + reqSchema['x-allow-unknown-query-parameters'] ?? + this.requestOpts.allowUnknownQueryParameters + ); + } + private buildMiddleware( path: string, reqSchema: OperationObject, @@ -92,8 +106,8 @@ export class RequestValidator { }); const allowUnknownQueryParameters = !!( - reqSchema['x-allow-unknown-query-parameters'] ?? - this.requestOpts.allowUnknownQueryParameters + reqSchema['x-eov-allow-unknown-query-parameters'] ?? + this.warnUnknownQueryParametersKeyword(reqSchema) ); return (req: OpenApiRequest, res: Response, next: NextFunction): void => { @@ -191,7 +205,7 @@ export class RequestValidator { } else { throw new BadRequest({ path: req.path, - message: `'${property}' should be equal to one of the allowed values: ${options + message: `'${property}' must be equal to one of the allowed values: ${options .map((o) => o.option) .join(', ')}.`, }); @@ -216,12 +230,12 @@ export class RequestValidator { for (const q of queryParams) { if (!knownQueryParams.has(q)) { throw new BadRequest({ - path: `.query.${q}`, + path: `/query/${q}`, message: `Unknown query parameter '${q}'`, }); } else if (!allowedEmpty?.has(q) && (query[q] === '' || null)) { throw new BadRequest({ - path: `.query.${q}`, + path: `/query/${q}`, message: `Empty value found for query parameter '${q}'`, }); } diff --git a/src/middlewares/openapi.response.validator.ts b/src/middlewares/openapi.response.validator.ts index 28494375..715a1dfa 100644 --- a/src/middlewares/openapi.response.validator.ts +++ b/src/middlewares/openapi.response.validator.ts @@ -1,5 +1,5 @@ import { RequestHandler } from 'express'; -import * as ajv from 'ajv'; +import Ajv, { ValidateFunction, Options } from 'ajv'; import mung from '../framework/modded.express.mung'; import { createResponseAjv } from '../framework/ajv'; import { @@ -19,24 +19,24 @@ import * as mediaTypeParser from 'media-typer'; import * as contentTypeParser from 'content-type'; interface ValidateResult { - validators: { [key: string]: ajv.ValidateFunction }; + validators: { [key: string]: ValidateFunction }; body: object; statusCode: number; path: string; accepts: string[]; } export class ResponseValidator { - private ajvBody: ajv.Ajv; + private ajvBody: Ajv; private spec: OpenAPIV3.Document; private validatorsCache: { - [key: string]: { [key: string]: ajv.ValidateFunction }; + [key: string]: { [key: string]: ValidateFunction }; } = {}; - private eovOptions: ValidateResponseOpts + private eovOptions: ValidateResponseOpts; constructor( - openApiSpec: OpenAPIV3.Document, - options: ajv.Options = {}, - eovOptions: ValidateResponseOpts = {} + openApiSpec: OpenAPIV3.Document, + options: Options = {}, + eovOptions: ValidateResponseOpts = {}, ) { this.spec = openApiSpec; this.ajvBody = createResponseAjv(openApiSpec, options); @@ -80,7 +80,7 @@ export class ResponseValidator { } catch (err) { // If a custom error handler was provided, we call that if (err instanceof InternalServerError && this.eovOptions.onError) { - this.eovOptions.onError(err, body, req) + this.eovOptions.onError(err, body, req); } else { // No custom error handler, or something unexpected happen. throw err; @@ -96,7 +96,7 @@ export class ResponseValidator { public _getOrBuildValidator( req: OpenApiRequest, responses: OpenAPIV3.ResponsesObject, - ): { [key: string]: ajv.ValidateFunction } { + ): { [key: string]: ValidateFunction } { // get the request content type - used only to build the cache key const contentTypeMeta = ContentType.from(req); const contentType = @@ -149,7 +149,7 @@ export class ResponseValidator { if (body !== undefined) { // response contains content/body throw new InternalServerError({ - path: '.response', + path: '/response', message: 'response should NOT have a body', }); } @@ -173,7 +173,7 @@ export class ResponseValidator { if (body === undefined || body === null) { throw new InternalServerError({ - path: '.response', + path: '/response', message: 'response body required.', }); } @@ -212,9 +212,9 @@ export class ResponseValidator { * @param responses * @returns a map of validators */ - private buildValidators( - responses: OpenAPIV3.ResponsesObject, - ): { [key: string]: ajv.ValidateFunction } { + private buildValidators(responses: OpenAPIV3.ResponsesObject): { + [key: string]: ValidateFunction; + } { const validationTypes = (response) => { if (!response.content) { return ['no_content']; @@ -289,7 +289,7 @@ export class ResponseValidator { const validators = {}; for (const [code, contentTypeSchemas] of Object.entries(responseSchemas)) { if (Object.keys(contentTypeSchemas).length === 0) { - validators[code] = {}; + validators[code] = {}; } for (const contentType of Object.keys(contentTypeSchemas)) { const schema = contentTypeSchemas[contentType]; diff --git a/src/middlewares/parsers/req.parameter.mutator.ts b/src/middlewares/parsers/req.parameter.mutator.ts index 202aaa03..de8bb69f 100644 --- a/src/middlewares/parsers/req.parameter.mutator.ts +++ b/src/middlewares/parsers/req.parameter.mutator.ts @@ -1,5 +1,5 @@ import { Request } from 'express'; -import { Ajv } from 'ajv'; +import Ajv from 'ajv'; import { OpenAPIV3, OpenApiRequest, @@ -340,7 +340,7 @@ export class RequestParameterMutator { for (const v of vs) { if (v?.match(RESERVED_CHARS)) { const message = `Parameter '${name}' must be url encoded. Its value may not contain reserved characters.`; - throw new BadRequest({ path: `.query.${name}`, message: message }); + throw new BadRequest({ path: `/query/${name}`, message: message }); } } } diff --git a/src/middlewares/parsers/schema.parse.ts b/src/middlewares/parsers/schema.parse.ts index 29562e27..cbd58e47 100644 --- a/src/middlewares/parsers/schema.parse.ts +++ b/src/middlewares/parsers/schema.parse.ts @@ -1,6 +1,6 @@ import { OpenAPIV3, ParametersSchema, BadRequest } from '../../framework/types'; import { dereferenceParameter, normalizeParameter } from './util'; -import { Ajv } from 'ajv'; +import Ajv from 'ajv'; const PARAM_TYPE = { query: 'query', diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index 8f84ea78..3f7ae01e 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -1,4 +1,4 @@ -import { Ajv } from 'ajv'; +import Ajv from 'ajv'; import ajv = require('ajv'); import * as cloneDeep from 'lodash.clonedeep'; import * as _get from 'lodash.get'; @@ -329,11 +329,15 @@ export class SchemaPreprocessor { delete newSchema.required; } - ancestor._discriminator ??= { - validators: {}, - options: o.options, - property: o.discriminator, - }; + // Expose `_discriminator` to consumers without exposing to AJV + Object.defineProperty(ancestor, '_discriminator', { + enumerable: false, + value: ancestor._discriminator ?? { + validators: {}, + options: o.options, + property: o.discriminator, + }, + }); for (const option of options) { ancestor._discriminator.validators[option] = @@ -346,6 +350,38 @@ export class SchemaPreprocessor { } } + /** + * Attach custom `x-eov-*-serdes` vendor extension for performing + * serialization (response) and deserialization (request) of data. + * + * This only applies to `type=string` schemas with a `format` that was flagged for serdes. + * + * The goal of this function is to define a JSON schema that: + * 1) Only performs the method for matching req/res (e.g. never deserialize a response) + * 2) Validates initial data THEN performs serdes THEN validates output. In that order. + * 3) Hide internal schema keywords (and its validation errors) from user. + * + * The solution is in three parts: + * 1) Remove the `type` keywords and replace it with a custom clone `x-eov-type`. + * This ensures that we control the order of type validations, + * and allows the response serialization to occur before AJV enforces the type. + * 2) Add an `x-eov-req-serdes` keyword. + * This keyword will deserialize the request string AFTER all other validations occur, + * ensuring that the string is valid before modifications. + * This keyword is only attached when deserialization is enabled. + * 3) Add an `x-eov-res-serdes` keyword. + * This keyword will serialize the response object BEFORE any other validations occur, + * ensuring the output is validated as a string. + * This keyword is only attached when serialization is enabled. + * 4) If `nullable` is set, set the type as every possible type. + * Then initial type checking will _always_ pass and the `x-eov-type` will narrow it down later. + * + * See [`createAjv`](../../framework/ajv/index.ts) for custom keyword definitions. + * + * @param {object} parent - parent schema + * @param {object} schema - schema + * @param {object} state - traversal state + */ private handleSerDes( parent: SchemaObject, schema: SchemaObject, @@ -356,8 +392,20 @@ export class SchemaPreprocessor { !!schema.format && this.serDesMap[schema.format] ) { - (schema).type = [this.serDesMap[schema.format].jsonType || 'object', 'string']; - schema['x-eov-serdes'] = this.serDesMap[schema.format]; + const serDes = this.serDesMap[schema.format]; + (schema)['x-eov-type'] = schema.type; + if ('nullable' in schema) { + // Ajv requires `type` keyword with `nullable` (regardless of value). + (schema).type = ['string', 'number', 'boolean', 'object', 'array']; + } else { + delete schema.type; + } + if (serDes.deserialize) { + schema['x-eov-req-serdes'] = serDes; + } + if (serDes.serialize) { + schema['x-eov-res-serdes'] = serDes; + } } } diff --git a/src/middlewares/parsers/util.ts b/src/middlewares/parsers/util.ts index f7031d93..aa0642e3 100644 --- a/src/middlewares/parsers/util.ts +++ b/src/middlewares/parsers/util.ts @@ -1,4 +1,4 @@ -import { Ajv } from 'ajv'; +import Ajv from 'ajv'; import { OpenAPIV3 } from '../../framework/types'; import ajv = require('ajv'); import { OpenAPIFramework } from '../../framework'; diff --git a/src/middlewares/util.ts b/src/middlewares/util.ts index 1d747bc5..7ca7aa2d 100644 --- a/src/middlewares/util.ts +++ b/src/middlewares/util.ts @@ -1,4 +1,4 @@ -import * as Ajv from 'ajv'; +import type { ErrorObject } from 'ajv-draft-04'; import { Request } from 'express'; import { ValidationError } from '../framework/types'; @@ -11,7 +11,9 @@ export class ContentType { private constructor(contentType: string | null) { this.contentType = contentType; if (contentType) { - this.withoutBoundary = contentType.replace(/;\s{0,}boundary.*/, '').toLowerCase(); + this.withoutBoundary = contentType + .replace(/;\s{0,}boundary.*/, '') + .toLowerCase(); this.mediaType = this.withoutBoundary.split(';')[0].toLowerCase().trim(); this.charSet = this.withoutBoundary.split(';')[1]?.toLowerCase(); this.isWildCard = RegExp(/^[a-z]+\/\*$/).test(this.contentType); @@ -42,9 +44,7 @@ export class ContentType { * TODO - do this some other way * @param errors */ -export function augmentAjvErrors( - errors: Ajv.ErrorObject[] = [], -): Ajv.ErrorObject[] { +export function augmentAjvErrors(errors: ErrorObject[] = []): ErrorObject[] { errors.forEach((e) => { if (e.keyword === 'enum') { const params: any = e.params; @@ -54,22 +54,35 @@ export function augmentAjvErrors( : e.message; } }); - return errors; + const serDesPaths = new Set(); + return errors.filter((e) => { + if (serDesPaths.has(e.schemaPath)) { + return false; + } + if (e.params['x-eov-res-serdes']) { + // If response serialization failed, + // silence additional errors about not being a string. + serDesPaths.add(e.schemaPath.replace('x-eov-res-serdes', 'x-eov-type')); + } + return true; + }); } export function ajvErrorsToValidatorError( status: number, - errors: Ajv.ErrorObject[], + errors: ErrorObject[], ): ValidationError { return { status, errors: errors.map((e) => { const params: any = e.params; const required = - params?.missingProperty && e.dataPath + '.' + params.missingProperty; + params?.missingProperty && + e.instancePath + '/' + params.missingProperty; const additionalProperty = params?.additionalProperty && - e.dataPath + '.' + params.additionalProperty; - const path = required ?? additionalProperty ?? e.dataPath ?? e.schemaPath; + e.instancePath + '/' + params.additionalProperty; + const path = + required ?? additionalProperty ?? e.instancePath ?? e.schemaPath; return { path, message: e.message, diff --git a/src/openapi.validator.ts b/src/openapi.validator.ts index db1a3f3d..a578ef5c 100644 --- a/src/openapi.validator.ts +++ b/src/openapi.validator.ts @@ -1,3 +1,4 @@ +import { Options } from 'ajv'; import ono from 'ono'; import * as express from 'express'; import * as _uniq from 'lodash.uniq'; @@ -6,6 +7,7 @@ import { Application, Response, NextFunction, Router } from 'express'; import { OpenApiContext } from './framework/openapi.context'; import { Spec } from './framework/openapi.spec.loader'; import { + NormalizedOpenApiValidatorOpts, OpenApiValidatorOpts, ValidateRequestOpts, ValidateResponseOpts, @@ -35,22 +37,18 @@ export { } from './framework/types'; export class OpenApiValidator { - readonly options: OpenApiValidatorOpts; + readonly options: NormalizedOpenApiValidatorOpts; readonly ajvOpts: AjvOptions; constructor(options: OpenApiValidatorOpts) { - this.validateOptions(options); - this.normalizeOptions(options); - if (options.validateApiSpec == null) options.validateApiSpec = true; if (options.validateRequests == null) options.validateRequests = true; if (options.validateResponses == null) options.validateResponses = false; if (options.validateSecurity == null) options.validateSecurity = true; if (options.fileUploader == null) options.fileUploader = {}; if (options.$refParser == null) options.$refParser = { mode: 'bundle' }; - if (options.unknownFormats == null) options.unknownFormats === true; - if (options.validateFormats == null) options.validateFormats = 'fast'; - if (options.formats == null) options.formats = []; + if (options.validateFormats == null) options.validateFormats = true; + if (options.formats == null) options.formats = {}; if (typeof options.operationHandlers === 'string') { /** @@ -86,8 +84,10 @@ export class OpenApiValidator { options.validateSecurity = {}; } - this.options = options; - this.ajvOpts = new AjvOptions(options); + this.validateOptions(options); + + this.options = this.normalizeOptions(options); + this.ajvOpts = new AjvOptions(this.options); } installMiddleware(spec: Promise): OpenApiRequestHandler[] { @@ -340,37 +340,64 @@ export class OpenApiValidator { } const unknownFormats = options.unknownFormats; - if (typeof unknownFormats === 'boolean') { - if (!unknownFormats) { + if (unknownFormats !== undefined) { + if (typeof unknownFormats === 'boolean') { + if (!unknownFormats) { + throw ono( + "unknownFormats must contain an array of unknownFormats, 'ignore' or true", + ); + } + } else if ( + typeof unknownFormats === 'string' && + unknownFormats !== 'ignore' && + !Array.isArray(unknownFormats) + ) throw ono( "unknownFormats must contain an array of unknownFormats, 'ignore' or true", ); - } - } else if ( - typeof unknownFormats === 'string' && - unknownFormats !== 'ignore' && - !Array.isArray(unknownFormats) - ) - throw ono( - "unknownFormats must contain an array of unknownFormats, 'ignore' or true", + console.warn('unknownFormats is deprecated.'); + } + + if (Array.isArray(options.formats)) { + console.warn( + 'formats as an array is deprecated. Use object instead https://ajv.js.org/options.html#formats', ); + } + + if (typeof options.validateFormats === 'string') { + console.warn( + `"validateFormats" as a string is deprecated. Set to a boolean and use "ajvFormats"`, + ); + } } - private normalizeOptions(options: OpenApiValidatorOpts): void { + private normalizeOptions( + options: OpenApiValidatorOpts, + ): NormalizedOpenApiValidatorOpts { + if (Array.isArray(options.formats)) { + const formats: Options['formats'] = {}; + for (const { name, type, validate } of options.formats) { + if (type) { + const formatValidator: + | { + type: 'number'; + validate: (x: number) => boolean; + } + | { + type: 'string'; + validate: (x: string) => boolean; + } = { type, validate }; + formats[name] = formatValidator; + } else { + formats[name] = validate; + } + } + options.formats = formats; + } + if (!options.serDes) { options.serDes = defaultSerDes; } else { - if (!Array.isArray(options.unknownFormats)) { - options.unknownFormats = Array(); - } - options.serDes.forEach((currentSerDes) => { - if ( - (options.unknownFormats as string[]).indexOf(currentSerDes.format) === - -1 - ) { - (options.unknownFormats as string[]).push(currentSerDes.format); - } - }); defaultSerDes.forEach((currentDefaultSerDes) => { let defaultSerDesOverride = options.serDes.find( (currentOptionSerDes) => { @@ -382,6 +409,25 @@ export class OpenApiValidator { } }); } + + if (typeof options.validateFormats === 'string') { + if (!options.ajvFormats) { + options.ajvFormats = { mode: options.validateFormats }; + } + options.validateFormats = true; + } else if (options.validateFormats && !options.ajvFormats) { + options.ajvFormats = { mode: 'fast' }; + } + + if (Array.isArray(options.unknownFormats)) { + for (const format of options.unknownFormats) { + options.formats[format] = true; + } + } else if (options.unknownFormats === 'ignore') { + options.validateFormats = false; + } + + return options as NormalizedOpenApiValidatorOpts; } private isOperationHandlerOptions( diff --git a/test/additional.props.spec.ts b/test/additional.props.spec.ts index 2f1a05c4..e76d78eb 100644 --- a/test/additional.props.spec.ts +++ b/test/additional.props.spec.ts @@ -43,9 +43,9 @@ describe(packageJson.name, () => { expect(r.body.errors).to.be.an('array'); expect(r.body.errors).to.have.length(2); const m1 = r.body.errors[0].message; - expect(m1).to.equal('should NOT have additional properties'); + expect(m1).to.equal('must NOT have additional properties'); const m2 = r.body.errors[1].message; - expect(m2).to.equal('should be number'); + expect(m2).to.equal('must be number'); })); it('should return 400 if additionalProperties=false, but extra props sent', async () => @@ -60,7 +60,7 @@ describe(packageJson.name, () => { expect(r.body.errors).to.be.an('array'); expect(r.body.errors).to.have.length(1); const message = r.body.errors[0].message; - expect(message).to.equal('should NOT have additional properties'); + expect(message).to.equal('must NOT have additional properties'); })); it('should return 200 if additonalProperities=true and extra props are sent', async () => diff --git a/test/ajv.options.spec.ts b/test/ajv.options.spec.ts index 085ab09d..5407dd5c 100644 --- a/test/ajv.options.spec.ts +++ b/test/ajv.options.spec.ts @@ -1,20 +1,29 @@ import { expect } from 'chai'; +import { NormalizedOpenApiValidatorOpts } from '../src/framework/types'; import { AjvOptions } from '../src/framework/ajv/options'; describe('AjvOptions', () => { // hard code base options // These are normalized when express-openapi-validator parses options, however // this test bypasses that, thus we manually set them to expected values - const baseOptions = { + const baseOptions: NormalizedOpenApiValidatorOpts = { apiSpec: './spec', validateApiSpec: false, - validateRequests: true, + validateRequests: { + allowUnknownQueryParameters: false, + coerceTypes: false, + }, validateResponses: { coerceTypes: false, removeAdditional: true, }, serDes: [], - formats: [], + formats: {}, + validateSecurity: false, + fileUploader: {}, + $refParser: { mode: 'bundle' }, + operationHandlers: false, + validateFormats: true, }; it('should not validate schema for requests since schema is validated on startup', async () => { diff --git a/test/all.of.spec.ts b/test/all.of.spec.ts index 3c71930a..e1a2792e 100644 --- a/test/all.of.spec.ts +++ b/test/all.of.spec.ts @@ -61,5 +61,5 @@ describe(packageJson.name, () => { // .post(`${app.basePath}/all_of`) // .send([{ id: 1, name: 'jim' }]) // .expect(400) - // .then((r: any) => expect(r.body.message).to.contain('should be object'))); + // .then((r: any) => expect(r.body.message).to.contain('must be object'))); }); diff --git a/test/coercion.spec.ts b/test/coercion.spec.ts index c1cc42a9..053ac6fc 100644 --- a/test/coercion.spec.ts +++ b/test/coercion.spec.ts @@ -35,7 +35,7 @@ describe(packageJson.name, () => { }) .expect(400) .then((r) => { - expect(r.body.message).to.contain('is_cat should be boolean'); + expect(r.body.message).to.contain('request/body/is_cat must be boolean'); })); it('should return 400 when age is passed as string, but number is expected', async () => @@ -48,7 +48,7 @@ describe(packageJson.name, () => { }) .expect(400) .then((r) => { - expect(r.body.message).to.contain('age should be number'); + expect(r.body.message).to.contain('request/body/age must be number'); })); it('should return 400 when age (number) is null', async () => @@ -61,7 +61,7 @@ describe(packageJson.name, () => { }) .expect(400) .then((r) => { - expect(r.body.message).to.contain('age should be number'); + expect(r.body.message).to.contain('request/body/age must be number'); })); it('should return 200 when all are typed correctly', async () => @@ -100,6 +100,6 @@ describe(packageJson.name, () => { }) .expect(400) .then((r) => { - expect(r.body.message).to.contain('is_cat should be string'); + expect(r.body.message).to.contain('request/body/is_cat must be string'); })); }); diff --git a/test/headers.2.spec.ts b/test/headers.2.spec.ts index b8a9a2f7..d67c0afa 100644 --- a/test/headers.2.spec.ts +++ b/test/headers.2.spec.ts @@ -38,7 +38,7 @@ describe(packageJson.name, () => { .then((r) => { const e = r.body; expect(e.message).to.contain( - 'request.headers should have required property ', + 'request/headers must have required property ', ); }); }); @@ -55,7 +55,7 @@ describe(packageJson.name, () => { .then((r) => { const e = r.body; expect(e.message).to.contain( - 'should NOT be longer than 255 characters', + 'must NOT have more than 255 characters', ); }); }); diff --git a/test/headers.spec.ts b/test/headers.spec.ts index 25aa4dca..dae8ff82 100644 --- a/test/headers.spec.ts +++ b/test/headers.spec.ts @@ -34,7 +34,7 @@ describe(packageJson.name, () => { .then((r) => { const e = r.body.errors; expect(e).to.have.length(1); - expect(e[0].path).to.equal('.headers.x-attribute-id'); + expect(e[0].path).to.equal('/headers/x-attribute-id'); })); describe(`POST .../pets`, () => { diff --git a/test/httperror.spec.ts b/test/httperror.spec.ts index 6b87d652..91ed8475 100644 --- a/test/httperror.spec.ts +++ b/test/httperror.spec.ts @@ -7,11 +7,11 @@ describe(packageJson.name, () => { it('should be an instance of BadRequest', (done) => { console.log('Testing instaceof detection of BadRequest'); const err = { - path: '.query.domain_id', + path: '/query/domain_id', errors: [ { - path: '.query.domain_id', - message: "should have required property 'domain_id'", + path: '/query/domain_id', + message: "must have required property 'domain_id'", error_code: 'required.openapi.validation', }, ], diff --git a/test/ignore.paths.spec.ts b/test/ignore.paths.spec.ts index 70334bba..b795b7a3 100644 --- a/test/ignore.paths.spec.ts +++ b/test/ignore.paths.spec.ts @@ -77,7 +77,7 @@ describe('ignorePaths as RegExp', () => { .then(r => { const e = r.body.errors; expect(e[0].path).contains('id'); - expect(e[0].message).equals('should be integer'); + expect(e[0].message).equals('must be integer'); }); }); @@ -87,7 +87,7 @@ describe('ignorePaths as RegExp', () => { .expect(400) .then(r => { const e = r.body.errors; - expect(e[0].message).to.equal("should have required property 'id'"); + expect(e[0].message).to.equal("must have required property 'id'"); })); it('should return 404 if route is defined in openapi but not express and params are valid', async () => @@ -176,7 +176,7 @@ describe('ignorePaths as Function', () => { .then(r => { const e = r.body.errors; expect(e[0].path).contains('id'); - expect(e[0].message).equals('should be integer'); + expect(e[0].message).equals('must be integer'); }); }); @@ -186,7 +186,7 @@ describe('ignorePaths as Function', () => { .expect(400) .then(r => { const e = r.body.errors; - expect(e[0].message).to.equal("should have required property 'id'"); + expect(e[0].message).to.equal("must have required property 'id'"); })); it('should return 404 if route is defined in openapi but not express and params are valid', async () => diff --git a/test/multipart.disabled.spec.ts b/test/multipart.disabled.spec.ts index 80fe7158..dad4d24c 100644 --- a/test/multipart.disabled.spec.ts +++ b/test/multipart.disabled.spec.ts @@ -36,10 +36,10 @@ describe(packageJson.name, () => { .with.length(2); expect(e.body.errors[0]) .has.property('message') - .equal("should have required property 'file'"); + .equal("must have required property 'file'"); expect(e.body.errors[1]) .has.property('message') - .equal("should have required property 'metadata'"); + .equal("must have required property 'metadata'"); })); it('should throw 400 when required form field is missing during multipart upload', async () => @@ -106,7 +106,7 @@ describe(packageJson.name, () => { const e = r.body.errors; expect(e).to.have.length(1); expect(e[0].path).to.contain('number'); - expect(e[0].message).to.equal('should be >= 5'); + expect(e[0].message).to.equal('must be >= 5'); })); }); }); diff --git a/test/nested.routes.spec.ts b/test/nested.routes.spec.ts index 2e45304e..b9057b6c 100644 --- a/test/nested.routes.spec.ts +++ b/test/nested.routes.spec.ts @@ -52,6 +52,6 @@ describe(packageJson.name, () => { .expect(500) .then((r: any) => { const e = r.body; - expect(e.message).to.contain(".response[0] should have required property 'id'"); + expect(e.message).to.contain("/response/0 must have required property 'id'"); })); }); diff --git a/test/nullable.spec.ts b/test/nullable.spec.ts index 37ad1886..0e8e7c52 100644 --- a/test/nullable.spec.ts +++ b/test/nullable.spec.ts @@ -63,7 +63,7 @@ describe(packageJson.name, () => { .send({}) .expect(400) .then((r) => { - expect(r.body.errors[0].path).to.equal('.body.name'); + expect(r.body.errors[0].path).to.equal('/body/name'); })); it('should fail if required and not provided (nullable false', async () => @@ -72,7 +72,7 @@ describe(packageJson.name, () => { .send({}) .expect(400) .then((r) => { - expect(r.body.errors[0].path).to.equal('.body.name'); + expect(r.body.errors[0].path).to.equal('/body/name'); })); it('should fail if required and provided as null when nullable is false', async () => @@ -83,7 +83,7 @@ describe(packageJson.name, () => { }) .expect(400) .then((r) => { - expect(r.body.errors[0].path).to.equal('.body.name'); + expect(r.body.errors[0].path).to.equal('/body/name'); })); it('should allow nullable object', async () => diff --git a/test/one.of.spec.ts b/test/one.of.spec.ts index aeec30eb..15c652d2 100644 --- a/test/one.of.spec.ts +++ b/test/one.of.spec.ts @@ -83,7 +83,7 @@ describe(packageJson.name, () => { .then(r => { const e = r.body; expect(e.message).to.contain( - 'should match exactly one schema in oneOf', + 'must match exactly one schema in oneOf', ); }); }); @@ -139,7 +139,7 @@ describe(packageJson.name, () => { .then(r => { const e = r.body; expect(e.message).to.contain( - 'should match exactly one schema in oneOf', + 'must match exactly one schema in oneOf', ); }); }); diff --git a/test/openapi.spec.ts b/test/openapi.spec.ts index 9c922675..6a810bb0 100644 --- a/test/openapi.spec.ts +++ b/test/openapi.spec.ts @@ -37,8 +37,8 @@ describe(packageJson.name, () => { .then(r => { const e = r.body.errors; expect(e).to.have.length(2); - expect(e[0].path).to.equal('.query.limit'); - expect(e[1].path).to.equal('.query.test'); + expect(e[0].path).to.equal('/query/limit'); + expect(e[1].path).to.equal('/query/test'); })); it('should respond with json on proper get call', async () => @@ -62,7 +62,15 @@ describe(packageJson.name, () => { }) .set('Accept', 'application/json') .expect('Content-Type', /json/) - .expect(400)); + .expect(400) + .then(r => { + expect(r.body.errors).to.deep.equal([ + { + path: '/query/unknown_param', + message: "Unknown query parameter 'unknown_param'" + } + ]); + })); it('should return 400 when improper range specified', async () => request(apps[i]) @@ -78,7 +86,7 @@ describe(packageJson.name, () => { const e = r.body.errors; expect(e).to.have.length(1); expect(e[0].path).to.contain('limit'); - expect(e[0].message).to.equal('should be >= 5'); + expect(e[0].message).to.equal('must be >= 5'); })); it('should return 400 when non-urlencoded JSON in query param', async () => @@ -123,7 +131,7 @@ describe(packageJson.name, () => { expect(e).to.have.length(1); expect(e[0].path).to.contain('testJson'); expect(e[0].message).to.equal( - 'should be equal to one of the allowed values: bar, baz', + 'must be equal to one of the allowed values: bar, baz', ); })); @@ -180,7 +188,7 @@ describe(packageJson.name, () => { expect(e).to.have.length(1); expect(e[0].path).to.contain('testArray'); expect(e[0].message).to.equal( - 'should be equal to one of the allowed values: foo, bar, baz', + 'must be equal to one of the allowed values: foo, bar, baz', ); })); @@ -206,7 +214,7 @@ describe(packageJson.name, () => { expect(e).to.have.length(1); expect(e[0].path).to.contain('testArrayExplode'); expect(e[0].message).to.equal( - 'should be equal to one of the allowed values: foo, bar, baz', + 'must be equal to one of the allowed values: foo, bar, baz', ); })); }); @@ -220,7 +228,7 @@ describe(packageJson.name, () => { .then(r => { const e = r.body.errors; expect(e[0].message).to.equal( - "should have required property 'name'", + "must have required property 'name'", ); })); @@ -232,7 +240,7 @@ describe(packageJson.name, () => { .then(r => { const e = r.body.errors; expect(e[0].message).to.equal( - "should have required property 'name'", + "must have required property 'name'", ); })); @@ -267,7 +275,7 @@ describe(packageJson.name, () => { .then(r => { const e = r.body.errors; expect(e[0].message).to.equal( - "should have required property 'name'", + "must have required property 'name'", ); }), ); @@ -370,7 +378,7 @@ describe(packageJson.name, () => { .then(r => { const e = r.body.errors; expect(e[0].path).contains('id'); - expect(e[0].message).equals('should be integer'); + expect(e[0].message).equals('must be integer'); }); }); @@ -386,7 +394,7 @@ describe(packageJson.name, () => { .then(r => { const e = r.body.errors; expect(e[0].message).equals( - 'should be equal to one of the allowed values: foo, bar, baz', + 'must be equal to one of the allowed values: foo, bar, baz', ); }); }); diff --git a/test/path.level.parameters.spec.ts b/test/path.level.parameters.spec.ts index b316cfd3..56d1f5e8 100644 --- a/test/path.level.parameters.spec.ts +++ b/test/path.level.parameters.spec.ts @@ -38,7 +38,7 @@ describe(packageJson.name, () => { expect(r.body.errors).to.be.an('array'); expect(r.body.errors).to.have.length(1); const message = r.body.errors[0].message; - expect(message).to.equal("should have required property 'pathLevel'"); + expect(message).to.equal("must have required property 'pathLevel'"); })); it('should return 400 if operationLevel query parameter is not provided', async () => @@ -51,7 +51,7 @@ describe(packageJson.name, () => { expect(r.body.errors).to.have.length(1); const message = r.body.errors[0].message; expect(message).to.equal( - "should have required property 'operationLevel'", + "must have required property 'operationLevel'", ); })); @@ -65,8 +65,8 @@ describe(packageJson.name, () => { expect(r.body.errors).to.have.length(2); const messages = r.body.errors.map(err => err.message); expect(messages).to.have.members([ - "should have required property 'pathLevel'", - "should have required property 'operationLevel'", + "must have required property 'pathLevel'", + "must have required property 'operationLevel'", ]); })); diff --git a/test/query.params.allow.unknown.spec.ts b/test/query.params.allow.unknown.spec.ts index 04af5ad1..22b360fa 100644 --- a/test/query.params.allow.unknown.spec.ts +++ b/test/query.params.allow.unknown.spec.ts @@ -54,7 +54,7 @@ describe(packageJson.name, () => { }) .expect(200)); - it('should fail if operation overrides x-allow-unknown-query-parameters=false', async () => + it('should fail if operation overrides x-eov-allow-unknown-query-parameters=false', async () => request(app) .get(`${app.basePath}/unknown_query_params/disallow`) .query({ diff --git a/test/query.params.spec.ts b/test/query.params.spec.ts index 8969fbbd..536f0034 100644 --- a/test/query.params.spec.ts +++ b/test/query.params.spec.ts @@ -78,7 +78,7 @@ describe(packageJson.name, () => { expect(r.body.errors).to.be.an('array'); })); - it('should return 200 if operation overrides x-allow-unknown-query-parameters=true', async () => + it('should return 200 if operation overrides x-eov-allow-unknown-query-parameters=true', async () => request(app) .get(`${app.basePath}/unknown_query_params/allow`) .query({ @@ -105,7 +105,7 @@ describe(packageJson.name, () => { expect(r.body.errors) .to.be.an('array') .with.length(1); - expect(r.body.errors[0].path).to.equal('.query.breed'); + expect(r.body.errors[0].path).to.equal('/query/breed'); })); it('should allow empty query param value with allowEmptyValue: true', async () => diff --git a/test/read.only.spec.ts b/test/read.only.spec.ts index 0538df54..0680306a 100644 --- a/test/read.only.spec.ts +++ b/test/read.only.spec.ts @@ -143,7 +143,7 @@ describe(packageJson.name, () => { .then((r) => { const body = r.body; // id is a readonly property and should not be allowed in the request - expect(body.message).to.contain('request.body.reviews[0].id'); + expect(body.message).to.contain('request/body/reviews/0/id'); })); it('should pass validation if required read only properties to be missing from request ($ref)', async () => @@ -205,7 +205,7 @@ describe(packageJson.name, () => { .then((r) => { expect(r.body.errors[0]) .to.have.property('message') - .equals("should have required property 'id'"); + .equals("must have required property 'id'"); })); it('should require readonly required property in response', async () => @@ -224,6 +224,6 @@ describe(packageJson.name, () => { .set('content-type', 'application/json') .expect(500) .then((r) => { - expect(r.body.message).includes("should have required property 'id'"); + expect(r.body.message).includes("must have required property 'id'"); })); }); diff --git a/test/request.bodies.ref.spec.ts b/test/request.bodies.ref.spec.ts index 4518eace..26e7d98a 100644 --- a/test/request.bodies.ref.spec.ts +++ b/test/request.bodies.ref.spec.ts @@ -123,7 +123,7 @@ describe('request bodies', () => { expect(r.body.errors).to.have.length(1); const message = r.body.errors[0].message; expect(message).to.equal( - "should have required property 'testProperty'", + "must have required property 'testProperty'", ); })); @@ -148,7 +148,7 @@ describe('request bodies', () => { }, ]) .expect(400) - .then((r) => expect(r.body.message).to.include('should be object'))); + .then((r) => expect(r.body.message).to.include('must be object'))); it('should return 200 if a json suffex is used for content-type', async () => request(app) @@ -178,10 +178,10 @@ describe('request bodies', () => { .then((r) => { const { body } = r; expect(body.message).to.include( - '.response should NOT have additional properties', + '/response must NOT have additional properties', ); expect(body.errors[0].message).to.equals( - 'should NOT have additional properties', + 'must NOT have additional properties', ); })); @@ -197,13 +197,13 @@ describe('request bodies', () => { .then((r) => { const errors = r.body.errors; expect(errors).to.be.an('array').with.length(2); - expect(errors[0].path).to.equal('.body.invalidProperty'); + expect(errors[0].path).to.equal('/body/invalidProperty'); expect(errors[0].message).to.equal( - 'should NOT have additional properties', + 'must NOT have additional properties', ); - expect(errors[1].path).to.equal('.body.invalidProperty2'); + expect(errors[1].path).to.equal('/body/invalidProperty2'); expect(errors[1].message).to.equal( - 'should NOT have additional properties', + 'must NOT have additional properties', ); })); }); diff --git a/test/resources/query.params.yaml b/test/resources/query.params.yaml index ae345865..599552d7 100644 --- a/test/resources/query.params.yaml +++ b/test/resources/query.params.yaml @@ -120,7 +120,7 @@ paths: $ref: '#/components/schemas/Pet' /unknown_query_params/allow: get: - x-allow-unknown-query-parameters: true + x-eov-allow-unknown-query-parameters: true parameters: - name: value in: query @@ -132,7 +132,7 @@ paths: description: success /unknown_query_params/disallow: get: - x-allow-unknown-query-parameters: false + x-eov-allow-unknown-query-parameters: false parameters: - name: value in: query diff --git a/test/resources/serdes.yaml b/test/resources/serdes.yaml index 9e818093..d8fff47b 100644 --- a/test/resources/serdes.yaml +++ b/test/resources/serdes.yaml @@ -18,6 +18,11 @@ paths: schema: type: string enum: ['functionNotExists', 'functionBadFormat'] + - name: shortOrLong + in: query + required: false + schema: + $ref: "#/components/schemas/ShortOrLong" responses: 200: description: "" @@ -51,6 +56,12 @@ components: DateTime: type: string format: date-time + ShortOrLong: + type: string + format: mongo-objectid + anyOf: + - maxLength: 2 + - minLength: 4 StringList: type: string format: string-list @@ -65,3 +76,5 @@ components: $ref: "#/components/schemas/DateTime" creationDate: $ref: "#/components/schemas/Date" + shortOrLong: + $ref: "#/components/schemas/ShortOrLong" diff --git a/test/resources/unknown.keywords.yaml b/test/resources/unknown.keywords.yaml new file mode 100644 index 00000000..b254d601 --- /dev/null +++ b/test/resources/unknown.keywords.yaml @@ -0,0 +1,34 @@ +openapi: 3.0.3 +info: + title: Keywords + version: 1.0.0 +servers: +- url: /v1 +paths: + /persons: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Person' + responses: + 200: + description: Invalid ID supplied + x-ignored-by-validator: true + +components: + schemas: + Person: + required: + - id + type: object + x-custom-keyword: Ignored by validator. + properties: + id: + x-internal-keyword: 123 + type: integer + format: int64 + name: + x-custom-keyword: [Still ignored by validator.] + type: string diff --git a/test/response.validation.defaults.spec.ts b/test/response.validation.defaults.spec.ts index 84bf385c..3cef764e 100644 --- a/test/response.validation.defaults.spec.ts +++ b/test/response.validation.defaults.spec.ts @@ -51,6 +51,6 @@ describe('response validation with type coercion', () => { .get(`${app.basePath}/default_inline?q=400_bad`) .expect(500) .then((r) => { - expect(r.body.message).to.include('should have required property'); + expect(r.body.message).to.include('must have required property'); })); }); diff --git a/test/response.validation.on.error.spec.ts b/test/response.validation.on.error.spec.ts index 7a5b9bfd..36ad9643 100644 --- a/test/response.validation.on.error.spec.ts +++ b/test/response.validation.on.error.spec.ts @@ -66,7 +66,7 @@ describe(packageJson.name, () => { const data = [{ id: 'bad_id', name: 'name', tag: 'tag' }]; expect(r.body).to.eql(data); expect(onErrorArgs.length).to.equal(3); - expect(onErrorArgs[0].message).to.equal('.response[0].id should be integer'); + expect(onErrorArgs[0].message).to.equal('/response/0/id must be integer'); expect(onErrorArgs[1]).to.eql(data); expect(onErrorArgs[2].query).to.eql({ mode: 'bad_type' @@ -90,7 +90,7 @@ describe(packageJson.name, () => { const data = [{ id: 'bad_id_throw', name: 'name', tag: 'tag' }]; expect(r.body.message).to.equal('error in onError handler'); expect(onErrorArgs.length).to.equal(3); - expect(onErrorArgs[0].message).to.equal('.response[0].id should be integer'); + expect(onErrorArgs[0].message).to.equal('/response/0/id must be integer'); expect(onErrorArgs[1]).to.eql(data); })); }); diff --git a/test/response.validation.options.spec.ts b/test/response.validation.options.spec.ts index bfc790a2..6e99e120 100644 --- a/test/response.validation.options.spec.ts +++ b/test/response.validation.options.spec.ts @@ -54,7 +54,7 @@ describe(packageJson.name, () => { .get(`${app.basePath}/pets?mode=bad_type`) .expect(500) .then((r: any) => { - expect(r.body.message).to.contain('should be integer'); + expect(r.body.message).to.contain('must be integer'); expect(r.body).to.have.property('code').that.equals(500); })); diff --git a/test/response.validation.spec.ts b/test/response.validation.spec.ts index 35c5241e..f55e343b 100644 --- a/test/response.validation.spec.ts +++ b/test/response.validation.spec.ts @@ -114,7 +114,7 @@ describe(packageJson.name, () => { .get(`${app.basePath}/pets?mode=bad_type`) .expect(500) .then((r: any) => { - expect(r.body.message).to.contain('should be integer'); + expect(r.body.message).to.contain('must be integer'); expect(r.body).to.have.property('code').that.equals(500); })); @@ -124,7 +124,7 @@ describe(packageJson.name, () => { .get(`${app.basePath}/object`) .expect(500) .then((r: any) => { - expect(r.body.message).to.contain('should be object'); + expect(r.body.message).to.contain('must be object'); expect(r.body).to.have.property('code').that.equals(500); })); @@ -134,7 +134,7 @@ describe(packageJson.name, () => { .send({ id: 1, name: 'fido' }) .expect(500) .then((r: any) => { - expect(r.body.message).to.contain('should be object'); + expect(r.body.message).to.contain('must be object'); expect(r.body).to.have.property('code').that.equals(500); })); @@ -149,7 +149,7 @@ describe(packageJson.name, () => { .get(`${app.basePath}/pets?mode=empty_object`) .expect(500) .then((r: any) => { - expect(r.body.message).to.contain('should be array'); + expect(r.body.message).to.contain('must be array'); expect(r.body).to.have.property('code').that.equals(500); })); @@ -203,7 +203,7 @@ describe(packageJson.name, () => { .expect(500) .then((r: any) => { const e = r.body; - expect(e.message).to.contain('should NOT have additional properties'); + expect(e.message).to.contain('must NOT have additional properties'); expect(e.code).to.equal(500); })); @@ -223,7 +223,7 @@ describe(packageJson.name, () => { .expect(500) .then((r: any) => { const e = r.body; - expect(e.message).to.contain('should NOT have additional properties'); + expect(e.message).to.contain('must NOT have additional properties'); expect(e.code).to.equal(500); })); diff --git a/test/response.validator.spec.ts b/test/response.validator.spec.ts index b640fecb..09b06c78 100644 --- a/test/response.validator.spec.ts +++ b/test/response.validator.spec.ts @@ -16,7 +16,12 @@ const fakeReq: OpenApiRequest = { }; describe(packageJson.name, () => { it('should validate the using default (in this case the error object)', async () => { - const v = new ResponseValidator(cloneDeep(apiSpec)); + const v = new ResponseValidator(cloneDeep(apiSpec), { + formats: { + 'date-time': true, + }, + strict: false, + }); const responses = petsResponseSchema(); const validators = v._getOrBuildValidator(fakeReq, responses); @@ -36,7 +41,12 @@ describe(packageJson.name, () => { }); it('should throw error when default response is invalid', async () => { - const v = new ResponseValidator(apiSpec); + const v = new ResponseValidator(apiSpec, { + formats: { + 'date-time': true, + }, + strict: false, + }); const responses = petsResponseSchema(); const validators = v._getOrBuildValidator(fakeReq, responses); @@ -55,12 +65,17 @@ describe(packageJson.name, () => { } catch (e) { expect(e.status).to.equal(500); expect(e.errors).to.be.an('array'); - expect(e.errors[0].message).to.equal('should be string'); + expect(e.errors[0].message).to.equal('must be string'); } }); it('should return an error if field type is invalid', async () => { - const v = new ResponseValidator(apiSpec); + const v = new ResponseValidator(apiSpec, { + formats: { + 'date-time': true, + }, + strict: false, + }); const responses = petsResponseSchema(); const validators = v._getOrBuildValidator(fakeReq, responses); @@ -74,7 +89,7 @@ describe(packageJson.name, () => { }); } catch (e) { expect(e).to.be.not.null; - expect(e.message).to.contain('should be integer'); + expect(e.message).to.contain('must be integer'); expect(e.message).to.not.contain('additional properties'); } @@ -88,7 +103,7 @@ describe(packageJson.name, () => { }); } catch (e) { expect(e).to.be.not.null; - expect(e.message).to.contain('should be array'); + expect(e.message).to.contain('must be array'); } try { @@ -101,7 +116,7 @@ describe(packageJson.name, () => { }); } catch (e) { expect(e).to.be.not.null; - expect(e.message).to.contain('should be string'); + expect(e.message).to.contain('must be string'); } }); }); diff --git a/test/serdes.spec.ts b/test/serdes.spec.ts index 0260326d..499b7878 100644 --- a/test/serdes.spec.ts +++ b/test/serdes.spec.ts @@ -61,7 +61,8 @@ describe('serdes', () => { res.json({ id: req.params.id, creationDateTime: date, - creationDate: date + creationDate: date, + shortOrLong: 'a', }); }); app.post([`${app.basePath}/users`], (req, res) => { @@ -77,7 +78,6 @@ describe('serdes', () => { res.json(req.body); }); app.use((err, req, res, next) => { - console.error(err) res.status(err.status ?? 500).json({ message: err.message, code: err.status ?? 500, @@ -98,7 +98,7 @@ describe('serdes', () => { .get(`${app.basePath}/users/1234`) .expect(400) .then((r) => { - expect(r.body.message).to.equal('request.params.id should match pattern "^[0-9a-fA-F]{24}$"'); + expect(r.body.message).to.equal('request/params/id must match pattern "^[0-9a-fA-F]{24}$"'); })); it('should control GOOD id format and get a response in expected format', async () => @@ -117,7 +117,8 @@ describe('serdes', () => { .send({ id: '5fdefd13a6640bb5fb5fa925', creationDateTime: '2020-12-20T07:28:19.213Z', - creationDate: '2020-12-20' + creationDate: '2020-12-20', + shortOrLong: 'ab', }) .set('Content-Type', 'application/json') .expect(200) @@ -133,12 +134,13 @@ describe('serdes', () => { .send({ id: '5fdefd13a6640bb5fb5fa', creationDateTime: '2020-12-20T07:28:19.213Z', - creationDate: '2020-12-20' + creationDate: '2020-12-20', + shortOrLong: 'abcd', }) .set('Content-Type', 'application/json') .expect(400) .then((r) => { - expect(r.body.message).to.equal('request.body.id should match pattern "^[0-9a-fA-F]{24}$"'); + expect(r.body.message).to.equal('request/body/id must match pattern "^[0-9a-fA-F]{24}$"'); })); it('should POST throw error on invalid schema Date', async () => @@ -152,9 +154,29 @@ describe('serdes', () => { .set('Content-Type', 'application/json') .expect(400) .then((r) => { - expect(r.body.message).to.equal('request.body.creationDate should match format "date"'); + expect(r.body.message).to.equal('request/body/creationDate must match format "date"'); })); + it('should enforce anyOf validations', async () => + request(app) + .post(`${app.basePath}/users`) + .send({ + id: '5fdefd13a6640bb5fb5fa925', + creationDateTime: '2020-12-20T07:28:19.213Z', + creationDate: '2020-12-20', + shortOrLong: 'abc', + }) + .set('Content-Type', 'application/json') + .expect(400) + .then((r) => { + expect(r.body.message).to.equal( + [ + 'request/body/shortOrLong must NOT have more than 2 characters', + 'request/body/shortOrLong must NOT have fewer than 4 characters', + 'request/body/shortOrLong must match a schema in anyOf', + ].join(', '), + ); + })); }); @@ -194,6 +216,7 @@ describe('serdes serialize response components only', () => { id: new ObjectID(req.params.id), creationDateTime: date, creationDate: undefined, + shortOrLong: 'a', }; if (req.query.baddateresponse === 'functionNotExists') { result.creationDate = new ObjectID(); @@ -222,7 +245,6 @@ describe('serdes serialize response components only', () => { res.json(req.body); }); app.use((err, req, res, next) => { - console.error(err) res.status(err.status ?? 500).json({ message: err.message, code: err.status ?? 500, @@ -243,7 +265,7 @@ describe('serdes serialize response components only', () => { .get(`${app.basePath}/users/1234`) .expect(400) .then((r) => { - expect(r.body.message).to.equal('request.params.id should match pattern "^[0-9a-fA-F]{24}$"'); + expect(r.body.message).to.equal('request/params/id must match pattern "^[0-9a-fA-F]{24}$"'); })); it('should control GOOD id format and get a response in expected format', async () => @@ -283,7 +305,7 @@ describe('serdes serialize response components only', () => { .set('Content-Type', 'application/json') .expect(400) .then((r) => { - expect(r.body.message).to.equal('request.body.id should match pattern "^[0-9a-fA-F]{24}$"'); + expect(r.body.message).to.equal('request/body/id must match pattern "^[0-9a-fA-F]{24}$"'); })); it('should POST throw error on invalid schema Date', async () => @@ -297,7 +319,7 @@ describe('serdes serialize response components only', () => { .set('Content-Type', 'application/json') .expect(400) .then((r) => { - expect(r.body.message).to.equal('request.body.creationDate should match format "date"'); + expect(r.body.message).to.equal('request/body/creationDate must match format "date"'); })); it('should throw error 500 on invalid object type instead of Date expected', async () => @@ -306,8 +328,30 @@ describe('serdes serialize response components only', () => { .query({ baddateresponse: 'functionNotExists' }) .expect(500) .then((r) => { - console.log(r); - expect(r.body.message).to.equal('.response.creationDate format is invalid'); + expect(r.body.message).to.equal( + '/response/creationDate format is invalid', + ); + })); + + it('should enforce anyOf validations', async () => + request(app) + .post(`${app.basePath}/users`) + .send({ + id: '5fdefd13a6640bb5fb5fa925', + creationDateTime: '2020-12-20T07:28:19.213Z', + creationDate: '2020-12-20', + shortOrLong: 'abc', + }) + .set('Content-Type', 'application/json') + .expect(400) + .then((r) => { + expect(r.body.message).to.equal( + [ + 'request/body/shortOrLong must NOT have more than 2 characters', + 'request/body/shortOrLong must NOT have fewer than 4 characters', + 'request/body/shortOrLong must match a schema in anyOf', + ].join(', '), + ); })); /* @@ -319,7 +363,6 @@ describe('serdes serialize response components only', () => { .query({baddateresponse : 'functionBadFormat'}) .expect(200) .then((r) => { - console.log(r.body); expect(r.body.message).to.equal('Something saying that date is not date-time format'); })); @@ -327,7 +370,7 @@ describe('serdes serialize response components only', () => { }); -describe('serdes with jsonType array type string-list', () => { +describe('serdes with array type string-list', () => { let app = null; before(async () => { @@ -351,7 +394,6 @@ describe('serdes with jsonType array type string-list', () => { }, { format: 'string-list', - jsonType: 'array', deserialize: (s): string[] => s.split(',').map(s => s.trim()), serialize: (o): string => (o as string[]).join(','), }, @@ -387,7 +429,6 @@ describe('serdes with jsonType array type string-list', () => { res.json(req.body); }); app.use((err, req, res, next) => { - console.error(err) res.status(err.status ?? 500).json({ message: err.message, code: err.status ?? 500, @@ -408,7 +449,7 @@ describe('serdes with jsonType array type string-list', () => { .get(`${app.basePath}/users/1234`) .expect(400) .then((r) => { - expect(r.body.message).to.equal('request.params.id should match pattern "^[0-9a-fA-F]{24}$"'); + expect(r.body.message).to.equal('request/params/id must match pattern "^[0-9a-fA-F]{24}$"'); })); it('should control GOOD id format and get a response in expected format', async () => { @@ -431,7 +472,8 @@ describe('serdes with jsonType array type string-list', () => { id: '5fdefd13a6640bb5fb5fa925', tags: 'aa,bb,cc', creationDateTime: '2020-12-20T07:28:19.213Z', - creationDate: '2020-12-20' + creationDate: '2020-12-20', + shortOrLong: 'abcdef', }) .set('Content-Type', 'application/json') .expect(200) @@ -454,7 +496,7 @@ describe('serdes with jsonType array type string-list', () => { .set('Content-Type', 'application/json') .expect(400) .then((r) => { - expect(r.body.message).to.equal('request.body.id should match pattern "^[0-9a-fA-F]{24}$"'); + expect(r.body.message).to.equal('request/body/id must match pattern "^[0-9a-fA-F]{24}$"'); })); it('should POST throw error on invalid schema Date', async () => @@ -469,7 +511,7 @@ describe('serdes with jsonType array type string-list', () => { .set('Content-Type', 'application/json') .expect(400) .then((r) => { - expect(r.body.message).to.equal('request.body.creationDate should match format "date"'); + expect(r.body.message).to.equal('request/body/creationDate must match format "date"'); })); it('should POST throw error for deserialize on request of non-string format', async () => @@ -484,9 +526,29 @@ describe('serdes with jsonType array type string-list', () => { .set('Content-Type', 'application/json') .expect(400) .then((r) => { - expect(r.body.message).to.equal('request.body.tags must be a string'); + expect(r.body.message).to.equal('request/body/tags must be string'); })); + it('should enforce anyOf validations', async () => + request(app) + .post(`${app.basePath}/users`) + .send({ + id: '5fdefd13a6640bb5fb5fa925', + creationDateTime: '2020-12-20T07:28:19.213Z', + creationDate: '2020-12-20', + shortOrLong: 'abc', + }) + .set('Content-Type', 'application/json') + .expect(400) + .then((r) => { + expect(r.body.message).to.equal( + [ + 'request/body/shortOrLong must NOT have more than 2 characters', + 'request/body/shortOrLong must NOT have fewer than 4 characters', + 'request/body/shortOrLong must match a schema in anyOf', + ].join(', '), + ); + })); }); diff --git a/test/serialized.objects.spec.ts b/test/serialized.objects.spec.ts index a214359f..71c987b7 100644 --- a/test/serialized.objects.spec.ts +++ b/test/serialized.objects.spec.ts @@ -75,7 +75,7 @@ describe(packageJson.name, () => { .expect(400) .then((response) => { expect(response.body.message).to.equal( - 'request.query.settings should be object', + 'request/query/settings must be object', ); })); @@ -90,7 +90,7 @@ describe(packageJson.name, () => { .then((r) => { expect(r.body) .to.have.property('message') - .that.equals('request.query.settings.tag_ids should be array'); + .that.equals('request.query.settings.tag_ids must be array'); })); it('should explode query param object e.g. tag_ids, state as query params', async () => diff --git a/test/unknown.keywords.spec.ts b/test/unknown.keywords.spec.ts new file mode 100644 index 00000000..88f5f725 --- /dev/null +++ b/test/unknown.keywords.spec.ts @@ -0,0 +1,36 @@ +import * as path from 'path'; +import * as request from 'supertest'; +import { createApp } from './common/app'; + +describe('Unknown x- keywords', () => { + let app = null; + + before(async () => { + const apiSpec = path.join('test', 'resources', 'unknown.keywords.yaml'); + app = await createApp( + { + apiSpec, + }, + 3005, + (app) => { + app.post(`${app.basePath}/persons`, (req, res) => + res.json({ + ...req.body, + }), + ); + }, + true, + ); + }); + + after(() => app.server.close()); + + it('should return 200 for valid request with unknown x- keywords', async () => + request(app) + .post(`${app.basePath}/persons`) + .send({ + id: 10, + name: 'jacob', + }) + .expect(200)); +}); diff --git a/test/write.only.spec.ts b/test/write.only.spec.ts index ac23babf..04b6d4b2 100644 --- a/test/write.only.spec.ts +++ b/test/write.only.spec.ts @@ -71,7 +71,7 @@ describe(packageJson.name, () => { .then(r => { const body = r.body; expect(body.message).to.contain('role'); - expect(body.errors[0].path).to.contain('.response.role'); + expect(body.errors[0].path).to.contain('/response/role'); expect(body.errors[0].message).to.contain('write-only'); })); @@ -106,7 +106,7 @@ describe(packageJson.name, () => { .then(r => { const body = r.body; expect(body.message).to.contain('role_x'); - expect(body.errors[0].path).to.contain('.response.reviews[0].role_x'); + expect(body.errors[0].path).to.contain('/response/reviews/0/role_x'); expect(body.errors[0].message).to.contain('write-only'); })); @@ -131,6 +131,6 @@ describe(packageJson.name, () => { .expect(400) .then(r => { const body = r.body; - expect(body.message).to.contain('request.body.reviews[0].id'); + expect(body.message).to.contain('request/body/reviews/0/id'); })); });