restify
Version:
REST framework
689 lines (587 loc) • 18.6 kB
JavaScript
// Copyright 2012 Mark Cavage, Inc. All rights reserved.
'use strict';
var EventEmitter = require('events').EventEmitter;
var url = require('url');
var util = require('util');
var LRU = require('lru-cache');
var Negotiator = require('negotiator');
var _ = require('lodash');
var assert = require('assert-plus');
var cloneRegexp = require('clone-regexp');
var errors = require('restify-errors');
var semver = require('semver');
var utils = require('./utils');
///--- Globals
var DEF_CT = 'application/octet-stream';
var BadRequestError = errors.BadRequestError;
var InternalError = errors.InternalError;
var InvalidArgumentError = errors.InvalidArgumentError;
var InvalidVersionError = errors.InvalidVersionError;
var MethodNotAllowedError = errors.MethodNotAllowedError;
var ResourceNotFoundError = errors.ResourceNotFoundError;
var UnsupportedMediaTypeError = errors.UnsupportedMediaTypeError;
var shallowCopy = utils.shallowCopy;
///--- Helpers
/**
* Given a request, try to match it against the regular expression to
* get the route params.
* i.e., /foo/:param1/:param2
*
* @private
* @function matchURL
* @param {String | RegExp} re - a string or regular expression
* @param {Object} req - the request object
* @returns {Object} params
*/
function matchURL(re, req) {
var i = 0;
var result = re.exec(req.path());
var params = {};
if (!result) {
return false;
}
// This means the user original specified a regexp match, not a url
// string like /:foo/:bar
if (!re.restifyParams) {
for (i = 1; i < result.length; i++) {
params[i - 1] = result[i];
}
return params;
}
// This was a static string, like /foo
if (re.restifyParams.length === 0) {
return params;
}
// This was the "normal" case, of /foo/:id
re.restifyParams.forEach(function forEach(p) {
if (++i < result.length) {
params[p] = decodeURIComponent(result[i]);
}
});
return params;
}
/**
* Called while installing routes. attempts to compile the passed in string
* or regexp and register it.
*
* @private
* @function compileURL
* @param {Object} options - an options object
* @returns {RegExp} url regexp
*/
function compileURL(options) {
if (options.url instanceof RegExp) {
return options.url;
}
assert.string(options.url, 'url');
var params = [];
var pattern = '^';
var re;
var _url = url.parse(options.url).pathname;
_url.split('/').forEach(function forEach(frag) {
if (frag.length <= 0) {
return false;
}
pattern += '\\/+';
if (frag.charAt(0) === ':') {
var label = frag;
var index = frag.indexOf('(');
var subexp;
if (index === -1) {
if (options.urlParamPattern) {
subexp = options.urlParamPattern;
} else {
subexp = '[^/]*';
}
} else {
label = frag.substring(0, index);
subexp = frag.substring(index + 1, frag.length - 1);
}
pattern += '(' + subexp + ')';
params.push(label.slice(1));
} else {
pattern += frag;
}
return true;
});
if (options.strict && _url.slice(-1) === '/') {
pattern += '\\/';
}
if (!options.strict) {
pattern += '[\\/]*';
}
if (pattern === '^') {
pattern += '\\/';
}
pattern += '$';
re = new RegExp(pattern, options.flags);
re.restifyParams = params;
return re;
}
///--- API
/**
* Router class handles mapping of http verbs and a regexp path,
* to an array of handler functions.
*
* @class
* @public
* @param {Object} options - an options object
*/
function Router(options) {
assert.object(options, 'options');
assert.object(options.log, 'options.log');
EventEmitter.call(this);
// eslint-disable-next-line new-cap
this.cache = LRU({ max: 100 });
this.contentType = options.contentType || [];
if (!Array.isArray(this.contentType)) {
this.contentType = [this.contentType];
}
assert.arrayOfString(this.contentType, 'options.contentType');
this.strict = Boolean(options.strictRouting);
this.log = options.log;
this.mounts = {};
this.name = 'RestifyRouter';
// A list of methods to routes
this.routes = {
DELETE: [],
GET: [],
HEAD: [],
OPTIONS: [],
PATCH: [],
POST: [],
PUT: []
};
// So we can return 405 vs 404, we maintain a reverse mapping of URLs
// to method
this.reverse = {};
this.versions = options.versions || options.version || [];
if (!Array.isArray(this.versions)) {
this.versions = [this.versions];
}
assert.arrayOfString(this.versions, 'options.versions');
this.versions.forEach(function forEach(v) {
if (semver.valid(v)) {
return true;
}
throw new InvalidArgumentError('%s is not a valid semver', v);
});
this.versions.sort();
}
util.inherits(Router, EventEmitter);
module.exports = Router;
/**
* Takes an object of route params and query params, and 'renders' a URL.
*
* @public
* @function render
* @param {String} routeName - the route name
* @param {Object} params - an object of route params
* @param {Object} query - an object of query params
* @returns {String} URL
*/
Router.prototype.render = function render(routeName, params, query) {
function pathItem(match, key) {
if (params.hasOwnProperty(key) === false) {
throw new Error(
'Route <' + routeName + '> is missing parameter <' + key + '>'
);
}
return '/' + encodeURIComponent(params[key]);
}
function queryItem(key) {
return encodeURIComponent(key) + '=' + encodeURIComponent(query[key]);
}
var route = this.mounts[routeName];
if (!route) {
return null;
}
var _path = route.spec.path;
var _url = _path.replace(/\/:([A-Za-z0-9_]+)(\([^\\]+?\))?/g, pathItem);
var items = Object.keys(query || {}).map(queryItem);
var queryString = items.length > 0 ? '?' + items.join('&') : '';
return _url + queryString;
};
/**
* Adds a route.
*
* @public
* @function mount
* @param {Object} options - an options object
* @returns {String} returns the route name if creation is successful.
*/
Router.prototype.mount = function mount(options) {
assert.object(options, 'options');
assert.string(options.method, 'options.method');
assert.string(options.name, 'options.name');
var exists;
var name = options.name;
var route;
var routes = this.routes[options.method];
var self = this;
var type = options.contentType || self.contentType;
var versions = options.versions || options.version || self.versions;
if (type) {
if (!Array.isArray(type)) {
type = [type];
}
type
.filter(function filter(t) {
return t;
})
.sort()
.join();
}
if (versions) {
if (!Array.isArray(versions)) {
versions = [versions];
}
versions.sort();
}
exists = routes.some(function some(r) {
return r.name === name;
});
if (exists) {
return false;
}
route = {
name: name,
method: options.method,
path: compileURL({
url: options.path || options.url,
flags: options.flags,
urlParamPattern: options.urlParamPattern,
strict: self.strict
}),
spec: options,
types: type,
versions: versions
};
routes.push(route);
if (!this.reverse[route.path.source]) {
this.reverse[route.path.source] = [];
}
if (this.reverse[route.path.source].indexOf(route.method) === -1) {
this.reverse[route.path.source].push(route.method);
}
this.mounts[route.name] = route;
this.emit('mount', route.method, route.path, route.types, route.versions);
return route.name;
};
/**
* Unmounts a route.
*
* @public
* @function unmount
* @param {String} name - the route name
* @returns {String} the name of the deleted route.
*/
Router.prototype.unmount = function unmount(name) {
var route = this.mounts[name];
if (!route) {
this.log.warn('router.unmount(%s): route does not exist', name);
return false;
}
var reverse = this.reverse[route.path.source];
var routes = this.routes[route.method];
this.routes[route.method] = routes.filter(function filter(r) {
return r.name !== route.name;
});
if (!this.findByPath(route.spec.path, { method: route.method })) {
this.reverse[route.path.source] = reverse.filter(function filter(r) {
return r !== route.method;
});
if (this.reverse[route.path.source].length === 0) {
delete this.reverse[route.path.source];
}
}
delete this.mounts[name];
var cache = this.cache;
cache.dump().forEach(function forEach(i) {
if (i.v.name === name) {
cache.del(i.k);
}
});
return name;
};
/**
* Get a route from the router.
*
* @public
* @function get
* @param {String} name - the name of the route to retrieve
* @param {Object} req - the request object
* @param {Function} cb - callback function
* @returns {undefined} no return value
*/
Router.prototype.get = function get(name, req, cb) {
var params;
var route = false;
var routes = this.routes[req.method] || [];
for (var i = 0; i < routes.length; i++) {
if (routes[i].name === name) {
route = routes[i];
try {
params = matchURL(route.path, req);
} catch (e) {
// if we couldn't match the URL, log it out.
console.log(e);
}
break;
}
}
if (route) {
cb(null, route, params || {});
} else {
cb(new InternalError('Route not found: ' + name));
}
};
/**
* Find a route from inside the router, handles versioned routes.
*
* @public
* @function find
* @param {Object} req - the request object
* @param {Object} res - the response object
* @param {Function} callback - callback function
* @returns {undefined} no return value
*/
Router.prototype.find = function find(req, res, callback) {
var candidates = [];
var ct = req.headers['content-type'] || DEF_CT;
var cacheKey = req.method + req.url + req.version() + ct;
var cacheVal;
var neg;
var params;
var r;
var reverse;
var routes = this.routes[req.method] || [];
var typed;
var versioned;
var maxV;
if ((cacheVal = this.cache.get(cacheKey))) {
res.methods = cacheVal.methods.slice();
req._matchedVersion = cacheVal.matchedVersion;
callback(null, cacheVal, shallowCopy(cacheVal.params));
return;
}
for (var i = 0; i < routes.length; i++) {
try {
params = matchURL(routes[i].path, req);
} catch (e) {
this.log.trace({ err: e }, 'error parsing URL');
callback(new BadRequestError(e.message));
return;
}
if (params === false) {
continue;
}
reverse = this.reverse[routes[i].path.source];
if (routes[i].types.length && req.isUpload()) {
candidates.push({
p: params,
r: routes[i]
});
typed = true;
continue;
}
// GH-283: we want to find the latest version for a given route,
// not the first one. However, if neither the client nor
// server specified any version, we're done, because neither
// cared
if (routes[i].versions.length === 0) {
if (req.version() === '*') {
r = routes[i];
break;
}
callback(
new InvalidVersionError(
'%s is not supported by %s %s',
req.version() || '?',
req.method,
req.path()
)
);
return;
}
if (routes[i].versions.length > 0) {
candidates.push({
p: params,
r: routes[i]
});
versioned = true;
}
}
if (!r) {
// If upload and typed
if (typed) {
var _t = ct.split(/\s*,\s*/);
candidates = candidates.filter(function filter(c) {
neg = new Negotiator({
headers: {
accept: c.r.types.join(', ')
}
});
var tmp = neg.preferredMediaType(_t);
return tmp && tmp.length;
});
// Pick the first one in case not versioned
if (candidates.length) {
r = candidates[0].r;
params = candidates[0].p;
}
}
if (versioned) {
candidates.forEach(function forEach(c) {
var k = c.r.versions;
var v = semver.maxSatisfying(k, req.version());
if (v) {
if (!r || !maxV || semver.gt(v, maxV)) {
r = c.r;
params = c.p;
maxV = v;
}
}
});
}
}
// In order, we check if the route exists, in which case, we're good.
// Otherwise we look to see if ver was set to false; that would tell us
// we indeed did find a matching route (method+url), but the version
// field didn't line up, so we return bad version. If no route and no
// version, we now need to go walk the reverse map and look at whether
// we should return 405 or 404.
if (params && r) {
cacheVal = {
methods: reverse,
name: r.name,
params: params,
spec: r.spec
};
if (versioned) {
req._matchedVersion = maxV;
cacheVal.matchedVersion = maxV;
}
this.cache.set(cacheKey, cacheVal);
res.methods = reverse.slice();
callback(null, cacheVal, shallowCopy(params));
return;
}
if (typed) {
callback(new UnsupportedMediaTypeError(ct));
return;
}
if (versioned) {
callback(
new InvalidVersionError(
'%s is not supported by %s %s',
req.version() || '?',
req.method,
req.path()
)
);
return;
}
// Check for 405 instead of 404
var j;
var urls = Object.keys(this.reverse);
for (j = 0; j < urls.length; j++) {
if (matchURL(new RegExp(urls[j]), req)) {
res.methods = this.reverse[urls[j]].slice();
res.setHeader('Allow', res.methods.join(', '));
var err = new MethodNotAllowedError(
'%s is not allowed',
req.method
);
callback(err);
return;
}
}
// clean up the url in case of potential xss
// https://github.com/restify/node-restify/issues/1018
var cleanedUrl = url.parse(req.url).pathname;
callback(new ResourceNotFoundError('%s does not exist', cleanedUrl));
};
/**
* Find a route by path. Scans the route list for a route with the same RegEx.
* i.e. /foo/:param1/:param2 would match an existing route with different
* parameter names /foo/:id/:name since the compiled RegExs match.
*
* @public
* @function findByPath
* @param {String | RegExp} path - a path to find a route for.
* @param {Object} options - an options object
* @returns {Object} returns the route if a match is found
*/
Router.prototype.findByPath = function findByPath(path, options) {
assert.string(path, 'path');
assert.object(options, 'options');
assert.string(options.method, 'options.method');
var route;
var routes = this.routes[options.method] || [];
var routeRegex = compileURL({
url: path,
flags: options.flags,
urlParamPattern: options.urlParamPattern,
strict: this.strict
});
for (var i = 0; i < routes.length; i++) {
if (routeRegex.toString() === routes[i].path.toString()) {
route = routes[i];
break;
}
}
return route;
};
/**
* toString() serialization.
*
* @public
* @function toString
* @returns {String} stringified router
*/
Router.prototype.toString = function toString() {
var self = this;
var str = this.name + ':\n';
Object.keys(this.routes).forEach(function forEach(k) {
var routes = self.routes[k].map(function map(r) {
return r.name;
});
str += '\t\t' + k + ': [' + routes.join(', ') + ']\n';
});
return str;
};
/**
* Return information about the routes registered in the router.
*
* @public
* @returns {object} The routes in the router.
*/
Router.prototype.getDebugInfo = function getRoutes() {
var self = this;
var routeInfo = [];
_.forOwn(self.mounts, function forOwn(value, routeName) {
if (self.mounts.hasOwnProperty(routeName)) {
var mountedRoute = self.mounts[routeName];
var routeRegex = mountedRoute.path;
routeInfo.push({
name: mountedRoute.name,
method: mountedRoute.method.toLowerCase(),
input: mountedRoute.spec.path,
compiledRegex: cloneRegexp(routeRegex),
// any url params are saved on the regex object as a key/val
// bucket.
compiledUrlParams:
routeRegex.restifyParams &&
Object.keys(routeRegex.restifyParams).length > 0
? shallowCopy(routeRegex.restifyParams)
: null,
versions:
mountedRoute.versions.length > 1
? mountedRoute.versions
: null
});
}
});
return routeInfo;
};