UNPKG

openapi-backend

Version:

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

282 lines 11.6 kB
"use strict"; 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.OpenAPIRouter = void 0; const _ = __importStar(require("lodash")); const bath_es5_1 = __importDefault(require("bath-es5")); const cookie = __importStar(require("cookie")); const qs_1 = require("qs"); /** * Class that handles routing * * @export * @class OpenAPIRouter */ class OpenAPIRouter { /** * Creates an instance of OpenAPIRouter * * @param opts - constructor options * @param {D} 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 * @memberof OpenAPIRouter */ constructor(opts) { var _a; this.definition = opts.definition; this.apiRoot = opts.apiRoot || '/'; this.ignoreTrailingSlashes = (_a = opts.ignoreTrailingSlashes) !== null && _a !== void 0 ? _a : true; } matchOperation(req, strict) { // normalize request for matching req = this.normalizeRequest(req); // if request doesn't match apiRoot, throw 404 if (!req.path.startsWith(this.apiRoot)) { if (strict) { throw Error('404-notFound: no route matches request'); } else { return undefined; } } // get relative path const normalizedPath = this.normalizePath(req.path); // get all operations matching exact path const exactPathMatches = this.getOperations().filter(({ path }) => path === normalizedPath); // check if there's one with correct method and return if found const exactMatch = exactPathMatches.find(({ method }) => method === req.method); if (exactMatch) { return exactMatch; } // check with path templates const templatePathMatches = this.getOperations().filter(({ path }) => { // convert openapi path template to a regex pattern i.e. /{id}/ becomes /[^/]+/ const pathPattern = `^${path.replace(/\{.*?\}/g, '[^/]+')}$`; return Boolean(normalizedPath.match(new RegExp(pathPattern, 'g'))); }); // if no operations match the path, throw 404 if (!templatePathMatches.length) { if (strict) { throw Error('404-notFound: no route matches request'); } else { return undefined; } } // find matching operation const match = _.chain(templatePathMatches) // order matches by length (specificity) .orderBy((op) => op.path.replace(RegExp(/\{.*?\}/g), '').length, 'desc') // then check if one of the matched operations matches the method .find(({ method }) => method === req.method) .value(); if (!match) { if (strict) { throw Error('405-methodNotAllowed: this method is not registered for the route'); } else { return undefined; } } return match; } /** * Flattens operations into a simple array of Operation objects easy to work with * * @returns {Operation<D>[]} * @memberof OpenAPIRouter */ getOperations() { var _a; const paths = ((_a = this.definition) === null || _a === void 0 ? void 0 : _a.paths) || {}; return _.chain(paths) .entries() .flatMap(([path, pathBaseObject]) => { const methods = _.pick(pathBaseObject, ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']); return _.entries(methods).map(([method, operation]) => { const op = operation; return { ...op, path, method, // append the path base object's parameters to the operation's parameters parameters: [ ...(op.parameters || []), ...((pathBaseObject === null || pathBaseObject === void 0 ? void 0 : pathBaseObject.parameters) || []), // path base object parameters ], // operation-specific security requirement override global requirements security: op.security || this.definition.security || [], }; }); }) .value(); } /** * Gets a single operation based on operationId * * @param {string} operationId * @returns {Operation<D>} * @memberof OpenAPIRouter */ getOperation(operationId) { return this.getOperations().find((op) => op.operationId === operationId); } /** * Normalises request: * - http method to lowercase * - remove path leading slash * - remove path query string * * @export * @param {Request} req * @returns {Request} */ normalizeRequest(req) { var _a; let path = ((_a = req.path) === null || _a === void 0 ? void 0 : _a.trim()) || ''; // add leading prefix to path if (!path.startsWith('/')) { path = `/${path}`; } // remove query string from path path = path.split('?')[0]; // normalize method to lowercase const method = req.method.trim().toLowerCase(); return { ...req, path, method }; } /** * Normalises path for matching: strips apiRoot prefix from the path * * Also depending on configuration, will remove trailing slashes * * @export * @param {string} path * @returns {string} */ normalizePath(pathInput) { let path = pathInput.trim(); // strip apiRoot from path if (path.startsWith(this.apiRoot)) { path = path.replace(new RegExp(`^${this.apiRoot}/?`), '/'); } // remove trailing slashes from path if ignoreTrailingSlashes = true while (this.ignoreTrailingSlashes && path.length > 1 && path.endsWith('/')) { path = path.substr(0, path.length - 1); } return path; } /** * Parses and normalizes a request * - parse json body * - parse query string * - parse cookies from headers * - parse path params based on uri template * * @export * @param {Request} req * @param {Operation<D>} [operation] * @param {string} [patbh] * @returns {ParsedRequest} */ parseRequest(req, operation) { var _a; let requestBody = req.body; if (req.body && typeof req.body !== 'object') { try { // attempt to parse json requestBody = JSON.parse(req.body.toString()); } catch { // suppress json parsing errors // we will emit error if validation requires it later } } // header keys are converted to lowercase, so Content-Type becomes content-type const headers = _.mapKeys(req.headers, (val, header) => header.toLowerCase()); // parse cookie from headers const cookieHeader = headers['cookie']; const cookies = cookie.parse(_.flatten([cookieHeader]).join('; ')); // parse query const queryString = typeof req.query === 'string' ? req.query.replace('?', '') : req.path.split('?')[1]; const query = typeof req.query === 'object' ? _.cloneDeep(req.query) : (0, qs_1.parse)(queryString); // normalize req = this.normalizeRequest(req); let params = {}; if (operation) { // get relative path const normalizedPath = this.normalizePath(req.path); // parse path params if path is given const pathParams = (0, bath_es5_1.default)(operation.path); params = pathParams.params(normalizedPath) || {}; // parse query parameters with specified style for parameter for (const queryParam in query) { if (query[queryParam]) { const parameter = (_a = operation.parameters) === null || _a === void 0 ? void 0 : _a.find((param) => !('$ref' in param) && (param === null || param === void 0 ? void 0 : param.in) === 'query' && (param === null || param === void 0 ? void 0 : param.name) === queryParam); if (parameter) { if (parameter.content && parameter.content['application/json']) { query[queryParam] = JSON.parse(query[queryParam]); } else if (parameter.explode === false && queryString) { let commaQueryString = queryString.replace(/%2C/g, ','); if (parameter.style === 'spaceDelimited') { commaQueryString = commaQueryString.replace(/ /g, ',').replace(/%20/g, ','); } if (parameter.style === 'pipeDelimited') { commaQueryString = commaQueryString.replace(/\|/g, ',').replace(/%7C/g, ','); } // use comma parsing e.g. &a=1,2,3 const commaParsed = (0, qs_1.parse)(commaQueryString, { comma: true }); query[queryParam] = commaParsed[queryParam]; } else if (parameter.explode === false) { let decoded = query[queryParam].replace(/%2C/g, ','); if (parameter.style === 'spaceDelimited') { decoded = decoded.replace(/ /g, ',').replace(/%20/g, ','); } if (parameter.style === 'pipeDelimited') { decoded = decoded.replace(/\|/g, ',').replace(/%7C/g, ','); } query[queryParam] = decoded.split(','); } } } } } return { ...req, params, headers, query, cookies, requestBody, }; } } exports.OpenAPIRouter = OpenAPIRouter; //# sourceMappingURL=router.js.map