import { IOperation, IChassisDataStore, IChassisMiddleware, } from "../interfaces"; import assert = require("assert"); import _ = require("lodash"); import { Vars, APIs } from "../helpers"; import { validate } from "../middleware" /************************************************************************* * * Troven CONFIDENTIAL * __________________ * * (c) 2017-2020 Troven Ventures Pty Ltd. All Rights Reserved. * * NOTICE: All information contained herein is, and remains * the property of Troven Pty Ltd and its licensors, * if any. The intellectual and technical concepts contained * herein are proprietary to Troven Pty Ltd * and its suppliers and may be covered by International and Regional Patents, * patents in process, and are protected by trade secret or copyright law. * Dissemination of this information or reproduction of this material * is strictly forbidden unless prior written permission is obtained * from Troven Pty Ltd. */ export class CRUDMiddleware implements IChassisMiddleware { name: string = "generic_data_store"; store: IChassisDataStore; method_actions: any = { GET: "read", POST: "create", PUT: "update", DELETE: "delete", LIST: "list", }; constructor(store_plugin: IChassisDataStore) { this.store = store_plugin; if (store_plugin.name.indexOf(".") > 0) { this.name = store_plugin.name; } else { this.name = "api." + store_plugin.name; } store_plugin.context.log({ code: "api:crud:middleware", name: this.name, plugin: store_plugin.name, }); } /** * fn() is the generic entry point to initialize a plugin. * * @param context * @param options */ fn(operation: IOperation, _options: any) { let store = this.store; let self = this; // merge plugin options, middleware options and request parameters .. let options = _.extend({ required: true }, store.options, _options); return function (req: any, res: any, next: Function) { assert(req && res && next, "invalid middleware"); // get actionId from 'features' then method lookup, defaults to the operation's actionId. let actionId = options.actionId || self.method_actions[operation.actionId.toUpperCase()] || operation.actionId; // heuristic map infer 'list' versus 'read' if ( actionId == "read" && (operation.resource.endsWith("/") || req.params.length == 0) ) { actionId = "list"; } // create a model from the request's JSON payload let model = req.body; model = self.model_policies(model, options); // closure to handle success / failure of the CRUD operation let response_handler = function (err, r) { if (err) { operation.context.warn( err.code ? err : { code: "api:crud:failed:" + actionId, message: err, operationId: operation.operationId, actionId: actionId, options: options, model: model._id, } ); res.status(err.statusCode || 400); // 400 bad request res.send({ code: err.code, message: err.message || err.err, errors: err.errors, model: model[store.getIDField()], }); } else { // broadcast local event operation.context.bus.emit("api:crud:" + actionId, { model: model, options: options, }); if (options.next !== true || !_.isEmpty(r)) { // send response res.status(200); r = self.model_transforms(r, options); res.send(r); } else { next(); } } }; try { self.handle( operation, actionId, model, req, options, response_handler ); } catch (err) { console.error(err); let _error = { code: "api:crud:error:" + actionId, error: err, statusCode: 500, operationId: operation.operationId, actionId: actionId, options: options, model: model._id, }; response_handler(_error, null); } }; } route_to_fields(route: string) { let re = /(\/\:\w+)/g; let match = null; let fields = []; do { match = re.exec(route + "/"); if (match) fields.push(match[0].substring(2)); } while (match); return fields; } handle( operation: IOperation, actionId: string, model: any, req: any, _options: any, response_handler: Function ) { let store = this.store; let options = _.extend( {}, _.pick(this.store.options, ["database", "collection", "filters"]), _options ); // get request & query params based on OASv3 spec let req_path_keys: string[] = operation.parameterKeys("path"); let req_query_keys: string[] = operation.parameterKeys("query"); // merge params - path params take precedence over query params. // let op_ctx = _.extend({}, req.query, req.params); let op_ctx = APIs.getOperationContext(operation, req, options); // filtering - pick based on OASv3 parameters let filterBy = _.isString(_options.filterBy) ? [_options.filterBy] : _options.filterBy; if (_.isEmpty(filterBy) && op_ctx.params[this.store.getIDField()]) filterBy = [ this.store.getIDField() ]; (op_ctx as any).filterBy = filterBy; // filtering - interpolated feature / plugin options let op_filters = Vars.$$(_.extend({}, options.filters), op_ctx); // merged filters - request-based overriden by feature / plugin options let filter = !_.isEmpty(op_filters) ? op_filters : filterBy ? _.pick(op_ctx.params, filterBy) : {}; store.context.log({ code: "api:crud:handle", name: this.name, operationId: operation.operationId, actionId: actionId, store: store.name, type: Vars.typeof(model), options: options, filter: filter, ctx: op_ctx, param_keys: req_path_keys, query_keys: req_query_keys, filterBy: filterBy, }); // perform the CRUD facade operation switch (actionId) { case "bulk": store.bulk(operation, true, model as any[], op_ctx, options, response_handler); break; case "create": if ( this.validate_model(operation, model, response_handler) ) { store.create( operation, model, op_ctx, options, response_handler ); } break; case "read": store.read( operation, filter, op_ctx, options, response_handler ); break; case "list": store.list( operation, filter, op_ctx, options, response_handler ); break; case "aggregate": store.aggregate( operation, filter, op_ctx, options, response_handler ); break; case "update": if ( this.validate_model(operation, model, response_handler) ) { store.update( operation, model, op_ctx, options, response_handler ); } break; case "delete": store.delete( operation, filter, op_ctx, options, response_handler ); break; case "health": case "healthz": store.healthy(options, response_handler); break; default: // polymorphic heuristics let crud_fn = store[actionId]; if (crud_fn) { store.context.log({ code: "crud:operation:inferred", operationId: operation.operationId, actionId: actionId, }); crud_fn(model, op_ctx, response_handler); } else { let _error = { code: "crud:operation:unknown", operationId: operation.operationId, actionId: actionId, }; response_handler(_error); } break; } } validate_model(operation: IOperation, model: any, callback: Function) { if (!_.isObject(model)) { callback({ code: "crud:model:mistyped", type: Vars.typeof(model) }); return false; } let invalid = validate.validateBody(operation, model); if (invalid) { callback({ code: "crud:model:invalid", errors: invalid.errors }); return false; } return true; } model_transforms(model: any, options: any): any { model = Vars.decycle(model); // remove circular references let self = this; if (_.isArray(model)) { const rows = []; _.each(model, (m) => { rows.push(self.model_policies(m, options)); }); return rows; } else { return self.model_policies(model, options); } } model_policies(model: any, options: any): any { if (options.defaults) model = _.defaults(model, options.defaults); if (options.model) model = _.extend(model, options.model); if (options.redact) model = _.omit(model, options.redact); if (options.pick) model = _.pick(model, options.pick); return model; } }