UNPKG

@onehilltech/blueprint

Version:

lightweight, simple, elegant framework for building mean applications

712 lines (581 loc) 21.3 kB
/* * Copyright (c) 2018 One Hill Technologies, LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const { BO } = require ('base-object'); const assert = require ('assert'); const debug = require ('debug')('blueprint:RouterBuilder'); const express = require ('express'); const { checkSchema } = require ('express-validator/check'); const path = require ('path'); const util = require ('util'); const { forOwn, isFunction, isObjectLike, isPlainObject, isString, flattenDeep, isArray, extend, mapValues, transform, get } = require ('lodash'); const { checkPolicy, executeAction, handleValidationResult, render, legacySanitizer, legacyValidator, actionValidator } = require ('./middleware'); const { check, policyMaker } = require ('./policies'); const SINGLE_ACTION_CONTROLLER_METHOD = '__invoke'; const SINGLE_RESOURCE_BASE_PATH = '/:rcId'; function isRouter (r) { return !!r.specification && !!r.build; } /** * Factory method that generates an action object. */ function makeAction (controller, method, opts) { let action = {action: controller + '@' + method}; return extend (action, opts); } /** * @class MethodCall * * Helper class for using reflection to call a method. * * @param obj * @param method * @constructor */ const MethodCall = BO.extend ({ invoke () { return this.method.apply (this.obj, arguments); } }); module.exports = BO.extend ({ basePath: '/', _router: null, validators: null, sanitizers: null, app: null, init () { this._super.call (this, ...arguments); assert (!!this.app, 'You must define the {app} property.'); this._specs = []; this._routers = []; }, addSpecification (spec) { this._specs.push (spec); return this; }, addRouter (route, router) { if (isPlainObject (router)) { debug (`adding nested routers: [${Object.keys (router)}]`); forOwn (router, (value, key) => { if (isRouter (value)) { this.addRouter (route, value); } else { let childRoute = `${route}${key}/`; this.addRouter (childRoute, value); } }); } else { debug (`building/adding child router for ${route};\n${util.inspect (router.specification)}]\n\n`); this._routers.push ({ path: route, router: router.build (this.app) }); } return this; }, build () { this._router = express.Router (); // Add each specification and pre-built router to the router. this._specs.forEach (spec => this._addRouterSpecification (this.basePath, spec)); this._routers.forEach (router => this._router.use (router.path, router.router)); return this._router; }, /** * Add a router specification to the current router. * * @param route * @param spec */ _addRouterSpecification (route, spec) { if (isFunction (spec) && spec.name === 'router') { // The spec is an express.Router. We can just use it directly in the // router and continue on our merry way. this._router.use (route, spec); } else if (isPlainObject (spec)) { // The first step is to apply the policy in the specification, if exists. This // is because we need to determine if the current request can even access the // router path before we attempt to process it. if (spec.policy) { let middleware = this._makePolicyMiddleware (spec.policy); if (middleware.length) this._router.use (route, middleware); } // Next, we process any "use" methods. if (spec.use) this._router.use (route, spec.use); // Next, we start with the head verb since it must be defined before the get // verb. Otherwise, express will use the get verb over the head verb. if (spec.head) this._processToken (route, 'head', spec.head); forOwn (spec, (value, key) => { if (['head', 'use', 'policy'].includes (key)) return; switch (key[0]) { case '/': this._addRoute (route, key, value); break; case ':': this._addParameter (key, value); break; default: this._processToken (route, key, value); } }); } }, /** * Process a token from the router specification. * * @param route * @param token * @param value * @private */ _processToken (route, token, value) { switch (token) { case 'resource': this._addResource (route, value); break; default: this._addMethod (route, token, value); break; } }, /** * Define a verb on the router for the route. * * @param route * @param method * @param opts * @private */ _addMethod (route, method, opts) { debug (`defining ${method.toUpperCase ()} ${route}`); let verbFunc = this._router[method.toLowerCase ()]; if (!verbFunc) throw new Error (`${method} is not a supported http verb`); // 1. validate // 2. sanitize // 3. policies // 4a. before // 4b. execute // 4c. after let middleware = []; if (isString (opts)) { middleware.push (this._actionStringToMiddleware (opts, route)); } else if (isArray (opts)) { // Add the array of functions to the middleware. middleware.push (opts); } else { // Make sure there is either an action or view defined. if (!((opts.action && !opts.view) || (!opts.action && opts.view))) throw new Error (`${method} ${route} must define an action or view property`); // Add all middleware that should happen before execution. We are going // to be deprecating this feature after v4 release. if (opts.before) middleware.push (opts.before); if (opts.action) { middleware.push (this._actionStringToMiddleware (opts.action, route, opts)); } else if (opts.view) { if (opts.policy) middleware.push (this._makePolicyMiddleware (opts.policy)); middleware.push (render (opts.view)); } // Add all middleware that should happen after execution. We are going // to be deprecating this feature after v4 release. if (opts.after) middleware.push (opts.after); } // Define the route route. Let's be safe and make sure there is no // empty middleware being added to the route. if (middleware.length) { let stack = flattenDeep (middleware); verbFunc.call (this._router, route, stack); } }, _addResource (route, opts) { debug (`defining resource ${route}`); const spec = this._makeRouterSpecificationForResource (route, opts); this._addRouterSpecification (route, spec); }, _addRoute (currentPath, route, definition) { let fullPath = path.resolve (currentPath, route); debug (`adding ${route} at ${currentPath}`); let routerPath = currentPath !== '/' ? `${currentPath}${route}` : route; this._addRouterSpecification (routerPath, definition); }, /** * Add a parameter to the active router. * * @param param * @param opts * @private */ _addParameter (param, opts) { debug (`adding parameter ${param} to router`); let handler; if (isFunction (opts)) { handler = opts; } else if (isObjectLike (opts)) { if (opts.action) { // The parameter invokes an operation on the controller. let controller = this._resolveControllerAction (opts.action); if (!controller) throw new Error (`Cannot resolve controller action for parameter [action=${opts.action}]`); handler = controller.invoke (); } else { throw new Error (`Invalid parameter specification [param=${param}]`); } } else { throw new Error (`Parameter specification must be a Function or BO [param=${param}]`); } this._router.param (param.slice (1), handler); }, /** * Make a router specification for the resource definition. * * @param route * @param opts * @returns {{}} * @private */ _makeRouterSpecificationForResource (route, opts) { // Locate the controller specified in the options. let controllerName = opts.controller; if (!controllerName) throw new Error (`${path} is missing controller property`); let controller = get (this.app.resources.controllers, controllerName); if (!controller) throw new Error (`${controllerName} controller does not exist`); // Get the actions of the controller. let {actions,namespace,name} = controller; if (!actions) throw new Error (`${controllerName} must define actions property`); let {resourceId} = controller; if (!resourceId) throw new Error (`${controllerName} must define resourceId property`); const {allow, deny, policy} = opts; if (allow && deny) throw new Error (`${route} can only define allow or deny property, not both`); // All actions in the resource controller are allowed from the beginning. We // adjust this collection based on the actions defined by the allow/deny property. let allowed = Object.keys (actions); if (allow) allowed = allow; if (deny) { // Remove the actions that are being denied. for (let i = 0, len = deny.length; i < len; ++ i) allowed.splice (allowed.indexOf (deny[i]), 1); } // Build the specification for managing the resource. let singleBasePath = `/:${resourceId}`; let spec = {}; let singleSpec = {}; // Set the policy for all actions of this resource controller. if (policy) spec.policy = policy; let actionOptions = opts.actions || {}; allowed.forEach (function (actionName) { let action = actions[actionName]; let actionConfig = actionOptions[actionName] || {}; if (isArray (action)) { action.forEach (item => processAction (item)); } else if (isObjectLike (action)) { processAction (action); } function processAction (action) { // The options for the action will inherit the options for the resource. It // will then take the configuration defined for the corresponding action. let actionOption = { }; let {options} = opts; if (options) actionOption.options = options; actionOption = extend (actionOption, actionConfig); // If there is no policy explicitly specified, then auto-generate the policy // definition for the action. This will allow the developer to include the // policy in the correct directly for it to be auto-loaded. if (!actionOption.policy) { let prefix = '?'; if (namespace) prefix += namespace + '.'; const policyName = `${prefix}${name}.${actionName}`; actionOption.policy = check (policyName); } if (action.path) { if (action.path.startsWith (SINGLE_RESOURCE_BASE_PATH)) { let part = action.path.slice (SINGLE_RESOURCE_BASE_PATH.length); if (part.length === 0) { // We are working with an action for a single resource. singleSpec[action.verb] = makeAction (controllerName, action.method, actionOption); } else { if (!singleSpec[part]) singleSpec[part] = {}; singleSpec[part][action.verb] = makeAction (controllerName, action.method, actionOption); } } else { // We are working with an action for the collective resources. spec[action.path] = {}; spec[action.path][action.verb] = makeAction (controllerName, action.method, actionOption); } } else { // We are working with an action for the collective resources. spec[action.verb] = makeAction (controllerName, action.method, actionOption); } } }); // Add the specification for managing a since resource to the specification // for managing all the resources. spec[singleBasePath] = singleSpec; return spec; }, /** * Convert an action string to a express middleware function. * * @param action * @param path * @param opts * @returns {Array} * @private */ _actionStringToMiddleware (action, path, opts = {}) { let middleware = []; // Resolve controller and its method. The expected format is controller@method. We are // also going to pass params to the controller method. let controllerAction = this._resolveControllerAction (action); let params = {path}; if (opts.options) params.options = opts.options; let result = controllerAction.invoke (params); if (isFunction (result) && (result.length === 2 || result.length === 3)) { // Push the function/array onto the middleware stack. If there is a policy, // then we need to push that before we push the function onto the middleware // stack. if (opts.policy) middleware.push (this._makePolicyMiddleware (opts.policy)); middleware.push (result); } else if (isArray (result)) { // Push the function/array onto the middleware stack. If there is a policy, // then we need to push that before any of the functions. if (opts.policy) middleware.push (this._makePolicyMiddleware (opts.policy)); middleware.push (result); } else if (isPlainObject (result) || (result.prototype && result.prototype.execute)) { let plainObject = !(result.prototype && result.prototype.execute); if (!plainObject) result = new result ({controller: controllerAction.obj}); // The user elects to have separate validation, sanitize, and execution // section for the controller method. There must be a execution function. let {validate, sanitize, execute, schema} = result; if (!execute) throw new Error (`Controller action must define an \'execute\' property [${path}]`); // Perform static checks first. if (schema) { // We have an express-validator schema. The validator and sanitizer should // be built into the schema. schema = this._normalizeSchema (schema); middleware.push ([checkSchema (schema), handleValidationResult]); } // The controller method has the option of validating and sanitizing the // input data dynamically. We need to check for either one and add middleware // functions if it exists. if (validate || sanitize) { if (validate) { // The validator can be a f(req) middleware function, an object-like // schema, or a array of validator middleware functions. if (isFunction (validate)) { if (plainObject) { switch (validate.length) { case 2: middleware.push (legacyValidator (validate)); break; case 3: // This is a Express middleware function middleware.push (validate); break; } } else { // The validate method is on the action object. We need to pass it // to the action validator middleware. middleware.push (actionValidator (result)) } } else if (isArray (validate)) { // We have a middleware function, or an array of middleware functions. middleware.push (validate); } else if (isPlainObject (validate)) { console.warn (`*** deprecated: ${action}: Validation schema must be declared on the 'schema' property`); // We have an express-validator schema. let schema = this._normalizeSchema (validate); middleware.push (checkSchema (schema)); } else { throw new Error (`validate must be a f(req, res, next), [...f(req, res, next)], or object-like validation schema [path=${path}]`); } // Push the middleware that will evaluate the validation result. If the // validation fails, then this middleware will stop the request's progress. middleware.push (handleValidationResult); } // The optional sanitize must be a middleware f(req,res,next). Let's add this // after the validation operation. if (sanitize) { console.warn (`*** deprecated: ${action}: Define sanitize operations on the 'validate' or 'schema' property.`); if (isFunction (sanitize)) { switch (sanitize.length) { case 2: middleware.push (legacySanitizer (sanitize)); break; default: throw new Error (`Sanitize function must have the signature f(req,res,next)`); } } else if (isArray (sanitize)) { middleware.push (sanitize); } else if (isObjectLike (sanitize)) { console.warn (`*** deprecated: ${action}: Sanitizing schema must be declared on the 'schema' property`); // We have an express-validator schema. let schema = this._normalizeSchema (sanitize); middleware.push (checkSchema (schema)); } // Push the middleware that will evaluate the validation result. If the // validation fails, then this middleware will stop the request's progress. middleware.push (handleValidationResult); } } // The request is validated and the data has been sanitized. We can now work // on the actual data in the request. Let's check the policies for the request // and then execute it. let {policy} = opts; if (policy) middleware.push (this._makePolicyMiddleware (policy)); // Lastly, push the execution function onto the middleware stack. If the // execute takes 2 parameters, we are going to assume it returns a Promise. // Otherwise, it is a middleware function. switch (execute.length) { case 2: // The execute method is returning a Promise. middleware.push (executeAction (result)); break; case 3: // The execute method is a middleware function. middleware.push (execute); break; } } else { throw new Error (`Controller action expected to return a Function, BO, or an Action`); } return middleware; }, /** * Resolve a controller from an action specification. * * @param action * @private */ _resolveControllerAction (action) { let [controllerName, actionName] = action.split ('@'); if (!controllerName) throw new Error (`The action must include a controller name [${action}]`); if (!actionName) actionName = SINGLE_ACTION_CONTROLLER_METHOD; // Locate the controller object in our loaded controllers. If the controller // does not exist, then throw an exception. let controller = get (this.app.resources.controllers, controllerName); if (!controller) throw new Error (`${controllerName} not found`); // Locate the action method on the loaded controller. If the method does // not exist, then throw an exception. let method = controller[actionName]; if (!method) throw new Error (`${controllerName} does not define method ${actionName}`); return new MethodCall ({ obj: controller, method }); }, /** * Make a policy middleware from the policy. * * @param policy Policy object * @private */ _makePolicyMiddleware (definition) { let policy = policyMaker (definition, this.app); return policy !== null ? [checkPolicy (policy)] : []; }, /** * Normalize the validation schema. This will convert all custom policies * into the expected definition for express-validator. * * @param schema * @private */ _normalizeSchema (schema) { const {validators, sanitizers} = this.app.resources; const validatorNames = Object.keys (validators || {}); const sanitizerNames = Object.keys (sanitizers || {}); return mapValues (schema, (definition) => { return transform (definition, (result, value, key) => { if (validatorNames.includes (key)) { result.custom = { options: validators[key] }; } else if (sanitizerNames.includes (key)) { result.customSanitizer = { options: sanitizers[key] } } else { result[key] = value; } }, {}); }); } });