UNPKG

openapi-backend

Version:

Build, Validate, Route, Authenticate and Mock using OpenAPI definitions. Framework-agnostic

757 lines 31.3 kB
"use strict"; // library code, any is fine /* eslint-disable @typescript-eslint/no-explicit-any */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.OpenAPIValidator = exports.ValidationContext = void 0; const _ = __importStar(require("lodash")); const ajv_1 = __importDefault(require("ajv")); const router_1 = require("./router"); const utils_1 = __importDefault(require("./utils")); const backend_1 = require("./backend"); var ValidationContext; (function (ValidationContext) { ValidationContext["RequestBody"] = "requestBodyValidator"; ValidationContext["Params"] = "paramsValidator"; ValidationContext["Response"] = "responseValidator"; ValidationContext["ResponseHeaders"] = "responseHeadersValidator"; })(ValidationContext || (exports.ValidationContext = ValidationContext = {})); /** * Returns a function that validates that a signed number is within the given bit range * @param {number} bits */ function getBitRangeValidator(bits) { const max = Math.pow(2, bits - 1); return (value) => value >= -max && value < max; } // Formats defined by the OAS const defaultFormats = { int32: { // signed 32 bits type: 'number', validate: getBitRangeValidator(32), }, int64: { // signed 64 bits (a.k.a long) type: 'number', validate: getBitRangeValidator(64), }, float: { type: 'number', validate: () => true, }, double: { type: 'number', validate: () => true, }, byte: { // base64 encoded characters type: 'string', validate: /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/, }, binary: { // any sequence of octets type: 'string', validate: () => true, }, password: { // A hint to UIs to obscure input. type: 'string', validate: () => true, }, }; /** * Class that handles JSON schema validation * * @export * @class OpenAPIValidator */ class OpenAPIValidator { /** * Creates an instance of OpenAPIValidation * * @param opts - constructor options * @param {Document | string} opts.definition - the OpenAPI definition, file path or Document object * @param {object} opts.ajvOpts - default ajv constructor opts (default: { unknownFormats: 'ignore' }) * @param {OpenAPIRouter} opts.router - passed instance of OpenAPIRouter. Will create own child if no passed * @param {boolean} opts.lazyCompileValidators - skips precompiling Ajv validators and compiles only when needed * @param {boolean} opts.coerceTypes - coerce types in request query and path parameters * @memberof OpenAPIRequestValidator */ constructor(opts) { this.definition = opts.definition; this.ajvOpts = { strict: false, ...(opts.ajvOpts || {}), }; this.customizeAjv = opts.customizeAjv; this.coerceTypes = opts.coerceTypes || false; // initalize router this.router = opts.router || new router_1.OpenAPIRouter({ definition: this.definition }); // initialize validator stores this.requestValidators = {}; this.responseValidators = {}; this.statusBasedResponseValidators = {}; this.responseHeadersValidators = {}; // precompile validators if not in lazy mode if (!opts.lazyCompileValidators) { this.preCompileRequestValidators(); this.preCompileResponseValidators(); this.preCompileResponseHeaderValidators(); } } /** * Pre-compiles Ajv validators for requests of all api operations * * @memberof OpenAPIValidator */ preCompileRequestValidators() { const operations = this.router.getOperations(); for (const operation of operations) { const operationId = utils_1.default.getOperationId(operation); this.requestValidators[operationId] = this.buildRequestValidatorsForOperation(operation); } } /** * Pre-compiles Ajv validators for responses of all api operations * * @memberof OpenAPIValidator */ preCompileResponseValidators() { const operations = this.router.getOperations(); for (const operation of operations) { const operationId = utils_1.default.getOperationId(operation); this.responseValidators[operationId] = this.buildResponseValidatorForOperation(operation); this.statusBasedResponseValidators[operationId] = this.buildStatusBasedResponseValidatorForOperation(operation); } } /** * Pre-compiles Ajv validators for response headers of all api operations * * @memberof OpenAPIValidator */ preCompileResponseHeaderValidators() { const operations = this.router.getOperations(); for (const operation of operations) { const operationId = utils_1.default.getOperationId(operation); this.responseHeadersValidators[operationId] = this.buildResponseHeadersValidatorForOperation(operation); } } /** * Validates a request against prebuilt Ajv validators and returns the validation result. * * The method will first match the request to an API operation and use the pre-compiled Ajv validation schema to * validate it. * * @param {Request} req - request to validate * @param {(Operation<D> | string)} operation - operation to validate against * @returns {ValidationResult} * @memberof OpenAPIRequestValidator */ validateRequest(req, operation) { const result = { valid: true, coerced: { ...req } }; result.errors = []; if (!operation) { operation = this.router.matchOperation(req); } else if (typeof operation === 'string') { operation = this.router.getOperation(operation); } if (!operation || !operation.operationId) { throw new Error(`Unknown operation`); } // get pre-compiled ajv schemas for operation const { operationId } = operation; const validators = this.getRequestValidatorsForOperation(operationId) || []; // build a parameter object to validate const { params, query, headers, cookies, requestBody } = this.router.parseRequest(req, operation); // convert singular query parameters to arrays if specified as array in operation parametes if (query) { for (const [name, value] of _.entries(query)) { if (typeof value === 'string') { const operationParameter = _.find(operation.parameters, { name, in: 'query' }); if (operationParameter) { const { schema } = operationParameter; if (schema && schema.type === 'array') { query[name] = [value]; } } } } } const parameters = _.omitBy({ path: params, query, header: headers, cookie: cookies, }, _.isNil); if (typeof req.body !== 'object' && req.body !== undefined) { const payloadFormats = _.keys(_.get(operation, 'requestBody.content', {})); if (payloadFormats.length === 1 && payloadFormats[0] === 'application/json') { // check that JSON isn't malformed when the only payload format is JSON try { JSON.parse(`${req.body}`); } catch (err) { if (err instanceof Error) { result.errors.push({ keyword: 'parse', instancePath: '', schemaPath: '#/requestBody', params: [], message: err.message, }); } } } } if (typeof requestBody === 'object' || headers['content-type'] === 'application/json') { // include request body in validation if an object is provided parameters.requestBody = requestBody; } // validate parameters against each pre-compiled schema for (const validate of validators) { validate(parameters); if (validate.errors) { result.errors.push(...validate.errors); } else if (this.coerceTypes) { result.coerced.query = parameters.query; result.coerced.params = parameters.path; } } if (_.isEmpty(result.errors)) { // set empty errors array to null so we can check for result.errors truthiness result.errors = null; } else { // there were errors, set valid to false result.valid = false; } return result; } /** * Validates a response against a prebuilt Ajv validator and returns the result * * @param {*} res * @param {(Operation<D> | string)} operation * @package {number} [statusCode] * @returns {ValidationResult} * @memberof OpenAPIRequestValidator */ validateResponse(res, operation, statusCode) { const result = { valid: true }; result.errors = []; const op = typeof operation === 'string' ? this.router.getOperation(operation) : operation; if (!op || !op.operationId) { throw new Error(`Unknown operation`); } const { operationId } = op; let validate = null; if (statusCode) { // use specific status code const validateMap = this.getStatusBasedResponseValidatorForOperation(operationId); if (validateMap) { validate = utils_1.default.findStatusCodeMatch(statusCode, validateMap); } } else { // match against all status codes validate = this.getResponseValidatorForOperation(operationId); } if (validate) { // perform validation against response validate(res); if (validate.errors) { result.errors.push(...validate.errors); } } else { // maybe we should warn about this? TODO: add option to enable / disable warnings // console.warn(`No validation matched for ${JSON.stringify({ operationId, statusCode })}`); } if (_.isEmpty(result.errors)) { // set empty errors array to null so we can check for result.errors truthiness result.errors = null; } else { // there were errors, set valid to false result.valid = false; } return result; } /** * Validates response headers against a prebuilt Ajv validator and returns the result * * @param {*} headers * @param {(Operation<D> | string)} operation * @param {number} [opts.statusCode] * @param {SetMatchType} [opts.setMatchType] - one of 'any', 'superset', 'subset', 'exact' * @returns {ValidationResult} * @memberof OpenAPIRequestValidator */ validateResponseHeaders(headers, operation, opts) { const result = { valid: true }; result.errors = []; const op = typeof operation === 'string' ? this.router.getOperation(operation) : operation; if (!op || !op.operationId) { throw new Error(`Unknown operation`); } let setMatchType = opts && opts.setMatchType; const statusCode = opts && opts.statusCode; if (!setMatchType) { setMatchType = backend_1.SetMatchType.Any; } else if (!_.includes(Object.values(backend_1.SetMatchType), setMatchType)) { throw new Error(`Unknown setMatchType ${setMatchType}`); } const { operationId } = op; const validateMap = this.getResponseHeadersValidatorForOperation(operationId); if (validateMap) { let validateForStatus; if (statusCode) { validateForStatus = utils_1.default.findStatusCodeMatch(statusCode, validateMap); } else { validateForStatus = utils_1.default.findDefaultStatusCodeMatch(validateMap).res; } if (validateForStatus) { const validate = validateForStatus[setMatchType]; if (validate) { headers = _.mapKeys(headers, (value, headerName) => headerName.toLowerCase()); validate({ headers }); if (validate.errors) { result.errors.push(...validate.errors); } } } } if (_.isEmpty(result.errors)) { // set empty errors array to null so we can check for result.errors truthiness result.errors = null; } else { // there were errors, set valid to false result.valid = false; } return result; } /** * Get an array of request validator functions for an operation by operationId * * @param {string} operationId * @returns {*} {(ValidateFunction[] | null)} * @memberof OpenAPIValidator */ getRequestValidatorsForOperation(operationId) { if (this.requestValidators[operationId] === undefined) { const operation = this.router.getOperation(operationId); this.requestValidators[operationId] = this.buildRequestValidatorsForOperation(operation); } return this.requestValidators[operationId]; } /** * Compiles a schema with Ajv instance and handles circular references. * * @param ajv The Ajv instance * @param schema The schema to compile */ static compileSchema(ajv, schema) { const decycledSchema = this.decycle(schema); return ajv.compile(decycledSchema); } /** * Produces a deep clone which replaces object reference cycles with JSONSchema refs. * This function is based on [cycle.js]{@link https://github.com/douglascrockford/JSON-js/blob/master/cycle.js}, which was referred by * the [MDN]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value}. * @param object An object for which to remove cycles */ static decycle(object) { const objects = new WeakMap(); // object to path mappings return (function derez(value, path) { // The derez function recurses through the object, producing the deep copy. let oldPath; // The path of an earlier occurance of value let nu; // The new object or array // typeof null === "object", so go on if this value is really an object but not // one of the weird builtin objects. if (typeof value === 'object' && value !== null && !(value instanceof Boolean) && !(value instanceof Date) && !(value instanceof Number) && !(value instanceof RegExp) && !(value instanceof String)) { // If the value is an object or array, look to see if we have already // encountered it. If so, return a {"$ref":PATH} object. This uses an // ES6 WeakMap. oldPath = objects.get(value); if (oldPath !== undefined) { return { $ref: oldPath }; } // Otherwise, accumulate the unique value and its path. objects.set(value, path); // If it is an array, replicate the array. if (Array.isArray(value)) { nu = []; value.forEach((element, i) => { nu[i] = derez(element, path + '/' + i); }); } else { // If it is an object, replicate the object. nu = {}; Object.keys(value).forEach((name) => { nu[name] = derez(value[name], path + '/' + name); }); } return nu; } return value; })(object, '#'); } /** * Builds Ajv request validation functions for an operation and registers them to requestValidators * * @param {Operation<D>} operation * @returns {*} {(ValidateFunction[] | null)} * @memberof OpenAPIValidator */ buildRequestValidatorsForOperation(operation) { if (!(operation === null || operation === void 0 ? void 0 : operation.operationId)) { // no operationId, don't register a validator return null; } // validator functions for this operation const validators = []; // schema for operation requestBody if (operation.requestBody) { const requestBody = operation.requestBody; const jsonbody = requestBody.content['application/json']; if (jsonbody && jsonbody.schema) { const requestBodySchema = { title: 'Request', type: 'object', additionalProperties: true, properties: { requestBody: jsonbody.schema, }, }; requestBodySchema.required = []; if (_.keys(requestBody.content).length === 1) { // if application/json is the only specified format, it's required requestBodySchema.required.push('requestBody'); } // add compiled params schema to schemas for this operation id const requestBodyValidator = this.getAjv(ValidationContext.RequestBody); validators.push(OpenAPIValidator.compileSchema(requestBodyValidator, requestBodySchema)); } } // schema for operation parameters in: path,query,header,cookie const paramsSchema = { title: 'Request', type: 'object', additionalProperties: true, properties: { path: { type: 'object', additionalProperties: false, properties: {}, required: [], }, query: { type: 'object', properties: {}, additionalProperties: false, required: [], }, header: { type: 'object', additionalProperties: true, properties: {}, required: [], }, cookie: { type: 'object', additionalProperties: true, properties: {}, required: [], }, }, required: [], }; // params are dereferenced here, no reference objects. const { parameters } = operation; if (parameters) { parameters.map((parameter) => { const param = parameter; const target = paramsSchema.properties[param.in]; // Header params are case-insensitive according to https://tools.ietf.org/html/rfc7230#page-22, so they are // normalized to lower case and validated as such. const normalizedParamName = param.in === 'header' ? param.name.toLowerCase() : param.name; if (param.required) { target.required = target.required || []; target.required = _.uniq([...target.required, normalizedParamName]); paramsSchema.required = _.uniq([...paramsSchema.required, param.in]); } target.properties = target.properties || {}; const paramSchema = param.schema; // Assign the target schema's additionalProperties to the param schema's additionalProperties if the param's additionalProperties is set. // This is to support free-form query params where `additionalProperties` is an object. // https://swagger.io/specification/?sbsearch=free%20form if (paramSchema && (paramSchema === null || paramSchema === void 0 ? void 0 : paramSchema.additionalProperties) !== undefined) { target.additionalProperties = paramSchema.additionalProperties; } if (param.content && param.content['application/json']) { target.properties[normalizedParamName] = param.content['application/json'].schema; } else { target.properties[normalizedParamName] = param.schema; } }); } // add compiled params schema to requestValidators for this operation id const paramsValidator = this.getAjv(ValidationContext.Params, { coerceTypes: true }); validators.push(OpenAPIValidator.compileSchema(paramsValidator, paramsSchema)); return validators; } /** * Get response validator function for an operation by operationId * * @param {string} operationId * @returns {*} {(ValidateFunction | null)} * @memberof OpenAPIValidator */ getResponseValidatorForOperation(operationId) { if (this.responseValidators[operationId] === undefined) { const operation = this.router.getOperation(operationId); this.responseValidators[operationId] = this.buildResponseValidatorForOperation(operation); } return this.responseValidators[operationId]; } /** * Builds an ajv response validator function for an operation and registers it to responseValidators * * @param {Operation<D>} operation * @returns {*} {(ValidateFunction | null)} * @memberof OpenAPIValidator */ buildResponseValidatorForOperation(operation) { if (!operation || !operation.operationId) { // no operationId, don't register a validator return null; } if (!operation.responses) { // operation has no responses, don't register a validator return null; } const responseSchemas = []; _.mapKeys(operation.responses, (res, _status) => { const response = res; if (response.content && response.content['application/json'] && response.content['application/json'].schema) { responseSchemas.push(response.content['application/json'].schema); } return null; }); if (_.isEmpty(responseSchemas)) { // operation has no response schemas, don't register a validator return null; } // compile the validator function and register to responseValidators const schema = { oneOf: responseSchemas }; const responseValidator = this.getAjv(ValidationContext.Response); return OpenAPIValidator.compileSchema(responseValidator, schema); } /** * Get response validator function for an operation by operationId * * @param {string} operationId * @returns {*} {(StatusBasedResponseValidatorsFunctionMap | null)} * @memberof OpenAPIRequestValidator */ getStatusBasedResponseValidatorForOperation(operationId) { if (this.statusBasedResponseValidators[operationId] === undefined) { const operation = this.router.getOperation(operationId); this.statusBasedResponseValidators[operationId] = this.buildStatusBasedResponseValidatorForOperation(operation); } return this.statusBasedResponseValidators[operationId]; } /** * Builds an ajv response validator function for an operation and registers it to responseHeadersValidators * * @param {Operation<D>} operation * @returns {*} {(StatusBasedResponseValidatorsFunctionMap | null)} * @memberof OpenAPIValidator */ buildStatusBasedResponseValidatorForOperation(operation) { if (!operation || !operation.operationId) { // no operationId, don't register a validator return null; } if (!operation.responses) { // operation has no responses, don't register a validator return null; } const responseValidators = {}; const validator = this.getAjv(ValidationContext.Response); _.mapKeys(operation.responses, (res, status) => { const response = res; if (response.content && response.content['application/json'] && response.content['application/json'].schema) { const validateFn = response.content['application/json'].schema; responseValidators[status] = OpenAPIValidator.compileSchema(validator, validateFn); } if (!response.content && status === '204') { const validateFn = { type: 'null', title: 'The root schema', description: 'The root schema comprises the entire JSON document.', default: null, }; responseValidators[status] = OpenAPIValidator.compileSchema(validator, validateFn); } return null; }); return responseValidators; } /** * Get response validator function for an operation by operationId * * @param {string} operationId * @returns {*} {(ResponseHeadersValidateFunctionMap | null)} * @memberof OpenAPIRequestValidator */ getResponseHeadersValidatorForOperation(operationId) { if (this.responseHeadersValidators[operationId] === undefined) { const operation = this.router.getOperation(operationId); this.responseHeadersValidators[operationId] = this.buildResponseHeadersValidatorForOperation(operation); } return this.responseHeadersValidators[operationId]; } /** * Builds an ajv response validator function for an operation and returns it * * @param {Operation<D>} operation * @returns {*} {(ResponseHeadersValidateFunctionMap | null)} * @memberof OpenAPIValidator */ buildResponseHeadersValidatorForOperation(operation) { if (!operation || !operation.operationId) { // no operationId, don't register a validator return null; } if (!operation.responses) { // operation has no responses, don't register a validator return null; } const headerValidators = {}; const validator = this.getAjv(ValidationContext.ResponseHeaders, { coerceTypes: true }); _.mapKeys(operation.responses, (res, status) => { const response = res; const validateFns = {}; const properties = {}; const required = []; _.mapKeys(response.headers, (h, headerName) => { const header = h; headerName = headerName.toLowerCase(); if (header.schema) { properties[headerName] = header.schema; required.push(headerName); } return null; }); validateFns[backend_1.SetMatchType.Any] = OpenAPIValidator.compileSchema(validator, { type: 'object', properties: { headers: { type: 'object', additionalProperties: true, properties, required: [], }, }, }); validateFns[backend_1.SetMatchType.Superset] = OpenAPIValidator.compileSchema(validator, { type: 'object', properties: { headers: { type: 'object', additionalProperties: true, properties, required, }, }, }); validateFns[backend_1.SetMatchType.Subset] = OpenAPIValidator.compileSchema(validator, { type: 'object', properties: { headers: { type: 'object', additionalProperties: false, properties, required: [], }, }, }); validateFns[backend_1.SetMatchType.Exact] = OpenAPIValidator.compileSchema(validator, { type: 'object', properties: { headers: { type: 'object', additionalProperties: false, properties, required, }, }, }); headerValidators[status] = validateFns; return null; }); return headerValidators; } /** * Get Ajv options * * @param {ValidationContext} validationContext * @param {AjvOpts} [opts={}] * @returns Ajv * @memberof OpenAPIValidator */ getAjv(validationContext, opts = {}) { const ajvOpts = { ...this.ajvOpts, ...opts }; const ajv = new ajv_1.default(ajvOpts); for (const [name, format] of Object.entries(defaultFormats)) { ajv.addFormat(name, format); } if (this.customizeAjv) { return this.customizeAjv(ajv, ajvOpts, validationContext); } return ajv; } } exports.OpenAPIValidator = OpenAPIValidator; //# sourceMappingURL=validation.js.map