Skip to content

Commit

Permalink
fix: observe validateApiSpec and avoid schema re-checks (performance) (
Browse files Browse the repository at this point in the history
…#544)

* fix: observe validateApiSpec and avoid schema re-checks

* update deps

* remove unused error

* chore: update lockfile and patch version

* chore: update npmignore

* chore: change history

* fix: observe validateApiSpec and avoid schema re-checks

* remove unused error

* chore: update excludes

* add coverage

* cover serdes opts

* test: cover options branches

* fix: optons

* remove commented code

* doc: update comment
  • Loading branch information
cdimascio committed Feb 28, 2021
1 parent 492e1f9 commit e794c59
Show file tree
Hide file tree
Showing 7 changed files with 237 additions and 92 deletions.
1 change: 1 addition & 0 deletions .npmignore
Expand Up @@ -3,6 +3,7 @@
/assets
/examples
/example
/packages
/docs
node_modules
/src
Expand Down
86 changes: 86 additions & 0 deletions src/framework/ajv/options.ts
@@ -0,0 +1,86 @@
import ajv = require('ajv');
import {
OpenApiValidatorOpts,
Options,
RequestValidatorOptions,
ValidateRequestOpts,
ValidateResponseOpts,
} from '../types';

export class AjvOptions {
private options: OpenApiValidatorOpts;
constructor(options: OpenApiValidatorOpts) {
this.options = options;
}
get preprocessor(): ajv.Options {
return this.baseOptions();
}

get response(): ajv.Options {
const { coerceTypes, removeAdditional } = <ValidateResponseOpts>(
this.options.validateResponses
);
return {
...this.baseOptions(),
useDefaults: false,
coerceTypes,
removeAdditional,
};
}

get request(): RequestValidatorOptions {
const { allowUnknownQueryParameters, coerceTypes, removeAdditional } = <
ValidateRequestOpts
>this.options.validateRequests;
return {
...this.baseOptions(),
allowUnknownQueryParameters,
coerceTypes,
removeAdditional,
};
}

get multipart(): Options {
return this.baseOptions();
}

private baseOptions(): Options {
const {
coerceTypes,
unknownFormats,
validateFormats,
serDes,
} = this.options;
const serDesMap = {};
for (const serDesObject of serDes) {
if (!serDesMap[serDesObject.format]) {
serDesMap[serDesObject.format] = serDesObject;
} else {
if (serDesObject.serialize) {
serDesMap[serDesObject.format].serialize = serDesObject.serialize;
}
if (serDesObject.deserialize) {
serDesMap[serDesObject.format].deserialize = serDesObject.deserialize;
}
}
}

return {
validateSchema: false, // this is true for statup validation, thus it can be bypassed here
nullable: true,
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,
};
}
}
1 change: 1 addition & 0 deletions src/framework/index.ts
Expand Up @@ -35,6 +35,7 @@ export class OpenAPIFramework {
'validateApiSpec' in args ? !!args.validateApiSpec : true;
const validator = new OpenAPISchemaValidator({
version: apiDoc.openapi,
validateApiSpec,
// extensions: this.apiDoc[`x-${args.name}-schema-extension`],
});

Expand Down
20 changes: 17 additions & 3 deletions src/framework/openapi.schema.validator.ts
Expand Up @@ -4,13 +4,27 @@ import * as draftSchema from 'ajv/lib/refs/json-schema-draft-04.json';
import * as openapi3Schema from './openapi.v3.schema.json';
import { OpenAPIV3 } from './types.js';

export interface OpenAPISchemaValidatorOpts {
version: string;
validateApiSpec: boolean;
extensions?: object;
}
export class OpenAPISchemaValidator {
private validator: Ajv.ValidateFunction;
constructor({ version }: { version: string; extensions?: object }) {
const v = new Ajv({ schemaId: 'auto', allErrors: true });
constructor(opts: OpenAPISchemaValidatorOpts) {
const options: any = {
schemaId: 'auto',
allErrors: true,
};

if (!opts.validateApiSpec) {
options.validateSchema = false;
}

const v = new Ajv(options);
v.addMetaSchema(draftSchema);

const ver = version && parseInt(String(version), 10);
const ver = opts.version && parseInt(String(opts.version), 10);
if (!ver) throw Error('version missing from OpenAPI specification');
if (ver != 3) throw Error('OpenAPI v3 specification version is required');

Expand Down
1 change: 0 additions & 1 deletion src/middlewares/openapi.request.validator.ts
Expand Up @@ -13,7 +13,6 @@ import {
ValidateRequestOpts,
OpenApiRequestMetadata,
NotFound,
MethodNotAllowed,
BadRequest,
ParametersSchema,
BodySchema,
Expand Down
105 changes: 17 additions & 88 deletions src/openapi.validator.ts
Expand Up @@ -15,14 +15,12 @@ import {
OpenApiRequestMetadata,
ValidateSecurityOpts,
OpenAPIV3,
RequestValidatorOptions,
Options,
} from './framework/types';
import { defaultResolver } from './resolvers';
import { OperationHandlerOptions } from './framework/types';
import { defaultSerDes } from './framework/base.serdes';
import { SchemaPreprocessor } from './middlewares/parsers/schema.preprocessor';

import { AjvOptions } from './framework/ajv/options';

export {
OpenApiValidatorOpts,
Expand Down Expand Up @@ -343,23 +341,27 @@ export class OpenApiValidator {
}

private normalizeOptions(options: OpenApiValidatorOpts): void {
if(!options.serDes) {
if (!options.serDes) {
options.serDes = defaultSerDes;
}
else {
if(!Array.isArray(options.unknownFormats)) {
} else {
if (!Array.isArray(options.unknownFormats)) {
options.unknownFormats = Array<string>();
}
options.serDes.forEach(currentSerDes => {
if((options.unknownFormats as string[]).indexOf(currentSerDes.format) === -1) {
(options.unknownFormats as string[]).push(currentSerDes.format)
options.serDes.forEach((currentSerDes) => {
if (
(options.unknownFormats as string[]).indexOf(currentSerDes.format) ===
-1
) {
(options.unknownFormats as string[]).push(currentSerDes.format);
}
});
defaultSerDes.forEach(currentDefaultSerDes => {
let defautSerDesOverride = options.serDes.find(currentOptionSerDes => {
return currentDefaultSerDes.format === currentOptionSerDes.format;
});
if(!defautSerDesOverride) {
defaultSerDes.forEach((currentDefaultSerDes) => {
let defautSerDesOverride = options.serDes.find(
(currentOptionSerDes) => {
return currentDefaultSerDes.format === currentOptionSerDes.format;
},
);
if (!defautSerDesOverride) {
options.serDes.push(currentDefaultSerDes);
}
});
Expand All @@ -376,76 +378,3 @@ export class OpenApiValidator {
}
}
}

class AjvOptions {
private options: OpenApiValidatorOpts;
constructor(options: OpenApiValidatorOpts) {
this.options = options;
}
get preprocessor(): ajv.Options {
return this.baseOptions();
}

get response(): ajv.Options {
const { coerceTypes, removeAdditional } = <ValidateResponseOpts>(
this.options.validateResponses
);
return {
...this.baseOptions(),
useDefaults: false,
coerceTypes,
removeAdditional,
};
}

get request(): RequestValidatorOptions {
const { allowUnknownQueryParameters, coerceTypes, removeAdditional } = <ValidateRequestOpts>(
this.options.validateRequests
);
return {
...this.baseOptions(),
allowUnknownQueryParameters,
coerceTypes,
removeAdditional,
};
}

get multipart(): Options {
return this.baseOptions();
}

private baseOptions(): Options {
const { coerceTypes, unknownFormats, validateFormats, serDes } = this.options;
const serDesMap = {};
for (const serDesObject of serDes) {
if(!serDesMap[serDesObject.format]) {
serDesMap[serDesObject.format] = serDesObject;
}
else {
if (serDesObject.serialize) {
serDesMap[serDesObject.format].serialize = serDesObject.serialize;
}
if (serDesObject.deserialize) {
serDesMap[serDesObject.format].deserialize = serDesObject.deserialize;
}
}
}

return {
nullable: true,
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,
};
}
}
115 changes: 115 additions & 0 deletions test/ajv.options.spec.ts
@@ -0,0 +1,115 @@
import { expect } from 'chai';
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 = {
apiSpec: './spec',
validateApiSpec: false,
validateRequests: true,
validateResponses: {
coerceTypes: false,
removeAdditional: true,
},
serDes: [],
formats: [],
};

it('should not validate schema for requests since schema is validated on startup', async () => {
const ajv = new AjvOptions(baseOptions);
const options = ajv.request;
expect(options.validateSchema).to.be.false;
});

it('should not validate schema for response since schema is validated on startup', async () => {
const ajv = new AjvOptions(baseOptions);
const options = ajv.response;
expect(options.validateSchema).to.be.false;
});

it('should not validate schema for preprocessor since schema is validated on startup', async () => {
const ajv = new AjvOptions(baseOptions);
const options = ajv.preprocessor;
expect(options.validateSchema).to.be.false;
});

it('should not validate schema for multipar since schema is validated on startup', async () => {
const ajv = new AjvOptions(baseOptions);
const options = ajv.multipart;
expect(options.validateSchema).to.be.false;
});

it('should set serdes deserialize', () => {
const ajv = new AjvOptions({
...baseOptions,
serDes: [
{
format: 'custom-1',
deserialize: () => 'test',
},
],
});
const options = ajv.multipart;
expect(options.serDesMap['custom-1']).has.property('deserialize');
expect(options.serDesMap['custom-1']).does.not.have.property('serialize');
});

it('should set serdes serialize', () => {
const ajv = new AjvOptions({
...baseOptions,
serDes: [
{
format: 'custom-1',
serialize: () => 'test',
},
],
});
const options = ajv.multipart;
expect(options.serDesMap).has.property('custom-1');
expect(options.serDesMap['custom-1']).has.property('serialize');
expect(options.serDesMap['custom-1']).does.not.have.property('deserialize');
});

it('should set serdes serialize and deserialize', () => {
const ajv = new AjvOptions({
...baseOptions,
serDes: [
{
format: 'custom-1',
serialize: () => 'test',
deserialize: (s) => {},
},
],
});
const options = ajv.multipart;
expect(options.serDesMap).has.property('custom-1');
expect(options.serDesMap['custom-1']).has.property('serialize');
expect(options.serDesMap['custom-1']).has.property('deserialize');
});

it('should set serdes serialize and deserialize separately', () => {
const ajv = new AjvOptions({
...baseOptions,
serDes: [
{
format: 'custom-1',
serialize: () => 'test',
},
{
format: 'custom-1',
deserialize: () => 'test',
},
{
format: 'custom-1',
serialize: () => 'test',
},
],
});
const options = ajv.multipart;
expect(options.serDesMap).has.property('custom-1');
expect(options.serDesMap['custom-1']).has.property('serialize');
expect(options.serDesMap['custom-1']).has.property('deserialize');
});
});

0 comments on commit e794c59

Please sign in to comment.