UNPKG

openapi-backend

Version:

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

632 lines 26.6 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.OpenAPIBackend = exports.SetMatchType = void 0; const _ = __importStar(require("lodash")); const openapi_schema_validator_1 = __importDefault(require("openapi-schema-validator")); const refparser_1 = require("./refparser"); const dereference_json_schema_1 = require("dereference-json-schema"); const mock_json_schema_1 = require("mock-json-schema"); const router_1 = require("./router"); const validation_1 = require("./validation"); const utils_1 = __importDefault(require("./utils")); /** * The different possibilities for set matching. * * @enum {string} */ var SetMatchType; (function (SetMatchType) { SetMatchType["Any"] = "any"; SetMatchType["Superset"] = "superset"; SetMatchType["Subset"] = "subset"; SetMatchType["Exact"] = "exact"; })(SetMatchType || (exports.SetMatchType = SetMatchType = {})); /** * Main class and the default export of the 'openapi-backend' module * * @export * @class OpenAPIBackend */ class OpenAPIBackend { /** * Creates an instance of OpenAPIBackend. * * @param opts - constructor options * @param {D | string} opts.definition - the OpenAPI definition, file path or Document object * @param {string} opts.apiRoot - the root URI of the api. all paths are matched relative to apiRoot * @param {boolean} opts.strict - strict mode, throw errors or warn on OpenAPI spec validation errors (default: false) * @param {boolean} opts.quick - quick startup, attempts to optimise startup; might break things (default: false) * @param {boolean} opts.validate - whether to validate requests with Ajv (default: true) * @param {boolean} opts.ignoreTrailingSlashes - whether to ignore trailing slashes when routing (default: true) * @param {boolean} opts.ajvOpts - default ajv opts to pass to the validator * @param {boolean} opts.coerceTypes - enable coerce typing of request path and query parameters. Requires validate to be enabled. (default: false) * @param {{ [operationId: string]: Handler | ErrorHandler }} opts.handlers - Operation handlers to be registered * @memberof OpenAPIBackend */ constructor(opts) { var _a, _b, _c; this.allowedHandlers = [ '404', 'notFound', '405', 'methodNotAllowed', '501', 'notImplemented', '400', 'validationFail', 'unauthorizedHandler', 'preRoutingHandler', 'postRoutingHandler', 'postSecurityHandler', 'preOperationHandler', 'postResponseHandler', ]; const optsWithDefaults = { apiRoot: '/', validate: true, strict: false, quick: false, ignoreTrailingSlashes: true, handlers: {}, securityHandlers: {}, coerceTypes: false, ...opts, }; this.apiRoot = (_a = optsWithDefaults.apiRoot) !== null && _a !== void 0 ? _a : '/'; this.inputDocument = optsWithDefaults.definition; this.strict = !!optsWithDefaults.strict; this.quick = !!optsWithDefaults.quick; this.validate = !!optsWithDefaults.validate; this.ignoreTrailingSlashes = !!optsWithDefaults.ignoreTrailingSlashes; this.handlers = { ...optsWithDefaults.handlers }; // Copy to avoid mutating passed object this.securityHandlers = { ...optsWithDefaults.securityHandlers }; // Copy to avoid mutating passed object this.ajvOpts = (_b = optsWithDefaults.ajvOpts) !== null && _b !== void 0 ? _b : {}; this.customizeAjv = optsWithDefaults.customizeAjv; this.coerceTypes = (_c = optsWithDefaults.coerceTypes) !== null && _c !== void 0 ? _c : false; } /** * Initalizes OpenAPIBackend. * * 1. Loads and parses the OpenAPI document passed in constructor options * 2. Validates the OpenAPI document * 3. Builds validation schemas for all API operations * 4. Marks property `initalized` to true * 5. Registers all [Operation Handlers](#operation-handlers) passed in constructor options * * The init() method should be called right after creating a new instance of OpenAPIBackend * * @returns parent instance of OpenAPIBackend * @memberof OpenAPIBackend */ async init() { try { // parse the document if (this.quick) { // in quick mode we don't care when the document is ready this.loadDocument(); } else { await this.loadDocument(); } if (!this.quick) { // validate the document this.validateDefinition(); } // dereference the document into definition (make sure not to copy) if (typeof this.inputDocument === 'string') { this.definition = (await (0, refparser_1.dereference)(this.inputDocument)); } else if (this.quick && typeof this.inputDocument === 'object') { // use sync dereference in quick mode this.definition = (0, dereference_json_schema_1.dereferenceSync)(this.inputDocument); } else { this.definition = (await (0, refparser_1.dereference)(this.document || this.inputDocument)); } } catch (err) { if (this.strict) { // in strict-mode, fail hard and re-throw the error throw err; } else { // just emit a warning about the validation errors console.warn(err); } } // initalize router with dereferenced definition this.router = new router_1.OpenAPIRouter({ definition: this.definition, apiRoot: this.apiRoot, ignoreTrailingSlashes: this.ignoreTrailingSlashes, }); // initalize validator with dereferenced definition if (this.validate !== false) { this.validator = new validation_1.OpenAPIValidator({ definition: this.definition, ajvOpts: this.ajvOpts, customizeAjv: this.customizeAjv, router: this.router, lazyCompileValidators: Boolean(this.quick), coerceTypes: this.coerceTypes, }); } // we are initalized this.initalized = true; // register all handlers if (this.handlers) { this.register(this.handlers); } // register all security handlers if (this.securityHandlers) { for (const [name, handler] of Object.entries(this.securityHandlers)) { if (handler) { this.registerSecurityHandler(name, handler); } } } // return this instance return this; } /** * Loads the input document asynchronously and sets this.document * * @memberof OpenAPIBackend */ async loadDocument() { this.document = (await (0, refparser_1.parse)(this.inputDocument)); return this.document; } /** * Handles a request * 1. Routing: Matches the request to an API operation * 2. Validation: Validates the request against the API operation schema * 3. Handling: Passes the request on to a registered handler * * @param {Request} req * @param {...any[]} handlerArgs * @returns {Promise} handler return value * @memberof OpenAPIBackend */ async handleRequest(req, ...handlerArgs) { if (!this.initalized) { // auto-initalize if not yet initalized await this.init(); } // initalize context object with a reference to this OpenAPIBackend instance const context = { api: this }; // handle request with correct handler const response = await (async () => { // parse request context.request = this.router.parseRequest(req); // preRoutingHandler const preRoutingHandler = this.handlers['preRoutingHandler']; if (preRoutingHandler) { await preRoutingHandler(context, ...handlerArgs); } // match operation (routing) try { context.operation = this.router.matchOperation(req, true); } catch (err) { // postRoutingHandler on routing failure const postRoutingHandler = this.handlers['postRoutingHandler']; if (postRoutingHandler) { await postRoutingHandler(context, ...handlerArgs); } let handler = this.handlers['404'] || this.handlers['notFound']; if (err instanceof Error && err.message.startsWith('405')) { // 405 method not allowed handler = this.handlers['405'] || this.handlers['methodNotAllowed'] || handler; } if (!handler) { throw err; } return handler(context, ...handlerArgs); } const operationId = context.operation.operationId; // parse request again now with matched operation context.request = this.router.parseRequest(req, context.operation); // postRoutingHandler on routing success const postRoutingHandler = this.handlers['postRoutingHandler']; if (postRoutingHandler) { await postRoutingHandler(context, ...handlerArgs); } // get security requirements for the matched operation // global requirements are already included in the router const securityRequirements = context.operation.security || []; const securitySchemes = _.flatMap(securityRequirements, _.keys); // run registered security handlers for all security requirements const securityHandlerResults = {}; await Promise.all(securitySchemes.map(async (name) => { securityHandlerResults[name] = undefined; if (this.securityHandlers[name]) { const securityHandler = this.securityHandlers[name]; // return a promise that will set the security handler result return await Promise.resolve() .then(() => securityHandler(context, ...handlerArgs)) .then((result) => { securityHandlerResults[name] = result; }) // save rejected error as result, if thrown .catch((error) => { securityHandlerResults[name] = { error }; }); } else { // if no handler is found for scheme, set to undefined securityHandlerResults[name] = undefined; } })); // auth logic const requirementsSatisfied = securityRequirements.map((requirementObject) => { /* * Security Requirement Objects that contain multiple schemes require * that all schemes MUST be satisfied for a request to be authorized. */ for (const requirement of Object.keys(requirementObject)) { const requirementResult = securityHandlerResults[requirement]; // falsy return values are treated as auth fail if (Boolean(requirementResult) === false) { return false; } // handle error object passed earlier if (typeof requirementResult === 'object' && Object.keys(requirementResult).includes('error') && Object.keys(requirementResult).length === 1) { return false; } } return true; }); /* * When a list of Security Requirement Objects is defined on the Open API * object or Operation Object, only one of Security Requirement Objects * in the list needs to be satisfied to authorize the request. */ const authorized = requirementsSatisfied.some((securityResult) => securityResult === true); // add the results and authorized state to the context object context.security = { authorized, ...securityHandlerResults, }; // postSecurityHandler const postSecurityHandler = this.handlers['postSecurityHandler']; if (postSecurityHandler) { await postSecurityHandler(context, ...handlerArgs); } // call unauthorizedHandler handler if auth fails if (!authorized && securityRequirements.length > 0) { const unauthorizedHandler = this.handlers['unauthorizedHandler']; if (unauthorizedHandler) { return unauthorizedHandler(context, ...handlerArgs); } } // check whether this request should be validated const validate = typeof this.validate === 'function' ? this.validate(context, ...handlerArgs) : Boolean(this.validate); // validate request const validationFailHandler = this.handlers['validationFail']; if (validate) { context.validation = this.validator.validateRequest(req, context.operation); if (context.validation.errors) { // 400 request validation fail if (validationFailHandler) { return validationFailHandler(context, ...handlerArgs); } // if no validation handler is specified, just ignore it and proceed to route handler } // parse request again now with coerced types, if needed if (this.validator.coerceTypes) { context.request = this.router.parseRequest(context.validation.coerced, context.operation); } } // preOperationHandler – runs just before the operation handler const preOperationHandler = this.handlers['preOperationHandler']; if (preOperationHandler) { await preOperationHandler(context, ...handlerArgs); } // get operation handler const operationHandler = this.handlers[operationId]; if (!operationHandler) { // 501 not implemented const notImplementedHandler = this.handlers['501'] || this.handlers['notImplemented']; if (!notImplementedHandler) { throw Error(`501-notImplemented: ${operationId} no handler registered`); } return notImplementedHandler(context, ...handlerArgs); } // handle route return operationHandler(context, ...handlerArgs); }).bind(this)(); // post response handler const postResponseHandler = this.handlers['postResponseHandler']; if (postResponseHandler) { // pass response to postResponseHandler context.response = response; return postResponseHandler(context, ...handlerArgs); } // return response return response; } /** * Registers a handler for an operation * * @param {string} operationId * @param {Handler} handler * @memberof OpenAPIBackend */ registerHandler(operationId, handler) { // make sure we are registering a function and not anything else if (typeof handler !== 'function') { throw new Error('Handler should be a function'); } // if initalized, check that operation matches an operationId or is one of our allowed handlers if (this.initalized) { const operation = this.router.getOperation(operationId); if (!operation && !_.includes(this.allowedHandlers, operationId)) { const err = `Unknown operationId ${operationId}`; // in strict mode, throw Error, otherwise just emit a warning if (this.strict) { throw new Error(`${err}. Refusing to register handler`); } else { console.warn(err); } } } // register the handler this.handlers[operationId] = handler; } /** * Overloaded register() implementation * * @param {...any[]} args * @memberof OpenAPIBackend */ register(...args) { if (typeof args[0] === 'string') { // register a single handler const operationId = args[0]; const handler = args[1]; this.registerHandler(operationId, handler); } else { // register multiple handlers const handlers = args[0]; for (const operationId in handlers) { if (handlers[operationId]) { this.registerHandler(operationId, handlers[operationId]); } } } } /** * Registers a security handler for a security scheme * * @param {string} name - security scheme name * @param {Handler} handler - security handler * @memberof OpenAPIBackend */ registerSecurityHandler(name, handler) { var _a; // make sure we are registering a function and not anything else if (typeof handler !== 'function') { throw new Error('Security handler should be a function'); } // if initialized, check that operation matches a security scheme if (this.initalized) { const securitySchemes = ((_a = this.definition.components) === null || _a === void 0 ? void 0 : _a.securitySchemes) || {}; if (!securitySchemes[name]) { const err = `Unknown security scheme ${name}`; // in strict mode, throw Error, otherwise just emit a warning if (this.strict) { throw new Error(`${err}. Refusing to register security handler`); } else { console.warn(err); } } } // register the handler this.securityHandlers[name] = handler; } /** * Mocks a response for an operation based on example or response schema * * @param {string} operationId - operationId of the operation for which to mock the response * @param {object} opts - (optional) options * @param {number} opts.responseStatus - (optional) the response code of the response to mock (default: 200) * @param {string} opts.mediaType - (optional) the media type of the response to mock (default: application/json) * @param {string} opts.example - (optional) the specific example to use (if operation has multiple examples) * @returns {{ status: number; mock: any }} * @memberof OpenAPIBackend */ mockResponseForOperation(operationId, opts = {}) { let status = 200; const defaultMock = {}; const operation = this.router.getOperation(operationId); if (!operation || !operation.responses) { return { status, mock: defaultMock }; } // resolve status code const { responses } = operation; let response; if (opts.code && responses[opts.code]) { // 1. check for provided code opt (default: 200) status = Number(opts.code); response = responses[opts.code]; } else { // 2. check for a default response const res = utils_1.default.findDefaultStatusCodeMatch(responses); status = res.status; response = res.res; } if (!response || !response.content) { return { status, mock: defaultMock }; } const { content } = response; // resolve media type // 1. check for mediaType opt in content (default: application/json) // 2. pick first media type in content const mediaType = opts.mediaType || 'application/json'; const mediaResponse = content[mediaType] || content[Object.keys(content)[0]]; if (!mediaResponse) { return { status, mock: defaultMock }; } const { examples, schema } = mediaResponse; // if example argument was provided, locate and return its value if (opts.example && examples) { const exampleObject = examples[opts.example]; if (exampleObject && exampleObject.value) { return { status, mock: exampleObject.value }; } } // if operation has an example, return its value if (mediaResponse.example) { return { status, mock: mediaResponse.example }; } // pick the first example from examples if (examples) { const exampleObject = examples[Object.keys(examples)[0]]; return { status, mock: exampleObject.value }; } // mock using json schema if (schema) { return { status, mock: (0, mock_json_schema_1.mock)(schema) }; } // we should never get here, schema or an example must be provided return { status, mock: defaultMock }; } /** * Validates this.document, which is the parsed OpenAPI document. Throws an error if validation fails. * * @returns {D} parsed document * @memberof OpenAPIBackend */ validateDefinition() { const validateOpenAPI = new openapi_schema_validator_1.default({ version: 3 }); const { errors } = validateOpenAPI.validate(this.document); if (errors.length) { const prettyErrors = JSON.stringify(errors, null, 2); throw new Error(`Document is not valid OpenAPI. ${errors.length} validation errors:\n${prettyErrors}`); } return this.document; } /** * Flattens operations into a simple array of Operation objects easy to work with * * Alias for: router.getOperations() * * @returns {Operation<D>[]} * @memberof OpenAPIBackend */ getOperations() { return this.router.getOperations(); } /** * Gets a single operation based on operationId * * Alias for: router.getOperation(operationId) * * @param {string} operationId * @returns {Operation<D>} * @memberof OpenAPIBackend */ getOperation(operationId) { return this.router.getOperation(operationId); } /** * Matches a request to an API operation (router) * * Alias for: router.matchOperation(req) * * @param {Request} req * @returns {Operation<D>} * @memberof OpenAPIBackend */ matchOperation(req) { return this.router.matchOperation(req); } /** * Validates a request and returns the result. * * The method will first match the request to an API operation and use the pre-compiled Ajv validation schemas to * validate it. * * Alias for validator.validateRequest * * @param {Request} req - request to validate * @param {(Operation<D> | string)} [operation] * @returns {ValidationStatus} * @memberof OpenAPIBackend */ validateRequest(req, operation) { return this.validator.validateRequest(req, operation); } /** * Validates a response and returns the result. * * The method will use the pre-compiled Ajv validation schema to validate a request it. * * Alias for validator.validateResponse * * @param {*} res - response to validate * @param {(Operation<D> | string)} [operation] * @param {number} status * @returns {ValidationStatus} * @memberof OpenAPIBackend */ validateResponse(res, operation, statusCode) { return this.validator.validateResponse(res, operation, statusCode); } /** * Validates response headers and returns the result. * * The method will use the pre-compiled Ajv validation schema to validate a request it. * * Alias for validator.validateResponseHeaders * * @param {*} headers - response to validate * @param {(Operation<D> | string)} [operation] * @param {number} [opts.statusCode] * @param {SetMatchType} [opts.setMatchType] - one of 'any', 'superset', 'subset', 'exact' * @returns {ValidationStatus} * @memberof OpenAPIBackend */ validateResponseHeaders(headers, operation, opts) { return this.validator.validateResponseHeaders(headers, operation, opts); } } exports.OpenAPIBackend = OpenAPIBackend; //# sourceMappingURL=backend.js.map