Skip to content

Commit

Permalink
feat: add response validator (#22)
Browse files Browse the repository at this point in the history
* feat: added response validator class

* fix: aligned remaining security validator syntax to the other classes

* feat: added ResponseValidator to the Endpoint class

* docs: updated readme with new response validator param

* chore: dropped support for node 14 (EOL) in favor of import assertion of json
  • Loading branch information
danielgolub committed May 20, 2023
1 parent 091db91 commit 2533fca
Show file tree
Hide file tree
Showing 18 changed files with 1,471 additions and 263 deletions.
7 changes: 5 additions & 2 deletions .eslintrc.yml
Expand Up @@ -12,9 +12,12 @@ overrides:
- "test/**/**/*.js"
rules:
no-console: off
parser: "@babel/eslint-parser"
parserOptions:
ecmaVersion: latest
sourceType: module
requireConfigFile: false
babelOptions:
plugins:
- '@babel/plugin-syntax-import-assertions'
rules:
max-len:
- 1
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/node.js.yml
Expand Up @@ -16,7 +16,7 @@ jobs:

strategy:
matrix:
node-version: [14.x, 16.x, 18.x]
node-version: [16.x, 18.x, 20.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/

steps:
Expand Down
6 changes: 5 additions & 1 deletion README.md
Expand Up @@ -49,6 +49,7 @@ const config = {
next();
}
},
enforceResponseValidation: false,
};

const app = express();
Expand All @@ -60,7 +61,10 @@ new openapiMiddleware.ExpressMiddleware(config)
app.use(router);
app.listen(2020, () => console.log('server is running!'));
})
.on('invalidResponse', (error) => {
console.error('silently failed on invalid response', error);
})
.on('error', (error) => {
console.error(error);
console.error('startup error', error);
});
```
57 changes: 37 additions & 20 deletions lib/Endpoint.js
@@ -1,6 +1,7 @@
import debug from 'debug';
import ParameterValidator from './ParameterValidator.js';
import SecurityValidator from './SecurityValidator.js';
import ResponseValidator from './ResponseValidator.js';

/**
* Endpoint instance (combines ParameterValidator + SecurityValidator)
Expand All @@ -9,22 +10,20 @@ import SecurityValidator from './SecurityValidator.js';
export default class Endpoint {
/**
* Endpoint constructor
* @property {Object[]} securitySchemes - openapi parameters definition
* @param {Map<string, function>} securityCallbacks
* @param {string} path
* @param {string} method
* @param {object} definition
* @param {Map<string, function>} controllerFunc
* @see {@link https://swagger.io/docs/specification/authentication/} - for securitySchemes
* @see {@link https://swagger.io/docs/specification/paths-and-operations/} - for definition
* @param {Object[]} securitySchemes openapi top-level security definition
* @param {Map<string, function>} securityCallbacks matching security definition validators
* @param {object} definition openapi endpoint definition
* @param {Map<string, function>} controllerFunc matching endpoint controller function
* @param {boolean} enforceResponseValidation flag for failing invalid responses (that don't match the endpoint's responseScheme)
* @see {@link https://swagger.io/docs/specification/authentication/}
* @see {@link https://swagger.io/docs/specification/paths-and-operations/}
*/
constructor(securitySchemes, securityCallbacks, path, method, definition, controllerFunc) {
this.path = path;
this.endpointDefinition = definition;
constructor(securitySchemes, securityCallbacks, definition, controllerFunc, enforceResponseValidation) {
this.securitySchemes = securitySchemes;
this.securityCallbacks = securityCallbacks;
this.method = method;
this.endpointDefinition = definition;
this.controllerFunc = controllerFunc;
this.enforceResponseValidation = enforceResponseValidation;

this.debug = debug('openapi:endpoint');

Expand All @@ -41,24 +40,42 @@ export default class Endpoint {
[contentType]: this.endpointDefinition.requestBody.content[contentType].schema,
}), {}) : null;
this.paramValidator = new ParameterValidator(this.endpointDefinition.parameters, requestBodyMap);
if (this.endpointDefinition.responses) {
this.responseValidator = new ResponseValidator(this.endpointDefinition.responses, this.enforceResponseValidation);
}
if (this.endpointDefinition.security) {
this.securityValidator = new SecurityValidator(this.securitySchemes, this.endpointDefinition.security, this.securityCallbacks);
}
}

/**
* Test endpoint against a given input
* @param {Map<string, any>} path
* @param {Map<string, any>} headers
* @param {Map<string, any>} queryParams
* @param {string} contentType
* @param {Map<string, any>} bodyParams
* Test request against the endpoint input validators
* @param {Map<string, any>} path path parameters object
* @param {Map<string, any>} headers header parameters object
* @param {Map<string, any>} queryParams query string parameters object
* @param {string} contentType content type of the input
* @param {Map<string, any>} bodyParams body
* @throws {module:ParameterError|module:SecurityError}
*/
test(path, headers, queryParams, contentType, bodyParams) {
async testIncoming(path, headers, queryParams, contentType, bodyParams) {
this.paramValidator.test(path, queryParams, { [contentType]: bodyParams });
if (this.securityValidator) {
this.securityValidator.test(headers, queryParams);
await this.securityValidator.test(headers, queryParams);
}
}

/**
* Test response against the endpoint output validators
* @param {string} statusCode response status code
* @param {string} contentType response content type
* @param {object} responseBody response body
* @return {Promise<ResponseError|boolean>}
*/
async testOutgoing(statusCode, contentType, responseBody) {
if (this.responseValidator) {
return this.responseValidator.test(statusCode, contentType, responseBody);
}

return true;
}
}
81 changes: 5 additions & 76 deletions lib/ParameterValidator.js
@@ -1,7 +1,7 @@
import debug from 'debug';
import inspector from 'schema-inspector';
import _ from 'lodash';
import ParameterError from './errors/ParameterError.js';
import { convertOpenAPIToSchemaInspector, parseContentTypesPayloads } from './helpers.js';

/**
* Parameter Validation class (should be initiated once per endpoint upon its setup)
Expand All @@ -24,67 +24,6 @@ export default class ParameterValidator {
this.setupSchema();
}

/**
* Parameter builder from openapi definition to schema-inspector
* @private
* @param {object} parent parent object in which we need to edit its properties
* @param {integer} i current iterator
* @return {object}
* @see {@link https://www.npmjs.com/package/schema-inspector} - for returned result
*/
static buildParam(parent, i) {
const param = parent.properties[i];
const correlations = _.pick(param, ['type', 'minLength', 'maxLength', 'properties', 'required', 'items']);
const newParam = {
...correlations,
min: param.minimum,
max: param.maximum,
};

if (param.type === 'number') {
if (param.format?.includes('int')) {
newParam.type = 'integer';
}
// TODO: multipleOf
}
if (param.type === 'string') {
if (param?.format === 'date') {
newParam.pattern = 'date';
}
if (param?.format === 'date-time') {
newParam.pattern = 'date-time';
}
if (param?.pattern) {
newParam.pattern = param.pattern;
}
}

if (param.nullable) {
newParam.type = ['null', param.type];
}

return newParam;
}

/**
* Recursion class that goes through each open api definition (to convert to schema-inspector)
* @private
* @param {object} param
*/
eachRecursive(param) {
// eslint-disable-next-line no-param-reassign
if (param.properties) {
// eslint-disable-next-line no-restricted-syntax
for (const i of Object.keys(param.properties)) {
// eslint-disable-next-line no-param-reassign
param.properties[i] = ParameterValidator.buildParam(param, i);
// eslint-disable-next-line no-param-reassign
param.properties[i].optional = !param.required || !param.required.includes(i);
this.eachRecursive(param.properties[i]);
}
}
}

/**
* Setup the dynamic schema of parameters (path / query / body)
* @private
Expand All @@ -110,7 +49,7 @@ export default class ParameterValidator {
},
};
this.schema.required.push('path');
this.eachRecursive(this.schema.properties.path);
convertOpenAPIToSchemaInspector(this.schema.properties.path);
}

if (this.definition.qsParamsDefinition) {
Expand All @@ -122,23 +61,13 @@ export default class ParameterValidator {
},
};
this.schema.required.push('qs');
this.eachRecursive(this.schema.properties.qs);
convertOpenAPIToSchemaInspector(this.schema.properties.qs);
}

if (this.definition.requestBodyDefinition) {
this.schema.properties.requestBody = {
type: 'object',
strict: true,
error: 'request body content-type invalid',
properties: {
...Object.keys(this.definition.requestBodyDefinition).reduce((all, contentType) => ({
...all,
[contentType]: _.pick(this.definition.requestBodyDefinition[contentType], ['properties', 'type', 'required']),
}), {}),
},
};
this.schema.properties.requestBody = parseContentTypesPayloads(this.definition.requestBodyDefinition);
this.schema.required.push('requestBody');
this.eachRecursive(this.schema.properties.requestBody);
convertOpenAPIToSchemaInspector(this.schema.properties.requestBody);
}

this.debug('created validation schema', JSON.stringify(this.schema));
Expand Down
112 changes: 112 additions & 0 deletions lib/ResponseValidator.js
@@ -0,0 +1,112 @@
import debug from 'debug';
import inspector from 'schema-inspector';
import _ from 'lodash';
import ResponseError from './errors/ResponseError.js';
import { convertOpenAPIToSchemaInspector, parseContentTypesPayloads } from './helpers.js';
import openApiResponsesSchema from './openapi-validators/responses.json' assert { type: "json" };

/**
* Response Validation class (should be initiated once per endpoint upon its setup)
* @module ResponseValidator
*/
export default class ResponseValidator {
/**
* ResponseValidator
* @property {Object[]} responseDefinition - openapi responses definition
* @property {boolean} shouldEnforce - whether to throw or silently fail on invalid response
* @see {@link https://swagger.io/docs/specification/describing-responses/} - for responseDefinition
*/
constructor(responseDefinition, shouldEnforce) {
this.debug = debug('openapi:response');
/**
* OpenAPI definition for responses
* @type object
* @see {@link https://swagger.io/docs/specification/describing-responses/} - for responseDefinition
*/
this.definition = responseDefinition;

/**
* whether to throw or silently fail on invalid response
* @type {boolean}
*/
this.shouldEnforce = shouldEnforce;

this.debug('set response definition', JSON.stringify(this.definition));

this.validateOpenAPISchema();
this.setupTestSchema();
}

/**
* Validate the openapi schema
* @private
*/
validateOpenAPISchema() {
const validationSchema = {
...openApiResponsesSchema,
exec(schema, value) {
const statusCodes = Object.keys(value);
const invalidStatusCodes = statusCodes.filter((statusCode) => !/\d/.test(statusCode) && statusCode !== 'default');
if (invalidStatusCodes.length > 0) {
this.report(`invalid status codes: ${invalidStatusCodes.join(', ')}`, 'INVALID_STATUS_CODES');
}
},
};

const result = inspector.validate(validationSchema, this.definition);
if (!result.valid) {
throw new ResponseError('invalid openapi schema provided', result.error);
}
}

/**
* Convert openapi input to schema-inspector format
* @private
*/
setupTestSchema() {
const testProperties = _.reduce(this.definition, (all, { content: contentTypes }, statusCode) => {
const statusContentTypes = _.reduce(contentTypes, (allContentTypes, contentTypeDef, contentType) => {
const data = { ...contentTypeDef.schema };
convertOpenAPIToSchemaInspector(data);

return {
...allContentTypes,
[contentType]: data,
};
}, {});

return {
...all,
[statusCode]: parseContentTypesPayloads(statusContentTypes),
};
}, {});

this.testSchema = {
type: 'object',
strict: true,
error: 'status code is illegal',
properties: testProperties,
};
}

/**
* test an outgoing response against the current instance of ResponseValidator
* @param {string} statusCode response status code
* @param {string} contentType response content type
* @param {object} body response body
* @return {ResponseError|boolean}
*/
test(statusCode, contentType, body) {
const result = inspector.validate(this.testSchema, { [statusCode]: { [contentType]: body } });

if (!result.valid) {
const error = new ResponseError('invalid response sent from endpoint controller', result.error);
if (this.shouldEnforce) {
throw error;
}
return error;
}

return true;
}
}

0 comments on commit 2533fca

Please sign in to comment.