@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
JavaScript
;
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