Skip to content

Commit ef8e7ba

Browse files
authored
fix: instantiate Ajv2020 for OAS 3.1 (#1009)
* chore: create factories for ajvInstance and schema * test: writing some tests * chore: removing ts from editorconfig * chore: add eslint
1 parent c5a991f commit ef8e7ba

File tree

11 files changed

+2655
-93
lines changed

11 files changed

+2655
-93
lines changed

examples/3-eov-operations/package-lock.json

Lines changed: 2495 additions & 52 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/3-eov-operations/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
"morgan": "^1.10.0"
1717
},
1818
"devDependencies": {
19+
"@eslint/js": "^9.14.0",
20+
"eslint": "^9.14.0",
21+
"globals": "^15.12.0",
1922
"nodemon": "^2.0.4",
20-
"prettier": "^2.1.1"
23+
"prettier": "^2.1.1",
24+
"typescript-eslint": "^8.13.0"
2125
}
2226
}

examples/9-nestjs/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
"shx": "^0.3.3"
2727
},
2828
"devDependencies": {
29+
"@eslint/js": "^9.14.0",
30+
"eslint": "^9.14.0",
31+
"globals": "^15.12.0",
2932
"@nestjs/cli": "^10.4.5",
3033
"@nestjs/testing": "^10.3.8",
3134
"@types/jest": "^27.0.2",

src/framework/ajv/factory.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Options } from "ajv";
2+
import AjvDraft4 from 'ajv-draft-04';
3+
import Ajv2020 from 'ajv/dist/2020';
4+
import { assertVersion } from "../openapi/assert.version";
5+
import { AjvInstance } from "../types";
6+
7+
export const factoryAjv = (version: string, options: Options): AjvInstance => {
8+
const { minor } = assertVersion(version)
9+
10+
let ajvInstance: AjvInstance
11+
12+
if (minor === '0') {
13+
ajvInstance = new AjvDraft4(options);
14+
} else if (minor == '1') {
15+
ajvInstance = new Ajv2020(options);
16+
17+
// Open API 3.1 has a custom "media-range" attribute defined in its schema, but the spec does not define it. "It's not really intended to be validated"
18+
// https://github.com/OAI/OpenAPI-Specification/issues/2714#issuecomment-923185689
19+
// Since the schema is non-normative (https://github.com/OAI/OpenAPI-Specification/pull/3355#issuecomment-1915695294) we will only validate that it's a string
20+
// as the spec states
21+
ajvInstance.addFormat('media-range', true);
22+
}
23+
24+
return ajvInstance
25+
}

src/framework/ajv/index.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import AjvDraft4 from 'ajv-draft-04';
2+
import Ajv2020 from 'ajv/dist/2020'
23
import { DataValidateFunction } from 'ajv/dist/types';
34
import ajvType from 'ajv/dist/vocabularies/jtd/type';
45
import addFormats from 'ajv-formats';
56
import { formats } from './formats';
6-
import { OpenAPIV3, Options, SerDes } from '../types';
7+
import { AjvInstance, OpenAPIV3, Options, SerDes } from '../types';
78
import * as traverse from 'json-schema-traverse';
9+
import { factoryAjv } from './factory';
810

911
interface SerDesSchema extends Partial<SerDes> {
1012
kind?: 'req' | 'res';
@@ -13,27 +15,28 @@ interface SerDesSchema extends Partial<SerDes> {
1315
export function createRequestAjv(
1416
openApiSpec: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1,
1517
options: Options = {},
16-
): AjvDraft4 {
18+
): AjvInstance {
1719
return createAjv(openApiSpec, options);
1820
}
1921

2022
export function createResponseAjv(
2123
openApiSpec: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1,
2224
options: Options = {},
23-
): AjvDraft4 {
25+
): AjvInstance {
2426
return createAjv(openApiSpec, options, false);
2527
}
2628

2729
function createAjv(
2830
openApiSpec: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1,
2931
options: Options = {},
3032
request = true,
31-
): AjvDraft4 {
33+
): AjvInstance {
3234
const { ajvFormats, ...ajvOptions } = options;
33-
const ajv = new AjvDraft4({
35+
36+
const ajv = factoryAjv(openApiSpec.openapi, {
3437
...ajvOptions,
35-
formats: formats,
36-
});
38+
formats
39+
})
3740

3841
// Clean openApiSpec
3942
traverse(openApiSpec, { allKeys: true }, <traverse.Callback>(schema => {

src/framework/openapi.schema.validator.ts

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
1-
import AjvDraft4, {
1+
import {
22
ErrorObject,
33
Options,
44
ValidateFunction,
55
} from 'ajv-draft-04';
66
import addFormats from 'ajv-formats';
7-
// https://github.com/OAI/OpenAPI-Specification/blob/master/schemas/v3.0/schema.json
8-
import * as openapi3Schema from './openapi.v3.schema.json';
9-
// https://github.com/OAI/OpenAPI-Specification/blob/master/schemas/v3.1/schema.json with dynamic refs replaced due to AJV bug - https://github.com/ajv-validator/ajv/issues/1745
10-
import * as openapi31Schema from './openapi.v3_1.modified.schema.json';
117
import { OpenAPIV3 } from './types.js';
12-
13-
import Ajv2020 from 'ajv/dist/2020';
8+
import { factoryAjv } from './ajv/factory';
9+
import { factorySchema } from './openapi/factory.schema';
1410

1511
export interface OpenAPISchemaValidatorOpts {
1612
version: string;
@@ -32,32 +28,8 @@ export class OpenAPISchemaValidator {
3228
options.validateSchema = false;
3329
}
3430

35-
const [ok, major, minor] = /^(\d+)\.(\d+).(\d+)?$/.exec(opts.version);
36-
37-
if (!ok) {
38-
throw Error('Version missing from OpenAPI specification')
39-
};
40-
41-
if (major !== '3' || minor !== '0' && minor !== '1') {
42-
throw new Error('OpenAPI v3.0 or v3.1 specification version is required');
43-
}
44-
45-
let ajvInstance;
46-
let schema;
47-
48-
if (minor === '0') {
49-
schema = openapi3Schema;
50-
ajvInstance = new AjvDraft4(options);
51-
} else if (minor == '1') {
52-
schema = openapi31Schema;
53-
ajvInstance = new Ajv2020(options);
54-
55-
// Open API 3.1 has a custom "media-range" attribute defined in its schema, but the spec does not define it. "It's not really intended to be validated"
56-
// https://github.com/OAI/OpenAPI-Specification/issues/2714#issuecomment-923185689
57-
// Since the schema is non-normative (https://github.com/OAI/OpenAPI-Specification/pull/3355#issuecomment-1915695294) we will only validate that it's a string
58-
// as the spec states
59-
ajvInstance.addFormat('media-range', true);
60-
}
31+
const ajvInstance = factoryAjv(opts.version, options)
32+
const schema = factorySchema(opts.version)
6133

6234
addFormats(ajvInstance, ['email', 'regex', 'uri', 'uri-reference']);
6335

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Asserts open api version
3+
*
4+
* @param openApiVersion SemVer version
5+
* @returns destructured major and minor
6+
*/
7+
export const assertVersion = (openApiVersion: string) => {
8+
const [ok, major, minor] = /^(\d+)\.(\d+).(\d+)?$/.exec(openApiVersion);
9+
10+
if (!ok) {
11+
throw Error('Version missing from OpenAPI specification')
12+
};
13+
14+
if (major !== '3' || minor !== '0' && minor !== '1') {
15+
throw new Error('OpenAPI v3.0 or v3.1 specification version is required');
16+
}
17+
18+
return { major, minor }
19+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { assertVersion } from "./assert.version";
2+
3+
// https://github.com/OAI/OpenAPI-Specification/blob/master/schemas/v3.0/schema.json
4+
import * as openapi3Schema from '../openapi.v3.schema.json';
5+
// https://github.com/OAI/OpenAPI-Specification/blob/master/schemas/v3.1/schema.json with dynamic refs replaced due to AJV bug - https://github.com/ajv-validator/ajv/issues/1745
6+
import * as openapi31Schema from '../openapi.v3_1.modified.schema.json';
7+
8+
export const factorySchema = (version: string): Object => {
9+
const { minor } = assertVersion(version);
10+
11+
if (minor === '0') {
12+
return openapi3Schema;
13+
}
14+
15+
return openapi31Schema
16+
}

src/framework/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ import * as multer from 'multer';
33
import { FormatsPluginOptions } from 'ajv-formats';
44
import { Request, Response, NextFunction, RequestHandler } from 'express';
55
import { RouteMetadata } from './openapi.spec.loader';
6+
import AjvDraft4 from 'ajv-draft-04';
7+
import Ajv2020 from 'ajv/dist/2020';
68
export { OpenAPIFrameworkArgs };
79

10+
export type AjvInstance = AjvDraft4 | Ajv2020
11+
812
export type BodySchema =
913
| OpenAPIV3.ReferenceObject
1014
| OpenAPIV3.SchemaObject
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
openapi: 3.1.0
2+
info:
3+
title: API
4+
version: 1.0.0
5+
servers:
6+
- url: /v1
7+
components:
8+
schemas:
9+
EntityRequest:
10+
type: object
11+
properties:
12+
request:
13+
type: string
14+
unevaluatedProperties: false
15+
paths:
16+
/entity:
17+
post:
18+
description: POSTs my entity
19+
requestBody:
20+
description: Request body for entity
21+
required: true
22+
content:
23+
application/json:
24+
schema:
25+
$ref: '#/components/schemas/EntityRequest'
26+
responses:
27+
'204':
28+
description: No Content
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import * as request from 'supertest';
2+
import * as express from 'express';
3+
import { createApp } from "../common/app";
4+
import { join } from "path";
5+
6+
describe('Unevaluated Properties in requests', () => {
7+
let app;
8+
9+
before(async () => {
10+
const apiSpec = join('test', 'openapi_3.1', 'resources', 'unevaluated_properties.yaml');
11+
app = await createApp(
12+
{ apiSpec, validateRequests: true },
13+
3005,
14+
(app) => app.use(
15+
express
16+
.Router()
17+
.post(`/v1/entity`, (_req, res) =>
18+
res.status(204).json(),
19+
),
20+
)
21+
);
22+
});
23+
24+
after(() => {
25+
app.server.close();
26+
});
27+
28+
29+
it('should reject request body with unevaluated properties', async () => {
30+
return request(app)
31+
.post(`${app.basePath}/entity`)
32+
.set('Content-Type', 'application/json')
33+
.send({request: '123', additionalProperty: '321'})
34+
.expect(400);
35+
});
36+
37+
it('should accept request body without unevaluated properties', async () => {
38+
return request(app)
39+
.post(`${app.basePath}/entity`)
40+
.set('Content-Type', 'application/json')
41+
.send({request: '123' })
42+
.expect(204);
43+
});
44+
45+
})

0 commit comments

Comments
 (0)