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

fix: instantiate Ajv2020 for OAS 3.1 #1009

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2,547 changes: 2,495 additions & 52 deletions examples/3-eov-operations/package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion examples/3-eov-operations/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
"morgan": "^1.10.0"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"eslint": "^9.14.0",
"globals": "^15.12.0",
"nodemon": "^2.0.4",
"prettier": "^2.1.1"
"prettier": "^2.1.1",
"typescript-eslint": "^8.13.0"
}
}
3 changes: 3 additions & 0 deletions examples/9-nestjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
"shx": "^0.3.3"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"eslint": "^9.14.0",
"globals": "^15.12.0",
"@nestjs/cli": "^10.4.5",
"@nestjs/testing": "^10.3.8",
"@types/jest": "^27.0.2",
Expand Down
25 changes: 25 additions & 0 deletions src/framework/ajv/factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Options } from "ajv";
import AjvDraft4 from 'ajv-draft-04';
import Ajv2020 from 'ajv/dist/2020';
import { assertVersion } from "../openapi/assert.version";
import { AjvInstance } from "../types";

export const factoryAjv = (version: string, options: Options): AjvInstance => {
const { minor } = assertVersion(version)

let ajvInstance: AjvInstance

if (minor === '0') {
ajvInstance = new AjvDraft4(options);
} else if (minor == '1') {
ajvInstance = new Ajv2020(options);

// 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"
// https://github.com/OAI/OpenAPI-Specification/issues/2714#issuecomment-923185689
// 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
// as the spec states
ajvInstance.addFormat('media-range', true);
}

return ajvInstance
}
17 changes: 10 additions & 7 deletions src/framework/ajv/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import AjvDraft4 from 'ajv-draft-04';
import Ajv2020 from 'ajv/dist/2020'
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, SerDes } from '../types';
import { AjvInstance, OpenAPIV3, Options, SerDes } from '../types';
import * as traverse from 'json-schema-traverse';
import { factoryAjv } from './factory';

interface SerDesSchema extends Partial<SerDes> {
kind?: 'req' | 'res';
Expand All @@ -13,27 +15,28 @@ interface SerDesSchema extends Partial<SerDes> {
export function createRequestAjv(
openApiSpec: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1,
options: Options = {},
): AjvDraft4 {
): AjvInstance {
return createAjv(openApiSpec, options);
}

export function createResponseAjv(
openApiSpec: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1,
options: Options = {},
): AjvDraft4 {
): AjvInstance {
return createAjv(openApiSpec, options, false);
}

function createAjv(
openApiSpec: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1,
options: Options = {},
request = true,
): AjvDraft4 {
): AjvInstance {
const { ajvFormats, ...ajvOptions } = options;
const ajv = new AjvDraft4({

const ajv = factoryAjv(openApiSpec.openapi, {
...ajvOptions,
formats: formats,
});
formats
})

// Clean openApiSpec
traverse(openApiSpec, { allKeys: true }, <traverse.Callback>(schema => {
Expand Down
38 changes: 5 additions & 33 deletions src/framework/openapi.schema.validator.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import AjvDraft4, {
import {
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';
// 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
import * as openapi31Schema from './openapi.v3_1.modified.schema.json';
import { OpenAPIV3 } from './types.js';

import Ajv2020 from 'ajv/dist/2020';
import { factoryAjv } from './ajv/factory';
import { factorySchema } from './openapi/factory.schema';

export interface OpenAPISchemaValidatorOpts {
version: string;
Expand All @@ -32,32 +28,8 @@ export class OpenAPISchemaValidator {
options.validateSchema = false;
}

const [ok, major, minor] = /^(\d+)\.(\d+).(\d+)?$/.exec(opts.version);

if (!ok) {
throw Error('Version missing from OpenAPI specification')
};

if (major !== '3' || minor !== '0' && minor !== '1') {
throw new Error('OpenAPI v3.0 or v3.1 specification version is required');
}

let ajvInstance;
let schema;

if (minor === '0') {
schema = openapi3Schema;
ajvInstance = new AjvDraft4(options);
} else if (minor == '1') {
schema = openapi31Schema;
ajvInstance = new Ajv2020(options);

// 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"
// https://github.com/OAI/OpenAPI-Specification/issues/2714#issuecomment-923185689
// 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
// as the spec states
ajvInstance.addFormat('media-range', true);
}
const ajvInstance = factoryAjv(opts.version, options)
const schema = factorySchema(opts.version)

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

Expand Down
19 changes: 19 additions & 0 deletions src/framework/openapi/assert.version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Asserts open api version
*
* @param openApiVersion SemVer version
* @returns destructured major and minor
*/
export const assertVersion = (openApiVersion: string) => {
const [ok, major, minor] = /^(\d+)\.(\d+).(\d+)?$/.exec(openApiVersion);

if (!ok) {
throw Error('Version missing from OpenAPI specification')
};

if (major !== '3' || minor !== '0' && minor !== '1') {
throw new Error('OpenAPI v3.0 or v3.1 specification version is required');
}

return { major, minor }
}
16 changes: 16 additions & 0 deletions src/framework/openapi/factory.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { assertVersion } from "./assert.version";

// https://github.com/OAI/OpenAPI-Specification/blob/master/schemas/v3.0/schema.json
import * as openapi3Schema from '../openapi.v3.schema.json';
// 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
import * as openapi31Schema from '../openapi.v3_1.modified.schema.json';

export const factorySchema = (version: string): Object => {
const { minor } = assertVersion(version);

if (minor === '0') {
return openapi3Schema;
}

return openapi31Schema
}
4 changes: 4 additions & 0 deletions src/framework/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import * as multer from 'multer';
import { FormatsPluginOptions } from 'ajv-formats';
import { Request, Response, NextFunction, RequestHandler } from 'express';
import { RouteMetadata } from './openapi.spec.loader';
import AjvDraft4 from 'ajv-draft-04';
import Ajv2020 from 'ajv/dist/2020';
export { OpenAPIFrameworkArgs };

export type AjvInstance = AjvDraft4 | Ajv2020

export type BodySchema =
| OpenAPIV3.ReferenceObject
| OpenAPIV3.SchemaObject
Expand Down
28 changes: 28 additions & 0 deletions test/openapi_3.1/resources/unevaluated_properties.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
openapi: 3.1.0
info:
title: API
version: 1.0.0
servers:
- url: /v1
components:
schemas:
EntityRequest:
type: object
properties:
request:
type: string
unevaluatedProperties: false
paths:
/entity:
post:
description: POSTs my entity
requestBody:
description: Request body for entity
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/EntityRequest'
responses:
'204':
description: No Content
45 changes: 45 additions & 0 deletions test/openapi_3.1/unevaluated_properties.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as request from 'supertest';
import * as express from 'express';
import { createApp } from "../common/app";
import { join } from "path";

describe('Unevaluated Properties in requests', () => {
let app;

before(async () => {
const apiSpec = join('test', 'openapi_3.1', 'resources', 'unevaluated_properties.yaml');
app = await createApp(
{ apiSpec, validateRequests: true },
3005,
(app) => app.use(
express
.Router()
.post(`/v1/entity`, (_req, res) =>
res.status(204).json(),
),
)
);
});

after(() => {
app.server.close();
});


it('should reject request body with unevaluated properties', async () => {
return request(app)
.post(`${app.basePath}/entity`)
.set('Content-Type', 'application/json')
.send({request: '123', additionalProperty: '321'})
.expect(400);
});

it('should accept request body without unevaluated properties', async () => {
return request(app)
.post(`${app.basePath}/entity`)
.set('Content-Type', 'application/json')
.send({request: '123' })
.expect(204);
});

})
Loading