From 80a9404efe6b6310e5bc35354ec92e5caeeb5207 Mon Sep 17 00:00:00 2001 From: Douglas Christopher Wilson Date: Mon, 17 Nov 2014 00:57:20 -0500 Subject: [PATCH] Initial import from Express 4 --- .gitignore | 3 + .travis.yml | 13 + HISTORY.md | 5 + LICENSE | 23 ++ README.md | 51 +++ index.js | 632 ++++++++++++++++++++++++++++++++++++ lib/layer.js | 175 ++++++++++ lib/route.js | 187 +++++++++++ lib/utils.js | 28 ++ package.json | 36 +++ test/auto-head.js | 50 +++ test/auto-options.js | 77 +++++ test/fqdn-url.js | 74 +++++ test/param.js | 294 +++++++++++++++++ test/route.js | 302 ++++++++++++++++++ test/router.js | 728 ++++++++++++++++++++++++++++++++++++++++++ test/support/utils.js | 90 ++++++ 17 files changed, 2768 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 HISTORY.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 index.js create mode 100644 lib/layer.js create mode 100644 lib/route.js create mode 100644 lib/utils.js create mode 100644 package.json create mode 100644 test/auto-head.js create mode 100644 test/auto-options.js create mode 100644 test/fqdn-url.js create mode 100644 test/param.js create mode 100644 test/route.js create mode 100644 test/router.js create mode 100644 test/support/utils.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3cd27af --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +coverage/ +node_modules/ +npm-debug.log diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f837c44 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +language: node_js +node_js: + - "0.8" + - "0.10" + - "0.11" +matrix: + allow_failures: + - node_js: "0.11" + fast_finish: true +script: + - "npm run-script test-travis" +after_script: + - "npm install coveralls@2 && cat ./coverage/lcov.info | coveralls" diff --git a/HISTORY.md b/HISTORY.md new file mode 100644 index 0000000..f3e289f --- /dev/null +++ b/HISTORY.md @@ -0,0 +1,5 @@ +unreleased +========== + + * Initial release ported from Express 4.x + - Altered to work without Express diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..31e7f7d --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +(The MIT License) + +Copyright (c) 2013 Roman Shtylman +Copyright (c) 2014 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c1daf6e --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# router + +[![NPM Version][npm-image]][npm-url] +[![NPM Downloads][downloads-image]][downloads-url] +[![Node.js Version][node-version-image]][node-version-url] +[![Build Status][travis-image]][travis-url] +[![Test Coverage][coveralls-image]][coveralls-url] + +Simple middleware-style router + +## Installation + +**This module is not yet available on `npm`** + +```sh +$ npm install pillarjs/router +``` + +## API + +```js +var Router = require('router') +``` + +This module is currently an extracted version from the Express 4.x project, +but with the main change being it can be used with a plain `http.createServer` +object or something like `connect` by removing Express-specific API calls. + +Documentation is forthcoming, but the Express 4.x documentation can be found +at http://expressjs.com/4x/api.html#router + +## Testing + +```sh +$ npm test +``` + +## License + +[MIT](LICENSE) + +[npm-image]: https://img.shields.io/npm/v/router.svg?style=flat +[npm-url]: https://npmjs.org/package/router +[node-version-image]: https://img.shields.io/node/v/router.svg?style=flat +[node-version-url]: http://nodejs.org/download/ +[travis-image]: https://img.shields.io/travis/pillarjs/router.svg?style=flat +[travis-url]: https://travis-ci.org/pillarjs/router +[coveralls-image]: https://img.shields.io/coveralls/pillarjs/router.svg?style=flat +[coveralls-url]: https://coveralls.io/r/pillarjs/router?branch=master +[downloads-image]: https://img.shields.io/npm/dm/router.svg?style=flat +[downloads-url]: https://npmjs.org/package/router diff --git a/index.js b/index.js new file mode 100644 index 0000000..a804489 --- /dev/null +++ b/index.js @@ -0,0 +1,632 @@ +/*! + * router + * Copyright(c) 2013 Roman Shtylman + * Copyright(c) 2014 Douglas Christopher Wilson + * MIT Licensed + */ + +/** + * Module dependencies. + * @private + */ + +var debug = require('debug')('router') +var Layer = require('./lib/layer') +var methods = require('methods') +var mixin = require('utils-merge') +var parseUrl = require('parseurl') +var Route = require('./lib/route') +var utils = require('./lib/utils') + +/** + * 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 = Router + +/** + * 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 + */ + +function Router(options) { + if (!(this instanceof Router)) { + return new Router(options) + } + + var opts = options || {} + + function router(req, res, next) { + router.handle(req, res, next) + } + + // inherit from the correct prototype + router.__proto__ = this + + router.caseSensitive = opts.caseSensitive + router.mergeParams = opts.mergeParams + router.params = {} + router.strict = opts.strict + router.stack = [] + + 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') + } + + if (name[0] === ':') { + name = name.substr(1) + } + + 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 search = 1 + req.url.indexOf('?') + var pathlength = search ? search - 1 : req.url.length + var fqdn = req.url[0] !== '/' && 1 + req.url.substr(0, pathlength).indexOf('://') + var protohost = fqdn ? req.url.substr(0, req.url.indexOf('/', 2 + fqdn)) : '' + var idx = 0 + var removed = '' + var self = this + var slashAdded = false + var paramcalled = {} + + // store options for OPTIONS request + // only used if OPTIONS request + var options = [] + + // 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') { + done = wrap(done, function onDone(old, err) { + if (err || options.length === 0) { + return old(err) + } + + var methods = options.sort().join(', ') + + res.setHeader('Allow', methods) + res.setHeader('Content-Length', Buffer.byteLength(methods)) + res.setHeader('Content-Type', 'text/plain') + res.setHeader('X-Content-Type-Options', 'nosniff') + res.end(methods) + }) + } + + // setup basic req values + req.baseUrl = parentUrl + req.originalUrl = req.originalUrl || req.url + + next() + + function next(err) { + var layerError = err === 'route' + ? null + : err + + var layer = stack[idx++] + + if (slashAdded) { + req.url = req.url.substr(1) + slashAdded = false + } + + if (removed.length !== 0) { + req.baseUrl = parentUrl + req.url = protohost + removed + req.url.substr(protohost.length) + removed = '' + } + + if (!layer) { + defer(done, layerError) + return + } + + self.match_layer(layer, req, res, function (err, path) { + if (err || path === undefined) { + next(layerError || err) + return + } + + // route object and not middleware + var route = layer.route + + // if final route, then we support options + if (route) { + // we don't run any routes with error first + if (layerError) { + next(layerError) + return + } + + var method = req.method + var has_method = route._handles_method(method) + + // build up automatic options response + if (!has_method && method === 'OPTIONS') { + options.push.apply(options, route._options()) + } + + // don't even bother + if (!has_method && method !== 'HEAD') { + next() + return + } + + // we can now dispatch to the 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) { + next(layerError || err) + return + } + + 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 && '.' !== 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 (!fqdn && 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) + } + } +} + +/** + * Match request to a layer. + * + * @private + */ + +Router.prototype.match_layer = function match_layer(layer, req, res, done) { + var error = null + var path + + try { + path = parseUrl(req).pathname + + if (!layer.match(path)) { + path = undefined + } + } catch (err) { + error = err + } + + done(error, path) +} + +/** + * Process any parameters for the layer. + * + * @private + */ + +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.error || paramCalled.match === paramVal)) { + // 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() +} + +/** + * 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 + */ + +Router.prototype.use = function use(handler) { + var offset = 0 + var path = '/' + + // 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 = utils.flatten(slice.call(arguments, offset)) + + if (callbacks.length === 0) { + throw new TypeError('argument handler is required') + } + + callbacks.forEach(function (fn) { + 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) + }, this) + + 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 {string} path + * @return {Route} + * @public + */ + +Router.prototype.route = function route(path) { + var route = new Route(path) + + var layer = new Layer(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 +} + +// create Router#VERB functions +methods.concat('all').forEach(function(method){ + Router.prototype[method] = function (path) { + var route = this.route(path) + route[method].apply(route, slice.call(arguments, 1)) + return this + } +}) + +/** + * 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 gaps + while (i === o || o in parent) { + if (i in params) i++ + if (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(parent, 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) + } +} + +/** + * 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 new file mode 100644 index 0000000..d1124f9 --- /dev/null +++ b/lib/layer.js @@ -0,0 +1,175 @@ +/*! + * router + * Copyright(c) 2013 Roman Shtylman + * Copyright(c) 2014 Douglas Christopher Wilson + * MIT Licensed + */ + +/** + * Module dependencies. + * @private + */ + +var pathRegexp = require('path-to-regexp') +var debug = require('debug')('router:layer') + +/** + * Module variables. + * @private + */ + +var hasOwnProperty = Object.prototype.hasOwnProperty + +/** + * Expose `Layer`. + */ + +module.exports = Layer + +function Layer(path, options, fn) { + if (!(this instanceof Layer)) { + return new Layer(path, options, fn) + } + + debug('new %s', path) + options = options || {} + + this.handle = fn + this.name = fn.name || '' + this.params = undefined + this.path = undefined + this.regexp = pathRegexp(path, this.keys = [], options) + + if (path === '/' && options.end === false) { + this.regexp.fast_slash = true + } +} + +/** + * Handle the error for the layer. + * + * @param {Error} error + * @param {Request} req + * @param {Response} res + * @param {function} next + * @api private + */ + +Layer.prototype.handle_error = function handle_error(error, req, res, next) { + var fn = this.handle + + if (fn.length !== 4) { + // not a standard error handler + return next(error) + } + + try { + fn(error, req, res, next) + } catch (err) { + next(err) + } +} + +/** + * Handle the request for the layer. + * + * @param {Request} req + * @param {Response} res + * @param {function} next + * @api private + */ + +Layer.prototype.handle_request = function handle(req, res, next) { + var fn = this.handle + + if (fn.length > 3) { + // not a standard request handler + return next() + } + + try { + fn(req, res, next) + } catch (err) { + 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] + + var keys = this.keys + var params = this.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 = decode_param(m[i]) + + if (val !== undefined || !(hasOwnProperty.call(params, prop))) { + params[prop] = val + } + } + + return true +} + +/** + * Decode param value. + * + * @param {string} val + * @return {string} + * @api private + */ + +function decode_param(val){ + if (typeof val !== 'string') { + return val + } + + try { + return decodeURIComponent(val) + } catch (e) { + var err = new TypeError("Failed to decode param '" + val + "'") + err.status = 400 + throw err + } +} diff --git a/lib/route.js b/lib/route.js new file mode 100644 index 0000000..edce0dc --- /dev/null +++ b/lib/route.js @@ -0,0 +1,187 @@ +/*! + * router + * Copyright(c) 2013 Roman Shtylman + * Copyright(c) 2014 Douglas Christopher Wilson + * MIT Licensed + */ + + /** + * Module dependencies. + * @private + */ + +var debug = require('debug')('router:route') +var Layer = require('./layer') +var methods = require('methods') +var utils = require('./utils') + +/** + * Expose `Route`. + */ + +module.exports = Route + +/** + * Initialize `Route` with the given `path`, + * + * @param {String} path + * @api private + */ + +function Route(path) { + debug('new %s', path) + this.path = path + this.stack = [] + + // route handlers for various http methods + this.methods = {} +} + +/** + * @private + */ + +Route.prototype._handles_method = function _handles_method(method) { + if (this.methods._all) { + return true + } + + method = method.toLowerCase() + + if (method === 'head' && !this.methods['head']) { + method = 'get' + } + + return Boolean(this.methods[method]) +} + +/** + * @return {array} supported HTTP methods + * @private + */ + +Route.prototype._options = function _options() { + return Object.keys(this.methods).map(function (method) { + return method.toUpperCase() + }) +} + +/** + * dispatch req, res into this route + * + * @private + */ + +Route.prototype.dispatch = function dispatch(req, res, done) { + var idx = 0 + var stack = this.stack + if (stack.length === 0) { + return done() + } + + var method = req.method.toLowerCase() + if (method === 'head' && !this.methods['head']) { + method = 'get' + } + + req.route = this + + next() + + function next(err) { + if (err && err === 'route') { + return done() + } + + var layer = stack[idx++] + if (!layer) { + return done(err) + } + + if (layer.method && layer.method !== method) { + return next(err) + } + + if (err) { + layer.handle_error(err, req, res, next) + } else { + layer.handle_request(req, res, next) + } + } +} + +/** + * Add a handler for all HTTP verbs to this route. + * + * Behaves just like middleware and can respond or call `next` + * to continue processing. + * + * You can use multiple `.all` call to add multiple handlers. + * + * function check_something(req, res, next){ + * next() + * } + * + * function validate_user(req, res, next){ + * next() + * } + * + * route + * .all(validate_user) + * .all(check_something) + * .get(function(req, res, next){ + * res.send('hello world') + * }) + * + * @param {array|function} handler + * @return {Route} for chaining + * @api public + */ + +Route.prototype.all = function all(handler) { + var callbacks = utils.flatten([].slice.call(arguments)) + + if (callbacks.length === 0) { + throw new TypeError('argument handler is required') + } + + callbacks.forEach(function (fn) { + if (typeof fn !== 'function') { + throw new TypeError('argument handler must be a function') + } + + var layer = Layer('/', {}, fn) + layer.method = undefined + + this.methods._all = true + this.stack.push(layer) + }, this) + + return this +} + +methods.forEach(function (method) { + Route.prototype[method] = function (handler) { + var callbacks = utils.flatten([].slice.call(arguments)) + + if (callbacks.length === 0) { + throw new TypeError('argument handler is required') + } + + callbacks.forEach(function (fn) { + if (typeof fn !== 'function') { + throw new TypeError('argument handler must be a function') + } + + debug('%s %s', method, this.path) + + var layer = Layer('/', {}, fn) + layer.method = method + + this.methods[method] = true + this.stack.push(layer) + }, this) + + return this + } +}) diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..a57067e --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,28 @@ +/*! + * router + * Copyright(c) 2013 Roman Shtylman + * Copyright(c) 2014 Douglas Christopher Wilson + * MIT Licensed + */ + +/** + * Flatten the given `arr`. + * + * @param {array} arr + * @return {array} + * @private + */ + +exports.flatten = function flatten(arr, ret) { + ret = ret || [] + + for (var i = 0, len = arr.length; i < len; i++) { + if (Array.isArray(arr[i])) { + exports.flatten(arr[i], ret) + } else { + ret.push(arr[i]) + } + } + + return ret +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..52748bc --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "router", + "description": "Simple middleware-style router", + "author": "Douglas Christopher Wilson ", + "license": "MIT", + "repository": "pillarjs/router", + "dependencies": { + "debug": "~2.1.0", + "methods": "~1.1.0", + "parseurl": "~1.3.0", + "path-to-regexp": "0.1.3", + "utils-merge": "1.0.0" + }, + "devDependencies": { + "after": "0.8.1", + "finalhandler": "0.3.2", + "istanbul": "0.3.2", + "mocha": "~2.0.1", + "supertest": "~0.14.0" + }, + "files": [ + "lib/", + "LICENSE", + "HISTORY.md", + "README.md", + "index.js" + ], + "engines": { + "node": ">= 0.8" + }, + "scripts": { + "test": "mocha --reporter spec --bail --check-leaks test/", + "test-cov": "istanbul cover node_modules/mocha/bin/_mocha -- --reporter dot --check-leaks test/", + "test-travis": "istanbul cover node_modules/mocha/bin/_mocha --report lcovonly -- --reporter spec --check-leaks test/" + } +} diff --git a/test/auto-head.js b/test/auto-head.js new file mode 100644 index 0000000..f64a0fa --- /dev/null +++ b/test/auto-head.js @@ -0,0 +1,50 @@ + +var Router = require('..') +var utils = require('./support/utils') + +var createServer = utils.createServer +var request = utils.request + +describe('HEAD', function () { + it('should invoke get without head', function (done) { + var router = Router() + var server = createServer(router) + + router.get('/users', sethit(1), saw) + + request(server) + .head('/users') + .expect('Content-Type', 'text/plain') + .expect('x-fn-1', 'hit') + .expect(200, done) + }) + + it('should invoke head if prior to get', function (done) { + var router = Router() + var server = createServer(router) + + router.head('/users', sethit(1), saw) + router.get('/users', sethit(2), saw) + + request(server) + .head('/users') + .expect('Content-Type', 'text/plain') + .expect('x-fn-1', 'hit') + .expect(200, done) + }) +}) + +function saw(req, res) { + var msg = 'saw ' + req.method + ' ' + req.url + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end(msg) +} + +function sethit(num) { + var name = 'x-fn-' + String(num) + return function hit(req, res, next) { + res.setHeader(name, 'hit') + next() + } +} diff --git a/test/auto-options.js b/test/auto-options.js new file mode 100644 index 0000000..274fa6e --- /dev/null +++ b/test/auto-options.js @@ -0,0 +1,77 @@ + +var Router = require('..') +var utils = require('./support/utils') + +var createServer = utils.createServer +var request = utils.request + +describe('OPTIONS', function () { + it('should respond with defined routes', function (done) { + var router = Router() + var server = createServer(router) + + router.delete('/', saw) + router.get('/users', saw) + router.post('/users', saw) + router.put('/users', saw) + + request(server) + .options('/users') + .expect('Allow', 'GET, POST, PUT') + .expect(200, 'GET, POST, PUT', done) + }) + + it('should not include "all" routes', function (done) { + var router = Router() + var server = createServer(router) + + router.get('/', saw) + router.get('/users', saw) + router.put('/users', saw) + router.all('/users', sethit(1)) + + request(server) + .options('/users') + .expect('x-fn-1', 'hit') + .expect('Allow', 'GET, PUT') + .expect(200, 'GET, PUT', done) + }) + + it('should not respond if no matching path', function (done) { + var router = Router() + var server = createServer(router) + + router.get('/users', saw) + + request(server) + .options('/') + .expect(404, done) + }) + + it('should do nothing with explicit options route', function (done) { + var router = Router() + var server = createServer(router) + + router.get('/users', saw) + router.options('/users', saw) + + request(server) + .options('/users') + .expect(200, 'saw OPTIONS /users', done) + }) +}) + +function saw(req, res) { + var msg = 'saw ' + req.method + ' ' + req.url + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end(msg) +} + +function sethit(num) { + var name = 'x-fn-' + String(num) + return function hit(req, res, next) { + res.setHeader(name, 'hit') + next() + } +} diff --git a/test/fqdn-url.js b/test/fqdn-url.js new file mode 100644 index 0000000..22f04ec --- /dev/null +++ b/test/fqdn-url.js @@ -0,0 +1,74 @@ + +var Router = require('..') +var utils = require('./support/utils') + +var assert = utils.assert +var createServer = utils.createServer +var rawrequest = utils.rawrequest + +describe('FQDN url', function () { + it('should not obscure FQDNs', function (done) { + var router = new Router() + var server = createServer(router) + + router.use(saw) + + rawrequest(server) + .get('http://example.com/foo') + .expect(200, 'saw GET http://example.com/foo', done) + }) + + it('should strip/restore FQDN req.url', function (done) { + var router = new Router() + var server = createServer(router) + + router.use('/blog', setsaw(1)) + router.use(saw) + + rawrequest(server) + .get('http://example.com/blog/post/1') + .expect('x-saw-1', 'GET http://example.com/post/1') + .expect(200, 'saw GET http://example.com/blog/post/1', done) + }) + + it('should ignore FQDN in search', function (done) { + var router = new Router() + var server = createServer(router) + + router.use('/proxy', setsaw(1)) + router.use(saw) + + rawrequest(server) + .get('/proxy?url=http://example.com/blog/post/1') + .expect('x-saw-1', 'GET /?url=http://example.com/blog/post/1') + .expect(200, 'saw GET /proxy?url=http://example.com/blog/post/1', done) + }) + + it('should ignore FQDN in path', function (done) { + var router = new Router() + var server = createServer(router) + + router.use('/proxy', setsaw(1)) + router.use(saw) + + rawrequest(server) + .get('/proxy/http://example.com/blog/post/1') + .expect('x-saw-1', 'GET /http://example.com/blog/post/1') + .expect(200, 'saw GET /proxy/http://example.com/blog/post/1', done) + }) +}) + +function setsaw(num) { + var name = 'x-saw-' + String(num) + return function hit(req, res, next) { + res.setHeader(name, req.method + ' ' + req.url) + next() + } +} + +function saw(req, res) { + var msg = 'saw ' + req.method + ' ' + req.url + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end(msg) +} diff --git a/test/param.js b/test/param.js new file mode 100644 index 0000000..0257780 --- /dev/null +++ b/test/param.js @@ -0,0 +1,294 @@ + +var after = require('after') +var Router = require('..') +var utils = require('./support/utils') + +var assert = utils.assert +var createServer = utils.createServer +var request = utils.request + +describe('Router', function () { + describe('.param(name, fn)', function () { + it('should reject missing name', function () { + var router = new Router() + assert.throws(router.param.bind(router), /argument name is required/) + }) + + it('should reject bad name', function () { + var router = new Router() + assert.throws(router.param.bind(router, 42), /argument name must be a string/) + }) + + it('should reject missing fn', function () { + var router = new Router() + assert.throws(router.param.bind(router, 'id'), /argument fn is required/) + }) + + it('should reject bad fn', function () { + var router = new Router() + assert.throws(router.param.bind(router, 'id', 42), /argument fn must be a function/) + }) + + it('should map logic for a path param', function (done) { + var cb = after(2, done) + var router = new Router() + var server = createServer(router) + + router.param('id', function parseId(req, res, next, val) { + req.params.id = Number(val) + next() + }) + + router.get('/user/:id', function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('get user ' + req.params.id) + }) + + request(server) + .get('/user/2') + .expect(200, 'get user 2', cb) + + request(server) + .get('/user/bob') + .expect(200, 'get user NaN', cb) + }) + + it('should ignore leading colon on name', function (done) { + var router = new Router() + var server = createServer(router) + + router.param(':id', function parseId(req, res, next, val) { + req.params.id = Number(val) + next() + }) + + router.get('/user/:id', function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('get user ' + req.params.id) + }) + + request(server) + .get('/user/bob') + .expect(200, 'get user NaN', done) + }) + + it('should allow chaining', function (done) { + var router = new Router() + var server = createServer(router) + + router.param('id', function parseId(req, res, next, val) { + req.params.id = Number(val) + next() + }) + + router.param('id', function parseId(req, res, next, val) { + req.itemId = Number(val) + next() + }) + + router.get('/user/:id', function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('get user ' + req.params.id + ' (' + req.itemId + ')') + }) + + request(server) + .get('/user/2') + .expect(200, 'get user 2 (2)', done) + }) + + it('should automatically decode path value', function (done) { + var router = new Router() + var server = createServer(router) + + router.param('user', function parseUser(req, res, next, user) { + req.user = user + next() + }) + + router.get('/user/:id', function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('get user ' + req.params.id) + }) + + request(server) + .get('/user/%22bob%2Frobert%22') + .expect('get user "bob/robert"', done) + }) + + it('should 400 on invalid path value', function (done) { + var router = new Router() + var server = createServer(router) + + router.param('user', function parseUser(req, res, next, user) { + req.user = user + next() + }) + + router.get('/user/:id', function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('get user ' + req.params.id) + }) + + request(server) + .get('/user/%bob') + .expect(400, done) + }) + + it('should only invoke fn when necessary', function (done) { + var cb = after(2, done) + var router = new Router() + var server = createServer(router) + + router.param('id', function parseId(req, res, next, val) { + res.setHeader('x-id', val) + next() + }) + + router.param('user', function parseUser(req, res, next, user) { + throw new Error('boom') + }) + + router.get('/user/:user', saw) + router.put('/user/:id', saw) + + request(server) + .get('/user/bob') + .expect(500, /Error: boom/, cb) + + request(server) + .put('/user/bob') + .expect('x-id', 'bob') + .expect(200, 'saw PUT /user/bob', cb) + }) + + it('should only invoke fn once per request', function (done) { + var router = new Router() + var server = createServer(router) + + router.param('user', function parseUser(req, res, next, user) { + req.count = (req.count || 0) + 1 + req.user = user + next() + }) + + router.get('/user/:user', sethit(1)) + router.get('/user/:user', sethit(2)) + + router.use(function (req, res) { + res.end('get user ' + req.user + ' ' + req.count + ' times') + }) + + request(server) + .get('/user/bob') + .expect('get user bob 1 times', done) + }) + + it('should keep changes to req.params value', function (done) { + var router = new Router() + var server = createServer(router) + + router.param('id', function parseUser(req, res, next, val) { + req.count = (req.count || 0) + 1 + req.params.id = Number(val) + next() + }) + + router.get('/user/:id', function (req, res, next) { + res.setHeader('x-user-id', req.params.id) + next() + }) + + router.get('/user/:id', function (req, res) { + res.end('get user ' + req.params.id + ' ' + req.count + ' times') + }) + + request(server) + .get('/user/01') + .expect('get user 1 1 times', done) + }) + + it('should invoke fn if path value differs', function (done) { + var router = new Router() + var server = createServer(router) + + router.param('user', function parseUser(req, res, next, user) { + req.count = (req.count || 0) + 1 + req.user = user + req.vals = (req.vals || []).concat(user) + next() + }) + + router.get('/:user/bob', sethit(1)) + router.get('/user/:user', sethit(2)) + + router.use(function (req, res) { + res.end('get user ' + req.user + ' ' + req.count + ' times: ' + req.vals.join(', ')) + }) + + request(server) + .get('/user/bob') + .expect('get user bob 2 times: user, bob', done) + }) + + it('should catch exception in fn', function (done) { + var router = new Router() + var server = createServer(router) + + router.param('user', function parseUser(req, res, next, user) { + throw new Error('boom') + }) + + router.get('/user/:user', function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('get user ' + req.params.id) + }) + + request(server) + .get('/user/bob') + .expect(500, /Error: boom/, done) + }) + + it('should catch exception in chained fn', function (done) { + var router = new Router() + var server = createServer(router) + + router.param('user', function parseUser(req, res, next, user) { + process.nextTick(next) + }) + + router.param('user', function parseUser(req, res, next, user) { + throw new Error('boom') + }) + + router.get('/user/:user', function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('get user ' + req.params.id) + }) + + request(server) + .get('/user/bob') + .expect(500, /Error: boom/, done) + }) + }) +}) + +function createServer(router) { + return http.createServer(function onRequest(req, res) { + router(req, res, finalhandler(req, res)) + }) +} + +function sethit(num) { + var name = 'x-fn-' + String(num) + return function hit(req, res, next) { + res.setHeader(name, 'hit') + next() + } +} + +function saw(req, res) { + var msg = 'saw ' + req.method + ' ' + req.url + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end(msg) +} diff --git a/test/route.js b/test/route.js new file mode 100644 index 0000000..d1e89d0 --- /dev/null +++ b/test/route.js @@ -0,0 +1,302 @@ + +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('Router', function () { + describe('.route(path)', function () { + it('should return a new route', function () { + var router = new Router() + var route = router.route('/foo') + assert.equal(route.path, '/foo') + }) + + it('should respond to multiple methods', function (done) { + var cb = after(3, done) + var router = new Router() + var route = router.route('/foo') + var server = createServer(router) + + route.get(saw) + route.post(saw) + + request(server) + .get('/foo') + .expect(200, 'saw GET /foo', cb) + + request(server) + .post('/foo') + .expect(200, 'saw POST /foo', cb) + + request(server) + .put('/foo') + .expect(404, cb) + }) + + it('should stack', function (done) { + var cb = after(3, done) + var router = new Router() + var route = router.route('/foo') + var server = createServer(router) + + route.post(sethit(1)) + route.all(sethit(2)) + route.get(sethit(3)) + route.all(saw) + + request(server) + .get('/foo') + .expect('x-fn-2', 'hit') + .expect('x-fn-3', 'hit') + .expect(200, 'saw GET /foo', cb) + + request(server) + .post('/foo') + .expect('x-fn-1', 'hit') + .expect('x-fn-2', 'hit') + .expect(200, 'saw POST /foo', cb) + + request(server) + .put('/foo') + .expect('x-fn-2', 'hit') + .expect(200, 'saw PUT /foo', cb) + }) + + it('should not error on empty route', function (done) { + var router = new Router() + var route = router.route('/foo') + var server = createServer(router) + + request(server) + .get('/foo') + .expect(404, done) + }) + + it('should not invoke singular error route', function (done) { + var router = new Router() + var route = router.route('/foo') + var server = createServer(router) + + route.all(function handleError(err, req, res, next) { + throw new Error('boom!') + }) + + request(server) + .get('/foo') + .expect(404, done) + }) + + describe('.all(...fn)', function () { + it('should reject no arguments', function () { + var router = new Router() + var route = router.route('/') + assert.throws(route.all.bind(route), /argument handler is required/) + }) + + it('should reject empty array', function () { + var router = new Router() + var route = router.route('/') + assert.throws(route.all.bind(route, []), /argument handler is required/) + }) + + it('should reject invalid fn', function () { + var router = new Router() + var route = router.route('/') + assert.throws(route.all.bind(route, 2), /argument handler must be a function/) + }) + + it('should respond to all methods', function (done) { + var cb = after(3, done) + var router = new Router() + var route = router.route('/foo') + var server = createServer(router) + + route.all(saw) + + request(server) + .get('/foo') + .expect(200, 'saw GET /foo', cb) + + request(server) + .post('/foo') + .expect(200, 'saw POST /foo', cb) + + request(server) + .put('/foo') + .expect(200, 'saw PUT /foo', cb) + }) + + it('should accept multiple arguments', function (done) { + var router = new Router() + var route = router.route('/foo') + var server = createServer(router) + + route.all(sethit(1), sethit(2), helloWorld) + + request(server) + .get('/foo') + .expect('x-fn-1', 'hit') + .expect('x-fn-2', 'hit') + .expect(200, 'hello, world', done) + }) + }) + + 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 + '(...fn)', function () { + it('should respond to a ' + method.toUpperCase() + ' request', function (done) { + var router = new Router() + var route = router.route('/') + var server = createServer(router) + + route[method](helloWorld) + + request(server) + [method]('/') + .expect(200, body, done) + }) + + it('should reject no arguments', function () { + var router = new Router() + var route = router.route('/') + assert.throws(route[method].bind(route), /argument handler is required/) + }) + + it('should reject empty array', function () { + var router = new Router() + var route = router.route('/') + assert.throws(route[method].bind(route, []), /argument handler is required/) + }) + + it('should reject invalid fn', function () { + var router = new Router() + var route = router.route('/') + assert.throws(route[method].bind(route, 2), /argument handler must be a function/) + }) + + it('should accept multiple arguments', function (done) { + var router = new Router() + var route = router.route('/') + var server = createServer(router) + + route[method](sethit(1), sethit(2), helloWorld) + + request(server) + [method]('/') + .expect('x-fn-1', 'hit') + .expect('x-fn-2', 'hit') + .expect(200, body, done) + }) + }) + }) + + describe('error handling', function () { + it('should handle errors from next(err)', function (done) { + var router = new Router() + var route = router.route('/foo') + var server = createServer(router) + + route.all(function createError(req, res, next) { + next(new Error('boom!')) + }) + + route.all(helloWorld) + + route.all(function handleError(err, req, res, next) { + res.statusCode = 500 + res.end('caught: ' + err.message) + }) + + request(server) + .get('/foo') + .expect(500, 'caught: boom!', done) + }) + + it('should handle errors thrown', function (done) { + var router = new Router() + var route = router.route('/foo') + var server = createServer(router) + + route.all(function createError(req, res, next) { + throw new Error('boom!') + }) + + route.all(helloWorld) + + route.all(function handleError(err, req, res, next) { + res.statusCode = 500 + res.end('caught: ' + err.message) + }) + + request(server) + .get('/foo') + .expect(500, 'caught: boom!', done) + }) + + it('should handle errors thrown in error handlers', function (done) { + var router = new Router() + var route = router.route('/foo') + var server = createServer(router) + + route.all(function createError(req, res, next) { + throw new Error('boom!') + }) + + route.all(function handleError(err, req, res, next) { + throw new Error('oh, no!') + }) + + route.all(function handleError(err, req, res, next) { + res.statusCode = 500 + res.end('caught: ' + err.message) + }) + + request(server) + .get('/foo') + .expect(500, 'caught: oh, no!', done) + }) + }) + }) +}) + +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() + } +} + +function setsaw(num) { + var name = 'x-saw-' + String(num) + return function hit(req, res, next) { + res.setHeader(name, req.method + ' ' + req.url) + next() + } +} + +function saw(req, res) { + var msg = 'saw ' + req.method + ' ' + req.url + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end(msg) +} diff --git a/test/router.js b/test/router.js new file mode 100644 index 0000000..79af36c --- /dev/null +++ b/test/router.js @@ -0,0 +1,728 @@ + +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('Router', function () { + it('should return a function', function () { + assert.equal(typeof Router(), 'function') + }) + + it('should return a function using new', function () { + assert.equal(typeof (new Router()), 'function') + }) + + it('should reject missing callback', function () { + var router = new Router() + assert.throws(router.bind(router, {}, {}), /argument callback is required/) + }) + + describe('.all(path, fn)', function () { + it('should be chainable', function () { + var router = new Router() + assert.equal(router.all('/', helloWorld), router) + }) + + it('should respond to all methods', function (done) { + var cb = after(methods.length, done) + var router = new Router() + 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) + }) + }) + + describe('with "caseSensitive" option', function () { + it('should not match paths case-sensitively by default', function (done) { + var cb = after(3, done) + var router = new Router() + var server = createServer(router) + + router.all('/foo/bar', saw) + + request(server) + .get('/foo/bar') + .expect(200, 'saw GET /foo/bar', cb) + + request(server) + .get('/FOO/bar') + .expect(200, 'saw GET /FOO/bar', cb) + + request(server) + .get('/FOO/BAR') + .expect(200, 'saw GET /FOO/BAR', cb) + }) + + it('should not match paths case-sensitively when false', function (done) { + var cb = after(3, done) + var router = new Router({ caseSensitive: false }) + var server = createServer(router) + + router.all('/foo/bar', saw) + + request(server) + .get('/foo/bar') + .expect(200, 'saw GET /foo/bar', cb) + + request(server) + .get('/FOO/bar') + .expect(200, 'saw GET /FOO/bar', cb) + + request(server) + .get('/FOO/BAR') + .expect(200, 'saw GET /FOO/BAR', cb) + }) + + it('should match paths case-sensitively when true', function (done) { + var cb = after(3, done) + var router = new Router({ caseSensitive: true }) + var server = createServer(router) + + router.all('/foo/bar', saw) + + request(server) + .get('/foo/bar') + .expect(200, 'saw GET /foo/bar', cb) + + request(server) + .get('/FOO/bar') + .expect(404, 'Cannot GET /FOO/bar\n', cb) + + request(server) + .get('/FOO/BAR') + .expect(404, 'Cannot GET /FOO/BAR\n', cb) + }) + }) + + describe('with "strict" option', function () { + it('should accept optional trailing slashes by default', function (done) { + var cb = after(2, done) + var router = new Router() + var server = createServer(router) + + router.all('/foo', saw) + + request(server) + .get('/foo') + .expect(200, 'saw GET /foo', cb) + + request(server) + .get('/foo/') + .expect(200, 'saw GET /foo/', cb) + }) + + it('should accept optional trailing slashes when false', function (done) { + var cb = after(2, done) + var router = new Router({ strict: false }) + var server = createServer(router) + + router.all('/foo', saw) + + request(server) + .get('/foo') + .expect(200, 'saw GET /foo', cb) + + request(server) + .get('/foo/') + .expect(200, 'saw GET /foo/', cb) + }) + + it('should not accept optional trailing slashes when true', function (done) { + var cb = after(2, done) + var router = new Router({ strict: true }) + var server = createServer(router) + + router.all('/foo', saw) + + request(server) + .get('/foo') + .expect(200, 'saw GET /foo', cb) + + request(server) + .get('/foo/') + .expect(404, 'Cannot GET /foo/\n', 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 be chainable', function () { + var router = new Router() + assert.equal(router[method]('/', helloWorld), router) + }) + + it('should respond to a ' + method.toUpperCase() + ' request', function (done) { + var router = new Router() + var server = createServer(router) + + router[method]('/', helloWorld) + + request(server) + [method]('/') + .expect(200, body, done) + }) + + it('should reject invalid fn', function () { + var router = new Router() + assert.throws(router[method].bind(router, '/', 2), /argument handler must be a function/) + }) + + it('should accept multiple arguments', function (done) { + var router = new Router() + var server = createServer(router) + + router[method]('/', sethit(1), sethit(2), helloWorld) + + request(server) + [method]('/') + .expect('x-fn-1', 'hit') + .expect('x-fn-2', 'hit') + .expect(200, body, done) + }) + + describe('req.baseUrl', function () { + it('should be empty', function (done) { + var router = new Router() + var server = createServer(router) + + router[method]('/foo', function handle(req, res) { + res.setHeader('x-url-base', JSON.stringify(req.baseUrl)) + res.end() + }) + + request(server) + [method]('/foo') + .expect('x-url-base', '""') + .expect(200, done) + }) + }) + + describe('req.route', function () { + it('should be a Route', function (done) { + var router = new Router() + var server = createServer(router) + + router[method]('/foo', function handle(req, res) { + res.setHeader('x-is-route', String(req.route instanceof Router.Route)) + res.end() + }) + + request(server) + [method]('/foo') + .expect('x-is-route', 'true') + .expect(200, done) + }) + + it('should be the matched route', function (done) { + var router = new Router() + var server = createServer(router) + + router[method]('/foo', function handle(req, res) { + res.setHeader('x-is-route', String(req.route.path === '/foo')) + res.end() + }) + + request(server) + [method]('/foo') + .expect('x-is-route', 'true') + .expect(200, done) + }) + }) + }) + }) + + describe('.use(...fn)', function () { + it('should reject missing functions', function () { + var router = new Router() + assert.throws(router.use.bind(router), /argument handler is required/) + }) + + it('should reject empty array', function () { + var router = new Router() + assert.throws(router.use.bind(router, []), /argument handler is required/) + }) + + it('should reject non-functions', function () { + var router = new Router() + assert.throws(router.use.bind(router, '/', 'hello'), /argument handler must be a function/) + assert.throws(router.use.bind(router, '/', 5), /argument handler must be a function/) + assert.throws(router.use.bind(router, '/', null), /argument handler must be a function/) + assert.throws(router.use.bind(router, '/', new Date()), /argument handler must be a function/) + }) + + it('should be chainable', function () { + var router = new Router() + assert.equal(router.use(helloWorld), router) + }) + + it('should invoke function for all requests', function (done) { + var cb = after(3, done) + var router = new Router() + 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) + }) + + it('should not invoke for blank URLs', function (done) { + var router = new Router() + var server = createServer(function hander(req, res, next) { + req.url = '' + router(req, res, next) + }) + + router.use(saw) + + request(server) + .get('/') + .expect(404, done) + }) + + it('should support another router', function (done) { + var inner = new Router() + var router = new Router() + var server = createServer(router) + + inner.use(saw) + router.use(inner) + + request(server) + .get('/') + .expect(200, 'saw GET /', done) + }) + + it('should accept multiple arguments', function (done) { + var router = new Router() + var server = createServer(router) + + router.use(sethit(1), sethit(2), helloWorld) + + request(server) + .get('/') + .expect('x-fn-1', 'hit') + .expect('x-fn-2', 'hit') + .expect(200, 'hello, world', done) + }) + + it('should accept single array of middleware', function (done) { + var router = new Router() + var server = createServer(router) + + router.use([sethit(1), sethit(2), helloWorld]) + + request(server) + .get('/') + .expect('x-fn-1', 'hit') + .expect('x-fn-2', 'hit') + .expect(200, 'hello, world', done) + }) + + describe('req.baseUrl', function () { + it('should be empty', function (done) { + var router = new Router() + var server = createServer(router) + + router.use(sawBase) + + request(server) + .get('/foo/bar') + .expect(200, 'saw ', done) + }) + }) + }) + + describe('.use(path, ...fn)', function () { + it('should be chainable', function () { + var router = new Router() + assert.equal(router.use('/', helloWorld), router) + }) + + it('should invoke when req.url starts with path', function (done) { + var cb = after(3, done) + var router = new Router() + var server = createServer(router) + + router.use('/foo', saw) + + request(server) + .get('/') + .expect(404, cb) + + request(server) + .post('/foo') + .expect(200, 'saw POST /', cb) + + request(server) + .post('/foo/bar') + .expect(200, 'saw POST /bar', cb) + }) + + it('should match if path has trailing slash', function (done) { + var cb = after(3, done) + var router = new Router() + var server = createServer(router) + + router.use('/foo/', saw) + + request(server) + .get('/') + .expect(404, cb) + + request(server) + .post('/foo') + .expect(200, 'saw POST /', cb) + + request(server) + .post('/foo/bar') + .expect(200, 'saw POST /bar', cb) + }) + + it('should support array of paths', function (done) { + var cb = after(3, done) + var router = new Router() + var server = createServer(router) + + router.use(['/foo/', '/bar'], saw) + + request(server) + .get('/') + .expect(404, cb) + + request(server) + .get('/foo') + .expect(200, 'saw GET /', cb) + + request(server) + .get('/bar') + .expect(200, 'saw GET /', cb) + }) + + it('should support regexp path', function (done) { + var cb = after(4, done) + var router = new Router() + var server = createServer(router) + + router.use(/^\/[a-z]oo/, saw) + + request(server) + .get('/') + .expect(404, cb) + + request(server) + .get('/foo') + .expect(200, 'saw GET /', cb) + + request(server) + .get('/zoo/bear') + .expect(200, 'saw GET /bear', cb) + + request(server) + .get('/get/zoo') + .expect(404, cb) + }) + + it('should accept multiple arguments', function (done) { + var router = new Router() + var server = createServer(router) + + router.use('/foo', sethit(1), sethit(2), helloWorld) + + request(server) + .get('/foo') + .expect('x-fn-1', 'hit') + .expect('x-fn-2', 'hit') + .expect(200, 'hello, world', done) + }) + + describe('with "caseSensitive" option', function () { + it('should not match paths case-sensitively by default', function (done) { + var cb = after(3, done) + var router = new Router() + var server = createServer(router) + + router.use('/foo', saw) + + request(server) + .get('/foo/bar') + .expect(200, 'saw GET /bar', cb) + + request(server) + .get('/FOO/bar') + .expect(200, 'saw GET /bar', cb) + + request(server) + .get('/FOO/BAR') + .expect(200, 'saw GET /BAR', cb) + }) + + it('should not match paths case-sensitively when false', function (done) { + var cb = after(3, done) + var router = new Router({ caseSensitive: false }) + var server = createServer(router) + + router.use('/foo', saw) + + request(server) + .get('/foo/bar') + .expect(200, 'saw GET /bar', cb) + + request(server) + .get('/FOO/bar') + .expect(200, 'saw GET /bar', cb) + + request(server) + .get('/FOO/BAR') + .expect(200, 'saw GET /BAR', cb) + }) + + it('should match paths case-sensitively when true', function (done) { + var cb = after(3, done) + var router = new Router({ caseSensitive: true }) + var server = createServer(router) + + router.use('/foo', saw) + + request(server) + .get('/foo/bar') + .expect(200, 'saw GET /bar', cb) + + request(server) + .get('/FOO/bar') + .expect(404, 'Cannot GET /FOO/bar\n', cb) + + request(server) + .get('/FOO/BAR') + .expect(404, 'Cannot GET /FOO/BAR\n', cb) + }) + }) + + describe('with "strict" option', function () { + it('should accept optional trailing slashes by default', function (done) { + var cb = after(2, done) + var router = new Router() + var server = createServer(router) + + router.use('/foo', saw) + + request(server) + .get('/foo') + .expect(200, 'saw GET /', cb) + + request(server) + .get('/foo/') + .expect(200, 'saw GET /', cb) + }) + + it('should accept optional trailing slashes when false', function (done) { + var cb = after(2, done) + var router = new Router({ strict: false }) + var server = createServer(router) + + router.use('/foo', saw) + + request(server) + .get('/foo') + .expect(200, 'saw GET /', cb) + + request(server) + .get('/foo/') + .expect(200, 'saw GET /', cb) + }) + + it('should accept optional trailing slashes when true', function (done) { + var cb = after(2, done) + var router = new Router({ strict: true }) + var server = createServer(router) + + router.use('/foo', saw) + + request(server) + .get('/foo') + .expect(200, 'saw GET /', cb) + + request(server) + .get('/foo/') + .expect(200, 'saw GET /', cb) + }) + }) + + describe('req.baseUrl', function () { + it('should contain the stripped path', function (done) { + var router = new Router() + var server = createServer(router) + + router.use('/foo', sawBase) + + request(server) + .get('/foo/bar') + .expect(200, 'saw /foo', done) + }) + + it('should contain the stripped path for multiple levels', function (done) { + var router1 = new Router() + var router2 = new Router() + var server = createServer(router1) + + router1.use('/foo', router2) + router2.use('/bar', sawBase) + + request(server) + .get('/foo/bar/baz') + .expect(200, 'saw /foo/bar', done) + }) + + it('should be altered correctly', function(done){ + var router = new Router() + var server = createServer(router) + var sub1 = new Router() + var sub2 = new Router() + var sub3 = new Router() + + sub3.get('/zed', setsawBase(1)) + + sub2.use('/baz', sub3) + + sub1.use('/', setsawBase(2)) + + sub1.use('/bar', sub2) + sub1.use('/bar', setsawBase(3)) + + router.use(setsawBase(4)) + router.use('/foo', sub1) + router.use(setsawBase(5)) + router.use(helloWorld) + + request(server) + .get('/foo/bar/baz/zed') + .expect('x-saw-base-1', '/foo/bar/baz') + .expect('x-saw-base-2', '/foo') + .expect('x-saw-base-3', '/foo/bar') + .expect('x-saw-base-4', '') + .expect('x-saw-base-5', '') + .expect(200, done) + }) + }) + + describe('req.url', function () { + it('should strip path from req.url', function (done) { + var router = new Router() + var server = createServer(router) + + router.use('/foo', saw) + + request(server) + .get('/foo/bar') + .expect(200, 'saw GET /bar', done) + }) + + it('should restore req.url after stripping', function (done) { + var router = new Router() + var server = createServer(router) + + router.use('/foo', setsaw(1)) + router.use(saw) + + request(server) + .get('/foo/bar') + .expect('x-saw-1', 'GET /bar') + .expect(200, 'saw GET /foo/bar', done) + }) + + it('should strip/restore with trailing stash', function (done) { + var router = new Router() + var server = createServer(router) + + router.use('/foo', setsaw(1)) + router.use(saw) + + request(server) + .get('/foo/') + .expect('x-saw-1', 'GET /') + .expect(200, 'saw GET /foo/', done) + }) + }) + }) +}) + +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() + } +} + +function setsaw(num) { + var name = 'x-saw-' + String(num) + return function saw(req, res, next) { + res.setHeader(name, req.method + ' ' + req.url) + next() + } +} + +function setsawBase(num) { + var name = 'x-saw-base-' + String(num) + return function sawBase(req, res, next) { + res.setHeader(name, String(req.baseUrl)) + next() + } +} + +function saw(req, res) { + var msg = 'saw ' + req.method + ' ' + req.url + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end(msg) +} + +function sawBase(req, res) { + var msg = 'saw ' + req.baseUrl + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end(msg) +} diff --git a/test/support/utils.js b/test/support/utils.js new file mode 100644 index 0000000..e944d8f --- /dev/null +++ b/test/support/utils.js @@ -0,0 +1,90 @@ + +var assert = require('assert') +var finalhandler = require('finalhandler') +var http = require('http') +var request = require('supertest') + +exports.assert = assert +exports.createServer = createServer +exports.rawrequest = rawrequest +exports.request = request + +function createServer(router) { + return http.createServer(function onRequest(req, res) { + router(req, res, finalhandler(req, res)) + }) +} + +function rawrequest(server) { + var _headers = {} + var _path + + function expect(status, body, callback) { + if (arguments.length === 2) { + _headers[status.toLowerCase()] = body + return this + } + + server.listen(function(){ + var addr = this.address() + var hostname = addr.family === 'IPv6' ? '::1' : '127.0.0.1' + var port = addr.port + + var req = http.get({ + host: hostname, + path: _path, + port: port + }) + req.on('response', function(res){ + var buf = '' + + res.setEncoding('utf8') + res.on('data', function(s){ buf += s }) + res.on('end', function(){ + var err = null + + try { + for (var key in _headers) { + assert.equal(res.headers[key], _headers[key]) + } + + assert.equal(res.statusCode, status) + assert.equal(buf, body) + } catch (e) { + err = e + } + + server.close() + callback(err) + }) + }) + }) + } + + function get(path) { + _path = path + + return { + expect: expect + } + } + + return { + get: get + } +} + +function setsaw(num) { + var name = 'x-saw-' + String(num) + return function hit(req, res, next) { + res.setHeader(name, req.method + ' ' + req.url) + next() + } +} + +function saw(req, res) { + var msg = 'saw ' + req.method + ' ' + req.url + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end(msg) +}