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 mapping route params to regexps or functions for processing #1063

Merged
merged 6 commits into from
Aug 13, 2013
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/app/HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ App Framework Change History

### Router

* Added support for registering route param handler functions or regexps. This
allows routes to be defined as string paths while adding validation/formatting
to route params, e.g., `"/posts:id"`, and register an `id` param handler to
parse string values into a number and make it available at `req.params.id`.
([#1063][])

* Fixed issue with trying to URL-decode matching path segments that are
`undefined`. Routes defined as Regexps (instead of strings) can contain an
arbitrary number of captures; when executing the regex during dispatching, its
Expand All @@ -20,6 +26,7 @@ App Framework Change History


[#1004]: https://github.com/yui/yui3/issues/1004
[#1063]: https://github.com/yui/yui3/issues/1063


3.11.0
Expand Down
109 changes: 109 additions & 0 deletions src/app/docs/router/index.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,24 @@ When instantiating Router, you may optionally pass in a config object containing
</td>
</tr>

<tr>
<td>`params`</td>
<td>`{}`</td>
<td>
<p>
Map of params handlers in the form: `name` -> RegExp | Function.
</p>

<p>
This attribute is intended to be used to set params at init time, or to completely reset all params after init. To add params after init without resetting all existing params, use the `param()` method.
</p>

<p>
See [[#Router Params]] for details.
</p>
</td>
</tr>

<tr>
<td>`root`</td>
<td>`''`</td>
Expand Down Expand Up @@ -586,6 +604,97 @@ When a route is matched, the callback functions associated with that route will
Inside a route callback, the `this` keyword will always refer to the Router instance that executed that route.
</p>

<h4>Router Params</h4>

<p>
Usually it's desirable to define routes using string paths because doing so results in easily readable routes and named request parameters. When route params require specific validation or formatting there's a tendency to rewrite a string-based route path as a regular expression. Instead of switching to more complex regex-based routes, route param handlers can be registered to validate more complex routes.
</p>

<p>
A common example of route param validation and formatting is resource `id`s which should be a number and formatted as such (instead of a string). The following registers a param handler for `id` and it will always make sure it's a number:
</p>

```javascript
router.param('id', function (value) {
return parseInt(value, 10);
});
```

<p>
Now any routes registered that use the `:id` parameter will have that value validated and formatted using the above function.
</p>

```javascript
router.route('/posts/:id', function (req) {
Y.log('Post: ' + req.params.id);
});

router.save('/posts/10'); // => "Post: 10"
```

<p>
The above first validates and formats the original value of the `:id` placeholder from the URL (in this case the string `"10"`) by passing it to the registered `id` param handler function. That function returns the number `10` which is then assigned to `req.params.id` before the route handler is called.
</p>

<p>
Route param handlers can also be defined as regular expressions. Regex param handlers will have their `exec()` called with the param value parsed from the URL, and the resulting matches object/array (or `null`) will become the new param value. The following defines regex for `username` which will match alphanumeric and underscore characters:
</p>

```javascript
router.param('username', /^\w+$/);
```

<p>
If a param handler regex or function returns a value of `false`, `null`, `undefined`, or `NaN`, the current route will not match and be skipped. All other return values will be used in place of the original param value parsed from the URL.
</p>

<p>
The following defines two additional routes, one for `"/users/:username"`, and a catch-all route which will be called when none of the other routes match:
</p>

```javascript
router.route('/users/:username', function (req) {
// `req.params.username` is an array because the result of calling `exec()`
// on the regex is assigned as the param's value.
Y.log('User: ' + req.params.username[0]);
});

router.route('*', function () {
Y.log('Catch-all no routes matched!');
});

// URLs which match routes:
router.save('/users/ericf'); // => "User: ericf"

// URLs which do not match routes because params fail validation:
router.save('/posts/a'); // => "Catch-all no routes matched!"
router.save('/users/ericf,rgrove'); // => "Catch-all no routes matched!"
```

<p>
The last two `router.save()` calls above skip the main posts and users route handlers because the param values parsed from the URL fail validation of the `id` and `username` param handlers. When a param value fails validation, that route is skipped, and in this case the catch-all `"*"` route handler is called.
</p>

<p>
A router's param handlers are also accessible through its `params` attribute and can be set, in bulk, at init time or to reset all existing param handlers. The above param handlers could have been registered via the `params` attribute:
</p>

```javascript
var router = new Y.Router({
params: {
id: function (value) {
return parseInt(value, 10);
},

username: /^\w+$/
}
});
```

<p>
This is functionally equivalent to adding the param handlers via the `param()` method, except that it will replace any default params, and the params are added during the Router's initialization stage rather than after.
</p>

<h4>Chaining Routes and Middleware</h4>

<p>
Expand Down
174 changes: 170 additions & 4 deletions src/app/js/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Provides URL-based routing using HTML5 `pushState()` or the location hash.
var HistoryHash = Y.HistoryHash,
QS = Y.QueryString,
YArray = Y.Array,
YLang = Y.Lang,
YObject = Y.Object,

win = Y.config.win,

Expand Down Expand Up @@ -97,6 +99,16 @@ Y.Router = Y.extend(Router, Y.Base, {
@protected
**/

/**
Map which holds the registered param handlers in the form:
`name` -> RegExp | Function.

@property _params
@type Object
@protected
@since @SINCE@
**/

/**
Whether or not the `ready` event has fired yet.

Expand Down Expand Up @@ -144,11 +156,20 @@ Y.Router = Y.extend(Router, Y.Base, {
**/
_regexUrlOrigin: /^(?:[^\/#?:]+:\/\/|\/\/)[^\/]*/,

/**
Collection of registered routes.

@property _routes
@type Array
@protected
**/

// -- Lifecycle Methods ----------------------------------------------------
initializer: function (config) {
var self = this;

self._html5 = self.get('html5');
self._params = {};
self._routes = [];
self._url = self._getURL();

Expand Down Expand Up @@ -300,6 +321,68 @@ Y.Router = Y.extend(Router, Y.Base, {
});
},

/**
Adds a handler for a route param specified by _name_.

Param handlers can be registered via this method and are used to
validate/format values of named params in routes before dispatching to the
route's handler functions. Using param handlers allows routes to defined
using string paths which allows for `req.params` to use named params, but
still applying extra validation or formatting to the param values parsed
from the URL.

If a param handler regex or function returns a value of `false`, `null`,
`undefined`, or `NaN`, the current route will not match and be skipped. All
other return values will be used in place of the original param value parsed
from the URL.

@example
router.param('postId', function (value) {
return parseInt(value, 10);
});

router.param('username', /^\w+$/);

router.route('/posts/:postId', function (req) {
Y.log('Post: ' + req.params.id);
});

router.route('/users/:username', function (req) {
// `req.params.username` is an array because the result of calling
// `exec()` on the regex is assigned as the param's value.
Y.log('User: ' + req.params.username[0]);
});

router.route('*', function () {
Y.log('Catch-all no routes matched!');
});

// URLs which match routes:
router.save('/posts/1'); // => "Post: 1"
router.save('/users/ericf'); // => "User: ericf"

// URLs which do not match routes because params fail validation:
router.save('/posts/a'); // => "Catch-all no routes matched!"
router.save('/users/ericf,rgrove'); // => "Catch-all no routes matched!"

@method param
@param {String} name Name of the param used in route paths.
@param {Function|RegExp} handler Function to invoke or regular expression to
`exec()` during route dispatching whose return value is used as the new
param value. Values of `false`, `null`, `undefined`, or `NaN` will cause
the current route to not match and be skipped. When a function is
specified, it will be invoked in the context of this instance with the
following parameters:
@param {String} handler.value The current param value parsed from the URL.
@param {String} handler.name The name of the param.
@chainable
@since @SINCE@
**/
param: function (name, handler) {
this._params[name] = handler;
return this;
},

/**
Removes the `root` URL from the front of _url_ (if it's there) and returns
the result. The returned path will always have a leading `/`.
Expand Down Expand Up @@ -621,7 +704,7 @@ Y.Router = Y.extend(Router, Y.Base, {
decode = self._decode,
routes = self.match(path),
callbacks = [],
matches, req, res;
matches, paramsMatch, req, res;

self._dispatching = self._dispatched = true;

Expand Down Expand Up @@ -673,10 +756,33 @@ Y.Router = Y.extend(Router, Y.Base, {
return match && decode(match);
});

paramsMatch = true;

// Use named keys for parameter names if the route path contains
// named keys. Otherwise, use numerical match indices.
if (matches.length === route.keys.length + 1) {
req.params = YArray.hash(route.keys, matches.slice(1));
matches = matches.slice(1);
req.params = YArray.hash(route.keys, matches);

paramsMatch = YArray.every(route.keys, function (key, i) {
var paramHandler = self._params[key],
value = matches[i];

if (paramHandler && value && typeof value === 'string') {
value = typeof paramHandler === 'function' ?
paramHandler.call(self, value, key) :
paramHandler.exec(value);

if (value !== false && YLang.isValue(value)) {
Copy link
Member Author

Choose a reason for hiding this comment

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

Should a false value return from a param handler function mean the param value fails validation and therefore the current route should be skipped?

req.params[key] = value;
return true;
}

return false;
}

return true;
});
} else {
req.params = matches.concat();
}
Expand All @@ -685,8 +791,13 @@ Y.Router = Y.extend(Router, Y.Base, {
// request.
req.pendingRoutes = routes.length;

// Execute this route's `callbacks`.
req.next();
// Execute this route's `callbacks` or skip this route because
// some of the param regexps don't match.
if (paramsMatch) {
req.next();
} else {
req.next('route');
}
}
};

Expand Down Expand Up @@ -732,6 +843,18 @@ Y.Router = Y.extend(Router, Y.Base, {
return location.origin || (location.protocol + '//' + location.host);
},

/**
Getter for the `params` attribute.

@method _getParams
@return {Object} Mapping of param handlers: `name` -> RegExp | Function.
@protected
@since @SINCE@
**/
_getParams: function () {
return Y.merge(this._params);
},

/**
Gets the current route path, relative to the `root` (if any).

Expand Down Expand Up @@ -1202,6 +1325,25 @@ Y.Router = Y.extend(Router, Y.Base, {
return this;
},

/**
Setter for the `params` attribute.

@method _setParams
@param {Object} params Map in the form: `name` -> RegExp | Function.
@return {Object} The map of params: `name` -> RegExp | Function.
@protected
@since @SINCE@
**/
_setParams: function (params) {
this._params = {};

YObject.each(params, function (regex, name) {
this.param(name, regex);
}, this);

return Y.merge(this._params);
},

/**
Setter for the `routes` attribute.

Expand Down Expand Up @@ -1335,6 +1477,30 @@ Y.Router = Y.extend(Router, Y.Base, {
writeOnce: 'initOnly'
},

/**
Map of params handlers in the form: `name` -> RegExp | Function.

If a param handler regex or function returns a value of `false`, `null`,
`undefined`, or `NaN`, the current route will not match and be skipped.
All other return values will be used in place of the original param
value parsed from the URL.

This attribute is intended to be used to set params at init time, or to
completely reset all params after init. To add params after init without
resetting all existing params, use the `param()` method.

@attribute params
@type Object
@default `{}`
@see param
@since @SINCE@
**/
params: {
value : {},
getter: '_getParams',
setter: '_setParams'
},

/**
Absolute root path from which all routes should be evaluated.

Expand Down
Loading