├── .eslintrc ├── .flowconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── bin └── flow.sh ├── lib └── router.js ├── package.json ├── src └── router.js └── test └── router.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "node": true, 5 | "es6": true 6 | }, 7 | "rules": { 8 | "curly": [2, "multi-line"], 9 | "dot-notation": [2, {"allowPattern": "[^_a-z0-9]+"}], 10 | "global-strict": 0, 11 | "no-shadow": 0, 12 | "no-underscore-dangle": 0, 13 | "no-use-before-define": [2, "nofunc"], 14 | "quotes": [1, "single"], 15 | "strict": [2, "global"] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/lib/.* 3 | 4 | [include] 5 | 6 | [libs] 7 | 8 | [options] 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm_debug.log 3 | /docs 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "unstable" 5 | - "stable" 6 | - "0.10" 7 | after_script: 8 | - npm run coveralls 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Foss & Haas GmbH 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Rotunda](http://foss-haas.github.io/rotunda/rotunda-dark.png) 2 | 3 | **Rotunda** is a modern promise-based isomorphic routing library for Node.js and the browser inspired by the [express framework](http://expressjs.com) and [Django](https://www.djangoproject.com). 4 | 5 | [![license - MIT](https://img.shields.io/npm/l/rotunda.svg)](http://foss-haas.mit-license.org) [![Dependencies](https://img.shields.io/david/foss-haas/rotunda.svg)](https://david-dm.org/foss-haas/rotunda) 6 | 7 | [![NPM status](https://nodei.co/npm/rotunda.png?compact=true)](https://www.npmjs.com/package/rotunda) 8 | 9 | [![Build status](https://img.shields.io/travis/foss-haas/rotunda.svg)](https://travis-ci.org/foss-haas/rotunda) [![Coverage status](https://img.shields.io/coveralls/foss-haas/rotunda.svg)](https://coveralls.io/r/foss-haas/rotunda?branch=master) 10 | 11 | # Install 12 | 13 | **Note:** Rotunda uses language features that were introduced in ES2015 (ES6). The code has been converted to ES5 syntax using [Babel](http://babeljs.io) but expects certain globals like `Promise` and `Map` that may not be available in older JavaScript environments. If you plan to use Rotunda in older browsers or older versions of Node.js you should use a polyfill/shim library like [core-js](https://www.npmjs.com/package/core-js). 14 | 15 | ## With NPM 16 | 17 | ```sh 18 | npm install rotunda 19 | ``` 20 | 21 | ## From source 22 | 23 | ```sh 24 | git clone https://github.com/foss-haas/rotunda.git 25 | cd rotunda 26 | npm install 27 | npm run dist 28 | ``` 29 | 30 | In order to run the automated type checks for development, you will need to have [flow](http://flowtype.org) installed and available from the project folder. If flow is not available, the type checks will be skipped temporarily. 31 | 32 | # API 33 | 34 | ## new Router 35 | 36 | Creates a `Router` instance. 37 | 38 | **Arguments** 39 | 40 | * caseInsensitive: *boolean* (default: `false`) 41 | 42 | If enabled, routes and paths will be converted to lower case, emulating the behaviour of case-insensitive file systems. Parameter names are not affected and are always case-sensitive. 43 | 44 | **Examples** 45 | 46 | ```js 47 | import Router from 'rotunda'; 48 | let router = new Router(); 49 | let caselessRouter = new Router(true); 50 | 51 | // ES5 equivalent: 52 | 53 | var Router = require('rotunda').Router; 54 | var router = new Router(); 55 | var caselessRouter = new Router(true); 56 | ``` 57 | 58 | ## Router#param 59 | 60 | Defines a named parameter on the router. Returns the router instance itself to allow chaining. 61 | 62 | **Arguments** 63 | 64 | * **name**: *string* 65 | 66 | The parameter will be invoked by every route that matches its name. Parameter names are case-sensitive. If a route uses a parameter that was not defined, the value will be passed through to the route handler directly. 67 | 68 | * **resolve**: *function* (optional) 69 | 70 | Optionally the parameter can be assigned a resolve function that should return a promise for the parameter's value. The function will be passed the current value of the parameter as well as an object mapping the names of other parameters for the route to promises for their values. 71 | 72 | If the promise is rejected with a reason, the routing will be aborted and fail with the given reason. If the promise is rejected *without* a reason, the route will fail to match and the routing will continue with the next match or fail with an error indicating that the route could not be resolved if there are no other matches. 73 | 74 | Otherwise the result the result of the promise will be passed to any parameters that depend on it. Once all parameters have resolved successfully, their values will be passed to the route handler matching the route. 75 | 76 | **Note that it is possible to create a dead-lock if two parameters on the same route depend on each other's values to resolve.** 77 | 78 | * **schema**: *any* (optional) 79 | 80 | Optionally the parameter can be assigned a schema to validate any matching values against. The schema can be a [joi schema](https://github.com/hapijs/joi) or any value that has a method named `validate`. The method must accept a string value as input and return an object with two properties: `value` and `error`. 81 | 82 | A truthy `error` indicates that the schema validation has failed and will result in the router skipping the matched route and continuing with the next match or failing with an error indicating the route could not be resolved. 83 | 84 | If the value of `error` is non-truthy, the value of the `value` property will be used as the value of the parameter for the resolve function or the current route handler if the parameter has no resolve function. 85 | 86 | 87 | If you only want to assign a schema to the parameter, you can pass the schema as the second argument. 88 | 89 | If neither *resolve* nor *schema* are specified, the method has no effect. 90 | 91 | If both are defined, the value will first be validated against the schema and then passed to the resolve function. 92 | 93 | **Examples** 94 | 95 | ```js 96 | // Let's use joi for our schemas 97 | import joi from 'joi'; 98 | 99 | // Define a parameter that resolves immediately 100 | router.param('primeNumber', function (value) { 101 | // Joi has validated the value and converted it to a number 102 | // So we can just pass it to other code that expects a number 103 | if (isPrimeNumber(value)) return value; 104 | // Not a prime, probably the wrong route 105 | // Reject without reason to try the next route instead 106 | return Promise.reject(); 107 | }, joi.number().integer()); 108 | 109 | // Define a parameter that resolves asynchronously 110 | router.param('articleId', function (value) { 111 | // Let's make some kind of remote API call over AJAX with the validated ID 112 | return ajax.get(`/api/articles/${value}`); 113 | }, joi.number().integer()); 114 | 115 | // Define a parameter that depends on another parameter 116 | router.param('userArticleId', function (value, params) { 117 | return params.userId 118 | .then(function (validUserId) { 119 | // We have waited for the "userId" parameter to be resolved 120 | // Now let's do something that returns a promise 121 | return ajax.get(`/api/users/${validUserId}/articles/${value}`); 122 | }); 123 | }, joi.number().integer()); 124 | 125 | // Define a parameter with only a resolve function 126 | router.param('magic', function (value) { 127 | return ajax.post('/api/magic', {magic: value}) 128 | .then( 129 | function (magic) { 130 | return magic * 2; 131 | }, 132 | function (apiError) { 133 | // Reject with a reason to abort the routing 134 | return Promise.reject({ 135 | error: 'Out of magic!', 136 | reason: apiError 137 | }); 138 | } 139 | ) 140 | }); 141 | 142 | // Define a parameter with only a schema 143 | router.param('someNumber', joi.number()); 144 | 145 | // This has no effect 146 | router.param('nothing'); 147 | ``` 148 | 149 | ## Router#route 150 | 151 | Defines a route on the router. Returns the router instance itself to allow chaining. 152 | 153 | **Arguments** 154 | 155 | * **route**: *string* 156 | 157 | The absolute path of the route to define. Leading, trailing and redundant slashes will be ignored. Parameters are segments starting with a colon followed by the parameter name, e.g. `/users/:userId/profile` contains the parameter "userId". 158 | 159 | If any of the parameters have been defined on the router, their values will be validated and resolved before being passed to the handler. If any parameter fails to validate or resolve, the route handler will be skipped. 160 | 161 | Note that for any segment of the route any static matches will be preferred to parameters, e.g. for the path `/pages/manual` the route `/pages/:pageName` (static, parameter) will be preferred over the route `/:category/manual` (parameter, static) which in turn will be preferred over `/:category/:section` (parameter, parameter). 162 | 163 | * **handler**: *function* 164 | 165 | A function that returns a promise for the result of the given route. If the route contains any parameters, the handler will be passed an object mapping the names of the parameters to their resolved values. 166 | 167 | If the promise returned by the handler is rejected with an error, the routing will abort and fail with that error. If the promise is rejected without an error, the next route handler matching the route will be invoked. If no other handlers match the route, the routing will fail with an error indicating that the route could not be resolved. 168 | 169 | If the handler returns any other value than a promise, it will be wrapped in a resolved promise automatically. 170 | 171 | * **name**: *string* (optional) 172 | 173 | The route can optionally be registered using a given name. Only named routes can be reversed (see below). 174 | 175 | **Examples** 176 | 177 | ```js 178 | router.route('/users/:userId', function (params) { 179 | return Promise.resolve(`This is the user page for the User #${params.userId}!`); 180 | }); 181 | 182 | // Non-promise return values will be wrapped automatically 183 | router.route('/articles/:articleId', function (params) { 184 | return `This is the article page for Article #${params.userId}!`; 185 | }); 186 | 187 | // Parameters will have been resolved before the route handler is invoked 188 | router.param('comment', function (value) { 189 | return ajax.get(`/api/comments/${value}`); 190 | }, joi.number().integer().required()); 191 | router.route('/articles/:articleId/comments/:comment', function (params) { 192 | return `Comment: ${params.comment.title} by ${params.comment.author}`; 193 | }); 194 | 195 | // The raw values of parameters are available, too 196 | router.route('/articles/:articleId/comments/:comment', function (params) { 197 | var raw = params.$raw; 198 | return `URL: /articles/${raw.articleId}/comments/${raw.comment}`; 199 | }); 200 | ``` 201 | 202 | ## Router#reverse 203 | 204 | Returns a path that would resolve to the route name and parameters. 205 | 206 | **Arguments** 207 | 208 | * **name**: *string* 209 | 210 | The name of a named route registered with this router. If no route with the given name has been registered with the router, an error will be thrown. 211 | 212 | * **parameters**: *Object* (optional) 213 | 214 | An object mapping parameter names to parameter values. Any parameters not used by the route will be ignored. If any parameters are missing, an error will be thrown. 215 | 216 | Parameter values should be strings or values with string representations that are supported by the parameter definitions. 217 | 218 | **Examples** 219 | 220 | ```js 221 | router.param('articleId', joi.number().integer()); 222 | router.route('/articles/:articleId', function () {/*...*/}, 'article_detail'); 223 | 224 | // You can always pass in parameter values as strings 225 | router.reverse('article_detail', {articleId: '23'}); 226 | // -> "/articles/23" 227 | 228 | // You can also pass in non-string values 229 | router.reverse('article_detail', {articleId: 42}); 230 | // -> "/articles/42" 231 | 232 | // But be wary of passing in arbitrary objects 233 | router.reverse('article_detail', {articleId: {some: 'object'}}); 234 | // -> "/articles/[object Object]" 235 | 236 | // You always have to pass in all parameters 237 | router.reverse('article_detail', {articleId: '23'}); 238 | // -> Error: Failed to reverse article_detail. Missing param: articleId 239 | 240 | // Extra parameters will be ignored 241 | router.reverse('article_detail', {articleId: '23', size: 'xxl'}); 242 | // -> "/articles/23" 243 | ``` 244 | 245 | ## Router#resolve 246 | 247 | Attempts to resolve a path. Returns a promise that is rejected if the path does not successfully match any routes or resolved with the matching route handler's result. 248 | 249 | **Arguments** 250 | 251 | * **path**: *string* 252 | 253 | The absolute path to resolve. 254 | 255 | * **context**: *any* (optional) 256 | 257 | An additional argument that will be passed to the matching parameters' resolve functions and the route handler. 258 | 259 | **Examples** 260 | 261 | ```js 262 | router.param('articleId', joi.number().integer()); 263 | router.route('/articles/:articleId', function (params) { 264 | return `This is the article page for Article #${params.userId}!`; 265 | }); 266 | 267 | router.resolve('/articles/23').then( 268 | function (result) {console.log(result);}, 269 | function (err) {console.error(err);} 270 | ); 271 | // -> This is the article page for Article #23 272 | 273 | // Paths that don't match anything are rejected 274 | router.resolve('/articles/pants').then( 275 | function (result) {console.log(result);}, 276 | function (err) {console.error(err);} 277 | ); 278 | // -> Error: 404 279 | 280 | // Paths that match a route that is rejected with a reason are rejected 281 | router.route('/bad-route', function () { 282 | return Promise.reject(new Error('Out of order')); 283 | }); 284 | router.resolve('/bad-route').then( 285 | function (result) {console.log(result);}, 286 | function (err) {console.error(err);} 287 | ); 288 | // -> Error: Out of order 289 | 290 | // Parameters that are rejected with a reason also result in rejection 291 | router.param('bad-param', function () { 292 | return Promise.reject(new Error('Server error')); 293 | }); 294 | router.route('/some-route/bad-param', function () {/*never reached*/}); 295 | router.resolve('/some-route/bad-param').then( 296 | function (result) {console.log(result);}, 297 | function (err) {console.error(err);} 298 | ); 299 | // -> Error: Server error 300 | 301 | // Contexts can be used to pass run-time dependencies to a route 302 | router.param('article', function (value, params, context) { 303 | return context.api.getArticle(value); 304 | }); 305 | router.route('/articles/:article', function (params, context) { 306 | return `This is the article page for article #${params.article.title}`; 307 | }); 308 | router.resolve('/articles/23', {api: require('./my-api')}).then( 309 | function (result) {console.log(result);}, 310 | function (err) {console.error(err);} 311 | ); 312 | ``` 313 | 314 | # License 315 | 316 | The MIT/Expat license. For more information, see http://foss-haas.mit-license.org/ or the accompanying [LICENSE](https://github.com/foss-haas/rotunda/blob/master/LICENSE) file. 317 | -------------------------------------------------------------------------------- /bin/flow.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if command -v flow > /dev/null; then 3 | flow 4 | else 5 | echo 'Flow not found.' 6 | fi 7 | -------------------------------------------------------------------------------- /lib/router.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | Object.defineProperty(exports, '__esModule', { 4 | value: true 5 | }); 6 | 7 | var _createClass = (function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ('value' in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })(); 8 | 9 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } 10 | 11 | var RouteNode = (function () { 12 | function RouteNode() { 13 | _classCallCheck(this, RouteNode); 14 | 15 | this.terminal = []; 16 | this._dynamic = null; 17 | this._map = new Map(); 18 | } 19 | 20 | _createClass(RouteNode, [{ 21 | key: 'dynamic', 22 | value: function dynamic() { 23 | var node = this._dynamic || new RouteNode(); 24 | if (!this._dynamic) this._dynamic = node; 25 | return node; 26 | } 27 | }, { 28 | key: 'hasDynamic', 29 | value: function hasDynamic() { 30 | return Boolean(this._dynamic); 31 | } 32 | }, { 33 | key: 'get', 34 | value: function get(key) { 35 | return this._map.get(key); 36 | } 37 | }, { 38 | key: 'has', 39 | value: function has(key) { 40 | return this._map.has(key); 41 | } 42 | }, { 43 | key: 'set', 44 | value: function set(key, node) { 45 | this._map.set(key, node); 46 | } 47 | }]); 48 | 49 | return RouteNode; 50 | })(); 51 | 52 | var Router = (function () { 53 | function Router() { 54 | var caseInsensitive = arguments.length <= 0 || arguments[0] === undefined ? false : arguments[0]; 55 | 56 | _classCallCheck(this, Router); 57 | 58 | this._caseInsensitive = caseInsensitive; 59 | this._params = new Map(); 60 | this._byName = new Map(); 61 | this._root = new RouteNode(); 62 | } 63 | 64 | _createClass(Router, [{ 65 | key: 'param', 66 | value: function param(name, resolve, schema) { 67 | if (resolve) { 68 | if (schema) this._params.set(name, { resolve: resolve, schema: schema });else this._params.set(name, resolve.validate ? { schema: resolve } : { resolve: resolve }); 69 | } else if (schema) this._params.set(name, { schema: schema }); 70 | return this; 71 | } 72 | }, { 73 | key: 'route', 74 | value: function route(path, resolve, name) { 75 | var _this = this; 76 | 77 | var tokens = path.split('/').filter(Boolean); 78 | var node = this._root; 79 | var paramNames = []; 80 | var route = { name: name, resolve: resolve, paramNames: paramNames, path: tokens }; 81 | tokens.forEach(function (token) { 82 | if (_this._caseInsensitive) token = token.toLowerCase(); 83 | if (token.charAt(0) === ':') { 84 | paramNames.push(token.slice(1)); 85 | node = node.dynamic(); 86 | } else { 87 | if (!node.has(token)) node.set(token, new RouteNode()); 88 | node = node.get(token); 89 | } 90 | }); 91 | node.terminal.push(route); 92 | if (name) this._byName.set(name, route); 93 | return this; 94 | } 95 | }, { 96 | key: 'reverse', 97 | value: function reverse(name, params) { 98 | var route = this._byName.get(name); 99 | if (!route) throw new Error('Unknown route: ' + name); 100 | return '/' + route.path.map(function (token) { 101 | if (token.charAt(0) === ':') { 102 | token = token.slice(1); 103 | if (params && params[token]) return params[token]; 104 | throw new Error('Failed to reverse route ' + name + '. Missing param: ' + token); 105 | } 106 | return token; 107 | }).join('/'); 108 | } 109 | }, { 110 | key: 'resolve', 111 | value: function resolve(path, context) { 112 | var caseInsensitive = this._caseInsensitive; 113 | var paramDefs = this._params; 114 | var tokens = path.split('/').filter(Boolean); 115 | var matches = []; 116 | 117 | function traverse(route) { 118 | var i = arguments.length <= 1 || arguments[1] === undefined ? 0 : arguments[1]; 119 | var paramValues = arguments.length <= 2 || arguments[2] === undefined ? [] : arguments[2]; 120 | 121 | if (i === tokens.length) { 122 | if (!route.terminal) return; 123 | route.terminal.forEach(function (r) { 124 | return matches.push({ route: r, paramValues: paramValues }); 125 | }); 126 | return; 127 | } 128 | var token = tokens[i]; 129 | if (caseInsensitive) token = token.toLowerCase(); 130 | if (route.has(token)) traverse(route.get(token), i + 1, paramValues); 131 | if (route.hasDynamic()) traverse(route.dynamic(), i + 1, paramValues.concat(token)); 132 | } 133 | 134 | function next(err) { 135 | if (err) return Promise.reject(err); 136 | if (!matches.length) return Promise.reject(404); 137 | var promisedParams = {}; 138 | var resolvedParams = { $raw: {} }; 139 | 140 | var _matches$shift = matches.shift(); 141 | 142 | var paramValues = _matches$shift.paramValues; 143 | var route = _matches$shift.route; 144 | 145 | function promiseParam(value, i) { 146 | var name = route.paramNames[i]; 147 | var promise = resolveParam(value, name).then(function (value) { 148 | resolvedParams[name] = value; 149 | return value; 150 | }); 151 | promisedParams[name] = promise; 152 | return promise; 153 | } 154 | 155 | function resolveParam(value, name) { 156 | resolvedParams.$raw[name] = value; 157 | if (!paramDefs.has(name)) return Promise.resolve(value); 158 | var param = paramDefs.get(name); 159 | if (param.schema) { 160 | var result = param.schema.validate(value); 161 | if (result.error) return Promise.reject(); 162 | value = result.value; 163 | } 164 | return Promise.resolve(value).then(function (v) { 165 | return param.resolve ? param.resolve(v, promisedParams, context) : v; 166 | }); 167 | } 168 | 169 | return Promise.all(paramValues.map(promiseParam)).then(function () { 170 | return route.resolve(resolvedParams, context); 171 | }).then(undefined, function (err) { 172 | return !err || err.ignore ? next() : next(err); 173 | }); 174 | } 175 | 176 | traverse(this._root); 177 | 178 | return next(); 179 | } 180 | }]); 181 | 182 | return Router; 183 | })(); 184 | 185 | exports['default'] = Router; 186 | module.exports = exports['default']; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rotunda", 3 | "version": "1.1.1", 4 | "description": "Modern promise-based isomorphic router.", 5 | "main": "lib/router.js", 6 | "author": "Alan Plum ", 7 | "license": "MIT", 8 | "files": [ 9 | "lib/", 10 | "package.json", 11 | "README.md", 12 | "LICENSE" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/foss-haas/rotunda.git" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/foss-haas/rotunda/issues" 20 | }, 21 | "homepage": "https://github.com/foss-haas/rotunda", 22 | "dependencies": {}, 23 | "devDependencies": { 24 | "babel": "^5.6.14", 25 | "babel-eslint": "^3.1.20", 26 | "core-js": "^0.9.18", 27 | "coveralls": "^2.11.2", 28 | "eslint": "^0.24.0", 29 | "expect.js": "^0.3.1", 30 | "istanbul": "^0.3.17", 31 | "mocha": "^2.2.5", 32 | "watch": "^0.16.0" 33 | }, 34 | "scripts": { 35 | "dist": "npm run lint && npm run test && npm run babel", 36 | "babel": "babel -d lib src", 37 | "watch": "watch 'npm run dist' src test", 38 | "test": "mocha --compilers js:babel/register -R spec", 39 | "lint": "eslint src && bin/flow.sh", 40 | "cover": "npm run lint && istanbul cover --report lcov _mocha -- --compilers js:babel/register -R spec", 41 | "coveralls": "npm run cover && cat ./coverage/lcov.info | coveralls ; rm -rf ./coverage" 42 | } 43 | } -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 'use strict'; 3 | type RouteFn = (params: {[key: string]: any}) => (Promise | any); 4 | type ParamFn = (value: any, params: ?{[name: string]: Promise}) => (Promise | any); 5 | type Schema = {validate: (value: any) => {value: any, error: ?Error}}; 6 | type Param = {resolve?: ParamFn, schema?: Schema}; 7 | type Route = {name?: string, resolve: RouteFn, paramNames: Array, path: Array}; 8 | 9 | class RouteNode { 10 | terminal: Array; 11 | _dynamic: ?RouteNode; 12 | _map: Map; 13 | constructor() { 14 | this.terminal = []; 15 | this._dynamic = null; 16 | this._map = new Map(); 17 | } 18 | dynamic(): RouteNode { 19 | var node: RouteNode = this._dynamic || new RouteNode(); 20 | if (!this._dynamic) this._dynamic = node; 21 | return node; 22 | } 23 | hasDynamic(): boolean { 24 | return Boolean(this._dynamic); 25 | } 26 | get(key: string): RouteNode { 27 | return this._map.get(key); 28 | } 29 | has(key: string): boolean { 30 | return this._map.has(key); 31 | } 32 | set(key: string, node: RouteNode): void { 33 | this._map.set(key, node); 34 | } 35 | } 36 | 37 | export default class Router { 38 | _caseInsensitive: boolean; 39 | _params: Map; 40 | _byName: Map; 41 | _root: RouteNode; 42 | constructor(caseInsensitive: boolean = false) { 43 | this._caseInsensitive = caseInsensitive; 44 | this._params = new Map(); 45 | this._byName = new Map(); 46 | this._root = new RouteNode(); 47 | } 48 | param(name: string, resolve: any, schema: any): Router { 49 | if (resolve) { 50 | if (schema) this._params.set(name, {resolve, schema}); 51 | else this._params.set(name, resolve.validate ? {schema: resolve} : {resolve}); 52 | } else if (schema) this._params.set(name, {schema}); 53 | return this; 54 | } 55 | route(path: string, resolve: RouteFn, name?: string): Router { 56 | var tokens: Array = path.split('/').filter(Boolean); 57 | var node: RouteNode = this._root; 58 | var paramNames: Array = []; 59 | var route: Route = {name, resolve, paramNames, path: tokens}; 60 | tokens.forEach(token => { 61 | if (this._caseInsensitive) token = token.toLowerCase(); 62 | if (token.charAt(0) === ':') { 63 | paramNames.push(token.slice(1)); 64 | node = node.dynamic(); 65 | } else { 66 | if (!node.has(token)) node.set(token, new RouteNode()); 67 | node = node.get(token); 68 | } 69 | }); 70 | node.terminal.push(route); 71 | if (name) this._byName.set(name, route); 72 | return this; 73 | } 74 | reverse(name: string, params: ?{[name: string]: any}): string { 75 | var route: ?Route = this._byName.get(name); 76 | if (!route) throw new Error(`Unknown route: ${name}`); 77 | return '/' + route.path.map(token => { 78 | if (token.charAt(0) === ':') { 79 | token = token.slice(1); 80 | if (params && params[token]) return params[token]; 81 | throw new Error(`Failed to reverse route ${name}. Missing param: ${token}`); 82 | } 83 | return token; 84 | }).join('/'); 85 | } 86 | resolve(path: string, context: any): Promise { 87 | var caseInsensitive: boolean = this._caseInsensitive; 88 | var paramDefs = this._params; 89 | var tokens: Array = path.split('/').filter(Boolean); 90 | var matches: Array<{route: Route, paramValues: Array}> = []; 91 | 92 | function traverse(route: RouteNode, i: number = 0, paramValues: Array = []) { 93 | if (i === tokens.length) { 94 | if (!route.terminal) return; 95 | route.terminal.forEach(r => matches.push({route: r, paramValues})); 96 | return; 97 | } 98 | var token = tokens[i]; 99 | if (caseInsensitive) token = token.toLowerCase(); 100 | if (route.has(token)) traverse(route.get(token), i + 1, paramValues); 101 | if (route.hasDynamic()) traverse(route.dynamic(), i + 1, paramValues.concat(token)); 102 | } 103 | 104 | function next(err: any): Promise { 105 | if (err) return Promise.reject(err); 106 | if (!matches.length) return Promise.reject(404); 107 | var promisedParams: {[t: string]: Promise} = {}; 108 | var resolvedParams: {[t: string]: any} = {$raw: {}}; 109 | var {paramValues, route} = matches.shift(); 110 | 111 | function promiseParam(value: any, i: number): Promise { 112 | var name = route.paramNames[i]; 113 | var promise = resolveParam(value, name) 114 | .then(value => { 115 | resolvedParams[name] = value; 116 | return value; 117 | }); 118 | promisedParams[name] = promise; 119 | return promise; 120 | } 121 | 122 | function resolveParam(value: any, name: string): Promise { 123 | resolvedParams.$raw[name] = value; 124 | if (!paramDefs.has(name)) return Promise.resolve(value); 125 | var param: Param = paramDefs.get(name); 126 | if (param.schema) { 127 | var result = param.schema.validate(value); 128 | if (result.error) return Promise.reject(); 129 | value = result.value; 130 | } 131 | return Promise.resolve(value) 132 | .then(v => param.resolve ? param.resolve(v, promisedParams, context) : v); 133 | } 134 | 135 | return Promise.all(paramValues.map(promiseParam)) 136 | .then(() => route.resolve(resolvedParams, context)) 137 | .then(undefined, err => (!err || err.ignore) ? next() : next(err)); 138 | } 139 | 140 | traverse(this._root); 141 | 142 | return next(); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /test/router.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /*global describe, it */ 3 | 'use strict'; 4 | require('core-js/shim'); 5 | import Router from '../src/router'; 6 | import expect from 'expect.js'; 7 | 8 | type DoneFn = (err: ?Error) => void; 9 | declare function it(msg: string, doFn: ?(done: DoneFn) => void): void; 10 | declare function describe(msg: string, doFn: ?(done: DoneFn) => void): void; 11 | 12 | describe('Router.param', () => { 13 | it('is chainable', () => { 14 | var router = new Router(); 15 | expect(router.param('x', function () {})).to.be(router); 16 | }); 17 | it('does nothing if no resolve function or schema is passed', () => { 18 | expect(new Router().param('x')._params.has('x')).to.be(false); 19 | }); 20 | it('registers only the resolve function if it is only passed a resolve function', () => { 21 | var resolveFn = function () {}; 22 | var param = new Router().param('x', resolveFn)._params.get('x'); 23 | expect(param).to.have.property('resolve', resolveFn); 24 | expect(param).not.to.have.property('schema'); 25 | }); 26 | it('registers only the schema if it is only passed a schema', () => { 27 | var schema = function () {}; 28 | var param = new Router().param('x', undefined, schema)._params.get('x'); 29 | expect(param).not.to.have.property('resolve'); 30 | expect(param).to.have.property('schema', schema); 31 | }); 32 | it('registers only the schema if it is passed a schema instead of a resolve function', () => { 33 | var schema = {validate() {}}; 34 | var param = new Router().param('x', schema)._params.get('x'); 35 | expect(param).not.to.have.property('resolve'); 36 | expect(param).to.have.property('schema', schema); 37 | }); 38 | it('registers both a resolve function and a schema if passed both', () => { 39 | var resolveFn = function () {}; 40 | var schema = {validate() {}}; 41 | var param = new Router().param('x', resolveFn, schema)._params.get('x'); 42 | expect(param).to.have.property('resolve', resolveFn); 43 | expect(param).to.have.property('schema', schema); 44 | }); 45 | }); 46 | 47 | describe('Router.route', () => { 48 | it('is chainable', () => { 49 | var router = new Router(); 50 | expect(router.route('/x', function () {})).to.be(router); 51 | }); 52 | it('does not register a route handler by name if no name is provided', () => { 53 | var router = new Router().route('/x', function () {}); 54 | expect(router._byName.size).to.be(0); 55 | }); 56 | it('registers a route handler by name if a name is provided', () => { 57 | var handler = function () {}; 58 | var router = new Router().route('/x', handler, 'lol'); 59 | expect(router._byName.size).to.be(1); 60 | expect(router._byName.get('lol')).to.have.property('resolve', handler); 61 | }); 62 | }); 63 | 64 | describe('Router.resolve', () => { 65 | describe('routes', () => { 66 | it('fail hard when rejected with reasons', done => { 67 | new Router() 68 | .route('/x', () => Promise.reject(500)) 69 | .route('/:y', () => done(new Error('Route should not match.'))) 70 | .resolve('/x') 71 | .then( 72 | () => Promise.reject(new Error('Route should not resolve.')), 73 | err => { 74 | expect(err).to.be(500); 75 | done(); 76 | } 77 | ) 78 | .then(undefined, done); 79 | }); 80 | it('fail over when rejected without reasons', done => { 81 | new Router() 82 | .route('/x', () => Promise.reject()) 83 | .route('/:y', () => 'done') 84 | .resolve('/x') 85 | .then(value => { 86 | expect(value).to.be('done'); 87 | done(); 88 | }) 89 | .then(undefined, done); 90 | }); 91 | }); 92 | describe('params', () => { 93 | it('fail hard when rejected with reasons', done => { 94 | new Router() 95 | .param('x', () => Promise.reject(500)) 96 | .route('/:x', () => done(new Error('Route should not match.'))) 97 | .route('/:y', () => done(new Error('Route should not match.'))) 98 | .resolve('/x') 99 | .then( 100 | () => Promise.reject(new Error('Route should not resolve.')), 101 | err => { 102 | expect(err).to.be(500); 103 | done(); 104 | } 105 | ) 106 | .then(undefined, done); 107 | }); 108 | it('fail over when rejected without reasons', done => { 109 | new Router() 110 | .param('x', () => Promise.reject()) 111 | .route('/:x', () => done(new Error('Route should not match.'))) 112 | .route('/:y', () => 'done') 113 | .resolve('/x') 114 | .then(value => { 115 | expect(value).to.be('done'); 116 | done(); 117 | }) 118 | .then(undefined, done); 119 | }); 120 | }); 121 | it('stops resolving when it has a match', done => { 122 | new Router() 123 | .route('/stuff', () => 'done') 124 | .route('/stuff', () => Promise.reject(new Error('Route should not match.'))) 125 | .resolve('/stuff') 126 | .then(value => { 127 | expect(value).to.be('done'); 128 | done(); 129 | }) 130 | .then(undefined, done); 131 | }) 132 | it('prefers static routes over dynamic routes', done => { 133 | var routes: Array = []; 134 | new Router() 135 | .route('/:a/:b/:c', () => { 136 | routes.push(5); 137 | return Promise.reject(); 138 | }) 139 | .route('/stuff/goes', () => Promise.reject('Route should not match.')) 140 | .route('/stuff/goes/here/not', () => Promise.reject('Route should not match.')) 141 | .route('/stuff/goes/here/:nil', () => Promise.reject('Route should not match.')) 142 | .route('/stuff/goes/:c', () => { 143 | routes.push(2); 144 | return Promise.reject(); 145 | }) 146 | .route('/stuff/:b/here', () => { 147 | routes.push(3); 148 | return Promise.reject(); 149 | }) 150 | .route('/stuff/:b/:c', () => { 151 | routes.push(4); 152 | return Promise.reject(); 153 | }) 154 | .route('/stuff/goes/here', () => { 155 | routes.push(1); 156 | return Promise.reject(); 157 | }) 158 | .route('/:x/:y/:z', () => { 159 | routes.push(6); 160 | return Promise.reject(); 161 | }) 162 | .resolve('/stuff/goes/here') 163 | .then( 164 | () => Promise.reject(new Error('Route should not resolve.')), 165 | err => { 166 | expect(err).to.be(404); 167 | expect(routes).to.eql([1, 2, 3, 4, 5, 6]); 168 | done(); 169 | } 170 | ) 171 | .then(undefined, done); 172 | }); 173 | it('handles dependent params', done => { 174 | new Router() 175 | .param('x', (value, params) => params.y.then(y => Number(value) * y)) 176 | .param('y', value => Number(value) * 3) 177 | .route('/:x/:y', params => params) 178 | .resolve('/2/5') 179 | .then(({x, y}) => { 180 | expect(x).to.equal(30); 181 | expect(y).to.equal(15); 182 | done(); 183 | }, done); 184 | }) 185 | it('passes context to params', done => { 186 | var ctx = {hello: 'world'}; 187 | new Router() 188 | .param('x', (value, params, context) => expect(context).to.be(ctx)) 189 | .route('/:x', () => null) 190 | .resolve('/hello', ctx) 191 | .then(() => done(), done); 192 | }); 193 | it('passes context to routes', done => { 194 | var ctx = {hello: 'world'}; 195 | new Router() 196 | .route('/hello', (params, context) => expect(context).to.be(ctx)) 197 | .resolve('/hello', ctx) 198 | .then(() => done(), done); 199 | }); 200 | it('passes raw params to routes', done => { 201 | var ctx = {hello: 'world'}; 202 | new Router() 203 | .param('x', () => 23) 204 | .route('/:x', params => expect(params.$raw).to.have.property('x', 'hello')) 205 | .resolve('/hello', ctx) 206 | .then(() => done(), done); 207 | }); 208 | }); 209 | 210 | describe('Router.reverse', () => { 211 | it('returns a path for a named route without parameters', () => { 212 | var router = new Router().route('/x', function () {}, 'example'); 213 | expect(router.reverse('example')).to.equal('/x'); 214 | }); 215 | it('returns a path for a named route with parameters', () => { 216 | var router = new Router().route('/x/:y', function () {}, 'example'); 217 | expect(router.reverse('example', {y: 'hi'})).to.equal('/x/hi'); 218 | }); 219 | it('converts parameter values to strings', () => { 220 | var obj = {toString: () => 'banana'} 221 | var router = new Router().route('/:x/:y', function () {}, 'example'); 222 | expect(router.reverse('example', {x: 23, y: obj})).to.equal('/23/banana'); 223 | }); 224 | it('fails if the name is not known', () => { 225 | var router = new Router(); 226 | expect(() => router.reverse('example')).to.throwException(); 227 | }); 228 | it('fails if any parameters are missing', () => { 229 | var router = new Router().route('/x/:y', function () {}, 'example'); 230 | expect(() => router.reverse('example')).to.throwException(); 231 | }); 232 | it('ignores parameters for routes without parameters', () => { 233 | var router = new Router().route('/x', function () {}, 'example'); 234 | expect(router.reverse('example', {a: 'b'})).to.equal('/x'); 235 | }); 236 | it('ignores superfluous parameters for routes with parameters', () => { 237 | var router = new Router().route('/x/:y', function () {}, 'example'); 238 | expect(router.reverse('example', {a: 'b', y: 'hi'})).to.equal('/x/hi'); 239 | }); 240 | }); 241 | --------------------------------------------------------------------------------