diff --git a/README.md b/README.md index f3cf27bb..1cd9908c 100644 --- a/README.md +++ b/README.md @@ -569,6 +569,21 @@ Determines whether the validator should validate responses. Also accepts respons } ``` + **onError:** + + A function that will be invoked on response validation error, instead of the default handling. Useful if you want to log an error or emit a metric, but don't want to actually fail the request. Receives the validation error and offending response body. + + For example: + + ``` + validateResponses: { + onError: (error, body) => { + console.log(`Response body fails validation: `, error); + console.debug(body); + } + } + ``` + ### ▪️ validateSecurity (optional) Determines whether the validator should validate securities e.g. apikey, basic, oauth2, openid, etc diff --git a/src/framework/types.ts b/src/framework/types.ts index a67612db..b9391fea 100644 --- a/src/framework/types.ts +++ b/src/framework/types.ts @@ -47,6 +47,7 @@ export type ValidateRequestOpts = { export type ValidateResponseOpts = { removeAdditional?: 'failing' | boolean; coerceTypes?: boolean | 'array'; + onError?: (err: InternalServerError, json: any) => void; }; export type ValidateSecurityOpts = { diff --git a/src/middlewares/openapi.response.validator.ts b/src/middlewares/openapi.response.validator.ts index 55569988..798a2ba3 100644 --- a/src/middlewares/openapi.response.validator.ts +++ b/src/middlewares/openapi.response.validator.ts @@ -13,6 +13,7 @@ import { OpenApiRequest, OpenApiRequestMetadata, InternalServerError, + ValidateResponseOpts, } from '../framework/types'; import * as mediaTypeParser from 'media-typer'; import * as contentTypeParser from 'content-type'; @@ -30,11 +31,19 @@ export class ResponseValidator { private validatorsCache: { [key: string]: { [key: string]: ajv.ValidateFunction }; } = {}; + private eovOptions: ValidateResponseOpts - constructor(openApiSpec: OpenAPIV3.Document, options: ajv.Options = {}) { + constructor( + openApiSpec: OpenAPIV3.Document, + options: ajv.Options = {}, + eovOptions: ValidateResponseOpts = {} + ) { this.spec = openApiSpec; this.ajvBody = createResponseAjv(openApiSpec, options); + this.eovOptions = eovOptions; + // This is a pseudo-middleware function. It doesn't get registered with + // express via `use` (mung).onError = (err, req, res, next) => { return next(err); }; @@ -60,13 +69,23 @@ export class ResponseValidator { ? accept.split(',').map((h) => h.trim()) : []; - return this._validate({ - validators, - body, - statusCode, - path, - accepts, // return 406 if not acceptable - }); + try { + return this._validate({ + validators, + body, + statusCode, + path, + accepts, // return 406 if not acceptable + }); + } catch (err) { + // If a custom error handler was provided, we call that + if (err instanceof InternalServerError && this.eovOptions.onError) { + this.eovOptions.onError(err, body) + } else { + // No custom error handler, or something unexpected happen. + throw err; + } + } } return body; }); diff --git a/src/openapi.validator.ts b/src/openapi.validator.ts index e95013dc..61a833b6 100644 --- a/src/openapi.validator.ts +++ b/src/openapi.validator.ts @@ -71,6 +71,7 @@ export class OpenApiValidator { options.validateResponses = { removeAdditional: false, coerceTypes: false, + onError: null }; } @@ -272,6 +273,8 @@ export class OpenApiValidator { return new middlewares.ResponseValidator( apiDoc, this.ajvOpts.response, + // This has already been converted from boolean if required + this.options.validateResponses as ValidateResponseOpts ).validate(); } diff --git a/test/response.validation.on.error.spec.ts b/test/response.validation.on.error.spec.ts new file mode 100644 index 00000000..371cacd4 --- /dev/null +++ b/test/response.validation.on.error.spec.ts @@ -0,0 +1,93 @@ +import * as path from 'path'; +import { expect } from 'chai'; +import * as request from 'supertest'; +import { createApp } from './common/app'; +import * as packageJson from '../package.json'; + +const apiSpecPath = path.join('test', 'resources', 'response.validation.yaml'); + +describe(packageJson.name, () => { + let app = null; + + let onErrorArgs = null; + before(async () => { + // set up express app + app = await createApp( + { + apiSpec: apiSpecPath, + validateResponses: { + onError: function(_err, body) { + onErrorArgs = Array.from(arguments); + if (body[0].id === 'bad_id_throw') { + throw new Error('error in onError handler'); + } + } + }, + }, + 3005, + app => { + app.get(`${app.basePath}/users`, (_req, res) => { + const json = ['user1', 'user2', 'user3']; + return res.json(json); + }); + app.get(`${app.basePath}/pets`, (req, res) => { + let json = {}; + if (req.query.mode === 'bad_type') { + json = [{ id: 'bad_id', name: 'name', tag: 'tag' }]; + } else if (req.query.mode === 'bad_type_throw') { + json = [{ id: 'bad_id_throw', name: 'name', tag: 'tag' }]; + } + return res.json(json); + }); + app.use((err, _req, res, _next) => { + res.status(err.status ?? 500).json({ + message: err.message, + code: err.status ?? 500, + }); + }); + }, + false, + ); + }); + + afterEach(() => { + onErrorArgs = null; + }) + + after(() => { + app.server.close(); + }); + + it('custom error handler invoked if response field has a value of incorrect type', async () => + request(app) + .get(`${app.basePath}/pets?mode=bad_type`) + .expect(200) + .then((r: any) => { + const data = [{ id: 'bad_id', name: 'name', tag: 'tag' }]; + expect(r.body).to.eql(data); + expect(onErrorArgs.length).to.equal(2); + expect(onErrorArgs[0].message).to.equal('.response[0].id should be integer'); + expect(onErrorArgs[1]).to.eql(data); + })); + + it('custom error handler not invoked on valid response', async () => + request(app) + .get(`${app.basePath}/users`) + .expect(200) + .then((r: any) => { + expect(r.body).is.an('array').with.length(3); + expect(onErrorArgs).to.equal(null); + })); + + it('returns error if custom error handler throws', async () => + request(app) + .get(`${app.basePath}/pets?mode=bad_type_throw`) + .expect(500) + .then((r: any) => { + 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(2); + expect(onErrorArgs[0].message).to.equal('.response[0].id should be integer'); + expect(onErrorArgs[1]).to.eql(data); + })); +});