Skip to content

Commit

Permalink
add support for response validation
Browse files Browse the repository at this point in the history
  • Loading branch information
Carmine DiMascio committed Sep 15, 2019
1 parent d7fd2b7 commit 7c27713
Show file tree
Hide file tree
Showing 9 changed files with 218 additions and 177 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ app.use('/spec', express.static(spec));

// 3. Install the OpenApiValidator onto your express app
new OpenApiValidator({
apiSpecPath: './openapi.yaml',
apiSpec: './openapi.yaml',
}).install(app);

// 4. Define routes using Express
Expand Down
37 changes: 19 additions & 18 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,26 @@ import ono from 'ono';
import { OpenAPIV3, OpenApiRequest } from './framework/types';

export interface OpenApiValidatorOpts {
apiSpecPath?: string;
apiSpec?: OpenAPIV3.Document | string;
apiSpec: OpenAPIV3.Document | string;
coerceTypes?: boolean;
validateResponses?: boolean;
validateRequests?: boolean;
multerOpts?: {};
}

export class OpenApiValidator {
private context: OpenApiContext;
private coerceTypes: boolean;
private multerOpts: {};
private options: OpenApiValidatorOpts;

constructor(options: OpenApiValidatorOpts) {
if (!options.apiSpecPath && !options.apiSpec)
throw ono('apiSpecPath or apiSpec required');
if (options.apiSpecPath && options.apiSpec)
throw ono('apiSpecPath or apiSpec required. not both.');
if (!options.apiSpec) throw ono('apiSpec required.');
if (options.coerceTypes == null) options.coerceTypes = true;
if (options.validateRequests == null) options.validateRequests = true;

this.coerceTypes = options.coerceTypes == null ? true : options.coerceTypes;
this.multerOpts = options.multerOpts;
this.options = options;

const openApiContext = new OpenApiContext({
apiDoc: options.apiSpecPath || options.apiSpec,
apiDoc: options.apiSpec,
});

this.context = openApiContext;
Expand All @@ -52,9 +50,10 @@ export class OpenApiValidator {
});
}

const coerceTypes = this.options.coerceTypes;
const aoav = new middlewares.RequestValidator(this.context.apiDoc, {
nullable: true,
coerceTypes: this.coerceTypes,
coerceTypes,
removeAdditional: false,
useDefaults: true,
});
Expand All @@ -64,14 +63,16 @@ export class OpenApiValidator {
};

const resOav = new middlewares.ResponseValidator(this.context.apiDoc, {
coerceTypes: this.coerceTypes,
coerceTypes,
});

app.use(
const use = [
middlewares.applyOpenApiMetadata(this.context),
middlewares.multipart(this.context, this.multerOpts),
validateMiddleware,
resOav.validate(),
);
middlewares.multipart(this.context, this.options.multerOpts),
];
if (this.options.validateRequests) use.push(validateMiddleware);
if (this.options.validateResponses) use.push(resOav.validate());

app.use(use);
}
}
16 changes: 8 additions & 8 deletions src/middlewares/openapi.response.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ export class ResponseValidator {
private ajv;
private spec;
private validatorsCache = {};
private useCache: boolean;

constructor(openApiSpec, options: any = {}) {
this.spec = openApiSpec;
this.ajv = createResponseAjv(openApiSpec, options);
(<any>mung).onError = function(err, req, res) {
// monkey patch mung to rethrow exception
throw err;
};
}

validate() {
Expand Down Expand Up @@ -47,10 +51,7 @@ export class ResponseValidator {
}

_validate({ validators, body, statusCode }) {
// TODO build validators should be cached per endpoint
// const validators: any = this.buildValidators(responses);

// find a response by status code or 'default'
// find the validator for the 'status code' e.g 200, 2XX or 'default'
let validator;
const status = statusCode;
if (status) {
Expand All @@ -65,7 +66,7 @@ export class ResponseValidator {
}

if (!validator) {
console.log('no validator found');
console.warn('no validator found');
// assume valid
return;
}
Expand All @@ -75,9 +76,8 @@ export class ResponseValidator {

if (!valid) {
const errors = validator.errors;
console.log(errors);
const message = this.ajv.errorsText(errors, {
dataVar: 'request',
dataVar: '', // responses
});
throw ono(ajvErrorsToValidatorError(500, errors), message);
}
Expand Down
16 changes: 8 additions & 8 deletions test/common/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ export async function createApp(

new OpenApiValidator(opts).install(app);

if (useRoutes) routes(app);

// Register error handler
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status || 500).json({
errors: err.errors,
if (useRoutes) {
routes(app);
// Register error handler
app.use((err, req, res, next) => {
res.status(err.status || 500).json({
errors: err.errors,
});
});
});
}

const server = await startServer(app, port);
const shutDown = () => {
Expand Down
2 changes: 1 addition & 1 deletion test/middleware.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe(packageJson.name, () => {
it('should throw when spec is missing', async () => {
const createMiddleware = () =>
new OpenApiValidator({
apiSpecPath: './not-found.yaml',
apiSpec: './not-found.yaml',
});

expect(createMiddleware).to.throw(
Expand Down
1 change: 0 additions & 1 deletion test/query.params.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ describe(packageJson.name, () => {
})
.expect(400)
.then(r => {
console.log(r.body);
expect(r.body.errors).to.be.an('array');
}));
});
5 changes: 5 additions & 0 deletions test/resources/response.validation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ paths:
get:
description: find pets
operationId: findPets
parameters:
- name: mode
in: query
schema:
type: string
responses:
'200':
description: pet response
Expand Down
165 changes: 25 additions & 140 deletions test/response.validation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,167 +1,52 @@
import * as path from 'path';
import * as fs from 'fs';
import * as express from 'express';
import * as jsyaml from 'js-yaml';
import { expect } from 'chai';
import * as request from 'supertest';
import { ResponseValidator } from '../src/middlewares/openapi.response.validator';
import { createApp } from './common/app';

const packageJson = require('../package.json');
const apiSpecPath = path.join('test', 'resources', 'response.validation.yaml');
const apiSpec = jsyaml.safeLoad(fs.readFileSync(apiSpecPath, 'utf8'));

describe(packageJson.name, () => {
let app = null;
let basePath = null;

before(async () => {
// Set up the express app
app = await createApp({ apiSpec, coerceTypes: false }, 3005, false);
basePath = app.basePath;
app.use(
`${basePath}`,
express.Router().get(`/pets/rejectadditionalProps`, (req, res) =>
res.json([
{
id: 1,
type: 'test',
tag: 'woah',
},
]),
),
app = await createApp(
{ apiSpec: apiSpecPath, validateResponses: true },
3005,
false,
);
basePath = app.basePath;
app.get(`${basePath}/pets`, (req, res) => {
let json = {};
if ((req.query.mode = 'bad_type')) {
json = [{ id: 'bad_id', name: 'name', tag: 'tag' }];
}
return res.json(json);
});
// Register error handler
app.use((err, req, res, next) => {
res.status(err.status || 500).json({
message: err.message,
code: err.status,
});
});
});

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

it('should fail if response does not match', async () =>
it('should fail if response field has a value of incorrect type', async () =>
request(app)
.get(`${basePath}/rejectadditionalProps`)
.get(`${basePath}/pets?mode=bad_type`)
.expect(500)
.then((r: any) => {
expect(r.body.error).to.be.not.null;
expect(r.body.message).to.contain('should be integer');
expect(r.body)
.to.have.property('code')
.that.equals(500);
}));

// TODO
it.only('should always return valid for non-JSON responses', async () => {
const v = new ResponseValidator(apiSpec);
const responses = petsResponseSchema();
const statusCode = 200;

try {
const validators = v._getOrBuildValidator(null, responses);
v._validate({
validators,
body: { id: 1, name: 'test', tag: 'tag' },
statusCode,
});
} catch (e) {
expect(e).to.be.not.null;
expect(e.message).to.contain('should be array');
}
});

// TODO
it.only('should validate the error object', async () => {});

it.only('should return an error if field type is invalid', async () => {
const v = new ResponseValidator(apiSpec);
const responses = petsResponseSchema();
const statusCode = 200;

const validators = v._getOrBuildValidator(null, responses);
try {
v._validate({
validators,
body: [{ id: 'bad-id', name: 'test', tag: 'tag' }],
statusCode,
});
} catch (e) {
expect(e).to.be.not.null;
expect(e.message).to.contain('should be integer');
expect(e.message).to.not.contain('additional properties');
}

try {
v._validate({
validators,
body: { id: 1, name: 'test', tag: 'tag' },
statusCode,
});
} catch (e) {
expect(e).to.be.not.null;
expect(e.message).to.contain('should be array');
}

try {
v._validate({
validators,
body: [{ id: 1, name: [], tag: 'tag' }],
statusCode,
});
} catch (e) {
expect(e).to.be.not.null;
expect(e.message).to.contain('should be string');
}
});

// TODO may not be possible to fix
// https://github.com/epoberezkin/ajv/issues/837
it.skip('should if additional properties are provided when set false', async () => {
const v = new ResponseValidator(apiSpec);
const responses = petsResponseSchema();
const statusCode = 200;
const body = [
{
id: 10,
name: 'test',
tag: 'tag',
additionalProp: 'test',
},
];
try {
const validators = v._getOrBuildValidator(null, responses);
v._validate({ validators, body, statusCode });
expect('here').to.be.null;
} catch (e) {
// TODO include params.additionalProperty: value in error message
// TODO maybe params should be in the response
expect(e.message).to.contain('should NOT have additional properties');
expect(e.status).to.equal(500);
expect(e.errors[0].message).to.contain(
'should NOT have additional properties',
);
}
});
});

function petsResponseSchema() {
return {
'200': {
description: 'pet response',
content: {
'application/json': {
schema: {
type: 'array',
items: {
$ref: '#/components/schemas/Pet',
},
},
},
},
},
default: {
description: 'unexpected error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
};
}
Loading

0 comments on commit 7c27713

Please sign in to comment.