├── .gitignore ├── LICENSE ├── README.md ├── lib └── dispatch.js ├── package.json └── test └── test-dispatch.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Caolan McMahon 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | 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 THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dispatch 2 | 3 | A really simple URL dispatcher for 4 | [Connect](http://github.com/senchalabs/connect) or a plain Node.js HTTP Server. 5 | Allows arbitrarily nested regular expressions for matching URLs and calling an 6 | associated function. 7 | 8 | ```js 9 | var Connect = require('connect'), 10 | dispatch = require('dispatch'); 11 | 12 | Connect.createServer( 13 | dispatch({ 14 | '/about': function(req, res, next){ 15 | ... 16 | }, 17 | '/user/:id': function(req, res, next, id){ 18 | ... 19 | }, 20 | '/user/posts': function(req, res, next){ 21 | ... 22 | }, 23 | '/user/posts/(\\w+)': function(req, res, next, post){ 24 | ... 25 | } 26 | }) 27 | ); 28 | ``` 29 | 30 | Or, using a vanilla HTTP Server: 31 | 32 | ```js 33 | var http = require('http'); 34 | 35 | var server = http.createServer( 36 | dispatch({ 37 | '/about': function(req, res){ 38 | ... 39 | }, 40 | '/user/:id': function(req, res, id){ 41 | ... 42 | } 43 | }) 44 | ); 45 | 46 | server.listen(8080); 47 | ``` 48 | 49 | Dispatch can be used with a straight-forward object literal containing view 50 | functions keyed by URL. As you can see from the last URL in the list, captured 51 | groups are passed to the matching function as an argument. 52 | 53 | You can also use :named parameters in a URL, which is just a more readable way 54 | of capturing ([^\/]+). Named parameters are passed to the matched function in 55 | the same way as normal regular expression groups. 56 | 57 | So far so predictable. However, it is also possible to nest these objects as 58 | you see fit: 59 | 60 | ```js 61 | Connect.createServer( 62 | dispatch({ 63 | '/about': function(req, res, next){ ... }, 64 | '/user': { 65 | '/': function(req, res, next){ ... }, 66 | '/posts': function(req, res, next){ ... }, 67 | '/posts/(\\w+)': function(req, res, next, post){ ... } 68 | } 69 | }) 70 | ); 71 | ``` 72 | 73 | This helps you tidy up the structure to make it more readable. It also makes 74 | renaming higher-level parts of the path much simpler. If we wanted to change 75 | 'user' to 'member', we'd now only have to do that once. Another advantage of 76 | being able to nest groups of URLs is mounting reusable apps in your site tree. 77 | Let's assume that 'user' is actually provided by another module: 78 | 79 | ```js 80 | Connect.createServer( 81 | dispatch({ 82 | '/about': function(req, res, next){ ... }, 83 | '/user': require('./user').urls 84 | }) 85 | ); 86 | ``` 87 | 88 | Easy! A really lightweight and flexible URL dispatcher that just does the 89 | obvious. 90 | 91 | Its also possible to define methods for URLs: 92 | 93 | ```js 94 | Connect.createServer( 95 | dispatch({ 96 | '/user': { 97 | 'GET /item': function(req, res, next){ ... }, 98 | 'POST /item': function(req, res, next){ ... }, 99 | } 100 | }) 101 | ); 102 | ``` 103 | 104 | Just prefix the URL with the http method in uppercase followed by whitespace 105 | and then the path you want to match against. Nested URLs always match the last 106 | method defined in the tree. Because of this, you can use the following style for 107 | matching request methods, if you prefer: 108 | 109 | ```js 110 | dispatch({ 111 | '/test': { 112 | GET: function (req, res, next) { 113 | ... 114 | }, 115 | POST: function (req, res, next) { 116 | ... 117 | } 118 | } 119 | }) 120 | ``` 121 | 122 | A couple of implementation points: 123 | 124 | 1. The regular expressions automatically have '^' and '$' added to the pattern 125 | at the start and end or the URL. 126 | 2. Only the first match is called, subsequent matches for a URL will not be 127 | called. 128 | 3. If there are no matches, the request is passed to the next handler in the 129 | Connect middleware chain. 130 | 131 | I like to combine this with [quip](http://github.com/caolan/quip) for rapid 132 | prototyping and just getting my ideas down in code: 133 | 134 | ```js 135 | var Connect = require('connect'), 136 | quip = require('quip'), 137 | dispatch = require('dispatch'); 138 | 139 | var server = Connect.createServer( 140 | quip(), 141 | dispatch({ 142 | '/': function(req, res, next){ 143 | res.text('hello world!'); 144 | }, 145 | '/api': function(req, res, next){ 146 | res.json({hello: 'world'}); 147 | } 148 | }) 149 | ); 150 | 151 | server.listen(8080); 152 | ``` 153 | 154 | Have fun! 155 | -------------------------------------------------------------------------------- /lib/dispatch.js: -------------------------------------------------------------------------------- 1 | var url = require('url'); 2 | 3 | /* 4 | * Accepts a nested set of object literals and creates a single-level object 5 | * by combining the keys. 6 | * 7 | * flattenKeys({'a': {'b': function () {}, 'c': function () {}}}) 8 | * {'ab': function () {}, 'ac': function () {}} 9 | * 10 | */ 11 | function flattenKeys(obj, /*optional args: */acc, prefix, prev_method) { 12 | acc = acc || []; 13 | prefix = prefix || ''; 14 | Object.keys(obj).forEach(function (k) { 15 | var split = splitURL(k); 16 | if (typeof obj[k] == 'function') { 17 | acc.push([prefix + split.url, split.method || prev_method, obj[k]]) 18 | } 19 | else { 20 | flattenKeys(obj[k], acc, prefix + split.url, split.method); 21 | } 22 | }); 23 | return acc; 24 | } 25 | 26 | /* 27 | * Compiles the url patterns to a reqular expression, returning an array 28 | * of arrays. 29 | * 30 | * compileKeys([['abc', 'GET', function () {}], ['xyz', 'POST', function () {}]]) 31 | * [[/^abc$/, 'GET', function () {}], [/^xyz$/, 'POST', function () {}]] 32 | */ 33 | function compileKeys(urls) { 34 | return urls.map(function (url) { 35 | // replace named params with regexp groups 36 | var pattern = url[0].replace(/\/:\w+/g, '(?:/([^\/]+))'); 37 | url[0] = new RegExp('^(?:' + pattern + ')$'); 38 | return url; 39 | }); 40 | } 41 | 42 | /* 43 | * Break apart a url into the path matching regular expression and the 44 | * optional method prefix. 45 | */ 46 | function splitURL(url) { 47 | var method, path, match = /^([A-Z]+)(?:\s+|$)/.exec(url); 48 | if (match) { 49 | method = match[1]; 50 | path = /^[A-Z]+\s+(.*)$/.exec(url); 51 | url = path ? path[1]: ''; 52 | } 53 | return {url: url, method: method}; 54 | } 55 | 56 | /* 57 | * The exported function for use as a Connect provider. 58 | * See test/test-dispatch.js for example usage. 59 | */ 60 | module.exports = function (urls) { 61 | var compiled = compileKeys(flattenKeys(urls)); 62 | return function (req, res, next) { 63 | var args = [req, res], isSome; 64 | if (next) { 65 | args.push(next); 66 | } 67 | isSome = compiled.some(function(x){ 68 | var match = x[0].exec(url.parse(req.url).pathname); 69 | if (match) { 70 | if (!x[1] || x[1] === req.method) { 71 | x[2].apply(null, args.concat(match.slice(1))); 72 | return true; 73 | } 74 | } 75 | return false; 76 | }); 77 | if (!isSome && next) next(); 78 | }; 79 | }; 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dispatch", 3 | "description": "A regular expression URL dispatcher for Connect", 4 | "main": "./lib/dispatch", 5 | "author": "Caolan McMahon", 6 | "version": "1.0.2", 7 | "repository": { 8 | "type": "git", 9 | "url": "http://github.com/caolan/dispatch.git" 10 | }, 11 | "bugs": { 12 | "url": "http://github.com/caolan/dispatch/issues" 13 | }, 14 | "licenses": [ 15 | { 16 | "type": "MIT", 17 | "url": "http://github.com/caolan/dispatch/raw/master/LICENSE" 18 | } 19 | ], 20 | "scripts": { 21 | "test": "./node_modules/.bin/nodeunit test" 22 | }, 23 | "devDependencies": { 24 | "nodeunit": "^0.9.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/test-dispatch.js: -------------------------------------------------------------------------------- 1 | var dispatch = require('../lib/dispatch'); 2 | 3 | exports['simple match'] = function(test){ 4 | test.expect(3); 5 | var request = {url: '/test', method: 'GET'}; 6 | dispatch({ 7 | '/test': function(req, res, next){ 8 | test.equals(req, request); 9 | test.equals(res, 'response'); 10 | test.equals(next, 'next'); 11 | test.done(); 12 | } 13 | })(request, 'response', 'next'); 14 | }; 15 | 16 | exports['no match'] = function(test){ 17 | var request = {url: '/abc', method: 'XYZ'}; 18 | dispatch({ 19 | '/test': function(req, res, next){ 20 | test.ok(false, 'should not be called'); 21 | } 22 | })(request, 'response', function(){ 23 | test.ok(true, 'next should be called when no matches'); 24 | test.done(); 25 | }); 26 | }; 27 | 28 | exports['regexp match'] = function(test){ 29 | var request = {url: '/abc/test123'}; 30 | dispatch({ 31 | '/(\\w+)/test\\d*': function(req, res, next, group){ 32 | test.equals(req, request); 33 | test.equals(res, 'response'); 34 | test.equals(next, 'next'); 35 | test.equals(group, 'abc'); 36 | test.done(); 37 | } 38 | })(request, 'response', 'next'); 39 | }; 40 | 41 | exports['multiple matches'] = function(test){ 42 | test.expect(4); 43 | var request = {url: '/abc', method: 'POST'}; 44 | dispatch({ 45 | '/(\\w+)/?': function(req, res, next, group){ 46 | test.equals(req, request); 47 | test.equals(res, 'response'); 48 | test.equals(next, 'next'); 49 | test.equals(group, 'abc'); 50 | }, 51 | '/(\\w+)': function(req, res, next, group){ 52 | test.ok(false, 'only first match should be called'); 53 | } 54 | })(request, 'response', 'next'); 55 | setTimeout(test.done, 10); 56 | }; 57 | 58 | exports['nested urls'] = function(test){ 59 | var request = {url: '/folder/some/other/path', method: 'GET'}; 60 | dispatch({ 61 | '/folder': { 62 | '/some/other': { 63 | '/path': function(req, res, next){ 64 | test.equals(req, request); 65 | test.equals(res, 'response'); 66 | test.equals(next, 'next'); 67 | test.done(); 68 | } 69 | } 70 | } 71 | })(request, 'response', 'next'); 72 | }; 73 | 74 | exports['nested urls with captured groups'] = function(test){ 75 | var request = {url: '/one/two/three', method: 'GET'}; 76 | dispatch({ 77 | '/(\\w+)': { 78 | '/(\\w+)': { 79 | '/(\\w+)': function(req, res, next, group1, group2, group3){ 80 | test.equals(req, request); 81 | test.equals(res, 'response'); 82 | test.equals(next, 'next'); 83 | test.equals(group1, 'one'); 84 | test.equals(group2, 'two'); 85 | test.equals(group3, 'three'); 86 | test.done(); 87 | } 88 | } 89 | }, 90 | '/one/two/three': function(req, res, next){ 91 | test.ok(false, 'should not be called, previous key matches'); 92 | test.done(); 93 | } 94 | })(request, 'response', 'next'); 95 | }; 96 | 97 | exports['method'] = function (test) { 98 | test.expect(5); 99 | var call_order = []; 100 | var request = {url: '/test', method: 'GET'}; 101 | var handle_req = dispatch({ 102 | 'GET /test': function(req, res, next){ 103 | call_order.push('GET'); 104 | test.equals(req, request); 105 | test.equals(res, 'response'); 106 | }, 107 | 'POST /test': function(req, res, next){ 108 | call_order.push('POST'); 109 | test.equals(req, request); 110 | test.equals(res, 'response'); 111 | } 112 | }); 113 | handle_req(request, 'response', 'next'); 114 | request.method = 'POST'; 115 | handle_req(request, 'response', 'next'); 116 | request.method = 'DELETE'; 117 | handle_req(request, 'response', function(){ 118 | test.same(call_order, ['GET', 'POST']); 119 | test.done(); 120 | }); 121 | }; 122 | 123 | exports['nested method'] = function (test) { 124 | test.expect(2); 125 | var call_order = []; 126 | var request = {url: '/path/test', method: 'GET'}; 127 | var handle_req = dispatch({ 128 | '/path': { 129 | 'GET /test': function(req, res, next){ 130 | test.equals(req, request); 131 | test.equals(res, 'response'); 132 | } 133 | } 134 | }); 135 | handle_req(request, 'response', function(){ 136 | test.ok(false, 'should not be called'); 137 | }); 138 | request.method = 'POST'; 139 | handle_req(request, 'response', function(){ 140 | test.done(); 141 | }); 142 | }; 143 | 144 | exports['nested already defined method'] = function (test) { 145 | test.expect(2); 146 | var call_order = []; 147 | var request = {url: '/path/create/item', method: 'POST'}; 148 | var handle_req = dispatch({ 149 | '/path': { 150 | 'POST /create': { 151 | '/item': function(req, res, next){ 152 | test.equals(req, request); 153 | test.equals(res, 'response'); 154 | } 155 | } 156 | } 157 | }); 158 | handle_req(request, 'response', function(){ 159 | test.ok(false, 'should not be called'); 160 | }); 161 | request.method = 'GET'; 162 | handle_req(request, 'response', function(){ 163 | test.done(); 164 | }); 165 | }; 166 | 167 | exports['nested redefine previous method'] = function (test) { 168 | test.expect(2); 169 | var call_order = []; 170 | var request = {url: '/path/create/item', method: 'GET'}; 171 | var handle_req = dispatch({ 172 | '/path': { 173 | 'POST /create': { 174 | 'GET /item': function(req, res, next){ 175 | test.equals(req, request); 176 | test.equals(res, 'response'); 177 | } 178 | } 179 | } 180 | }); 181 | handle_req(request, 'response', function(){ 182 | test.ok(false, 'should not be called'); 183 | }); 184 | request.method = 'POST'; 185 | handle_req(request, 'response', function(){ 186 | test.done(); 187 | }); 188 | }; 189 | 190 | exports['whitespace between method and pattern'] = function (test) { 191 | test.expect(6); 192 | var call_order = []; 193 | var request = {url: '/test', method: 'GET'}; 194 | var handle_req = dispatch({ 195 | 'GET /test': function(req, res, next){ 196 | test.equals(req, request); 197 | test.equals(res, 'response'); 198 | test.equals(next, 'next'); 199 | }, 200 | 'POST\t/test': function(req, res, next){ 201 | test.equals(req, request); 202 | test.equals(res, 'response'); 203 | test.equals(next, 'next'); 204 | } 205 | }); 206 | handle_req(request, 'response', 'next'); 207 | request.method = 'POST'; 208 | handle_req(request, 'response', 'next'); 209 | test.done(); 210 | }; 211 | 212 | exports['named param'] = function (test) { 213 | var request = {url: '/abc/test123'}; 214 | dispatch({ 215 | '/:name/test\\d{3}': function(req, res, next, name){ 216 | test.equals(req, request); 217 | test.equals(res, 'response'); 218 | test.equals(next, 'next'); 219 | test.equals(name, 'abc'); 220 | test.done(); 221 | } 222 | })(request, 'response', 'next'); 223 | }; 224 | 225 | exports['nested named param'] = function (test) { 226 | var request = {url: '/test/123'}; 227 | dispatch({ 228 | '/test': { 229 | '/:param': function(req, res, next, name){ 230 | test.equals(req, request); 231 | test.equals(res, 'response'); 232 | test.equals(next, 'next'); 233 | test.equals(name, '123'); 234 | test.done(); 235 | } 236 | } 237 | })(request, 'response', 'next'); 238 | }; 239 | 240 | exports['method as final object'] = function (test) { 241 | var request = {url: '/test/etc', method: 'POST'}; 242 | dispatch({ 243 | '/test': { 244 | GET: function(req, res, next){ 245 | test.ok(false, 'GET should not be called'); 246 | }, 247 | '/etc': { 248 | GET: function(req, res, next){ 249 | test.ok(false, 'GET should not be called'); 250 | }, 251 | POST: function(req, res, next){ 252 | test.equals(req, request); 253 | test.equals(res, 'response'); 254 | test.equals(next, 'next'); 255 | test.done(); 256 | }, 257 | } 258 | } 259 | })(request, 'response', 'next'); 260 | }; 261 | 262 | exports['without next function - eg, vanilla http servers'] = function (test) { 263 | var request = {url: '/test/123'}; 264 | dispatch({ 265 | '/test': { 266 | '/:param': function(req, res, name){ 267 | test.equals(req, request); 268 | test.equals(res, 'response'); 269 | test.equals(name, '123'); 270 | test.done(); 271 | } 272 | } 273 | })(request, 'response'); 274 | }; 275 | 276 | exports['with named param at end'] = function (test) { 277 | var request = {url: '/tests/123', method: 'GET'}; 278 | dispatch({ 279 | 'GET /tests/:x': function(req, res, x){ 280 | test.equals(req, request); 281 | test.equals(res, 'response'); 282 | test.equals(x, '123'); 283 | test.done(); 284 | } 285 | })(request, 'response'); 286 | }; 287 | 288 | exports['regexp with pipe'] = function (test) { 289 | test.expect(2); 290 | var requestFactory = function(url){ return {url: url, method: 'GET'}; }; 291 | var called = function(req, res){ 292 | test.ok(true); 293 | }; 294 | var notCalled = function(req, res){ 295 | test.ok(false, 'should not be called'); 296 | }; 297 | dispatch({ 298 | '/x|/y': called 299 | })(requestFactory('/x'), 'response'); 300 | dispatch({ 301 | '/x|/y': called 302 | })(requestFactory('/y'), 'response'); 303 | dispatch({ 304 | '/x|/y': notCalled 305 | })(requestFactory('/xx'), 'response'); 306 | dispatch({ 307 | '/x|/y': notCalled 308 | })(requestFactory('/yy'), 'response'); 309 | test.done(); 310 | }; 311 | --------------------------------------------------------------------------------