diff --git a/README.md b/README.md index d4b5194..bd5b40e 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,11 @@ $ npm install router ```js var finalhandler = require('finalhandler') -var http = require('http') -var Router = require('router') +var http = require('http') +var Router = require('router') var router = Router() + router.get('/', function (req, res) { res.setHeader('Content-Type', 'text/plain; charset=utf-8') res.end('Hello World!') @@ -179,11 +180,11 @@ router.route('/') ```js // import our modules -var http = require('http') -var Router = require('router') +var http = require('http') +var Router = require('router') var finalhandler = require('finalhandler') -var compression = require('compression') -var bodyParser = require('body-parser') +var compression = require('compression') +var bodyParser = require('body-parser') // store our message to display var message = "Hello World!" @@ -244,8 +245,8 @@ curl http://127.0.0.1:8080/api/set-message -X PATCH -H "Content-Type: applicatio ### Example using mergeParams ```js -var http = require('http') -var Router = require('router') +var http = require('http') +var Router = require('router') var finalhandler = require('finalhandler') // this example is about the mergeParams option @@ -295,6 +296,79 @@ curl http://127.0.0.1:8080/such_path > such_path ``` +## Implementing Your Own Router + +Implementing a custom path matching library on top of this module is as easy as using `Router.Engine`. For example, to implement an "exact" path matching module, we can do this: + +```js +var Engine = require('router').Engine +var slice = Array.prototype.slice + +/** + * Accepts the path and some options we defined in our engine. + */ +function toFunction (route, options) { + if (!options.end) { + return function (path) { + var matches = path.substr(0, route.length) === route + + return matches ? { path: path } : false + } + } + + return function (path) { + return path === route ? { path: path } : false + } +} + +/** + * The constructor must return the engine instance. + */ +function ExactRouter (options) { + return Engine.call(this, options) +} + +/** + * Inherits from the engine prototype. + */ +ExactRouter.prototype = Object.create(Engine.prototype) + +/** + * Set up `Router#use` with our custom path matching implementation. + */ +ExactRouter.prototype.use = function () { + // Use a simple utility for supporting a single path argument like `router`. + var opts = Engine.sanitizeUse.apply(null, arguments) + var match = toFunction(opts.path, { end: false }) + + return Engine.prototype.use.call(this, opts.path, match, opts.callbacks) +} + +/** + * Set up `Router#route` with our custom path patching implementation. + */ +ExactRouter.prototype.route = function (path) { + var match = toFunction(path, { end: true }) + + return Engine.prototype.route.call(this, path, match) +} + +/** + * Set up all the router method shorthands. + */ +Engine.methods.forEach(function (method) { + ExactRouter.prototype[method] = function (path) { + var route = this.route(path) + route[method].apply(route, slice.call(arguments, 1)) + return this + } +}) +``` + +Both the path matching function and the path itself must be passed into the `route` and `use` engine methods. This is for debugging, so `path` should be a human-readable path name. `Engine#use` also accepts an array of handlers to immediately include. The match function must return an object of `{ path: string, params: object }` or `false` if it didn't match. + +Note: The path matching utility should not throw errors. Decoding of parameters is handled by the engine. + ## License [MIT](LICENSE) diff --git a/engine.js b/engine.js new file mode 100644 index 0000000..8f81fc8 --- /dev/null +++ b/engine.js @@ -0,0 +1,798 @@ +/*! + * router + * Copyright(c) 2013 Roman Shtylman + * Copyright(c) 2014 Douglas Christopher Wilson + * MIT Licensed + */ + +'use strict' + +/** + * Module dependencies. + * @private + */ + +var debug = require('debug')('router') +var flatten = require('array-flatten') +var methods = require('methods') +var mixin = require('utils-merge') +var parseUrl = require('parseurl') +var setPrototypeOf = require('setprototypeof') +var Layer = require('./lib/layer') +var Route = require('./lib/route') + +/** + * Module variables. + * @private + */ + +var slice = Array.prototype.slice + +/* istanbul ignore next */ +var defer = typeof setImmediate === 'function' + ? setImmediate + : function(fn){ process.nextTick(fn.bind.apply(fn, arguments)) } + +/** + * Expose `Router`. + */ + +module.exports = Engine +module.exports.Route = Route +module.exports.sanitizeUse = sanitizeUse +module.exports.methods = methods.concat('all') + +/** + * Initialize a new `Router` with the given `options`. + * + * @param {object} options + * @return {Router} which is an callable function + * @public + */ + +function Engine(options) { + var opts = options || {} + + function router(req, res, next) { + router.handle(req, res, next) + } + + // inherit from the correct prototype + setPrototypeOf(router, this) + + router.mergeParams = opts.mergeParams + router.params = {} + router.stack = [] + + return router +} + +/** + * Engine prototype inherits from a Function. + */ + +Engine.prototype = Object.create(Function.prototype) + +/** + * Allow the `Layer` and `Route` to be overriden in child implementations. + */ +Engine.prototype.Layer = Layer +Engine.prototype.Route = Route + +/** + * Map the given param placeholder `name`(s) to the given callback. + * + * Parameter mapping is used to provide pre-conditions to routes + * which use normalized placeholders. For example a _:user_id_ parameter + * could automatically load a user's information from the database without + * any additional code. + * + * The callback uses the same signature as middleware, the only difference + * being that the value of the placeholder is passed, in this case the _id_ + * of the user. Once the `next()` function is invoked, just like middleware + * it will continue on to execute the route, or subsequent parameter functions. + * + * Just like in middleware, you must either respond to the request or call next + * to avoid stalling the request. + * + * router.param('user_id', function(req, res, next, id){ + * User.find(id, function(err, user){ + * if (err) { + * return next(err) + * } else if (!user) { + * return next(new Error('failed to load user')) + * } + * req.user = user + * next() + * }) + * }) + * + * @param {string} name + * @param {function} fn + * @public + */ + +Engine.prototype.param = function param(name, fn) { + if (!name) { + throw new TypeError('argument name is required') + } + + if (typeof name !== 'string') { + throw new TypeError('argument name must be a string') + } + + if (!fn) { + throw new TypeError('argument fn is required') + } + + if (typeof fn !== 'function') { + throw new TypeError('argument fn must be a function') + } + + var params = this.params[name] + + if (!params) { + params = this.params[name] = [] + } + + params.push(fn) + + return this +} + +/** + * Dispatch a req, res into the router. + * + * @private + */ + +Engine.prototype.handle = function handle(req, res, callback) { + if (!callback) { + throw new TypeError('argument callback is required') + } + + debug('dispatching %s %s', req.method, req.url) + + var idx = 0 + var methods + var protohost = getProtohost(req.url) || '' + var removed = '' + var self = this + var slashAdded = false + var paramcalled = {} + + // middleware and routes + var stack = this.stack + + // manage inter-router variables + var parentParams = req.params + var parentUrl = req.baseUrl || '' + var done = restore(callback, req, 'baseUrl', 'next', 'params') + + // setup next layer + req.next = next + + // for options requests, respond with a default if nothing else responds + if (req.method === 'OPTIONS') { + methods = [] + done = wrap(done, generateOptionsResponder(res, methods)) + } + + // setup basic req values + req.baseUrl = parentUrl + req.originalUrl = req.originalUrl || req.url + + next() + + function next(err) { + var layerError = err === 'route' + ? null + : err + + // remove added slash + if (slashAdded) { + req.url = req.url.substr(1) + slashAdded = false + } + + // restore altered req.url + if (removed.length !== 0) { + req.baseUrl = parentUrl + req.url = protohost + removed + req.url.substr(protohost.length) + removed = '' + } + + // no more matching layers + if (idx >= stack.length) { + defer(done, layerError) + return + } + + // get pathname of request + var path = getPathname(req) + + if (path == null) { + return done(layerError) + } + + // find next matching layer + var layer + var match + var route + + while (!match && idx < stack.length) { + layer = stack[idx++] + match = layer.match(path) + route = layer.route + + if (!match) { + continue + } + + if (!route) { + // process non-route handlers normally + continue + } + + if (layerError) { + // routes do not match with a pending error + match = false + continue + } + + var method = req.method; + var has_method = route._handles_method(method) + + // build up automatic options response + if (!has_method && method === 'OPTIONS' && methods) { + methods.push.apply(methods, route._methods()) + } + + // don't even bother matching route + if (!has_method && method !== 'HEAD') { + match = false + continue + } + } + + // no match + if (!match) { + return done(layerError) + } + + var layerPath = match.path + var layerParams = decodeLayerParams(match.params) + + if (layerParams instanceof Error) { + return done(layerParams) + } + + // store route for dispatch on change + if (route) { + req.route = route + } + + // Capture one-time layer values + req.params = self.mergeParams + ? mergeParams(layerParams, parentParams) + : layerParams + + // this should be done for the layer + self.process_params(match, paramcalled, req, res, function (err) { + if (err) { + return next(layerError || err) + } + + if (route) { + return layer.handle_request(req, res, next) + } + + trim_prefix(layer, layerError, layerPath, path) + }) + } + + function trim_prefix(layer, layerError, layerPath, path) { + var c = path[layerPath.length] + + if (c && c !== '/') { + next(layerError) + return + } + + // Trim off the part of the url that matches the route + // middleware (.use stuff) needs to have the path stripped + if (layerPath.length !== 0) { + debug('trim prefix (%s) from url %s', layerPath, req.url) + removed = layerPath + req.url = protohost + req.url.substr(protohost.length + removed.length) + + // Ensure leading slash + if (!protohost && req.url[0] !== '/') { + req.url = '/' + req.url + slashAdded = true + } + + // Setup base URL (no trailing slash) + req.baseUrl = parentUrl + (removed[removed.length - 1] === '/' + ? removed.substring(0, removed.length - 1) + : removed) + } + + debug('%s %s : %s', layer.name, layerPath, req.originalUrl) + + if (layerError) { + layer.handle_error(layerError, req, res, next) + } else { + layer.handle_request(req, res, next) + } + } +} + +/** + * Process any parameters for the layer. + * + * @private + */ + +Engine.prototype.process_params = function process_params(match, called, req, res, done) { + var params = this.params + + // captured parameters from the layer, keys and values + var keys = match.params && Object.keys(match.params) + + // fast track + if (!keys || keys.length === 0) { + return done() + } + + var i = 0 + var name + var paramIndex = 0 + var key + var paramVal + var paramCallbacks + var paramCalled + + // process params in order + // param callbacks can be async + function param(err) { + if (err) { + return done(err) + } + + if (i >= keys.length ) { + return done() + } + + paramIndex = 0 + key = keys[i++] + + if (!key) { + return done() + } + + paramVal = req.params[key] + paramCallbacks = params[key] + paramCalled = called[key] + + if (paramVal === undefined || !paramCallbacks) { + return param() + } + + // param previously called with same value or error occurred + if (paramCalled && (paramCalled.match === paramVal + || (paramCalled.error && paramCalled.error !== 'route'))) { + // restore value + req.params[key] = paramCalled.value + + // next param + return param(paramCalled.error) + } + + called[key] = paramCalled = { + error: null, + match: paramVal, + value: paramVal + } + + paramCallback() + } + + // single param callbacks + function paramCallback(err) { + var fn = paramCallbacks[paramIndex++] + + // store updated value + paramCalled.value = req.params[key] + + if (err) { + // store error + paramCalled.error = err + param(err) + return + } + + if (!fn) return param() + + try { + fn(req, res, paramCallback, paramVal, key) + } catch (e) { + paramCallback(e) + } + } + + param() +} + +/** + * Use the given middleware function, with optional path, defaulting to "/". + * + * Use (like `.all`) will run for any http METHOD, but it will not add + * handlers for those methods so OPTIONS requests will not consider `.use` + * functions even if they could respond. + * + * The other difference is that _route_ path is stripped and not visible + * to the handler function. The main effect of this feature is that mounted + * handlers can operate without any code changes regardless of the "prefix" + * pathname. + * + * @public + */ + +Engine.prototype.use = function use(path, match, handlers) { + if (!path) { + throw new TypeError('argument path is required') + } + + if (!match) { + throw new TypeError('argument match is required') + } + + if (typeof match !== 'function') { + throw new TypeError('argument match must be a function') + } + + if (!Array.isArray(handlers) || handlers.length === 0) { + throw new TypeError('argument handler is required') + } + + var layerMatch = path === '/' ? fast_slash : match + + for (var i = 0; i < handlers.length; i++) { + var fn = handlers[i] + + if (typeof fn !== 'function') { + throw new TypeError('argument handler must be a function') + } + + // add the middleware + debug('use %s %s', path, fn.name || '') + + var layer = new this.Layer(path, layerMatch, fn) + layer.route = undefined + this.stack.push(layer) + } + + return this +} + +/** + * Create a new Route for the given path. + * + * Each route contains a separate middleware stack and VERB handlers. + * + * See the Route api documentation for details on adding handlers + * and middleware to routes. + * + * @param {function} path + * @return {Route} + * @public + */ + +Engine.prototype.route = function route(path, match) { + if (!path) { + throw new TypeError('argument path is required') + } + + if (!match) { + throw new TypeError('argument match is required') + } + + if (typeof match !== 'function') { + throw new TypeError('argument match must be a function') + } + + var route = new this.Route(path) + var layer = new this.Layer(path, match, handle) + + function handle(req, res, next) { + route.dispatch(req, res, next) + } + + layer.route = route + + this.stack.push(layer) + + return route +} + +/** + * Generate a callback that will make an OPTIONS response. + * + * @param {OutgoingMessage} res + * @param {array} methods + * @private + */ + +function generateOptionsResponder(res, methods) { + return function onDone(fn, err) { + if (err || methods.length === 0) { + return fn(err) + } + + trySendOptionsResponse(res, methods, fn) + } +} + +/** + * Get pathname of request. + * + * @param {IncomingMessage} req + * @private + */ + +function getPathname(req) { + try { + return parseUrl(req).pathname + } catch (err) { + return undefined + } +} + +/** + * Get get protocol + host for a URL. + * + * @param {string} url + * @private + */ + +function getProtohost(url) { + if (url.length === 0 || url[0] === '/') { + return undefined + } + + var searchIndex = url.indexOf('?') + var pathLength = searchIndex !== -1 + ? searchIndex + : url.length + var fqdnIndex = url.substr(0, pathLength).indexOf('://') + + return fqdnIndex !== -1 + ? url.substr(0, url.indexOf('/', 3 + fqdnIndex)) + : undefined +} + +/** + * Merge params with parent params + * + * @private + */ + +function mergeParams(params, parent) { + if (typeof parent !== 'object' || !parent) { + return params + } + + // make copy of parent for base + var obj = mixin({}, parent) + + // simple non-numeric merging + if (!(0 in params) || !(0 in parent)) { + return mixin(obj, params) + } + + var i = 0 + var o = 0 + + // determine numeric gap in params + while (i in params) { + i++ + } + + // determine numeric gap in parent + while (o in parent) { + o++ + } + + // offset numeric indices in params before merge + for (i--; i >= 0; i--) { + params[i + o] = params[i] + + // create holes for the merge when necessary + if (i < o) { + delete params[i] + } + } + + return mixin(obj, params) +} + +/** + * Restore obj props after function + * + * @private + */ + +function restore(fn, obj) { + var props = new Array(arguments.length - 2) + var vals = new Array(arguments.length - 2) + + for (var i = 0; i < props.length; i++) { + props[i] = arguments[i + 2] + vals[i] = obj[props[i]] + } + + return function(err){ + // restore vals + for (var i = 0; i < props.length; i++) { + obj[props[i]] = vals[i] + } + + return fn.apply(this, arguments) + } +} + +/** + * Send an OPTIONS response. + * + * @private + */ + +function sendOptionsResponse(res, methods) { + var options = Object.create(null) + + // build unique method map + for (var i = 0; i < methods.length; i++) { + options[methods[i]] = true + } + + // construct the allow list + var allow = Object.keys(options).sort().join(', ') + + // send response + res.setHeader('Allow', allow) + res.setHeader('Content-Length', Buffer.byteLength(allow)) + res.setHeader('Content-Type', 'text/plain') + res.setHeader('X-Content-Type-Options', 'nosniff') + res.end(allow) +} + +/** + * Try to send an OPTIONS response. + * + * @private + */ + +function trySendOptionsResponse(res, methods, next) { + try { + sendOptionsResponse(res, methods) + } catch (err) { + next(err) + } +} + +/** + * Wrap a function + * + * @private + */ + +function wrap(old, fn) { + return function proxy() { + var args = new Array(arguments.length + 1) + + args[0] = old + for (var i = 0, len = arguments.length; i < len; i++) { + args[i + 1] = arguments[i] + } + + fn.apply(this, args) + } +} + +/** + * Attempt to decode layer parameters + */ + +function decodeLayerParams(params) { + try { + return decodeParams(params) + } catch (err) { + return err + } +} + +/** + * Decode all param values + * + * @param {object} params + * @return {object} + */ + +function decodeParams(params) { + var decodedParams = {} + + if (params) { + var keys = Object.keys(params) + + for (var i = 0; i < keys.length; i++) { + var key = keys[i] + + decodedParams[key] = decodeParam(params[key]) + } + } + + return decodedParams +} + +/** + * Decode param value + * + * @param {string} val + * @return {string} + * @api private + */ + +function decodeParam(val){ + if (typeof val !== 'string' || val.length === 0) { + return val + } + + try { + return decodeURIComponent(val) + } catch (err) { + if (err instanceof URIError) { + err.message = 'Failed to decode param \'' + val + '\'' + err.status = 400 + } + + throw err + } +} + +/** + * Helper for sanitizing `Router.prototype.use` arguments. + */ + +function sanitizeUse(handler) { + var offset = 0 + var path = '/' + + // default path to '/' + // disambiguate router.use([handler]) + if (handler != null && typeof handler !== 'function') { + var arg = handler + + while (Array.isArray(arg) && arg.length !== 0) { + arg = arg[0] + } + + // first arg is the path + if (typeof arg !== 'function') { + offset = 1 + path = handler + } + } + + var callbacks = flatten(slice.call(arguments, offset)) + + return { path: path, callbacks: callbacks } +} + +/** + * Always return path match. + */ + +function fast_slash () { + return { path: '' } +} diff --git a/index.js b/index.js index b488e58..23d9f89 100644 --- a/index.js +++ b/index.js @@ -12,724 +12,77 @@ * @private */ -var debug = require('debug')('router') -var flatten = require('array-flatten') -var Layer = require('./lib/layer') -var methods = require('methods') -var mixin = require('utils-merge') -var parseUrl = require('parseurl') -var Route = require('./lib/route') -var setPrototypeOf = require('setprototypeof') - -/** - * Module variables. - * @private - */ - +var Engine = require('./engine') +var pathToRegexp = require('./lib/path-to-regexp') var slice = Array.prototype.slice -/* istanbul ignore next */ -var defer = typeof setImmediate === 'function' - ? setImmediate - : function(fn){ process.nextTick(fn.bind.apply(fn, arguments)) } - /** * Expose `Router`. */ module.exports = Router +module.exports.Engine = Engine +module.exports.Route = Engine.Route /** - * Expose `Route`. - */ - -module.exports.Route = Route - -/** - * Initialize a new `Router` with the given `options`. - * - * @param {object} options - * @return {Router} which is an callable function - * @public + * Construct a router instance. */ -function Router(options) { +function Router (options) { if (!(this instanceof Router)) { return new Router(options) } var opts = options || {} + var router = Engine.call(this, opts) - function router(req, res, next) { - router.handle(req, res, next) - } - - // inherit from the correct prototype - setPrototypeOf(router, this) - - router.caseSensitive = opts.caseSensitive - router.mergeParams = opts.mergeParams - router.params = {} router.strict = opts.strict - router.stack = [] + router.caseSensitive = opts.caseSensitive return router } /** - * Router prototype inherits from a Function. - */ - -/* istanbul ignore next */ -Router.prototype = function () {} - -/** - * Map the given param placeholder `name`(s) to the given callback. - * - * Parameter mapping is used to provide pre-conditions to routes - * which use normalized placeholders. For example a _:user_id_ parameter - * could automatically load a user's information from the database without - * any additional code. - * - * The callback uses the same signature as middleware, the only difference - * being that the value of the placeholder is passed, in this case the _id_ - * of the user. Once the `next()` function is invoked, just like middleware - * it will continue on to execute the route, or subsequent parameter functions. - * - * Just like in middleware, you must either respond to the request or call next - * to avoid stalling the request. - * - * router.param('user_id', function(req, res, next, id){ - * User.find(id, function(err, user){ - * if (err) { - * return next(err) - * } else if (!user) { - * return next(new Error('failed to load user')) - * } - * req.user = user - * next() - * }) - * }) - * - * @param {string} name - * @param {function} fn - * @public - */ - -Router.prototype.param = function param(name, fn) { - if (!name) { - throw new TypeError('argument name is required') - } - - if (typeof name !== 'string') { - throw new TypeError('argument name must be a string') - } - - if (!fn) { - throw new TypeError('argument fn is required') - } - - if (typeof fn !== 'function') { - throw new TypeError('argument fn must be a function') - } - - var params = this.params[name] - - if (!params) { - params = this.params[name] = [] - } - - params.push(fn) - - return this -} - -/** - * Dispatch a req, res into the router. - * - * @private - */ - -Router.prototype.handle = function handle(req, res, callback) { - if (!callback) { - throw new TypeError('argument callback is required') - } - - debug('dispatching %s %s', req.method, req.url) - - var idx = 0 - var methods - var protohost = getProtohost(req.url) || '' - var removed = '' - var self = this - var slashAdded = false - var paramcalled = {} - - // middleware and routes - var stack = this.stack - - // manage inter-router variables - var parentParams = req.params - var parentUrl = req.baseUrl || '' - var done = restore(callback, req, 'baseUrl', 'next', 'params') - - // setup next layer - req.next = next - - // for options requests, respond with a default if nothing else responds - if (req.method === 'OPTIONS') { - methods = [] - done = wrap(done, generateOptionsResponder(res, methods)) - } - - // setup basic req values - req.baseUrl = parentUrl - req.originalUrl = req.originalUrl || req.url - - next() - - function next(err) { - var layerError = err === 'route' - ? null - : err - - // remove added slash - if (slashAdded) { - req.url = req.url.substr(1) - slashAdded = false - } - - // restore altered req.url - if (removed.length !== 0) { - req.baseUrl = parentUrl - req.url = protohost + removed + req.url.substr(protohost.length) - removed = '' - } - - // no more matching layers - if (idx >= stack.length) { - defer(done, layerError) - return - } - - // get pathname of request - var path = getPathname(req) - - if (path == null) { - return done(layerError) - } - - // find next matching layer - var layer - var match - var route - - while (match !== true && idx < stack.length) { - layer = stack[idx++] - match = matchLayer(layer, path) - route = layer.route - - if (typeof match !== 'boolean') { - // hold on to layerError - layerError = layerError || match - } - - if (match !== true) { - continue - } - - if (!route) { - // process non-route handlers normally - continue - } - - if (layerError) { - // routes do not match with a pending error - match = false - continue - } - - var method = req.method; - var has_method = route._handles_method(method) - - // build up automatic options response - if (!has_method && method === 'OPTIONS' && methods) { - methods.push.apply(methods, route._methods()) - } - - // don't even bother matching route - if (!has_method && method !== 'HEAD') { - match = false - continue - } - } - - // no match - if (match !== true) { - return done(layerError) - } - - // store route for dispatch on change - if (route) { - req.route = route - } - - // Capture one-time layer values - req.params = self.mergeParams - ? mergeParams(layer.params, parentParams) - : layer.params - var layerPath = layer.path - - // this should be done for the layer - self.process_params(layer, paramcalled, req, res, function (err) { - if (err) { - return next(layerError || err) - } - - if (route) { - return layer.handle_request(req, res, next) - } - - trim_prefix(layer, layerError, layerPath, path) - }) - } - - function trim_prefix(layer, layerError, layerPath, path) { - var c = path[layerPath.length] - - if (c && c !== '/') { - next(layerError) - return - } - - // Trim off the part of the url that matches the route - // middleware (.use stuff) needs to have the path stripped - if (layerPath.length !== 0) { - debug('trim prefix (%s) from url %s', layerPath, req.url) - removed = layerPath - req.url = protohost + req.url.substr(protohost.length + removed.length) - - // Ensure leading slash - if (!protohost && req.url[0] !== '/') { - req.url = '/' + req.url - slashAdded = true - } - - // Setup base URL (no trailing slash) - req.baseUrl = parentUrl + (removed[removed.length - 1] === '/' - ? removed.substring(0, removed.length - 1) - : removed) - } - - debug('%s %s : %s', layer.name, layerPath, req.originalUrl) - - if (layerError) { - layer.handle_error(layerError, req, res, next) - } else { - layer.handle_request(req, res, next) - } - } -} - -/** - * Process any parameters for the layer. - * - * @private + * Inherits from the router engine. */ -Router.prototype.process_params = function process_params(layer, called, req, res, done) { - var params = this.params - - // captured parameters from the layer, keys and values - var keys = layer.keys - - // fast track - if (!keys || keys.length === 0) { - return done() - } - - var i = 0 - var name - var paramIndex = 0 - var key - var paramVal - var paramCallbacks - var paramCalled - - // process params in order - // param callbacks can be async - function param(err) { - if (err) { - return done(err) - } - - if (i >= keys.length ) { - return done() - } - - paramIndex = 0 - key = keys[i++] - - if (!key) { - return done() - } - - name = key.name - paramVal = req.params[name] - paramCallbacks = params[name] - paramCalled = called[name] - - if (paramVal === undefined || !paramCallbacks) { - return param() - } - - // param previously called with same value or error occurred - if (paramCalled && (paramCalled.match === paramVal - || (paramCalled.error && paramCalled.error !== 'route'))) { - // restore value - req.params[name] = paramCalled.value - - // next param - return param(paramCalled.error) - } - - called[name] = paramCalled = { - error: null, - match: paramVal, - value: paramVal - } - - paramCallback() - } - - // single param callbacks - function paramCallback(err) { - var fn = paramCallbacks[paramIndex++] - - // store updated value - paramCalled.value = req.params[key.name] - - if (err) { - // store error - paramCalled.error = err - param(err) - return - } - - if (!fn) return param() - - try { - fn(req, res, paramCallback, paramVal, key.name) - } catch (e) { - paramCallback(e) - } - } - - param() -} +Router.prototype = Object.create(Engine.prototype) /** - * Use the given middleware function, with optional path, defaulting to "/". - * - * Use (like `.all`) will run for any http METHOD, but it will not add - * handlers for those methods so OPTIONS requests will not consider `.use` - * functions even if they could respond. - * - * The other difference is that _route_ path is stripped and not visible - * to the handler function. The main effect of this feature is that mounted - * handlers can operate without any code changes regardless of the "prefix" - * pathname. - * - * @public + * Create a `path-to-regexp` compatible `.use`. */ -Router.prototype.use = function use(handler) { - var offset = 0 - var path = '/' +Router.prototype.use = function use() { + var opts = Engine.sanitizeUse.apply(null, arguments) - // default path to '/' - // disambiguate router.use([handler]) - if (typeof handler !== 'function') { - var arg = handler - - while (Array.isArray(arg) && arg.length !== 0) { - arg = arg[0] - } - - // first arg is the path - if (typeof arg !== 'function') { - offset = 1 - path = handler - } - } - - var callbacks = flatten(slice.call(arguments, offset)) - - if (callbacks.length === 0) { - throw new TypeError('argument handler is required') - } - - for (var i = 0; i < callbacks.length; i++) { - var fn = callbacks[i] - - if (typeof fn !== 'function') { - throw new TypeError('argument handler must be a function') - } - - // add the middleware - debug('use %s %s', path, fn.name || '') - - var layer = new Layer(path, { - sensitive: this.caseSensitive, - strict: false, - end: false - }, fn) - - layer.route = undefined - - this.stack.push(layer) - } + var match = pathToRegexp(opts.path, { + sensitive: this.caseSensitive, + strict: false, + end: false + }) - return this + return Engine.prototype.use.call(this, opts.path, match, opts.callbacks) } /** - * Create a new Route for the given path. - * - * Each route contains a separate middleware stack and VERB handlers. - * - * See the Route api documentation for details on adding handlers - * and middleware to routes. - * - * @param {string} path - * @return {Route} - * @public + * Create a `path-to-regexp` compatible route. */ Router.prototype.route = function route(path) { - var route = new Route(path) - - var layer = new Layer(path, { + var match = pathToRegexp(path, { sensitive: this.caseSensitive, strict: this.strict, end: true - }, handle) - - function handle(req, res, next) { - route.dispatch(req, res, next) - } - - layer.route = route + }) - this.stack.push(layer) - return route + return Engine.prototype.route.call(this, path, match) } // create Router#VERB functions -methods.concat('all').forEach(function(method){ +Engine.methods.forEach(function (method) { Router.prototype[method] = function (path) { var route = this.route(path) route[method].apply(route, slice.call(arguments, 1)) return this } }) - -/** - * Generate a callback that will make an OPTIONS response. - * - * @param {OutgoingMessage} res - * @param {array} methods - * @private - */ - -function generateOptionsResponder(res, methods) { - return function onDone(fn, err) { - if (err || methods.length === 0) { - return fn(err) - } - - trySendOptionsResponse(res, methods, fn) - } -} - -/** - * Get pathname of request. - * - * @param {IncomingMessage} req - * @private - */ - -function getPathname(req) { - try { - return parseUrl(req).pathname; - } catch (err) { - return undefined; - } -} - -/** - * Get get protocol + host for a URL. - * - * @param {string} url - * @private - */ - -function getProtohost(url) { - if (url.length === 0 || url[0] === '/') { - return undefined - } - - var searchIndex = url.indexOf('?') - var pathLength = searchIndex !== -1 - ? searchIndex - : url.length - var fqdnIndex = url.substr(0, pathLength).indexOf('://') - - return fqdnIndex !== -1 - ? url.substr(0, url.indexOf('/', 3 + fqdnIndex)) - : undefined -} - -/** - * Match path to a layer. - * - * @param {Layer} layer - * @param {string} path - * @private - */ - -function matchLayer(layer, path) { - try { - return layer.match(path); - } catch (err) { - return err; - } -} - -/** - * Merge params with parent params - * - * @private - */ - -function mergeParams(params, parent) { - if (typeof parent !== 'object' || !parent) { - return params - } - - // make copy of parent for base - var obj = mixin({}, parent) - - // simple non-numeric merging - if (!(0 in params) || !(0 in parent)) { - return mixin(obj, params) - } - - var i = 0 - var o = 0 - - // determine numeric gap in params - while (i in params) { - i++ - } - - // determine numeric gap in parent - while (o in parent) { - o++ - } - - // offset numeric indices in params before merge - for (i--; i >= 0; i--) { - params[i + o] = params[i] - - // create holes for the merge when necessary - if (i < o) { - delete params[i] - } - } - - return mixin(obj, params) -} - -/** - * Restore obj props after function - * - * @private - */ - -function restore(fn, obj) { - var props = new Array(arguments.length - 2) - var vals = new Array(arguments.length - 2) - - for (var i = 0; i < props.length; i++) { - props[i] = arguments[i + 2] - vals[i] = obj[props[i]] - } - - return function(err){ - // restore vals - for (var i = 0; i < props.length; i++) { - obj[props[i]] = vals[i] - } - - return fn.apply(this, arguments) - } -} - -/** - * Send an OPTIONS response. - * - * @private - */ - -function sendOptionsResponse(res, methods) { - var options = Object.create(null) - - // build unique method map - for (var i = 0; i < methods.length; i++) { - options[methods[i]] = true - } - - // construct the allow list - var allow = Object.keys(options).sort().join(', ') - - // send response - res.setHeader('Allow', allow) - res.setHeader('Content-Length', Buffer.byteLength(allow)) - res.setHeader('Content-Type', 'text/plain') - res.setHeader('X-Content-Type-Options', 'nosniff') - res.end(allow) -} - -/** - * Try to send an OPTIONS response. - * - * @private - */ - -function trySendOptionsResponse(res, methods, next) { - try { - sendOptionsResponse(res, methods) - } catch (err) { - next(err) - } -} - -/** - * Wrap a function - * - * @private - */ - -function wrap(old, fn) { - return function proxy() { - var args = new Array(arguments.length + 1) - - args[0] = old - for (var i = 0, len = arguments.length; i < len; i++) { - args[i + 1] = arguments[i] - } - - fn.apply(this, args) - } -} diff --git a/lib/layer.js b/lib/layer.js index 99b25d2..3fd9b8e 100644 --- a/lib/layer.js +++ b/lib/layer.js @@ -12,7 +12,6 @@ * @private */ -var pathRegexp = require('path-to-regexp') var debug = require('debug')('router:layer') /** @@ -28,23 +27,13 @@ var hasOwnProperty = Object.prototype.hasOwnProperty module.exports = Layer -function Layer(path, options, fn) { - if (!(this instanceof Layer)) { - return new Layer(path, options, fn) - } - +function Layer(path, match, fn) { debug('new %s', path) - var opts = options || {} + this.path = path this.handle = fn this.name = fn.name || '' - this.params = undefined - this.path = undefined - this.regexp = pathRegexp(path, this.keys = [], opts) - - if (path === '/' && opts.end === false) { - this.regexp.fast_slash = true - } + this.match = match } /** @@ -95,81 +84,3 @@ Layer.prototype.handle_request = function handle(req, res, next) { next(err) } } - -/** - * Check if this route matches `path`, if so - * populate `.params`. - * - * @param {String} path - * @return {Boolean} - * @api private - */ - -Layer.prototype.match = function match(path) { - if (path == null) { - // no path, nothing matches - this.params = undefined - this.path = undefined - return false - } - - if (this.regexp.fast_slash) { - // fast path non-ending match for / (everything matches) - this.params = {} - this.path = '' - return true - } - - var m = this.regexp.exec(path) - - if (!m) { - this.params = undefined - this.path = undefined - return false - } - - // store values - this.params = {} - this.path = m[0] - - // iterate matches - var keys = this.keys - var params = this.params - - for (var i = 1; i < m.length; i++) { - var key = keys[i - 1] - var prop = key.name - var val = decode_param(m[i]) - - if (val !== undefined || !(hasOwnProperty.call(params, prop))) { - params[prop] = val - } - } - - return true -} - -/** - * Decode param value. - * - * @param {string} val - * @return {string} - * @private - */ - -function decode_param(val){ - if (typeof val !== 'string' || val.length === 0) { - return val - } - - try { - return decodeURIComponent(val) - } catch (err) { - if (err instanceof URIError) { - err.message = 'Failed to decode param \'' + val + '\'' - err.status = 400 - } - - throw err - } -} diff --git a/lib/path-to-regexp.js b/lib/path-to-regexp.js new file mode 100644 index 0000000..836eac9 --- /dev/null +++ b/lib/path-to-regexp.js @@ -0,0 +1,65 @@ +/*! + * router + * Copyright(c) 2013 Roman Shtylman + * Copyright(c) 2014 Douglas Christopher Wilson + * MIT Licensed + */ + +'use strict' + +/** + * Module dependencies. + * @private + */ + +var pathToRegexp = require('path-to-regexp') + +/** + * Expose `pathRegexp`. + */ + +module.exports = pathRegexp + +/** + * Create a function to match paths using `path-to-regexp`. + * + * @param {String} path + * @param {Object} options + * @api private + */ + +function pathRegexp (path, options) { + var keys = [] + var regexp = pathToRegexp(path, keys, options) + + return function (pathname) { + var m = regexp.exec(pathname) + + if (!m) { + return false + } + + // store values + var path = m[0] + var params = {} + var prop + var n = 0 + var key + var val + + for (var i = 1, len = m.length; i < len; ++i) { + key = keys[i - 1] + prop = key + ? key.name + : n++ + val = m[i] + + if (val !== undefined || !(hasOwnProperty.call(params, prop))) { + params[prop] = val + } + } + + return { path: path, params: params } + } + +} diff --git a/lib/route.js b/lib/route.js index 8b67f2d..6c65f77 100644 --- a/lib/route.js +++ b/lib/route.js @@ -14,8 +14,8 @@ var debug = require('debug')('router:route') var flatten = require('array-flatten') -var Layer = require('./layer') var methods = require('methods') +var Layer = require('./layer') /** * Module variables. @@ -31,7 +31,7 @@ var slice = Array.prototype.slice module.exports = Route /** - * Initialize `Route` with the given `path`, + * Initialize `Route` with the given `path`. * * @param {String} path * @api private @@ -182,7 +182,7 @@ Route.prototype.all = function all(handler) { throw new TypeError('argument handler must be a function') } - var layer = Layer('/', {}, fn) + var layer = new Layer('/', null, fn) layer.method = undefined this.methods._all = true @@ -209,7 +209,7 @@ methods.forEach(function (method) { debug('%s %s', method, this.path) - var layer = Layer('/', {}, fn) + var layer = new Layer('/', null, fn) layer.method = method this.methods[method] = true diff --git a/package.json b/package.json index de73033..ef8fdb6 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "LICENSE", "HISTORY.md", "README.md", + "engine.js", "index.js" ], "engines": { diff --git a/test/engine.js b/test/engine.js new file mode 100644 index 0000000..eb23e49 --- /dev/null +++ b/test/engine.js @@ -0,0 +1,214 @@ + +var after = require('after') +var methods = require('methods') +var Router = require('..') +var utils = require('./support/utils') + +var assert = utils.assert +var createServer = utils.createServer +var request = utils.request + +describe('Engine', function () { + var Engine = Router.Engine + + it('should return a function', function () { + assert.equal(typeof Engine(), 'function') + }) + + it('should return a function using new', function () { + assert.equal(typeof (new Engine()), 'function') + }) + + describe('errors', function () { + it('should throw when omitting Engine#use path', function () { + assert.throws(function() { + Engine.prototype.use.call({}) + }, /argument path is required/) + }) + + it('should throw when omitting Engine#use match', function () { + assert.throws(function() { + Engine.prototype.use.call({}, '/foo') + }, /argument match is required/) + }) + + it('should throw when Engine#use match is not a function', function () { + assert.throws(function() { + Engine.prototype.use.call({}, '/foo', {}) + }, /argument match must be a function/) + }) + + it('should throw when Engine#use has a no handlers', function () { + assert.throws(function() { + Engine.prototype.use.call({}, '/foo', function () {}, []) + }, /argument handler is required/) + }) + + it('should throw when omitting Engine#route path', function () { + assert.throws(function() { + Engine.prototype.route.call({}) + }, /argument path is required/) + }) + + it('should throw when omitting Engine#route match', function () { + assert.throws(function() { + Engine.prototype.route.call({}, '/foo') + }, /argument match is required/) + }) + + it('should throw when Engine#route match is not a function', function () { + assert.throws(function() { + Engine.prototype.route.call({}, '/foo', {}) + }, /argument match must be a function/) + }) + }) + + describe('custom router', function () { + var slice = Array.prototype.slice + + function toFunction (route, options) { + if (!options.end) { + return function (path) { + var matches = path.substr(0, route.length) === route + + return matches ? { path: path } : false + } + } + + return function (path) { + return path === route ? { path: path } : false + } + } + + function ExactRouter (options) { + return Engine.call(this, options) + } + + ExactRouter.prototype = Object.create(Engine.prototype) + + ExactRouter.prototype.use = function () { + var opts = Engine.sanitizeUse.apply(null, arguments) + var match = toFunction(opts.path, { end: false }) + + return Engine.prototype.use.call(this, opts.path, match, opts.callbacks) + } + + ExactRouter.prototype.route = function (path) { + var match = toFunction(path, { end: true }) + + return Engine.prototype.route.call(this, path, match) + } + + Engine.methods.forEach(function (method) { + ExactRouter.prototype[method] = function (path) { + var route = this.route(path) + route[method].apply(route, slice.call(arguments, 1)) + return this + } + }) + + describe('.all(path, fn)', function () { + it('should respond to all methods', function (done) { + var cb = after(methods.length, done) + var router = new ExactRouter() + var server = createServer(router) + router.all('/', helloWorld) + + methods.forEach(function (method) { + if (method === 'connect') { + // CONNECT is tricky and supertest doesn't support it + return cb() + } + + var body = method !== 'head' + ? 'hello, world' + : '' + + request(server) + [method]('/') + .expect(200, body, cb) + }) + }) + }) + + methods.slice().sort().forEach(function (method) { + if (method === 'connect') { + // CONNECT is tricky and supertest doesn't support it + return + } + + var body = method !== 'head' + ? 'hello, world' + : '' + + describe('.' + method + '(path, ...fn)', function () { + it('should respond to a ' + method.toUpperCase() + ' request', function (done) { + var router = new ExactRouter() + var server = createServer(router) + + router[method]('/', helloWorld) + + request(server) + [method]('/') + .expect(200, body, done) + }) + + it('should accept multiple arguments', function (done) { + var router = new ExactRouter() + var server = createServer(router) + + router[method]('/bar', sethit(1), sethit(2), helloWorld) + + request(server) + [method]('/bar') + .expect('x-fn-1', 'hit') + .expect('x-fn-2', 'hit') + .expect(200, body, done) + }) + }) + }) + + describe('.use(...fn)', function () { + it('should invoke function for all requests', function (done) { + var cb = after(3, done) + var router = new ExactRouter() + var server = createServer(router) + + router.use(saw) + + request(server) + .get('/') + .expect(200, 'saw GET /', cb) + + request(server) + .options('/') + .expect(200, 'saw OPTIONS /', cb) + + request(server) + .post('/foo') + .expect(200, 'saw POST /foo', cb) + }) + }) + }) +}) + +function saw(req, res) { + var msg = 'saw ' + req.method + ' ' + req.originalUrl + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end(msg) +} + +function helloWorld(req, res) { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') +} + +function sethit(num) { + var name = 'x-fn-' + String(num) + return function hit(req, res, next) { + res.setHeader(name, 'hit') + next() + } +}