1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 | const { BO } = require ('base-object');
|
18 | const assert = require ('assert');
|
19 | const debug = require ('debug')('blueprint:RouterBuilder');
|
20 | const express = require ('express');
|
21 | const { checkSchema } = require ('express-validator/check');
|
22 | const path = require ('path');
|
23 | const Router = require ('./router');
|
24 | const util = require ('util');
|
25 |
|
26 | const {
|
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 |
|
40 | const {
|
41 | checkPolicy,
|
42 | executeAction,
|
43 | handleValidationResult,
|
44 | render,
|
45 | legacySanitizer,
|
46 | legacyValidator,
|
47 | actionValidator
|
48 | } = require ('./middleware');
|
49 |
|
50 | const { check, policyMaker } = require ('./policies');
|
51 |
|
52 | const SINGLE_ACTION_CONTROLLER_METHOD = '__invoke';
|
53 | const SINGLE_RESOURCE_BASE_PATH = '/:rcId';
|
54 |
|
55 | function isRouter (r) {
|
56 | return !!r.specification && !!r.build;
|
57 | }
|
58 |
|
59 |
|
60 |
|
61 |
|
62 | function makeAction (controller, method, opts) {
|
63 | let action = {action: controller + '@' + method};
|
64 | return extend (action, opts);
|
65 | }
|
66 |
|
67 |
|
68 |
|
69 |
|
70 |
|
71 |
|
72 |
|
73 |
|
74 |
|
75 |
|
76 | const MethodCall = BO.extend ({
|
77 | invoke () {
|
78 | return this.method.apply (this.obj, arguments);
|
79 | }
|
80 | });
|
81 |
|
82 | module.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 |
|
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 |
|
142 |
|
143 |
|
144 |
|
145 |
|
146 | _addRouterSpecification (route, spec) {
|
147 | if (isFunction (spec) && spec.name === 'router') {
|
148 |
|
149 |
|
150 | this._router.use (route, spec);
|
151 | }
|
152 | else if (isPlainObject (spec)) {
|
153 |
|
154 |
|
155 |
|
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 |
|
164 | if (spec.use)
|
165 | this._router.use (route, spec.use);
|
166 |
|
167 |
|
168 |
|
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 |
|
195 |
|
196 |
|
197 |
|
198 |
|
199 |
|
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 |
|
215 |
|
216 |
|
217 |
|
218 |
|
219 |
|
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 |
|
230 |
|
231 |
|
232 |
|
233 |
|
234 |
|
235 |
|
236 | let middleware = [];
|
237 |
|
238 | if (isString (opts)) {
|
239 | middleware.push (this._actionStringToMiddleware (opts, route));
|
240 | }
|
241 | else if (isArray (opts)) {
|
242 |
|
243 | middleware.push (opts);
|
244 | }
|
245 | else {
|
246 |
|
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 |
|
251 |
|
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 |
|
267 |
|
268 | if (opts.after)
|
269 | middleware.push (opts.after);
|
270 | }
|
271 |
|
272 |
|
273 |
|
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 |
|
300 |
|
301 |
|
302 |
|
303 |
|
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 |
|
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 |
|
336 |
|
337 |
|
338 |
|
339 |
|
340 |
|
341 |
|
342 | _makeRouterSpecificationForResource (route, opts) {
|
343 |
|
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 |
|
521 |
|
522 | if (schema) {
|
523 |
|
524 |
|
525 | schema = this._normalizeSchema (schema);
|
526 | middleware.push ([checkSchema (schema), handleValidationResult]);
|
527 | }
|
528 |
|
529 |
|
530 |
|
531 |
|
532 | if (validate || sanitize) {
|
533 | if (validate) {
|
534 |
|
535 |
|
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 |
|
546 | middleware.push (validate);
|
547 | break;
|
548 | }
|
549 | }
|
550 | else {
|
551 |
|
552 |
|
553 | middleware.push (actionValidator (result))
|
554 | }
|
555 | }
|
556 | else if (isArray (validate)) {
|
557 |
|
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 |
|
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 |
|
572 |
|
573 | middleware.push (handleValidationResult);
|
574 | }
|
575 |
|
576 |
|
577 |
|
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 |
|
598 | let schema = this._normalizeSchema (sanitize);
|
599 | middleware.push (checkSchema (schema));
|
600 | }
|
601 |
|
602 |
|
603 |
|
604 | middleware.push (handleValidationResult);
|
605 | }
|
606 | }
|
607 |
|
608 |
|
609 |
|
610 |
|
611 | let {policy} = opts;
|
612 |
|
613 | if (policy)
|
614 | middleware.push (this._makePolicyMiddleware (policy));
|
615 |
|
616 |
|
617 |
|
618 |
|
619 | switch (execute.length)
|
620 | {
|
621 | case 2:
|
622 |
|
623 | middleware.push (executeAction (result));
|
624 | break;
|
625 |
|
626 | case 3:
|
627 |
|
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 |
|
641 |
|
642 |
|
643 |
|
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 |
|
655 |
|
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 |