UNPKG

9.88 kBJavaScriptView Raw
1'use strict';
2
3var EventEmitter = require('events').EventEmitter;
4var util = require('util');
5var http = require('http');
6
7var _ = require('lodash');
8var assert = require('assert-plus');
9var errors = require('restify-errors');
10var uuid = require('uuid');
11
12var Chain = require('./chain');
13var RouterRegistryRadix = require('./routerRegistryRadix');
14
15///--- Globals
16
17var MethodNotAllowedError = errors.MethodNotAllowedError;
18var ResourceNotFoundError = errors.ResourceNotFoundError;
19
20///--- API
21
22/**
23 * Router class handles mapping of http verbs and a regexp path,
24 * to an array of handler functions.
25 *
26 * @class
27 * @public
28 * @param {Object} options - an options object
29 * @param {Bunyan} options.log - Bunyan logger instance
30 * @param {Boolean} [options.onceNext=false] - Prevents calling next multiple
31 * times
32 * @param {Boolean} [options.strictNext=false] - Throws error when next() is
33 * called more than once, enabled onceNext option
34 * @param {Object} [options.registry] - route registry
35 * @param {Boolean} [options.ignoreTrailingSlash=false] - ignore trailing slash
36 * on paths
37 */
38function Router(options) {
39 assert.object(options, 'options');
40 assert.object(options.log, 'options.log');
41 assert.optionalBool(options.onceNext, 'options.onceNext');
42 assert.optionalBool(options.strictNext, 'options.strictNext');
43 assert.optionalBool(
44 options.ignoreTrailingSlash,
45 'options.ignoreTrailingSlash'
46 );
47
48 EventEmitter.call(this);
49
50 this.log = options.log;
51 this.onceNext = !!options.onceNext;
52 this.strictNext = !!options.strictNext;
53 this.name = 'RestifyRouter';
54
55 // Internals
56 this._anonymousHandlerCounter = 0;
57 this._registry = options.registry || new RouterRegistryRadix(options);
58}
59util.inherits(Router, EventEmitter);
60
61/**
62 * Lookup for route
63 *
64 * @public
65 * @memberof Router
66 * @instance
67 * @function lookup
68 * @param {Request} req - request
69 * @param {Response} res - response
70 * @returns {Chain|undefined} handler or undefined
71 */
72Router.prototype.lookup = function lookup(req, res) {
73 var pathname = req.getUrl().pathname;
74
75 // Find route
76 var registryRoute = this._registry.lookup(req.method, pathname);
77
78 // Not found
79 if (!registryRoute) {
80 return undefined;
81 }
82
83 // Decorate req
84 req.params = Object.assign(req.params, registryRoute.params);
85 req.route = registryRoute.route;
86
87 // Call handler chain
88 return registryRoute.handler;
89};
90
91/**
92 * Lookup by name
93 *
94 * @public
95 * @memberof Router
96 * @instance
97 * @function lookupByName
98 * @param {String} name - route name
99 * @param {Request} req - request
100 * @param {Response} res - response
101 * @returns {Chain|undefined} handler or undefined
102 */
103Router.prototype.lookupByName = function lookupByName(name, req, res) {
104 var self = this;
105 var route = self._registry.get()[name];
106
107 if (!route) {
108 return undefined;
109 }
110
111 // Decorate req
112 req.route = route;
113
114 return route.chain.run.bind(route.chain);
115};
116
117/**
118 * Takes an object of route params and query params, and 'renders' a URL.
119 *
120 * @public
121 * @function render
122 * @param {String} routeName - the route name
123 * @param {Object} params - an object of route params
124 * @param {Object} query - an object of query params
125 * @returns {String} URL
126 * @example
127 * server.get({
128 * name: 'cities',
129 * path: '/countries/:name/states/:state/cities'
130 * }, (req, res, next) => ...));
131 * let cities = server.router.render('cities', {
132 * name: 'Australia',
133 * state: 'New South Wales'
134 * });
135 * // cities: '/countries/Australia/states/New%20South%20Wales/cities'
136 */
137Router.prototype.render = function render(routeName, params, query) {
138 var self = this;
139
140 function pathItem(match, key) {
141 if (params.hasOwnProperty(key) === false) {
142 throw new Error(
143 'Route <' + routeName + '> is missing parameter <' + key + '>'
144 );
145 }
146 return '/' + encodeURIComponent(params[key]);
147 }
148
149 function queryItem(key) {
150 return encodeURIComponent(key) + '=' + encodeURIComponent(query[key]);
151 }
152
153 var route = self._registry.get()[routeName];
154
155 if (!route) {
156 return null;
157 }
158
159 var _path = route.spec.path;
160 var _url = _path.replace(/\/:([A-Za-z0-9_]+)(\([^\\]+?\))?/g, pathItem);
161 var items = Object.keys(query || {}).map(queryItem);
162 var queryString = items.length > 0 ? '?' + items.join('&') : '';
163 return _url + queryString;
164};
165
166/**
167 * Adds a route.
168 *
169 * @public
170 * @memberof Router
171 * @instance
172 * @function mount
173 * @param {Object} opts - an options object
174 * @param {String} opts.name - name
175 * @param {String} opts.method - method
176 * @param {String} opts.path - path can be any String
177 * @param {Function[]} handlers - handlers
178 * @returns {String} returns the route name if creation is successful.
179 * @fires ...String#mount
180 */
181Router.prototype.mount = function mount(opts, handlers) {
182 var self = this;
183
184 assert.object(opts, 'opts');
185 assert.string(opts.method, 'opts.method');
186 assert.arrayOfFunc(handlers, 'handlers');
187 assert.optionalString(opts.name, 'opts.name');
188
189 var chain = new Chain({
190 onceNext: self.onceNext,
191 strictNext: self.strictNext
192 });
193
194 // Route
195 var route = {
196 name: self._getRouteName(opts.name, opts.method, opts.path),
197 method: opts.method,
198 path: opts.path,
199 spec: opts,
200 chain: chain
201 };
202
203 handlers.forEach(function forEach(handler) {
204 // Assign name to anonymous functions
205 handler._name =
206 handler.name || 'handler-' + self._anonymousHandlerCounter++;
207
208 handler._identifier = `${handler._name} on ${opts.method} ${opts.path}`;
209
210 // Attach to middleware chain
211 chain.add(handler);
212 });
213
214 self._registry.add(route);
215 self.emit('mount', route.method, route.path);
216
217 return route;
218};
219
220/**
221 * Unmounts a route.
222 *
223 * @public
224 * @memberof Router
225 * @instance
226 * @function unmount
227 * @param {String} name - the route name
228 * @returns {Object|undefined} removed route if found
229 */
230Router.prototype.unmount = function unmount(name) {
231 assert.string(name, 'name');
232
233 var route = this._registry.remove(name);
234 return route;
235};
236
237/**
238 * toString() serialization.
239 *
240 * @public
241 * @memberof Router
242 * @instance
243 * @function toString
244 * @returns {String} stringified router
245 */
246Router.prototype.toString = function toString() {
247 return this._registry.toString();
248};
249
250/**
251 * Return information about the routes registered in the router.
252 *
253 * @public
254 * @memberof Router
255 * @instance
256 * @returns {object} The routes in the router.
257 */
258Router.prototype.getDebugInfo = function getDebugInfo() {
259 var routes = this._registry.get();
260 return _.mapValues(routes, function mapValues(route, routeName) {
261 return {
262 name: route.name,
263 method: route.method.toLowerCase(),
264 path: route.path,
265 handlers: route.chain.getHandlers()
266 };
267 });
268};
269
270/**
271 * Return mounted routes
272 *
273 * @public
274 * @memberof Router
275 * @instance
276 * @returns {object} The routes in the router.
277 */
278Router.prototype.getRoutes = function getRoutes() {
279 return this._registry.get();
280};
281
282/**
283 * Returns true if the router generated a 404 for an options request.
284 *
285 * TODO: this is relevant for CORS only. Should move this out eventually to a
286 * userland middleware? This also seems a little like overreach, as there is no
287 * option to opt out of this behavior today.
288 *
289 * @private
290 * @static
291 * @function _optionsError
292 * @param {Object} req - the request object
293 * @param {Object} res - the response object
294 * @returns {Boolean} is options error
295 */
296Router._optionsError = function _optionsError(req, res) {
297 var pathname = req.getUrl().pathname;
298 return req.method === 'OPTIONS' && pathname === '*';
299};
300
301/**
302 * Default route, when no route found
303 * Responds with a ResourceNotFoundError error.
304 *
305 * @private
306 * @memberof Router
307 * @instance
308 * @function defaultRoute
309 * @param {Request} req - request
310 * @param {Response} res - response
311 * @param {Function} next - next
312 * @returns {undefined} no return value
313 */
314Router.prototype.defaultRoute = function defaultRoute(req, res, next) {
315 var self = this;
316 var pathname = req.getUrl().pathname;
317
318 // Allow CORS
319 if (Router._optionsError(req, res, pathname)) {
320 res.send(200);
321 next(null, req, res);
322 return;
323 }
324
325 // Check for 405 instead of 404
326 var allowedMethods = http.METHODS.filter(function some(method) {
327 return method !== req.method && self._registry.lookup(method, pathname);
328 });
329
330 if (allowedMethods.length) {
331 res.methods = allowedMethods;
332 res.setHeader('Allow', allowedMethods.join(', '));
333 var methodErr = new MethodNotAllowedError(
334 '%s is not allowed',
335 req.method
336 );
337 next(methodErr, req, res);
338 return;
339 }
340
341 // clean up the url in case of potential xss
342 // https://github.com/restify/node-restify/issues/1018
343 var err = new ResourceNotFoundError('%s does not exist', pathname);
344 next(err, req, res);
345};
346
347/**
348 * Generate route name
349 *
350 * @private
351 * @memberof Router
352 * @instance
353 * @function _getRouteName
354 * @param {String|undefined} name - Name of the route
355 * @param {String} method - HTTP method
356 * @param {String} path - path
357 * @returns {String} name of the route
358 */
359Router.prototype._getRouteName = function _getRouteName(name, method, path) {
360 // Generate name
361 if (!name) {
362 name = method + '-' + path;
363 name = name.replace(/\W/g, '').toLowerCase();
364 }
365
366 // Avoid name conflict: GH-401
367 if (this._registry.get()[name]) {
368 name += uuid.v4().substr(0, 7);
369 }
370
371 return name;
372};
373
374module.exports = Router;