UNPKG

relu-core

Version:
639 lines (528 loc) 17.1 kB
// Copyright 2012 Mark Cavage, Inc. All rights reserved. 'use strict'; var EventEmitter = require('events').EventEmitter; var url = require('url'); var util = require('util'); var assert = require('assert-plus'); var LRU = require('lru-cache'); var Negotiator = require('negotiator'); var semver = require('semver'); var cors = require('./plugins/cors'); var errors = require('./errors'); 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} */ 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 (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} */ 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 (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 (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); 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.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 retrun 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 (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} */ 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 routeKey = routeName.replace(/\W/g, '').toLowerCase(); var route = this.mounts[routeKey]; 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 (t) { return (t); }).sort().join(); } if (versions) { if (!Array.isArray(versions)) { versions = [versions]; } versions.sort(); } exists = routes.some(function (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 }), 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 (r) { return (r.name !== route.name); }); this.reverse[route.path.source] = reverse.filter(function (r) { return (r !== route.method); }); if (this.reverse[route.path.source].length === 0) { delete this.reverse[route.path.source]; } delete this.mounts[name]; 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} */ Router.prototype.get = function get(name, req, cb) { var params; var route = false; var routes = this.routes[req.method] || []; var routeName = name.replace(/\W/g, '').toLowerCase(); for (var i = 0; i < routes.length; i++) { if (routes[i].name === routeName) { 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()); } }; /** * 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} */ 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 && req.version() === '*') { r = routes[i]; break; } 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 (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 (c) { var k = c.r.versions; var v = semver.maxSatisfying(k, req.version()); if (v) { if (!r || 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 it was an OPTIONS request, we need // to handle this having been a preflight request. 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; } //Checks if header is in cors.ALLOWED_HEADERS function inAllowedHeaders(header) { header = header.toLowerCase(); return (cors.ALLOW_HEADERS.indexOf(header) !== -1); } // This is a very generic preflight handler - it does // not handle requiring authentication, nor does it do // any special checking for extra user headers. The // user will need to defined their own .opts handler to // do that function preflight(methods) { var headers = req.headers['access-control-request-headers']; var method = req.headers['access-control-request-method']; var origin = req.headers.origin; if (req.method !== 'OPTIONS' || !origin || !method || methods.indexOf(method) === -1) { return (false); } // Last, check request-headers var ok = !headers || headers.split(/\s*,\s*/).every(inAllowedHeaders); if (!ok) { return (false); } // Verify the incoming origin against the whitelist. Pass the origin // through if there is a match. if (cors.matchOrigin(req, cors.origins)) { res.setHeader('Access-Control-Allow-Origin', origin); if (cors.credentials) { res.setHeader('Access-Control-Allow-Credentials', 'true'); } } else { res.setHeader('Access-Control-Allow-Origin', '*'); } res.setHeader('Access-Control-Allow-Methods', methods.join(', ')); res.setHeader('Access-Control-Allow-Headers', cors.ALLOW_HEADERS.join(', ')); res.setHeader('Access-Control-Max-Age', 3600); return (true); } // 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(', ')); if (preflight(res.methods)) { callback(null, {name: 'preflight'}); return; } 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 )); }; /** * toString() serialization. * @public * @function toString * @returns {String} */ Router.prototype.toString = function toString() { var self = this; var str = this.name + ':\n'; Object.keys(this.routes).forEach(function (k) { var routes = self.routes[k].map(function (r) { return (r.name); }); str += '\t\t' + k + ': [' + routes.join(', ') + ']\n'; }); return (str); };