UNPKG

21.1 kBJavaScriptView Raw
1/**
2 * RESTful resource routing middleware for koa.
3 *
4 * @author Alex Mingoia <talk@alexmingoia.com>
5 * @link https://github.com/alexmingoia/koa-router
6 */
7
8const compose = require('koa-compose');
9const HttpError = require('http-errors');
10const methods = require('methods');
11const { pathToRegexp } = require('path-to-regexp');
12const Layer = require('./layer');
13const debug = require('debug')('koa-router');
14
15/**
16 * @module koa-router
17 */
18
19module.exports = Router;
20
21/**
22 * Create a new router.
23 *
24 * @example
25 *
26 * Basic usage:
27 *
28 * ```javascript
29 * const Koa = require('koa');
30 * const Router = require('@koa/router');
31 *
32 * const app = new Koa();
33 * const router = new Router();
34 *
35 * router.get('/', (ctx, next) => {
36 * // ctx.router available
37 * });
38 *
39 * app
40 * .use(router.routes())
41 * .use(router.allowedMethods());
42 * ```
43 *
44 * @alias module:koa-router
45 * @param {Object=} opts
46 * @param {Boolean=false} opts.exclusive only run last matched route's controller when there are multiple matches
47 * @param {String=} opts.prefix prefix router paths
48 * @param {String|RegExp=} opts.host host for router match
49 * @constructor
50 */
51
52function Router(opts = {}) {
53 if (!(this instanceof Router)) return new Router(opts);
54
55 this.opts = opts;
56 this.methods = this.opts.methods || [
57 'HEAD',
58 'OPTIONS',
59 'GET',
60 'PUT',
61 'PATCH',
62 'POST',
63 'DELETE'
64 ];
65 this.exclusive = Boolean(this.opts.exclusive);
66
67 this.params = {};
68 this.stack = [];
69 this.host = this.opts.host;
70}
71
72/**
73 * Create `router.verb()` methods, where *verb* is one of the HTTP verbs such
74 * as `router.get()` or `router.post()`.
75 *
76 * Match URL patterns to callback functions or controller actions using `router.verb()`,
77 * where **verb** is one of the HTTP verbs such as `router.get()` or `router.post()`.
78 *
79 * Additionally, `router.all()` can be used to match against all methods.
80 *
81 * ```javascript
82 * router
83 * .get('/', (ctx, next) => {
84 * ctx.body = 'Hello World!';
85 * })
86 * .post('/users', (ctx, next) => {
87 * // ...
88 * })
89 * .put('/users/:id', (ctx, next) => {
90 * // ...
91 * })
92 * .del('/users/:id', (ctx, next) => {
93 * // ...
94 * })
95 * .all('/users/:id', (ctx, next) => {
96 * // ...
97 * });
98 * ```
99 *
100 * When a route is matched, its path is available at `ctx._matchedRoute` and if named,
101 * the name is available at `ctx._matchedRouteName`
102 *
103 * Route paths will be translated to regular expressions using
104 * [path-to-regexp](https://github.com/pillarjs/path-to-regexp).
105 *
106 * Query strings will not be considered when matching requests.
107 *
108 * #### Named routes
109 *
110 * Routes can optionally have names. This allows generation of URLs and easy
111 * renaming of URLs during development.
112 *
113 * ```javascript
114 * router.get('user', '/users/:id', (ctx, next) => {
115 * // ...
116 * });
117 *
118 * router.url('user', 3);
119 * // => "/users/3"
120 * ```
121 *
122 * #### Multiple middleware
123 *
124 * Multiple middleware may be given:
125 *
126 * ```javascript
127 * router.get(
128 * '/users/:id',
129 * (ctx, next) => {
130 * return User.findOne(ctx.params.id).then(function(user) {
131 * ctx.user = user;
132 * next();
133 * });
134 * },
135 * ctx => {
136 * console.log(ctx.user);
137 * // => { id: 17, name: "Alex" }
138 * }
139 * );
140 * ```
141 *
142 * ### Nested routers
143 *
144 * Nesting routers is supported:
145 *
146 * ```javascript
147 * const forums = new Router();
148 * const posts = new Router();
149 *
150 * posts.get('/', (ctx, next) => {...});
151 * posts.get('/:pid', (ctx, next) => {...});
152 * forums.use('/forums/:fid/posts', posts.routes(), posts.allowedMethods());
153 *
154 * // responds to "/forums/123/posts" and "/forums/123/posts/123"
155 * app.use(forums.routes());
156 * ```
157 *
158 * #### Router prefixes
159 *
160 * Route paths can be prefixed at the router level:
161 *
162 * ```javascript
163 * const router = new Router({
164 * prefix: '/users'
165 * });
166 *
167 * router.get('/', ...); // responds to "/users"
168 * router.get('/:id', ...); // responds to "/users/:id"
169 * ```
170 *
171 * #### URL parameters
172 *
173 * Named route parameters are captured and added to `ctx.params`.
174 *
175 * ```javascript
176 * router.get('/:category/:title', (ctx, next) => {
177 * console.log(ctx.params);
178 * // => { category: 'programming', title: 'how-to-node' }
179 * });
180 * ```
181 *
182 * The [path-to-regexp](https://github.com/pillarjs/path-to-regexp) module is
183 * used to convert paths to regular expressions.
184 *
185 *
186 * ### Match host for each router instance
187 *
188 * ```javascript
189 * const router = new Router({
190 * host: 'example.domain' // only match if request host exactly equal `example.domain`
191 * });
192 *
193 * ```
194 *
195 * OR host cloud be a regexp
196 *
197 * ```javascript
198 * const router = new Router({
199 * host: /.*\.?example\.domain$/ // all host end with .example.domain would be matched
200 * });
201 * ```
202 *
203 * @name get|put|post|patch|delete|del
204 * @memberof module:koa-router.prototype
205 * @param {String} path
206 * @param {Function=} middleware route middleware(s)
207 * @param {Function} callback route callback
208 * @returns {Router}
209 */
210
211for (const method_ of methods) {
212 function setMethodVerb(method) {
213 Router.prototype[method] = function (name, path, middleware) {
214 if (typeof path === 'string' || path instanceof RegExp) {
215 middleware = Array.prototype.slice.call(arguments, 2);
216 } else {
217 middleware = Array.prototype.slice.call(arguments, 1);
218 path = name;
219 name = null;
220 }
221
222 // Sanity check to ensure we have a viable path candidate (eg: string|regex|non-empty array)
223 if (
224 typeof path !== 'string' &&
225 !(path instanceof RegExp) &&
226 (!Array.isArray(path) || path.length === 0)
227 )
228 throw new Error(
229 `You have to provide a path when adding a ${method} handler`
230 );
231
232 this.register(path, [method], middleware, { name });
233
234 return this;
235 };
236 }
237
238 setMethodVerb(method_);
239}
240
241// Alias for `router.delete()` because delete is a reserved word
242// eslint-disable-next-line dot-notation
243Router.prototype.del = Router.prototype['delete'];
244
245/**
246 * Use given middleware.
247 *
248 * Middleware run in the order they are defined by `.use()`. They are invoked
249 * sequentially, requests start at the first middleware and work their way
250 * "down" the middleware stack.
251 *
252 * @example
253 *
254 * ```javascript
255 * // session middleware will run before authorize
256 * router
257 * .use(session())
258 * .use(authorize());
259 *
260 * // use middleware only with given path
261 * router.use('/users', userAuth());
262 *
263 * // or with an array of paths
264 * router.use(['/users', '/admin'], userAuth());
265 *
266 * app.use(router.routes());
267 * ```
268 *
269 * @param {String=} path
270 * @param {Function} middleware
271 * @param {Function=} ...
272 * @returns {Router}
273 */
274
275Router.prototype.use = function () {
276 const router = this;
277 const middleware = Array.prototype.slice.call(arguments);
278 let path;
279
280 // support array of paths
281 if (Array.isArray(middleware[0]) && typeof middleware[0][0] === 'string') {
282 const arrPaths = middleware[0];
283 for (const p of arrPaths) {
284 router.use.apply(router, [p].concat(middleware.slice(1)));
285 }
286
287 return this;
288 }
289
290 const hasPath = typeof middleware[0] === 'string';
291 if (hasPath) path = middleware.shift();
292
293 for (const m of middleware) {
294 if (m.router) {
295 const cloneRouter = Object.assign(
296 Object.create(Router.prototype),
297 m.router,
298 {
299 stack: [...m.router.stack]
300 }
301 );
302
303 for (let j = 0; j < cloneRouter.stack.length; j++) {
304 const nestedLayer = cloneRouter.stack[j];
305 const cloneLayer = Object.assign(
306 Object.create(Layer.prototype),
307 nestedLayer
308 );
309
310 if (path) cloneLayer.setPrefix(path);
311 if (router.opts.prefix) cloneLayer.setPrefix(router.opts.prefix);
312 router.stack.push(cloneLayer);
313 cloneRouter.stack[j] = cloneLayer;
314 }
315
316 if (router.params) {
317 function setRouterParams(paramArr) {
318 const routerParams = paramArr;
319 for (const key of routerParams) {
320 cloneRouter.param(key, router.params[key]);
321 }
322 }
323
324 setRouterParams(Object.keys(router.params));
325 }
326 } else {
327 const keys = [];
328 pathToRegexp(router.opts.prefix || '', keys);
329 const routerPrefixHasParam = router.opts.prefix && keys.length;
330 router.register(path || '([^/]*)', [], m, {
331 end: false,
332 ignoreCaptures: !hasPath && !routerPrefixHasParam
333 });
334 }
335 }
336
337 return this;
338};
339
340/**
341 * Set the path prefix for a Router instance that was already initialized.
342 *
343 * @example
344 *
345 * ```javascript
346 * router.prefix('/things/:thing_id')
347 * ```
348 *
349 * @param {String} prefix
350 * @returns {Router}
351 */
352
353Router.prototype.prefix = function (prefix) {
354 prefix = prefix.replace(/\/$/, '');
355
356 this.opts.prefix = prefix;
357
358 for (let i = 0; i < this.stack.length; i++) {
359 const route = this.stack[i];
360 route.setPrefix(prefix);
361 }
362
363 return this;
364};
365
366/**
367 * Returns router middleware which dispatches a route matching the request.
368 *
369 * @returns {Function}
370 */
371
372Router.prototype.routes = Router.prototype.middleware = function () {
373 const router = this;
374
375 const dispatch = function dispatch(ctx, next) {
376 debug('%s %s', ctx.method, ctx.path);
377
378 const hostMatched = router.matchHost(ctx.host);
379
380 if (!hostMatched) {
381 return next();
382 }
383
384 const path =
385 router.opts.routerPath || ctx.newRouterPath || ctx.path || ctx.routerPath;
386 const matched = router.match(path, ctx.method);
387 let layerChain;
388
389 if (ctx.matched) {
390 ctx.matched.push.apply(ctx.matched, matched.path);
391 } else {
392 ctx.matched = matched.path;
393 }
394
395 ctx.router = router;
396
397 if (!matched.route) return next();
398
399 const matchedLayers = matched.pathAndMethod;
400 const mostSpecificLayer = matchedLayers[matchedLayers.length - 1];
401 ctx._matchedRoute = mostSpecificLayer.path;
402 if (mostSpecificLayer.name) {
403 ctx._matchedRouteName = mostSpecificLayer.name;
404 }
405
406 layerChain = (
407 router.exclusive ? [mostSpecificLayer] : matchedLayers
408 ).reduce(function (memo, layer) {
409 memo.push(function (ctx, next) {
410 ctx.captures = layer.captures(path, ctx.captures);
411 ctx.params = ctx.request.params = layer.params(
412 path,
413 ctx.captures,
414 ctx.params
415 );
416 ctx.routerPath = layer.path;
417 ctx.routerName = layer.name;
418 ctx._matchedRoute = layer.path;
419 if (layer.name) {
420 ctx._matchedRouteName = layer.name;
421 }
422
423 return next();
424 });
425 return memo.concat(layer.stack);
426 }, []);
427
428 return compose(layerChain)(ctx, next);
429 };
430
431 dispatch.router = this;
432
433 return dispatch;
434};
435
436/**
437 * Returns separate middleware for responding to `OPTIONS` requests with
438 * an `Allow` header containing the allowed methods, as well as responding
439 * with `405 Method Not Allowed` and `501 Not Implemented` as appropriate.
440 *
441 * @example
442 *
443 * ```javascript
444 * const Koa = require('koa');
445 * const Router = require('@koa/router');
446 *
447 * const app = new Koa();
448 * const router = new Router();
449 *
450 * app.use(router.routes());
451 * app.use(router.allowedMethods());
452 * ```
453 *
454 * **Example with [Boom](https://github.com/hapijs/boom)**
455 *
456 * ```javascript
457 * const Koa = require('koa');
458 * const Router = require('@koa/router');
459 * const Boom = require('boom');
460 *
461 * const app = new Koa();
462 * const router = new Router();
463 *
464 * app.use(router.routes());
465 * app.use(router.allowedMethods({
466 * throw: true,
467 * notImplemented: () => new Boom.notImplemented(),
468 * methodNotAllowed: () => new Boom.methodNotAllowed()
469 * }));
470 * ```
471 *
472 * @param {Object=} options
473 * @param {Boolean=} options.throw throw error instead of setting status and header
474 * @param {Function=} options.notImplemented throw the returned value in place of the default NotImplemented error
475 * @param {Function=} options.methodNotAllowed throw the returned value in place of the default MethodNotAllowed error
476 * @returns {Function}
477 */
478
479Router.prototype.allowedMethods = function (options = {}) {
480 const implemented = this.methods;
481
482 return function allowedMethods(ctx, next) {
483 return next().then(function () {
484 const allowed = {};
485
486 if (!ctx.status || ctx.status === 404) {
487 for (let i = 0; i < ctx.matched.length; i++) {
488 const route = ctx.matched[i];
489 for (let j = 0; j < route.methods.length; j++) {
490 const method = route.methods[j];
491 allowed[method] = method;
492 }
493 }
494
495 const allowedArr = Object.keys(allowed);
496
497 if (!~implemented.indexOf(ctx.method)) {
498 if (options.throw) {
499 const notImplementedThrowable =
500 typeof options.notImplemented === 'function'
501 ? options.notImplemented() // set whatever the user returns from their function
502 : new HttpError.NotImplemented();
503
504 throw notImplementedThrowable;
505 } else {
506 ctx.status = 501;
507 ctx.set('Allow', allowedArr.join(', '));
508 }
509 } else if (allowedArr.length > 0) {
510 if (ctx.method === 'OPTIONS') {
511 ctx.status = 200;
512 ctx.body = '';
513 ctx.set('Allow', allowedArr.join(', '));
514 } else if (!allowed[ctx.method]) {
515 if (options.throw) {
516 const notAllowedThrowable =
517 typeof options.methodNotAllowed === 'function'
518 ? options.methodNotAllowed() // set whatever the user returns from their function
519 : new HttpError.MethodNotAllowed();
520
521 throw notAllowedThrowable;
522 } else {
523 ctx.status = 405;
524 ctx.set('Allow', allowedArr.join(', '));
525 }
526 }
527 }
528 }
529 });
530 };
531};
532
533/**
534 * Register route with all methods.
535 *
536 * @param {String} name Optional.
537 * @param {String} path
538 * @param {Function=} middleware You may also pass multiple middleware.
539 * @param {Function} callback
540 * @returns {Router}
541 */
542
543Router.prototype.all = function (name, path, middleware) {
544 if (typeof path === 'string') {
545 middleware = Array.prototype.slice.call(arguments, 2);
546 } else {
547 middleware = Array.prototype.slice.call(arguments, 1);
548 path = name;
549 name = null;
550 }
551
552 // Sanity check to ensure we have a viable path candidate (eg: string|regex|non-empty array)
553 if (
554 typeof path !== 'string' &&
555 !(path instanceof RegExp) &&
556 (!Array.isArray(path) || path.length === 0)
557 )
558 throw new Error('You have to provide a path when adding an all handler');
559
560 this.register(path, methods, middleware, { name });
561
562 return this;
563};
564
565/**
566 * Redirect `source` to `destination` URL with optional 30x status `code`.
567 *
568 * Both `source` and `destination` can be route names.
569 *
570 * ```javascript
571 * router.redirect('/login', 'sign-in');
572 * ```
573 *
574 * This is equivalent to:
575 *
576 * ```javascript
577 * router.all('/login', ctx => {
578 * ctx.redirect('/sign-in');
579 * ctx.status = 301;
580 * });
581 * ```
582 *
583 * @param {String} source URL or route name.
584 * @param {String} destination URL or route name.
585 * @param {Number=} code HTTP status code (default: 301).
586 * @returns {Router}
587 */
588
589Router.prototype.redirect = function (source, destination, code) {
590 // lookup source route by name
591 if (typeof source === 'symbol' || source[0] !== '/') {
592 source = this.url(source);
593 if (source instanceof Error) throw source;
594 }
595
596 // lookup destination route by name
597 if (
598 typeof destination === 'symbol' ||
599 (destination[0] !== '/' && !destination.includes('://'))
600 ) {
601 destination = this.url(destination);
602 if (destination instanceof Error) throw destination;
603 }
604
605 return this.all(source, (ctx) => {
606 ctx.redirect(destination);
607 ctx.status = code || 301;
608 });
609};
610
611/**
612 * Create and register a route.
613 *
614 * @param {String} path Path string.
615 * @param {Array.<String>} methods Array of HTTP verbs.
616 * @param {Function} middleware Multiple middleware also accepted.
617 * @returns {Layer}
618 * @private
619 */
620
621Router.prototype.register = function (path, methods, middleware, opts = {}) {
622 const router = this;
623 const { stack } = this;
624
625 // support array of paths
626 if (Array.isArray(path)) {
627 for (const curPath of path) {
628 router.register.call(router, curPath, methods, middleware, opts);
629 }
630
631 return this;
632 }
633
634 // create route
635 const route = new Layer(path, methods, middleware, {
636 end: opts.end === false ? opts.end : true,
637 name: opts.name,
638 sensitive: opts.sensitive || this.opts.sensitive || false,
639 strict: opts.strict || this.opts.strict || false,
640 prefix: opts.prefix || this.opts.prefix || '',
641 ignoreCaptures: opts.ignoreCaptures
642 });
643
644 if (this.opts.prefix) {
645 route.setPrefix(this.opts.prefix);
646 }
647
648 // add parameter middleware
649 for (let i = 0; i < Object.keys(this.params).length; i++) {
650 const param = Object.keys(this.params)[i];
651 route.param(param, this.params[param]);
652 }
653
654 stack.push(route);
655
656 debug('defined route %s %s', route.methods, route.path);
657
658 return route;
659};
660
661/**
662 * Lookup route with given `name`.
663 *
664 * @param {String} name
665 * @returns {Layer|false}
666 */
667
668Router.prototype.route = function (name) {
669 const routes = this.stack;
670
671 for (let len = routes.length, i = 0; i < len; i++) {
672 if (routes[i].name && routes[i].name === name) return routes[i];
673 }
674
675 return false;
676};
677
678/**
679 * Generate URL for route. Takes a route name and map of named `params`.
680 *
681 * @example
682 *
683 * ```javascript
684 * router.get('user', '/users/:id', (ctx, next) => {
685 * // ...
686 * });
687 *
688 * router.url('user', 3);
689 * // => "/users/3"
690 *
691 * router.url('user', { id: 3 });
692 * // => "/users/3"
693 *
694 * router.use((ctx, next) => {
695 * // redirect to named route
696 * ctx.redirect(ctx.router.url('sign-in'));
697 * })
698 *
699 * router.url('user', { id: 3 }, { query: { limit: 1 } });
700 * // => "/users/3?limit=1"
701 *
702 * router.url('user', { id: 3 }, { query: "limit=1" });
703 * // => "/users/3?limit=1"
704 * ```
705 *
706 * @param {String} name route name
707 * @param {Object} params url parameters
708 * @param {Object} [options] options parameter
709 * @param {Object|String} [options.query] query options
710 * @returns {String|Error}
711 */
712
713Router.prototype.url = function (name, params) {
714 const route = this.route(name);
715
716 if (route) {
717 const args = Array.prototype.slice.call(arguments, 1);
718 return route.url.apply(route, args);
719 }
720
721 return new Error(`No route found for name: ${String(name)}`);
722};
723
724/**
725 * Match given `path` and return corresponding routes.
726 *
727 * @param {String} path
728 * @param {String} method
729 * @returns {Object.<path, pathAndMethod>} returns layers that matched path and
730 * path and method.
731 * @private
732 */
733
734Router.prototype.match = function (path, method) {
735 const layers = this.stack;
736 let layer;
737 const matched = {
738 path: [],
739 pathAndMethod: [],
740 route: false
741 };
742
743 for (let len = layers.length, i = 0; i < len; i++) {
744 layer = layers[i];
745
746 debug('test %s %s', layer.path, layer.regexp);
747
748 // eslint-disable-next-line unicorn/prefer-regexp-test
749 if (layer.match(path)) {
750 matched.path.push(layer);
751
752 if (layer.methods.length === 0 || ~layer.methods.indexOf(method)) {
753 matched.pathAndMethod.push(layer);
754 if (layer.methods.length > 0) matched.route = true;
755 }
756 }
757 }
758
759 return matched;
760};
761
762/**
763 * Match given `input` to allowed host
764 * @param {String} input
765 * @returns {boolean}
766 */
767
768Router.prototype.matchHost = function (input) {
769 const { host } = this;
770
771 if (!host) {
772 return true;
773 }
774
775 if (!input) {
776 return false;
777 }
778
779 if (typeof host === 'string') {
780 return input === host;
781 }
782
783 if (typeof host === 'object' && host instanceof RegExp) {
784 return host.test(input);
785 }
786};
787
788/**
789 * Run middleware for named route parameters. Useful for auto-loading or
790 * validation.
791 *
792 * @example
793 *
794 * ```javascript
795 * router
796 * .param('user', (id, ctx, next) => {
797 * ctx.user = users[id];
798 * if (!ctx.user) return ctx.status = 404;
799 * return next();
800 * })
801 * .get('/users/:user', ctx => {
802 * ctx.body = ctx.user;
803 * })
804 * .get('/users/:user/friends', ctx => {
805 * return ctx.user.getFriends().then(function(friends) {
806 * ctx.body = friends;
807 * });
808 * })
809 * // /users/3 => {"id": 3, "name": "Alex"}
810 * // /users/3/friends => [{"id": 4, "name": "TJ"}]
811 * ```
812 *
813 * @param {String} param
814 * @param {Function} middleware
815 * @returns {Router}
816 */
817
818Router.prototype.param = function (param, middleware) {
819 this.params[param] = middleware;
820 for (let i = 0; i < this.stack.length; i++) {
821 const route = this.stack[i];
822 route.param(param, middleware);
823 }
824
825 return this;
826};
827
828/**
829 * Generate URL from url pattern and given `params`.
830 *
831 * @example
832 *
833 * ```javascript
834 * const url = Router.url('/users/:id', {id: 1});
835 * // => "/users/1"
836 * ```
837 *
838 * @param {String} path url pattern
839 * @param {Object} params url parameters
840 * @returns {String}
841 */
842Router.url = function (path) {
843 const args = Array.prototype.slice.call(arguments, 1);
844 return Layer.prototype.url.apply({ path }, args);
845};