Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bump AJV to v8 #713

Merged
merged 44 commits into from
May 29, 2022
Merged
Show file tree
Hide file tree
Changes from 43 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
95f635c
try upgrading to OAPIv3.1
Aug 31, 2021
5f9e3cd
Merge branch 'master' into ajvV8
JacobLey Mar 17, 2022
7f13a14
Remove 3.1-support related files
JacobLey Mar 19, 2022
2507329
Const typings on formats
JacobLey Mar 19, 2022
c201f65
Set _discriminator as non-enumerable
JacobLey Mar 19, 2022
3f4bc1a
Refactor `x-eov-serdes` to ensure order of validation
JacobLey Mar 19, 2022
2b06654
Update AJV options handling
JacobLey Mar 19, 2022
6bc5ce4
Update read/write only keywords
JacobLey Mar 19, 2022
5bd09d7
Add noop keywords
JacobLey Mar 19, 2022
c09e11a
Use AJV Draft 4 to validate OpenAPI doc
JacobLey Mar 19, 2022
ec9cf15
Use `must` keyword to match AJV validations
JacobLey Mar 19, 2022
bb092d4
Expected validation errors prefer `must` over `should`, `/` over `.`
JacobLey Mar 19, 2022
8790c22
Update README to reflect expected validation errors
JacobLey Mar 19, 2022
1c3a774
Explicitly pass formats to ignore
JacobLey Mar 19, 2022
c85c40e
Serdes validation errors contain more errors
JacobLey Mar 19, 2022
4117df7
Update example with expected AJV errors
JacobLey Mar 19, 2022
dc27f9d
Drop noisy test logs
JacobLey Mar 19, 2022
d828c16
Restore previous `Format` version
JacobLey Mar 19, 2022
764c727
Add failing tests for undeclared x-* keywords
JacobLey Mar 23, 2022
bce2cb2
Detect `x-*` prefixes and declare as noop for Ajv
JacobLey Mar 23, 2022
06efc3e
Update README to declare reserved vendor extension prefix
JacobLey Mar 23, 2022
d85b894
readOnly+writeOnly do not modify, and do attach errors
JacobLey Mar 27, 2022
e17f2ff
Remove test enforcing `x-eov-*` usage
JacobLey Mar 27, 2022
0e0b08e
Rely on strictSchema=false to handle unknown keywords
JacobLey Mar 27, 2022
eef091e
Explicitly pass strict=false to response validator test
JacobLey Mar 27, 2022
d2480ef
Add types to serdes validator, auto-true if missing method
JacobLey Mar 27, 2022
d24edd7
Rework serdes schema processor
JacobLey Mar 27, 2022
297b991
Update serdes test to reflect simpler validation messages
JacobLey Mar 27, 2022
0f59997
Consistent usage of / over . for json path
JacobLey Mar 27, 2022
e3a37f7
Add `eov` prefix to unknown query parameters flag
JacobLey Mar 27, 2022
4f56d9d
Create "normalized options" type that has stricter format
JacobLey Mar 27, 2022
be28f24
Set defaults in one place
JacobLey Mar 27, 2022
56a6b87
Add warnings for deprecated usage of options
JacobLey Mar 27, 2022
b812807
Move options handling to `normalizeOptions`, add `ajvFormats` option
JacobLey Mar 27, 2022
61b5868
Update README to reflect new options behavior
JacobLey Mar 27, 2022
ed123ce
Merge branch 'master' into ajvV8
JacobLey Mar 28, 2022
50b1e43
Consistent `/` over `.`
JacobLey Apr 3, 2022
1e4b9b1
Remove unnecessary serDesInternal check
JacobLey Apr 3, 2022
992cde0
Add `anyOf` test with serdes, expose all relevant errors
JacobLey Apr 4, 2022
10e3d50
Simplify format overriding by applying in order, remove constant
JacobLey Apr 4, 2022
dd9793c
Move redactable error to common types file
JacobLey Apr 4, 2022
76eb208
Tweak error redacting to only expose most relevant
JacobLey Apr 4, 2022
1938497
Refactor serdes (again...) to use keyword execution order
JacobLey Apr 6, 2022
56075cc
v4.14.0-beta.1
cdimascio May 29, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions examples/2-standard-multiple-api-specs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Expand Down
4 changes: 2 additions & 2 deletions examples/9-nestjs/src/modules/ping/ping.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
],
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 12 additions & 6 deletions src/framework/ajv/formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
244 changes: 137 additions & 107 deletions src/framework/ajv/index.ts
Original file line number Diff line number Diff line change
@@ -1,103 +1,128 @@
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<SerDes> {
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);
}

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,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

implied by ajv-draft-04

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') {
(<ajv.ValidateFunction>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) {
(<ajv.ValidateFunction>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];
(<ajv.ValidateFunction>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;
Expand All @@ -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) {
(<ajv.ValidateFunction>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);
(<ajv.ValidateFunction>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;
Expand Down
Loading