Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for named routes #36

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
48 changes: 46 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -122,18 +126,22 @@ 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
route naming and thus typo errors.

```js
var api = router.route('/api/')
var api = router.route('/api/', 'api')
```

## Router.Route(path)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
100 changes: 97 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -72,6 +73,7 @@ function Router(options) {
router.params = {}
router.strict = opts.strict
router.stack = []
router.routes = {}

return router
}
Expand Down Expand Up @@ -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`
Expand All @@ -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]
Expand All @@ -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))
Expand All @@ -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]

Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need to make findPath a fully-documented public expectation for other router implementations. The idea behind the router currently is that anyone can implement various different ones and mix and match as needed. The docs, in addition to describing this new method, should describe how we would expect other router implementations to implement it as well.

We may need to consider what's going to happen if we just call findPath on someone's existing handler function that's out there as well, since we've never done that before, so don't know what we'd be breaking. Let's get some research going on this :)

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.
*
Expand Down
6 changes: 4 additions & 2 deletions lib/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
31 changes: 29 additions & 2 deletions test/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just marking down the concern that there is a commented-out test in here still :)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it does depend on path-to-regexp 1.x so it couldn't be landed until that dependency is updated and I guess this test would be deleted or rewritten as part of that.

var cb = after(2, done)
var router = new Router()
var route = router.route('/:foo(*)')
Expand All @@ -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()
Expand Down
Loading