UNPKG

arrest

Version:

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

286 lines 10.5 kB
import { permittedFieldsOf } from '@casl/ability/extra'; import { Scopes } from '@vivocha/scopes'; import bodyParser from 'body-parser'; import cookieParser from 'cookie-parser'; import { Eredita } from 'eredita'; import _ from 'lodash'; import { ParameterObject, Schema, StaticSchemaObject, ValidationError } from 'openapi-police'; import { API } from './api.js'; import { checkAbility } from './util.js'; const swaggerPathRegExp = /\/:([^#\?\/]*)/g; export class Operation { constructor(resource, path, method, id, opts) { this.resource = resource; this.path = path; this.method = method; if (!id) { id = path; if (id.length && id[0] === '/') { id = id.substr(1); } if (!id.length) { id = method; } else if (method !== 'get') { id += '-' + method; } } this.internalId = id; this.info = this.getInfo(opts); if (!this.info || !this.info.operationId) { throw new Error('Required operationId missing'); } } get swaggerPath() { return this.path.replace(swaggerPathRegExp, '/{$1}'); } get swaggerScopes() { return { [this.info.operationId]: this.info.summary || `Execute ${this.info.operationId} on a ${this.resource.info.name}`, }; } getInfo(opts) { return Eredita.deepExtend({}, this.getDefaultInfo(opts), this.getCustomInfo(opts)); } getDefaultInfo(opts) { return { operationId: `${this.resource.info.name}.${this.internalId}`, tags: ['' + this.resource.info.name], responses: { default: { $ref: '#/components/responses/defaultError', }, }, }; } getCustomInfo(opts) { return {}; } createParameterValidators(key, parameters) { let validators = parameters.map((parameter) => { let required = parameter.required || false; let hasDefault = typeof parameter.schema === 'object' && typeof parameter.schema.default !== 'undefined'; let schema = new ParameterObject(parameter); return async function (req) { req.logger.debug(`validator ${key}.${parameter.name}, required ${required}, value ${req[key][parameter.name]}`); if (typeof req[key][parameter.name] === 'undefined' && required === true) { throw new ValidationError(`${key}.${parameter.name}`, schema.scope, 'required'); } else if (typeof req[key][parameter.name] !== 'undefined' || hasDefault) { req[key][parameter.name] = await schema.validate(req[key][parameter.name], { setDefault: true }, `${key}.${parameter.name}`); } }; }); return async function (req, res, next) { try { for (let v of validators) { await v(req); } next(); } catch (err) { next(err); } }; } createJSONParser() { return bodyParser.json(); } createUrlencodedParser() { return bodyParser.urlencoded({ extended: true }); } createBodyValidators() { if (this.info.requestBody) { const body = this.info.requestBody; if (!body.content) { throw new Error('Invalid request body'); // TODO maybe use another error type } if (body.content['application/json']) { return [this.createJSONParser(), this.createBodyValidator('application/json', body.content['application/json'], body.required)]; } if (body.content['application/x-www-form-urlencoded']) { return [ this.createUrlencodedParser(), this.createBodyValidator('application/x-www-form-urlencoded', body.content['application/x-www-form-urlencoded'], body.required), ]; } } else if (['put', 'post', 'patch'].includes(this.method)) { return [this.createJSONParser()]; } } createBodyValidator(type, bodySpec, required = false) { if (!bodySpec.schema) { throw new Error(`Schema missing for content type ${type}`); } const schema = new StaticSchemaObject(bodySpec.schema); return async (req, res, next) => { if (_.isEqual(req.body, {}) && !parseInt('' + req.header('content-length'))) { if (required === true) { next(new ValidationError('body', Schema.scope(schema), 'required')); } else { next(); } } else { try { req.body = await schema.validate(req.body, { setDefault: true, coerceTypes: type !== 'application/json', context: 'write', }, 'body'); } catch (err) { next(err); return; } next(); } }; } useSecurityValidator() { return !!this.scopes; } securityValidator(req, res, next) { req.logger.debug(`checking ability/scope, required: ${this.scopes}`); if (req.ability) { try { this.checkAbility(req.ability); } catch (err) { req.logger.warn(`insufficient ability`, err); req.logger.debug('insufficient privileges required', this.scopes, 'for user perms', req['perms']); next(API.newError(403, 'insufficient privileges')); } req.logger.debug('ability ok'); next(); } else if (req.scopes) { if (!req.scopes.match(this.scopes)) { req.logger.warn('insufficient scope', req.scopes); req.logger.debug('insufficient privileges required', this.scopes, 'for user perms', req.scopes); next(API.newError(403, 'insufficient privileges')); } else { req.logger.debug('scope ok'); next(); } } else { req.logger.warn('no scope'); next(API.newError(401, 'no scope')); } } errorHandler(err, req, res, next) { next(err); } attach(api) { this.api = api; const infoGetter = api.registerOperation(this.resource.basePath + this.swaggerPath, this.method, this.info); Object.defineProperty(this, 'info', { get: infoGetter, }); let swaggerScopes = this.swaggerScopes; let scopeNames = []; for (let i in swaggerScopes) { scopeNames.push(i); api.registerOauth2Scope(i, swaggerScopes[i]); } if (this.api.document.components && this.api.document.components.securitySchemes) { const schemes = this.api.document.components.securitySchemes; for (let k in schemes) { if (!this.info.security) { this.info.security = []; } const s = schemes[k]; this.info.security.push({ [k]: s.type === 'oauth2' ? scopeNames : [], }); } } if (scopeNames.length) { this.scopes = new Scopes(scopeNames); } } async router(router) { const middlewares = []; if (this.useSecurityValidator()) { middlewares.push(this.securityValidator.bind(this)); } let params = _.groupBy(this.info.parameters || [], 'in'); if (params.header) { middlewares.push(this.createParameterValidators('headers', params.header)); } if (params.cookie) { middlewares.push(cookieParser()); middlewares.push(this.createParameterValidators('cookies', params.cookie)); } if (params.path) { _.each(params.path, function (i) { i.required = true; }); middlewares.push(this.createParameterValidators('params', params.path)); } if (params.query) { middlewares.push(this.createParameterValidators('query', params.query)); } const bodyMiddlewares = this.createBodyValidators(); if (bodyMiddlewares) { middlewares.push(...bodyMiddlewares); } router[this.method](this.path, ...middlewares, this.handler.bind(this), this.errorHandler.bind(this)); return router; } checkAbility(ability, data, filterFields, filterData) { if (this.scopes) { for (let resource in this.scopes) { for (let action in this.scopes[resource]) { data = checkAbility(ability, resource, action, data, filterFields, filterData); } } } return data; } checkAbilityForPath(ability, path) { if (this.scopes) { for (let resource in this.scopes) { for (let action in this.scopes[resource]) { if (ability.can(action, resource, path)) { return true; } } } return false; } else { return true; } } filterFields(ability, data) { return this.checkAbility(ability, data, true); } permittedFields(ability) { let out = []; if (this.scopes) { for (let resource in this.scopes) { for (let action in this.scopes[resource]) { out = out.concat(permittedFieldsOf(ability, action, resource, { fieldsFrom: (rule) => rule.fields || ['**'], })); } } } return new Set(out); } } export class SimpleOperation extends Operation { constructor(customHandler, resource, path, method, id) { super(resource, path, method, id); this.customHandler = customHandler; } handler(req, res, next) { this.customHandler(req, res, next); } } //# sourceMappingURL=operation.js.map