openapi-backend
Version:
Build, Validate, Route, Authenticate and Mock using OpenAPI definitions. Framework-agnostic
282 lines • 11.6 kB
JavaScript
;
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