UNPKG

@elliots/openapi-ts-backend

Version:

Enables easy implementions of OpenAPI REST APIs in TypeScript with full typings of schemas and operations.

332 lines 15.8 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); }) : (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 __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.OpenApi = void 0; const OpenAPI = __importStar(require("openapi-backend")); const openapi_backend_1 = require("openapi-backend"); const Errors = __importStar(require("./errors")); const utils_1 = require("./utils"); const logger_1 = require("./logger"); const { LOG_LEVEL = 'info' } = process.env; const defaultHandlers = Object.freeze({ validationFail(apiContext) { var _a; throw new Errors.BadRequestError(apiContext.request, (_a = apiContext.validation.errors) !== null && _a !== void 0 ? _a : []); }, notFound(apiContext) { throw new Errors.NotFoundError(apiContext.request); }, notImplemented(apiContext) { throw new Errors.NotImplementedError(apiContext.request); } }); function isRawRequest(req) { return typeof req.method === 'string' && typeof req.path === 'string' && req.headers && typeof req.headers === 'object'; } /** * A HTTP API using an OpenAPI definition. * This uses the openapi-backend module to parse, route and validate requests created from events. * * @template T Type of custom data passed to each request's params * */ class OpenApi { /** * Constructor * @param params Parameters * @param [params.errorHandler] A function creating a response from an error thrown by the API. * @param [params.logger] A logger, or null to suppress all logging * @param [params.responseValidationStrategy] How to handle validation errors on responses * @param [params.responseBodyTrimming] What response body trimming to perform * @param [params.ajvOptions] Custom AJV options */ constructor({ errorHandler = Errors.defaultErrorHandler, logger = logger_1.createLogger(LOG_LEVEL), responseValidationStrategy = 'warn', responseBodyTrimming = 'failing', ajvOptions, customizeAjv, } = {}) { this.interceptors = []; this.apiPromises = []; this.errorHandler = errorHandler; this.logger = logger || logger_1.createLogger('silent'); // if logger is null, create silent logger this.responseValidationStrategy = responseValidationStrategy; this.responseBodyTrimming = responseBodyTrimming; this.ajvOptions = ajvOptions; this.customizeAjv = customizeAjv; const createParamValidator = () => { this.paramValidator = utils_1.getAjv(Object.assign(Object.assign({}, this.ajvOptions), { coerceTypes: 'array', strict: false })); }; // the ajv validator leaks memory when used like this, so we recreate it every 10 minutes setInterval(createParamValidator, 1000 * 60 * 10); // 10 minutes createParamValidator(); } getApis() { return Promise.all(this.apiPromises); } createApi(apiOptions, operations, authorizers = {}) { return __awaiter(this, void 0, void 0, function* () { const api = yield new OpenAPI.OpenAPIBackend(apiOptions).init(); for (const [id, handler] of Object.entries(operations)) { api.registerHandler(id, this.createHandler(handler, id, authorizers)); } return api; }); } parseParams(rawParams, operation, type, errors) { // This is mostly used to coerce types, which openapi-backend does internally but then throws away return utils_1.matchSchema(this.paramValidator, rawParams, utils_1.getParametersSchema(utils_1.getParameterMap(operation, type)), errors); } parseRequest(apiContext) { const { request: { method, path, params, headers, query, cookies, requestBody: body }, operation } = apiContext; const errors = []; const req = { method, path, params: this.parseParams(params, operation, 'path', errors), headers: this.parseParams(headers, operation, 'header', errors), query: this.parseParams(query, operation, 'query', errors), cookies: this.parseParams(cookies, operation, 'cookie', errors), body }; // This will throw 500 for errors since it reflects an inconsistency; // validation should have already been performed and this is only for coercion. // Ideally openapi-backend itself should handle coercion of request params. this.handleValidationErrors(errors, `Request doesn't match schema`, 'throw'); return req; } formatResponse(res) { const { statusCode = 500, headers, body } = res; return { statusCode, headers: utils_1.mapObject(headers, utils_1.oneOrMany(String)), body, }; } authorizeRequest(apiContext, req, res, operationParams, authorizers) { var _a, _b, _c, _d; return __awaiter(this, void 0, void 0, function* () { const { api: { definition }, operation } = apiContext; const securityRequirements = (_b = (_a = operation.security) !== null && _a !== void 0 ? _a : definition.security) !== null && _b !== void 0 ? _b : []; if (securityRequirements.length === 0) { return {}; } const errors = []; // Handle authorization here instead of using security handlers, to enable passing scopes and solve // issue with conflicting security schemes across requirements. for (const securityRequirement of securityRequirements) { const results = {}; let authorized = true; for (const [name, scopes] of Object.entries(securityRequirement)) { try { results[name] = yield authorizers[name](req, res, operationParams, { name, scheme: (_d = (_c = definition.components) === null || _c === void 0 ? void 0 : _c.securitySchemes) === null || _d === void 0 ? void 0 : _d[name], parameters: { scopes } }); } catch (error) { authorized = false; errors.push(error); } } if (authorized) { return results; } } throw new Errors.UnauthorizedError(apiContext.request, errors); }); } getDefaultStatusCode({ responses = {} }) { // If statusCode is not set and there is exactly one successful response, we use it automatically. const codes = Object.keys(responses || {}).map(Number).filter(utils_1.inRange(200, 400)); if (codes.length !== 1) { // No statusCode given and it's impossible to determine a default one from the response schemas throw new Error(`Cannot determine implicit status code from API definition response codes ${JSON.stringify(codes)}`); } return codes[0]; } createHandler(operationHandler, operationId, authorizers) { return (apiContext, { res, params }) => __awaiter(this, void 0, void 0, function* () { var _a, _b; const { api: { definition }, operation } = apiContext; const req = this.parseRequest(apiContext); const operationParams = Object.assign({ operation, security: { results: {} }, definition }, params); operationParams.security.results = yield this.authorizeRequest(apiContext, req, res, operationParams, authorizers); this.logger.info(`Calling operation ${operationId}`); // Note: The handler function may modify the "res" object and/or return a response body. // If "res.body" is undefined we use the return value as the body. const resBody = yield operationHandler(req, res, operationParams); res.body = (_a = res.body) !== null && _a !== void 0 ? _a : resBody; // If status code is not specified and a non-ambiguous default status code is available, use it res.statusCode = (_b = res.statusCode) !== null && _b !== void 0 ? _b : this.getDefaultStatusCode(operation); this.validateResponse(apiContext, res); }); } validateResponse({ api, operation }, res) { const { statusCode, headers, body } = res; const errors = []; // Note that this call uses a customizeAjv function to configure removeAdditional const bodyErrors = api.validateResponse(body, operation).errors; if (bodyErrors) { errors.push(...bodyErrors); } const headerErrors = api.validateResponseHeaders(headers, operation, { statusCode, setMatchType: OpenAPI.SetMatchType.Superset, }).errors; if (headerErrors) { errors.push(...headerErrors); } this.handleValidationErrors(errors, `Response doesn't match schema`, this.responseValidationStrategy); } handleValidationErrors(errors, title, strategy) { if (errors === null || errors === void 0 ? void 0 : errors.length) { this.fail(`${title}: ${utils_1.formatArray(errors, utils_1.formatValidationError)}`, strategy); } } fail(message, strategy) { if (strategy === 'throw') { throw new Error(message); } this.logger.warn(message); } /** * Register interceptor function(s) which are executed for every request (similar to Express MW). * Note that the interceptors are invoked before the request is validated or routed (mapped to an operation). * * @param interceptors Interceptor functions * @return This instance, for chaining of calls */ intercept(...interceptors) { this.interceptors.push(...interceptors); return this; } /** * Register an OpenAPI definition and associated operation handlers. * * @param params Parameters * @return This instance, for chaining of calls */ register({ definition, operations, authorizers, path }) { this.apiPromises.push(this.createApi({ handlers: Object.assign({}, defaultHandlers), definition, apiRoot: path, validate: true, ajvOpts: this.ajvOptions, customizeAjv: (ajv, ajvOpts, validationContext) => { if (validationContext === openapi_backend_1.ValidationContext.Response) { // Remove additional properties on response body only ajv._opts.removeAdditional = this.responseBodyTrimming === 'none' ? false : this.responseBodyTrimming; } // Invoke custom function as well if applicable if (this.customizeAjv) { ajv = this.customizeAjv(ajv, ajvOpts, validationContext); } return ajv; } }, operations, authorizers)); return this; } /** * Handle the given request by validating and routing it using the registered API definitions. * If the request is valid against the definition, the matching operation handler is invoked, and any value * returned from it is returned, including thrown errors. * If the request is invalid or no operation handler is found, an error is thrown. * * @param req Request * @param res Pending response (filled in by this method) * @param params Request params * @returns Empty promise if successful; rejected promise the request could not be routed * or if the operation handler threw an error. */ routeRequest(req, res, params) { return __awaiter(this, void 0, void 0, function* () { if (!isRawRequest(req)) { throw new Error(`Invalid HTTP request`); } const apis = yield this.getApis(); if (!apis.length) { throw new Error(`No APIs are registered`); } // Invoke the interceptors for (const interceptor of this.interceptors) { yield interceptor(req, res, params); } let err; // We support multiple API definitions by looping through them as long as Invalid operation is thrown for (const api of apis) { try { this.logger.debug(`Attempting to route ${req.path} to ${api.apiRoot}`); return yield api.handleRequest(req, { res, params }); } catch (e) { if (e instanceof Errors.NotFoundError) { this.logger.debug(`Route ${req.path} not found in ${api.apiRoot}`); err = e; } else { throw e; } } } throw err !== null && err !== void 0 ? err : new Error(`No routes registered`); }); } /** * Handle the given request by routing it and then wrapping the result in a response. * If an error was thrown, the error handler function is invoked to convert it to a response. * * @param req Request * @param args Custom data * @returns Response */ handleRequest(req, ...args) { var _a, _b; return __awaiter(this, void 0, void 0, function* () { const [data] = args; const params = { api: this, data }; const id = `${(_a = req.method) === null || _a === void 0 ? void 0 : _a.toUpperCase()} ${req.path}`; this.logger.info(`->${id}`); const res = { headers: {} }; try { yield this.routeRequest(req, res, params); } catch (err) { this.logger.warn(`Error: ${id}: "${err.name}: ${err.message}"`); yield this.errorHandler(req, res, params, err); } res.statusCode = (_b = res.statusCode) !== null && _b !== void 0 ? _b : 500; this.logger.info(`<-${id}: ${res.statusCode}`); return this.formatResponse(res); }); } } exports.OpenApi = OpenApi; //# sourceMappingURL=openapi.js.map