UNPKG

9.8 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 // Attach to middleware chain
209 chain.add(handler);
210 });
211
212 self._registry.add(route);
213 self.emit('mount', route.method, route.path);
214
215 return route;
216};
217
218/**
219 * Unmounts a route.
220 *
221 * @public
222 * @memberof Router
223 * @instance
224 * @function unmount
225 * @param {String} name - the route name
226 * @returns {Object|undefined} removed route if found
227 */
228Router.prototype.unmount = function unmount(name) {
229 assert.string(name, 'name');
230
231 var route = this._registry.remove(name);
232 return route;
233};
234
235/**
236 * toString() serialization.
237 *
238 * @public
239 * @memberof Router
240 * @instance
241 * @function toString
242 * @returns {String} stringified router
243 */
244Router.prototype.toString = function toString() {
245 return this._registry.toString();
246};
247
248/**
249 * Return information about the routes registered in the router.
250 *
251 * @public
252 * @memberof Router
253 * @instance
254 * @returns {object} The routes in the router.
255 */
256Router.prototype.getDebugInfo = function getDebugInfo() {
257 var routes = this._registry.get();
258 return _.mapValues(routes, function mapValues(route, routeName) {
259 return {
260 name: route.name,
261 method: route.method.toLowerCase(),
262 path: route.path,
263 handlers: route.chain.getHandlers()
264 };
265 });
266};
267
268/**
269 * Return mounted routes
270 *
271 * @public
272 * @memberof Router
273 * @instance
274 * @returns {object} The routes in the router.
275 */
276Router.prototype.getRoutes = function getRoutes() {
277 return this._registry.get();
278};
279
280/**
281 * Returns true if the router generated a 404 for an options request.
282 *
283 * TODO: this is relevant for CORS only. Should move this out eventually to a
284 * userland middleware? This also seems a little like overreach, as there is no
285 * option to opt out of this behavior today.
286 *
287 * @private
288 * @static
289 * @function _optionsError
290 * @param {Object} req - the request object
291 * @param {Object} res - the response object
292 * @returns {Boolean} is options error
293 */
294Router._optionsError = function _optionsError(req, res) {
295 var pathname = req.getUrl().pathname;
296 return req.method === 'OPTIONS' && pathname === '*';
297};
298
299/**
300 * Default route, when no route found
301 * Responds with a ResourceNotFoundError error.
302 *
303 * @private
304 * @memberof Router
305 * @instance
306 * @function defaultRoute
307 * @param {Request} req - request
308 * @param {Response} res - response
309 * @param {Function} next - next
310 * @returns {undefined} no return value
311 */
312Router.prototype.defaultRoute = function defaultRoute(req, res, next) {
313 var self = this;
314 var pathname = req.getUrl().pathname;
315
316 // Allow CORS
317 if (Router._optionsError(req, res, pathname)) {
318 res.send(200);
319 next(null, req, res);
320 return;
321 }
322
323 // Check for 405 instead of 404
324 var allowedMethods = http.METHODS.filter(function some(method) {
325 return method !== req.method && self._registry.lookup(method, pathname);
326 });
327
328 if (allowedMethods.length) {
329 res.methods = allowedMethods;
330 res.setHeader('Allow', allowedMethods.join(', '));
331 var methodErr = new MethodNotAllowedError(
332 '%s is not allowed',
333 req.method
334 );
335 next(methodErr, req, res);
336 return;
337 }
338
339 // clean up the url in case of potential xss
340 // https://github.com/restify/node-restify/issues/1018
341 var err = new ResourceNotFoundError('%s does not exist', pathname);
342 next(err, req, res);
343};
344
345/**
346 * Generate route name
347 *
348 * @private
349 * @memberof Router
350 * @instance
351 * @function _getRouteName
352 * @param {String|undefined} name - Name of the route
353 * @param {String} method - HTTP method
354 * @param {String} path - path
355 * @returns {String} name of the route
356 */
357Router.prototype._getRouteName = function _getRouteName(name, method, path) {
358 // Generate name
359 if (!name) {
360 name = method + '-' + path;
361 name = name.replace(/\W/g, '').toLowerCase();
362 }
363
364 // Avoid name conflict: GH-401
365 if (this._registry.get()[name]) {
366 name += uuid.v4().substr(0, 7);
367 }
368
369 return name;
370};
371
372module.exports = Router;