UNPKG

arrest

Version:

OpenAPI v3 compliant REST framework for Node.js, with support for MongoDB and JSON-Schema

243 lines 9.7 kB
import { Scopes } from '@vivocha/scopes'; import { getLogger } from 'debuggo'; import { Eredita } from 'eredita'; import express, { Router } from 'express'; import http from 'http'; import https from 'https'; import _ from 'lodash'; import needle from 'needle'; import { parse, rebase, ValidationError } from 'openapi-police'; import semver from 'semver'; import { deprecate } from 'util'; import { DEFAULT_DOCUMENT } from './defaults.js'; import { RESTError } from './error.js'; import { SchemaResource } from './schema.js'; import { rebaseOASDefinitions, refsRebaser, removeSchemaDeclaration, removeUnusedSchemas } from './util.js'; let reqId = 0; export class API { constructor(info) { this.originalSchemas = {}; this.dynamicSchemas = {}; this.document = Eredita.deepExtend(_.cloneDeep(DEFAULT_DOCUMENT), { info: info || {} }); if (!semver.valid(this.document.info.version)) { throw new Error('Invalid version'); } this.logger = getLogger(this.getDebugLabel(), undefined, false); this.resources = []; this.parseOptions = { scope: 'http://vivocha.com/api/v3', retriever: this.defaultSchemaRetriever.bind(this), }; } getDebugLabel() { return 'arrest'; } getDebugContext() { return `#${++reqId}`; } async defaultSchemaRetriever(url) { const response = await needle('get', url); if (response.statusCode !== 200) { throw new RESTError(response.statusCode); } return response.body; } addResource(resource) { this.resources.push(resource); resource.attach(this); return this; } registerSchema(name, schema) { this.originalSchemas[name] = _.cloneDeep(schema); if (!this.document.components) { this.document.components = {}; } if (!this.document.components.schemas) { this.document.components.schemas = {}; } this.document.components.schemas[name] = removeSchemaDeclaration(rebase(name, _.cloneDeep(schema), refsRebaser)); } registerDynamicSchema(name, schema) { this.dynamicSchemas[name] = schema; } registerOperation(path, method, operation) { if (!this.document.paths) { this.document.paths = {}; } let _path = path; if (_path.length > 1 && _path[_path.length - 1] === '/') { _path = _path.substr(0, _path.length - 1); } if (!this.document.paths[_path]) { this.document.paths[_path] = {}; } this.document.paths[_path][method] = operation; return () => this.document.paths[_path][method]; } registerTag(tag) { if (!this.document.tags) { this.document.tags = []; } this.document.tags.push(tag); } registerOauth2Scope(name, description) { const oauth2Defs = this.getOauth2Schemes(); oauth2Defs.forEach((i) => { for (let f in i.flows) { const flow = i.flows[f]; if (!flow.scopes) { flow.scopes = {}; } flow.scopes[name] = description; } }); } getOauth2Schemes() { const out = []; if (this.document.components && this.document.components.securitySchemes) { const schemes = this.document.components.securitySchemes; for (let k in schemes) { const s = schemes[k]; if (s.type === 'oauth2') { out.push(s); } } } return out; } async listen(httpPort, httpsPort, httpsOptions) { if (!httpPort && !httpsPort) { throw new Error('no listen ports specified'); } let router = await this.router(); let app = express(); app.use(router); app.use(API.handle404Error); let out = []; if (httpsPort) { if (!httpsOptions) { throw new Error('no https options'); } else { out.push(https.createServer(httpsOptions, app).listen(httpsPort)); } } if (httpPort) { out.push(http.createServer(app).listen(httpPort)); } return out.length == 1 ? out[0] : out; } router(options) { if (!this.internalRouter) { this.internalRouter = (async () => { this.logger.info('creating router'); const router = Router(options); router.use((_req, res, next) => { let req = _req; if (!req.logger) { req.logger = getLogger(this.getDebugLabel(), this.getDebugContext(), false); } next(); }); if (this.securityValidator !== API.prototype.securityValidator) { router.use(deprecate(this.securityValidator, 'API.securityValidator is deprecated. Use API.initSecurity instead.').bind(this)); } else { router.use(this.initSecurity.bind(this)); } for (let name in this.dynamicSchemas) { this.registerSchema(name, await this.dynamicSchemas[name].spec()); } if (this.document.components && this.document.components.schemas && Object.keys(this.document.components.schemas).length > 0) { this.addResource(new SchemaResource()); } // Move definitions into #/components/schemas/ as schemas this.document = rebaseOASDefinitions(this.document); // Remove unused schemas and parameters from openapi spec this.document = removeUnusedSchemas(this.document); const originalDocument = _.cloneDeep(this.document); router.get('/openapi.json', (req, res, next) => { if (!req.headers['host']) { next(API.newError(400, 'Bad Request', 'Missing Host header in the request')); } else { res.set('Access-Control-Allow-Origin', '*'); res.set('Access-Control-Allow-Methods', 'GET'); const out = _.cloneDeep(originalDocument); const baseUrl = `${req.headers['x-forwarded-proto'] || req.protocol}://${req.headers['host']}${req.baseUrl}`; out.servers = [ { url: baseUrl, }, ]; // Normalize OAuth2 urls if (out.components && out.components.securitySchemes) { for (let i in out.components.securitySchemes) { const s = out.components.securitySchemes[i]; if (s.type === 'oauth2' && s.flows) { for (let j in s.flows) { const f = s.flows[j]; ['authorizationUrl', 'tokenUrl', 'refreshUrl'].forEach((k) => { if (f[k]) { f[k] = new URL(f[k], baseUrl).toString(); } }); } } } } res.json(out); } }); this.document = await parse(this.document, this.parseOptions); for (let resource of this.resources) { await resource.router(router, options); } router.use(this.handleError); return router; })(); } return this.internalRouter; } async attach(base, options) { let router = await this.router(options); base.use('/v' + semver.major(this.document.info.version), router); return base; } initSecurity(req, _res, next) { req.logger.warn('using default security validator'); if (!req.scopes) { req.logger.warn('scopes not set, setting default to *'); req.scopes = new Scopes('*'); } next(); } securityValidator(req, res, next) { this.initSecurity(req, res, next); } handleError(err, req, res, next) { if (err.name === 'RESTError') { req.logger.error('REST ERROR', err); RESTError.send(res, err.code, err.message, err.info); } else if (err.name === 'ValidationError') { req.logger.error('DATA ERROR', err); RESTError.send(res, 400, err.name, ValidationError.getInfo(err)); } else { req.logger.error('GENERIC ERROR', err, err.stack); RESTError.send(res, 500, 'internal'); } } static newError(code, message, info, err) { return new RESTError(code, message, info, err); } static fireError(code, message, info, err) { throw API.newError(code, message, info, err); } static handle404Error(req, res, next) { req.logger.warn('404 Resource Not Found'); RESTError.send(res, 404, 'Not Found', 'the requested resource cannot be found, check the endpoint URL'); } } //# sourceMappingURL=api.js.map