UNPKG

21.3 kBJavaScriptView Raw
1/*
2 * Copyright (c) 2018 One Hill Technologies, LLC
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17const { BO } = require ('base-object');
18const assert = require ('assert');
19const debug = require ('debug')('blueprint:RouterBuilder');
20const express = require ('express');
21const { checkSchema } = require ('express-validator/check');
22const path = require ('path');
23const Router = require ('./router');
24const util = require ('util');
25
26const {
27 forOwn,
28 isFunction,
29 isObjectLike,
30 isPlainObject,
31 isString,
32 flattenDeep,
33 isArray,
34 extend,
35 mapValues,
36 transform,
37 get
38} = require ('lodash');
39
40const {
41 checkPolicy,
42 executeAction,
43 handleValidationResult,
44 render,
45 legacySanitizer,
46 legacyValidator,
47 actionValidator
48} = require ('./middleware');
49
50const { check, policyMaker } = require ('./policies');
51
52const SINGLE_ACTION_CONTROLLER_METHOD = '__invoke';
53const SINGLE_RESOURCE_BASE_PATH = '/:rcId';
54
55function isRouter (r) {
56 return !!r.specification && !!r.build;
57}
58
59/**
60 * Factory method that generates an action object.
61 */
62function makeAction (controller, method, opts) {
63 let action = {action: controller + '@' + method};
64 return extend (action, opts);
65}
66
67/**
68 * @class MethodCall
69 *
70 * Helper class for using reflection to call a method.
71 *
72 * @param obj
73 * @param method
74 * @constructor
75 */
76const MethodCall = BO.extend ({
77 invoke () {
78 return this.method.apply (this.obj, arguments);
79 }
80});
81
82module.exports = BO.extend ({
83 basePath: '/',
84
85 _router: null,
86
87 validators: null,
88
89 sanitizers: null,
90
91 app: null,
92
93 init () {
94 this._super.call (this, ...arguments);
95
96 assert (!!this.app, 'You must define the {app} property.');
97
98 this._specs = [];
99 this._routers = [];
100 },
101
102 addSpecification (spec) {
103 this._specs.push (spec);
104 return this;
105 },
106
107 addRouter (route, router) {
108 if (isPlainObject (router)) {
109 debug (`adding nested routers: [${Object.keys (router)}]`);
110
111 forOwn (router, (value, key) => {
112 if (isRouter (value)) {
113 this.addRouter (route, value);
114 }
115 else {
116 let childRoute = `${route}${key}/`;
117 this.addRouter (childRoute, value);
118 }
119 });
120 }
121 else {
122 debug (`building/adding child router for ${route};\n${util.inspect (router.specification)}]\n\n`);
123
124 this._routers.push ({ path: route, router: router.build (this.app) });
125 }
126
127 return this;
128 },
129
130 build () {
131 this._router = express.Router ();
132
133 // Add each specification and pre-built router to the router.
134 this._specs.forEach (spec => this._addRouterSpecification (this.basePath, spec));
135 this._routers.forEach (router => this._router.use (router.path, router.router));
136
137 return this._router;
138 },
139
140 /**
141 * Add a router specification to the current router.
142 *
143 * @param route
144 * @param spec
145 */
146 _addRouterSpecification (route, spec) {
147 if (isFunction (spec) && spec.name === 'router') {
148 // The spec is an express.Router. We can just use it directly in the
149 // router and continue on our merry way.
150 this._router.use (route, spec);
151 }
152 else if (isPlainObject (spec)) {
153 // The first step is to apply the policy in the specification, if exists. This
154 // is because we need to determine if the current request can even access the
155 // router path before we attempt to process it.
156 if (spec.policy) {
157 let middleware = this._makePolicyMiddleware (spec.policy);
158
159 if (middleware.length)
160 this._router.use (route, middleware);
161 }
162
163 // Next, we process any "use" methods.
164 if (spec.use)
165 this._router.use (route, spec.use);
166
167 // Next, we start with the head verb since it must be defined before the get
168 // verb. Otherwise, express will use the get verb over the head verb.
169 if (spec.head)
170 this._processToken (route, 'head', spec.head);
171
172 forOwn (spec, (value, key) => {
173 if (['head', 'use', 'policy'].includes (key))
174 return;
175
176 switch (key[0])
177 {
178 case '/':
179 this._addRoute (route, key, value);
180 break;
181
182 case ':':
183 this._addParameter (key, value);
184 break;
185
186 default:
187 this._processToken (route, key, value);
188 }
189 });
190 }
191 },
192
193 /**
194 * Process a token from the router specification.
195 *
196 * @param route
197 * @param token
198 * @param value
199 * @private
200 */
201 _processToken (route, token, value) {
202 switch (token) {
203 case 'resource':
204 this._addResource (route, value);
205 break;
206
207 default:
208 this._addMethod (route, token, value);
209 break;
210 }
211 },
212
213 /**
214 * Define a verb on the router for the route.
215 *
216 * @param route
217 * @param method
218 * @param opts
219 * @private
220 */
221 _addMethod (route, method, opts) {
222 debug (`defining ${method.toUpperCase ()} ${route}`);
223
224 let verbFunc = this._router[method.toLowerCase ()];
225
226 if (!verbFunc)
227 throw new Error (`${method} is not a supported http verb`);
228
229 // 1. validate
230 // 2. sanitize
231 // 3. policies
232 // 4a. before
233 // 4b. execute
234 // 4c. after
235
236 let middleware = [];
237
238 if (isString (opts)) {
239 middleware.push (this._actionStringToMiddleware (opts, route));
240 }
241 else if (isArray (opts)) {
242 // Add the array of functions to the middleware.
243 middleware.push (opts);
244 }
245 else {
246 // Make sure there is either an action or view defined.
247 if (!((opts.action && !opts.view) || (!opts.action && opts.view)))
248 throw new Error (`${method} ${route} must define an action or view property`);
249
250 // Add all middleware that should happen before execution. We are going
251 // to be deprecating this feature after v4 release.
252
253 if (opts.before)
254 middleware.push (opts.before);
255
256 if (opts.action) {
257 middleware.push (this._actionStringToMiddleware (opts.action, route, opts));
258 }
259 else if (opts.view) {
260 if (opts.policy)
261 middleware.push (this._makePolicyMiddleware (opts.policy));
262
263 middleware.push (render (opts.view));
264 }
265
266 // Add all middleware that should happen after execution. We are going
267 // to be deprecating this feature after v4 release.
268 if (opts.after)
269 middleware.push (opts.after);
270 }
271
272 // Define the route route. Let's be safe and make sure there is no
273 // empty middleware being added to the route.
274
275 if (middleware.length) {
276 let stack = flattenDeep (middleware);
277 verbFunc.call (this._router, route, stack);
278 }
279 },
280
281 _addResource (route, opts) {
282 debug (`defining resource ${route}`);
283
284 const spec = this._makeRouterSpecificationForResource (route, opts);
285 this._addRouterSpecification (route, spec);
286 },
287
288 _addRoute (currentPath, route, definition) {
289 let fullPath = path.resolve (currentPath, route);
290
291 debug (`adding ${route} at ${currentPath}`);
292
293 let routerPath = currentPath !== '/' ? `${currentPath}${route}` : route;
294
295 this._addRouterSpecification (routerPath, definition);
296 },
297
298 /**
299 * Add a parameter to the active router.
300 *
301 * @param param
302 * @param opts
303 * @private
304 */
305 _addParameter (param, opts) {
306 debug (`adding parameter ${param} to router`);
307
308 let handler;
309
310 if (isFunction (opts)) {
311 handler = opts;
312 }
313 else if (isObjectLike (opts)) {
314 if (opts.action) {
315 // The parameter invokes an operation on the controller.
316 let controller = this._resolveControllerAction (opts.action);
317
318 if (!controller)
319 throw new Error (`Cannot resolve controller action for parameter [action=${opts.action}]`);
320
321 handler = controller.invoke ();
322 }
323 else {
324 throw new Error (`Invalid parameter specification [param=${param}]`);
325 }
326 }
327 else {
328 throw new Error (`Parameter specification must be a Function or BO [param=${param}]`);
329 }
330
331 this._router.param (param.slice (1), handler);
332 },
333
334 /**
335 * Make a router specification for the resource definition.
336 *
337 * @param route
338 * @param opts
339 * @returns {{}}
340 * @private
341 */
342 _makeRouterSpecificationForResource (route, opts) {
343 // Locate the controller specified in the options.
344 let controllerName = opts.controller;
345
346 if (!controllerName)
347 throw new Error (`${path} is missing controller property`);
348
349 let controller = get (this.app.resources.controllers, controllerName);
350
351 if (!controller)
352 throw new Error (`${controllerName} controller does not exist`);
353
354 // Get the actions of the controller.
355 let {actions,namespace,name} = controller;
356
357 if (!actions)
358 throw new Error (`${controllerName} must define actions property`);
359
360 let {resourceId} = controller;
361
362 if (!resourceId)
363 throw new Error (`${controllerName} must define resourceId property`);
364
365 const {allow, deny, policy} = opts;
366
367 if (allow && deny)
368 throw new Error (`${route} can only define allow or deny property, not both`);
369
370 // All actions in the resource controller are allowed from the beginning. We
371 // adjust this collection based on the actions defined by the allow/deny property.
372
373 let allowed = Object.keys (actions);
374
375 if (allow)
376 allowed = allow;
377
378 if (deny) {
379 // Remove the actions that are being denied.
380 for (let i = 0, len = deny.length; i < len; ++ i)
381 allowed.splice (allowed.indexOf (deny[i]), 1);
382 }
383
384 // Build the specification for managing the resource.
385 let singleBasePath = `/:${resourceId}`;
386 let spec = {};
387 let singleSpec = {};
388
389 // Set the policy for all actions of this resource controller.
390 if (policy)
391 spec.policy = policy;
392
393 let actionOptions = opts.actions || {};
394
395 allowed.forEach (function (actionName) {
396 let action = actions[actionName];
397 let actionConfig = actionOptions[actionName] || {};
398
399 if (isArray (action)) {
400 action.forEach (item => processAction (item));
401 }
402 else if (isObjectLike (action)) {
403 processAction (action);
404 }
405
406 function processAction (action) {
407 // The options for the action will inherit the options for the resource. It
408 // will then take the configuration defined for the corresponding action.
409 let actionOption = { };
410
411 let {options} = opts;
412
413 if (options)
414 actionOption.options = options;
415
416 actionOption = extend (actionOption, actionConfig);
417
418 // If there is no policy explicitly specified, then auto-generate the policy
419 // definition for the action. This will allow the developer to include the
420 // policy in the correct directly for it to be auto-loaded.
421 if (!actionOption.policy) {
422 let prefix = '?';
423
424 if (namespace)
425 prefix += namespace + '.';
426
427 const policyName = `${prefix}${name}.${actionName}`;
428 actionOption.policy = check (policyName);
429 }
430
431 if (action.path) {
432 if (action.path.startsWith (SINGLE_RESOURCE_BASE_PATH)) {
433 let part = action.path.slice (SINGLE_RESOURCE_BASE_PATH.length);
434
435 if (part.length === 0) {
436 // We are working with an action for a single resource.
437 singleSpec[action.verb] = makeAction (controllerName, action.method, actionOption);
438 }
439 else {
440 if (!singleSpec[part])
441 singleSpec[part] = {};
442
443 singleSpec[part][action.verb] = makeAction (controllerName, action.method, actionOption);
444 }
445 }
446 else {
447 // We are working with an action for the collective resources.
448 spec[action.path] = {};
449 spec[action.path][action.verb] = makeAction (controllerName, action.method, actionOption);
450 }
451 }
452 else {
453 // We are working with an action for the collective resources.
454 spec[action.verb] = makeAction (controllerName, action.method, actionOption);
455 }
456 }
457 });
458
459 // Add the specification for managing a since resource to the specification
460 // for managing all the resources.
461 spec[singleBasePath] = singleSpec;
462
463 return spec;
464 },
465
466 /**
467 * Convert an action string to a express middleware function.
468 *
469 * @param action
470 * @param path
471 * @param opts
472 * @returns {Array}
473 * @private
474 */
475 _actionStringToMiddleware (action, path, opts = {}) {
476 let middleware = [];
477
478 // Resolve controller and its method. The expected format is controller@method. We are
479 // also going to pass params to the controller method.
480 let controllerAction = this._resolveControllerAction (action);
481 let params = {path};
482
483 if (opts.options)
484 params.options = opts.options;
485
486 let result = controllerAction.invoke (params);
487
488 if (isFunction (result) && (result.length === 2 || result.length === 3)) {
489 // Push the function/array onto the middleware stack. If there is a policy,
490 // then we need to push that before we push the function onto the middleware
491 // stack.
492
493 if (opts.policy)
494 middleware.push (this._makePolicyMiddleware (opts.policy));
495
496 middleware.push (result);
497 }
498 else if (isArray (result)) {
499 // Push the function/array onto the middleware stack. If there is a policy,
500 // then we need to push that before any of the functions.
501
502 if (opts.policy)
503 middleware.push (this._makePolicyMiddleware (opts.policy));
504
505 middleware.push (result);
506 }
507 else if (isPlainObject (result) || (result.prototype && result.prototype.execute)) {
508 let plainObject = !(result.prototype && result.prototype.execute);
509
510 if (!plainObject)
511 result = new result ({controller: controllerAction.obj});
512
513 // The user elects to have separate validation, sanitize, and execution
514 // section for the controller method. There must be a execution function.
515 let {validate, sanitize, execute, schema} = result;
516
517 if (!execute)
518 throw new Error (`Controller action must define an \'execute\' property [${path}]`);
519
520 // Perform static checks first.
521
522 if (schema) {
523 // We have an express-validator schema. The validator and sanitizer should
524 // be built into the schema.
525 schema = this._normalizeSchema (schema);
526 middleware.push ([checkSchema (schema), handleValidationResult]);
527 }
528
529 // The controller method has the option of validating and sanitizing the
530 // input data dynamically. We need to check for either one and add middleware
531 // functions if it exists.
532 if (validate || sanitize) {
533 if (validate) {
534 // The validator can be a f(req) middleware function, an object-like
535 // schema, or a array of validator middleware functions.
536
537 if (isFunction (validate)) {
538 if (plainObject) {
539 switch (validate.length) {
540 case 2:
541 middleware.push (legacyValidator (validate));
542 break;
543
544 case 3:
545 // This is a Express middleware function
546 middleware.push (validate);
547 break;
548 }
549 }
550 else {
551 // The validate method is on the action object. We need to pass it
552 // to the action validator middleware.
553 middleware.push (actionValidator (result))
554 }
555 }
556 else if (isArray (validate)) {
557 // We have a middleware function, or an array of middleware functions.
558 middleware.push (validate);
559 }
560 else if (isPlainObject (validate)) {
561 console.warn (`*** deprecated: ${action}: Validation schema must be declared on the 'schema' property`);
562
563 // We have an express-validator schema.
564 let schema = this._normalizeSchema (validate);
565 middleware.push (checkSchema (schema));
566 }
567 else {
568 throw new Error (`validate must be a f(req, res, next), [...f(req, res, next)], or object-like validation schema [path=${path}]`);
569 }
570
571 // Push the middleware that will evaluate the validation result. If the
572 // validation fails, then this middleware will stop the request's progress.
573 middleware.push (handleValidationResult);
574 }
575
576 // The optional sanitize must be a middleware f(req,res,next). Let's add this
577 // after the validation operation.
578 if (sanitize) {
579 console.warn (`*** deprecated: ${action}: Define sanitize operations on the 'validate' or 'schema' property.`);
580
581 if (isFunction (sanitize)) {
582 switch (sanitize.length) {
583 case 2:
584 middleware.push (legacySanitizer (sanitize));
585 break;
586
587 default:
588 throw new Error (`Sanitize function must have the signature f(req,res,next)`);
589 }
590 }
591 else if (isArray (sanitize)) {
592 middleware.push (sanitize);
593 }
594 else if (isObjectLike (sanitize)) {
595 console.warn (`*** deprecated: ${action}: Sanitizing schema must be declared on the 'schema' property`);
596
597 // We have an express-validator schema.
598 let schema = this._normalizeSchema (sanitize);
599 middleware.push (checkSchema (schema));
600 }
601
602 // Push the middleware that will evaluate the validation result. If the
603 // validation fails, then this middleware will stop the request's progress.
604 middleware.push (handleValidationResult);
605 }
606 }
607
608 // The request is validated and the data has been sanitized. We can now work
609 // on the actual data in the request. Let's check the policies for the request
610 // and then execute it.
611 let {policy} = opts;
612
613 if (policy)
614 middleware.push (this._makePolicyMiddleware (policy));
615
616 // Lastly, push the execution function onto the middleware stack. If the
617 // execute takes 2 parameters, we are going to assume it returns a Promise.
618 // Otherwise, it is a middleware function.
619 switch (execute.length)
620 {
621 case 2:
622 // The execute method is returning a Promise.
623 middleware.push (executeAction (result));
624 break;
625
626 case 3:
627 // The execute method is a middleware function.
628 middleware.push (execute);
629 break;
630 }
631 }
632 else {
633 throw new Error (`Controller action expected to return a Function, BO, or an Action`);
634 }
635
636 return middleware;
637 },
638
639 /**
640 * Resolve a controller from an action specification.
641 *
642 * @param action
643 * @private
644 */
645 _resolveControllerAction (action) {
646 let [controllerName, actionName] = action.split ('@');
647
648 if (!controllerName)
649 throw new Error (`The action must include a controller name [${action}]`);
650
651 if (!actionName)
652 actionName = SINGLE_ACTION_CONTROLLER_METHOD;
653
654 // Locate the controller object in our loaded controllers. If the controller
655 // does not exist, then throw an exception.
656 let controller = get (this.app.resources.controllers, controllerName);
657
658 if (!controller)
659 throw new Error (`${controllerName} not found`);
660
661 // Locate the action method on the loaded controller. If the method does
662 // not exist, then throw an exception.
663 let method = controller[actionName];
664
665 if (!method)
666 throw new Error (`${controllerName} does not define method ${actionName}`);
667
668 return new MethodCall ({ obj: controller, method });
669 },
670
671 /**
672 * Make a policy middleware from the policy.
673 *
674 * @param policy Policy object
675 * @private
676 */
677 _makePolicyMiddleware (definition) {
678 let policy = policyMaker (definition, this.app);
679 return policy !== null ? [checkPolicy (policy)] : [];
680 },
681
682 /**
683 * Normalize the validation schema. This will convert all custom policies
684 * into the expected definition for express-validator.
685 *
686 * @param schema
687 * @private
688 */
689 _normalizeSchema (schema) {
690 const {validators, sanitizers} = this.app.resources;
691 const validatorNames = Object.keys (validators || {});
692 const sanitizerNames = Object.keys (sanitizers || {});
693
694 return mapValues (schema, (definition) => {
695 return transform (definition, (result, value, key) => {
696 if (validatorNames.includes (key)) {
697 result.custom = {
698 options: validators[key]
699 };
700 }
701 else if (sanitizerNames.includes (key)) {
702 result.customSanitizer = {
703 options: sanitizers[key]
704 }
705 }
706 else {
707 result[key] = value;
708 }
709 }, {});
710 });
711 }
712});
713
\No newline at end of file