arrest
Version:
OpenAPI v3 compliant REST framework for Node.js, with support for MongoDB and JSON-Schema
299 lines • 11 kB
JavaScript
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 {
resource;
path;
method;
info;
api;
internalId;
scopes;
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(type) {
return bodyParser.json({ type: type || 'application/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/json-patch+json']) {
return [this.createJSONParser('application/json-patch+json'), this.createBodyValidator('application/json-patch+json', body.content['application/json-patch+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) {
if (typeof i.required === 'undefined') {
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 {
customHandler;
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