diff --git a/README.md b/README.md index a2e5e01..db9e778 100644 --- a/README.md +++ b/README.md @@ -51,11 +51,15 @@ Returns a function with the signature `router(req, res, callback)` where `callback([err])` must be provided to handle errors and fall-through from not handling requests. -### router.use([path], ...middleware) +### router.use([path], name, ...middleware) Use the given middleware function for all http methods on the given `path`, defaulting to the root path. +`name` is optional, but if it is supplied `path` must be a string and only +one `middleware` is allowed (each name must only apply to one path and one middleware). +Using a name enables `findPath` to be used to construct a path to a route. + `router` does not automatically see `use` as a handler. As such, it will not consider it one for handling `OPTIONS` requests. @@ -122,11 +126,14 @@ router.param('user_id', function (req, res, next, id) { }) ``` -### router.route(path) +### router.route(path, name) Creates an instance of a single `Route` for the given `path`. (See `Router.Route` below) +`name` is optional, using a name enables findPath to be used to +construct a path to a route. + Routes can be used to handle http `methods` with their own, optional middleware. Using `router.route(path)` is a recommended approach to avoiding duplicate @@ -134,6 +141,7 @@ route naming and thus typo errors. ```js var api = router.route('/api/') +var api = router.route('/api/', 'api') ``` ## Router.Route(path) @@ -175,6 +183,16 @@ router.route('/') }) ``` +### route.findPath(routePath, params) + +Constructs a path to a named route, with optional parameters. +Supports nested named routers. Nested names are separated by the '.' character. + +```js +var path = router.findPath('users', {user_id: 'userA'}) +var path = router.findPath('users.messages', {user_id: 'userA'}) +``` + ## Examples ```js @@ -295,6 +313,32 @@ curl http://127.0.0.1:8080/such_path > such_path ``` +### Example using named routes + +```js +var http = require('http') +var Router = require('router') +var finalhandler = require('finalhandler') + +var router = new Router() +var nestedRouter = new Router() + +// setup some parameters to be passed in to create a path +var params = {userid: 'user1'} + +var server = http.createServer(function onRequest(req, res) { + router(req, res, finalhandler(req, res)) +}) + +router.use('/users/:userid', 'users', nestedRouter).get('/', function (req, res) { + res.setHeader('Content-Type', 'text/plain; charset=utf-8') + // Use findPath to create a path with parameters filled in + res.end(router.findPath('users', params)) +}) + +server.listen(8080) +``` + ## License [MIT](LICENSE) diff --git a/index.js b/index.js index b488e58..5a4a73d 100644 --- a/index.js +++ b/index.js @@ -20,6 +20,7 @@ var mixin = require('utils-merge') var parseUrl = require('parseurl') var Route = require('./lib/route') var setPrototypeOf = require('setprototypeof') +var pathToRegexp = require('path-to-regexp') /** * Module variables. @@ -72,6 +73,7 @@ function Router(options) { router.params = {} router.strict = opts.strict router.stack = [] + router.routes = {} return router } @@ -429,7 +431,8 @@ Router.prototype.process_params = function process_params(layer, called, req, re } /** - * Use the given middleware function, with optional path, defaulting to "/". + * Use the given middleware function, with optional path, + * defaulting to "/" and optional name. * * 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` @@ -440,17 +443,34 @@ Router.prototype.process_params = function process_params(layer, called, req, re * handlers can operate without any code changes regardless of the "prefix" * pathname. * + * Note: If a name is supplied, a path must be specified and + * only one handler function is permitted. The handler must also + * implement the 'findPath' function. + * * @public + * @param {string=} path + * @param {string=} name + * @param {function} handler + * */ Router.prototype.use = function use(handler) { var offset = 0 var path = '/' + var name // default path to '/' // disambiguate router.use([handler]) if (typeof handler !== 'function') { var arg = handler + var arg1 = arguments[1] + // If a name is used, the second argument will be a string, not a function + if(typeof arg1 !== 'function' && arguments.length > 2) { + name = arg1 + if(typeof name !== 'string' || name.length === 0) { + throw new TypeError('name should be a non-empty string') + } + } while (Array.isArray(arg) && arg.length !== 0) { arg = arg[0] @@ -461,6 +481,9 @@ Router.prototype.use = function use(handler) { offset = 1 path = handler } + if (name) { + offset = 2 + } } var callbacks = flatten(slice.call(arguments, offset)) @@ -469,6 +492,22 @@ Router.prototype.use = function use(handler) { throw new TypeError('argument handler is required') } + if (name && typeof path !== 'string') { + throw new TypeError('only paths that are strings can be named') + } + + if (name && this.routes[name]) { + throw new Error('a route or handler named \"' + name + '\" already exists') + } + + if (name && callbacks.length > 1) { + throw new TypeError('Router.use cannot be called with multiple handlers if a name argument is used, each handler should have its own name') + } + + if (name && typeof callbacks[0].findPath !== 'function') { + throw new TypeError('handler must implement findPath function if Router.use is called with a name argument') + } + for (var i = 0; i < callbacks.length; i++) { var fn = callbacks[i] @@ -488,6 +527,10 @@ Router.prototype.use = function use(handler) { layer.route = undefined this.stack.push(layer) + + if(name) { + this.routes[name] = {'path':path, 'handler':fn}; + } } return this @@ -502,12 +545,24 @@ Router.prototype.use = function use(handler) { * and middleware to routes. * * @param {string} path + * @param {string=} name * @return {Route} * @public */ -Router.prototype.route = function route(path) { - var route = new Route(path) +Router.prototype.route = function route(path, name) { + if(name !== undefined && (typeof name !== 'string' || name.length === 0)) { + throw new Error('name should be a non-empty string') + } + if(name && this.routes[name]) { + throw new Error('a route or handler named \"' + name + '\" already exists') + } + + var route = new Route(path, name) + + if(name) { + this.routes[name] = route + } var layer = new Layer(path, { sensitive: this.caseSensitive, @@ -534,6 +589,45 @@ methods.concat('all').forEach(function(method){ } }) +/** + * Find a path for the previously created named route. The name + * supplied should be separated by '.' if nested routing is + * used. Parameters should be supplied if the route includes any + * (e.g. {userid: 'user1'}). + * + * @param {string} routePath - name of route or '.' separated + * path + * @param {Object=} params - parameters for route + * @return {string} + */ + +Router.prototype.findPath = function findPath(routePath, params) { + if (typeof routePath !== 'string') { + throw new TypeError('route path should be a string') + } + var firstDot = routePath.indexOf('.') + var routeToFind; + if (firstDot === -1) { + routeToFind = routePath + } else { + routeToFind = routePath.substring(0, firstDot) + } + var thisRoute = this.routes[routeToFind] + if (!thisRoute) { + throw new Error('route path \"'+ routeToFind + '\" does not match any named routes') + } + var toPath = pathToRegexp.compile(thisRoute.path) + var path = toPath(params) + if (firstDot === -1) { // only one segment or this is the last segment + return path + } + var subPath = routePath.substring(firstDot + 1) + if(thisRoute.handler === undefined || thisRoute.handler.findPath === undefined) { + throw new Error('part of route path \"' + subPath + '\" does not match any named nested routes') + } + return path + thisRoute.handler.findPath(subPath, params) +} + /** * Generate a callback that will make an OPTIONS response. * diff --git a/lib/route.js b/lib/route.js index 8b67f2d..f56d095 100644 --- a/lib/route.js +++ b/lib/route.js @@ -31,15 +31,17 @@ var slice = Array.prototype.slice module.exports = Route /** - * Initialize `Route` with the given `path`, + * Initialize `Route` with the given `path` and `name`, * * @param {String} path + * @param {String} name (optional) * @api private */ -function Route(path) { +function Route(path, name) { debug('new %s', path) this.path = path + this.name = name this.stack = [] // route handlers for various http methods diff --git a/package.json b/package.json index 3e73089..0714e37 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "debug": "~2.2.0", "methods": "~1.1.2", "parseurl": "~1.3.1", - "path-to-regexp": "0.1.7", + "path-to-regexp": "1.2.1", "setprototypeof": "1.0.0", "utils-merge": "1.0.0" }, diff --git a/test/route.js b/test/route.js index 6889727..bd1abcd 100644 --- a/test/route.js +++ b/test/route.js @@ -17,6 +17,33 @@ describe('Router', function () { assert.equal(route.path, '/foo') }) + it('should set the route name iff provided', function () { + var router = new Router() + var route = router.route('/abc', 'abcRoute') + assert.equal(route.path, '/abc') + assert.equal(route.name, 'abcRoute') + assert.equal(router.routes['abcRoute'], route) + var route2 = router.route('/def') + assert.equal(router.routes['abcRoute'], route) + assert.equal(null, router.routes[undefined]) + }) + + it('should not allow duplicate route or handler names', function () { + var router = new Router() + var route = router.route('/abc', 'abcRoute') + assert.throws(router.route.bind(router, '/xyz', 'abcRoute'), /a route or handler named "abcRoute" already exists/) + var nestedRouter = new Router() + router.use('/xyz', 'nestedRoute', nestedRouter) + assert.throws(router.route.bind(router, '/xyz', 'nestedRoute'), /a route or handler named "nestedRoute" already exists/) + }) + + it('should not allow empty names', function () { + var router = new Router() + assert.throws(router.route.bind(router, '/xyz', ''), /name should be a non-empty string/) + assert.throws(router.route.bind(router, '/xyz', new String('xyz')), /name should be a non-empty string/) + assert.throws(router.route.bind(router, '/xyz', {}), /name should be a non-empty string/) + }) + it('should respond to multiple methods', function (done) { var cb = after(3, done) var router = new Router() @@ -480,7 +507,7 @@ describe('Router', function () { .expect(200, cb) }) - it('should work in a named parameter', function (done) { + /* it('should work in a named parameter', function (done) { var cb = after(2, done) var router = new Router() var route = router.route('/:foo(*)') @@ -495,7 +522,7 @@ describe('Router', function () { request(server) .get('/fizz/buzz') .expect(200, {'0': 'fizz/buzz', 'foo': 'fizz/buzz'}, cb) - }) + })*/ it('should work before a named parameter', function (done) { var router = new Router() diff --git a/test/router.js b/test/router.js index 046bd32..9a57f89 100644 --- a/test/router.js +++ b/test/router.js @@ -539,6 +539,60 @@ describe('Router', function () { .expect(404, done) }) + it('should support named handlers', function () { + var router = new Router() + + var router2 = new Router() + router.use('/', 'router2', router2) + assert.notEqual(router.routes['router2'], undefined) + assert.equal(router.routes['router2'].handler, router2) + assert.equal(router.routes['router2'].path, '/') + + var router3 = new Router() + router.use('/mypath', 'router3', router3) + assert.notEqual(router.routes['router3'], undefined) + assert.equal(router.routes['router3'].handler, router3) + assert.equal(router.routes['router2'].handler, router2) + assert.equal(router.routes['router3'].path, '/mypath') + + var router4 = new Router() + router.use('/', 'router4', router4) + assert.equal(router.routes['router4'].handler, router4) + assert.equal(router.routes['router4'].path, '/') + }) + + it('should not allow duplicate names', function () { + var router = new Router() + router.use('/', 'router1', new Router()) + assert.throws(router.use.bind(router, '/', 'router1', new Router()), /a route or handler named "router1" already exists/) + router.route('/users', 'users') + assert.throws(router.use.bind(router, '/', 'users', new Router()), /a route or handler named "users" already exists/) + }) + + it('should not allow empty names', function () { + var router = new Router() + assert.throws(router.use.bind(router,'/', '', new Router()), /name should be a non-empty string/) + assert.throws(router.use.bind(router, '/users', '', new Router()), /name should be a non-empty string/) + assert.throws(router.use.bind(router, '/users', new String('users'), new Router()), /name should be a non-empty string/) + }) + + it('should not support named handlersif handler does not implement findPath', function () { + var router = new Router() + assert.throws(router.use.bind(router, '/', 'hello', function() {}, function () {}), /Router.use cannot be called with multiple handlers if a name argument is used, each handler should have its own name/) + assert.throws(router.use.bind(router, '/', 'hello', function(){}), /handler must implement findPath function if Router.use is called with a name argument/) + }) + + it('should not support named handlers for multiple handlers', function () { + var router = new Router() + assert.throws(router.use.bind(router, '/', 'hello', new Router(), new Router()), /Router.use cannot be called with multiple handlers if a name argument is used, each handler should have its own name/) + }) + + it('should not support named handlers unless path is a string', function () { + var router = new Router() + assert.throws(router.use.bind(router, ['/123', '/abc'], '123abc', new Router()), /only paths that are strings can be named/) + assert.throws(router.use.bind(router, /\/abc|\/xyz/, '123abc', new Router()), /only paths that are strings can be named/) + }) + describe('error handling', function () { it('should invoke error function after next(err)', function (done) { var router = new Router() @@ -983,6 +1037,50 @@ describe('Router', function () { .expect(200, 'saw GET /bar', done) }) }) + + describe('.findPath(path)', function () { + it('should only allow string paths', function() { + var router = new Router() + router.route('/users/:userid', 'users') + assert.throws(router.findPath.bind(router, function(){}), /route path should be a string/) + assert.throws(router.findPath.bind(router, new Router()), /route path should be a string/) + assert.throws(router.findPath.bind(router, {}), /route path should be a string/) + assert.throws(router.findPath.bind(router, new String('users'), {userid: 'user1'}), /route path should be a string/) + }) + + it('should return a path to a route', function () { + var router = new Router() + router.route('/users/:userid', 'users') + var path = router.findPath('users', {userid: 'user1'}); + assert.equal(path, '/users/user1') + var path2 = router.findPath('users', {userid: 'user2'}); + assert.equal(path2, '/users/user2') + }) + + it('should throw error if route cannot be matched', function () { + var router = new Router() + router.route('/users/:userid', 'users') + assert.throws(router.findPath.bind(router, 'users.hello', {userid: 'user1'}), /part of route path "hello" does not match any named nested routes/) + assert.throws(router.findPath.bind(router, 'hello', {userid: 'user1'}), /route path "hello" does not match any named routes/) + assert.throws(router.findPath.bind(router, 'users', {abc: 'user1'}), /Expected "userid" to be defined/) + assert.throws(router.findPath.bind(router, 'users', {}), /Expected "userid" to be defined/) + }) + + it('should support nested routers', function () { + var routerA = new Router() + var routerB = new Router() + routerA.use('/base/:path', 'routerB', routerB) + var r = routerB.route('/some/:thing', 'thing') + var path = routerA.findPath('routerB.thing', {path: 'foo', thing: 'bar'}) + assert.equal(path, '/base/foo/some/bar') + path = routerA.findPath('routerB', {path: 'foo'}) + assert.throws(routerA.findPath.bind(routerA, 'route', {path: 'foo'}), /route path "route" does not match any named routes/) + assert.equal(path, '/base/foo') + path = routerB.findPath('thing', {thing: 'bar'}) + assert.equal(path, '/some/bar') + assert.throws(routerA.findPath.bind(routerA, 'routerB.thing.hello', {path: 'foo', thing: 'bar'}), /part of route path "hello" does not match any named nested routes/) + }) + }) }) function helloWorld(req, res) {