├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── TODO ├── benchmark └── benchmark.js ├── example └── http-server.js ├── lib ├── journey.js └── journey │ ├── errors.js │ └── mock-request.js ├── package.json └── test └── journey-test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | Copyright (c) 2009 Alexis Sellier -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # Run all tests 3 | # 4 | test: 5 | @@node test/journey-test.js 6 | 7 | .PHONY: test 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | journey 2 | ======= 3 | 4 | > liberal JSON-only HTTP request routing for node. 5 | 6 | introduction 7 | ------------ 8 | 9 | Journey's goal is to provide a *fast* and *flexible* *RFC 2616 compliant* request router 10 | for *JSON* consuming clients. 11 | 12 | synopsis 13 | -------- 14 | 15 | var journey = require('journey'); 16 | 17 | // 18 | // Create a Router 19 | // 20 | var router = new(journey.Router); 21 | 22 | // Create the routing table 23 | router.map(function () { 24 | this.root.bind(function (req, res) { res.send("Welcome") }); 25 | this.get(/^trolls\/([0-9]+)$/).bind(function (req, res, id) { 26 | database('trolls').get(id, function (doc) { 27 | res.send(200, {}, doc); 28 | }); 29 | }); 30 | this.post('/trolls').bind(function (req, res, data) { 31 | sys.puts(data.type); // "Cave-Troll" 32 | res.send(200); 33 | }); 34 | }); 35 | 36 | require('http').createServer(function (request, response) { 37 | var body = ""; 38 | 39 | request.addListener('data', function (chunk) { body += chunk }); 40 | request.addListener('end', function () { 41 | // 42 | // Dispatch the request to the router 43 | // 44 | router.handle(request, body, function (result) { 45 | response.writeHead(result.status, result.headers); 46 | response.end(result.body); 47 | }); 48 | }); 49 | }).listen(8080); 50 | 51 | installation 52 | ------------ 53 | 54 | $ npm install journey 55 | 56 | API 57 | --- 58 | 59 | You create a router with the `journey.Router` constructor: 60 | 61 | var router = new(journey.Router); 62 | 63 | You define some routes, with bound functions: 64 | 65 | router.get('/hello').bind(function (req, res) { res.send('Hi there!') }); 66 | router.put('/candles').bind(function (req, res) { ... }); 67 | 68 | *Note that you may also use the `map` function to define routes.* 69 | 70 | The `router` object exposes a `handle` method, which takes three arguments: 71 | an `http.ServerRequest` instance, a body, and a callback, as such: 72 | 73 | function route(request, body, callback) 74 | 75 | and asynchronously calls the callback with an object containing the response 76 | headers, status and body, on the first matching route: 77 | 78 | { status: 200, 79 | headers: {"Content-Type":"application/json"}, 80 | body: '{"journey":"Welcome"}' 81 | } 82 | 83 | Note that the response body will either be JSON data, or empty. 84 | 85 | ### Routes # 86 | 87 | Here are a couple of example routes: 88 | 89 | // route // matching request 90 | router.get('/users') // GET /users 91 | router.post('/users') // POST /users 92 | router.del(/^users\/(\d+)$/) // DELETE /users/45 93 | router.put(/^users\/(\d+)$/) // PUT /users/45 94 | 95 | router.route('/articles') // * /articles 96 | router.route('POST', '/users') // POST /users 97 | router.route(['POST', 'PUT'], '/users') // POST or PUT /users 98 | 99 | router.root // GET / 100 | router.any // Matches all request 101 | router.post('/', { // Only match POST requests to / 102 | assert: function (req) { // with data in the body. 103 | return req.body.length > 0; 104 | } 105 | }); 106 | 107 | Any of these routes can be bound to a function or object which responds 108 | to the `apply` method. We use `bind` for that: 109 | 110 | router.get('/hello').bind(function (req, res) {}); 111 | 112 | If there is a match, the bound function is called, and passed the `response` object, 113 | as first argument. Calling the `send` method on this object will trigger the callback, 114 | passing the response to it: 115 | 116 | router.get('/hello').bind(function (req, res) { 117 | res.send(200, {}, {hello: "world"}); 118 | }); 119 | 120 | The send method is pretty flexible, here are a couple of examples: 121 | 122 | // status, headers, body 123 | res.send(404); // 404 {} '' 124 | res.send("Welcome"); // 200 {} '{"journey":"Welcome"}' 125 | res.send({hello:"world"}); // 200 {} '{"hello":"world"}' 126 | 127 | res.send(200, {"Server":"HAL/1.0"}, ["bob"]); 128 | 129 | As you can see, the body is automatically converted to JSON, and if a string is passed, 130 | it acts as a message from `journey`. To send a raw string back, you can use the `sendBody` method: 131 | 132 | res.sendBody(JSON.stringify({hello:"world"})); 133 | 134 | This will bypass JSON conversion. 135 | 136 | ### URL parameters # 137 | 138 | Consider a request such as `GET /users?limit=5`, I can get the url params like this: 139 | 140 | router.get('/users').bind(function (req, res, params) { 141 | params.limit; // 5 142 | }); 143 | 144 | How about a `POST` request, with form data, or JSON? Same thing, journey will parse the data, 145 | and pass it as the last argument to the bound function. 146 | 147 | ### Capture groups # 148 | 149 | Any captured data on a matched route gets passed as arguments to the bound function, so let's 150 | say we have a request like `GET /trolls/42`, and the following route: 151 | 152 | get(/^([a-z]+)\/([0-9]+)$/) 153 | 154 | Here's how we can access the captures: 155 | 156 | router.get(/^([a-z]+)\/([0-9]+)$/).bind(function (req, res, resource, id, params) { 157 | res; // response object 158 | resource; // "trolls" 159 | id; // 42 160 | params; // {} 161 | }); 162 | 163 | ### Summary # 164 | 165 | A bound function has the following template: 166 | 167 | function (request, responder, [capture1, capture2, ...], data/params) 168 | 169 | ### Paths # 170 | 171 | Sometimes it's useful to have a bunch of routes under a single namespace, that's what the `path` function does. 172 | Consider the following path and unbound routes: 173 | 174 | router.path('/domain', function () { 175 | this.get(); // match 'GET /domain' 176 | this.root; // match 'GET /domain/' 177 | this.get('/info'); // match 'GET /domain/info' 178 | 179 | this.path('/users', function () { 180 | this.post(); // match 'POST /domain/users' 181 | this.get(); // match 'GET /domain/users' 182 | }); 183 | }) 184 | 185 | ### Filters # 186 | 187 | Often it's convenient to disallow certain requests based on predefined criteria. A great example of this is Authorization: 188 | 189 | function authorize (request, body, cb) { 190 | return request.headers.authorized === true 191 | ? cb(null) 192 | : cb(new journey.NotAuthorized('Not Authorized')); 193 | } 194 | 195 | function authorizeAdmin (request, body, cb) { 196 | return request.headers.admin === true 197 | ? cb(null) 198 | : cb(new journey.NotAuthorized('Not Admin')); 199 | } 200 | 201 | Journey exposes this in three separate location through the `filter` API: 202 | 203 | #### Set a global filter 204 | 205 | var router = new(journey.Router)({ filter: authorize }); 206 | 207 | *Note: This filter will not actually be enforced until you use the APIs exposed in (2) and (3)* 208 | 209 | #### Set a scoped filter in your route function 210 | 211 | var router = new(journey.Router)({ filter: authorize }); 212 | 213 | router.map(function () { 214 | this.filter(function () { 215 | // 216 | // Routes in this scope will use the 'authorize' function 217 | // 218 | }); 219 | 220 | this.filter(authorizeAdmin, function () { 221 | // 222 | // Routes in this scope will use the 'authorizeAdmin' function 223 | // 224 | }); 225 | }); 226 | 227 | #### Set a filter on an individual route 228 | 229 | var router = new(journey.Router)({ filter: authorize }); 230 | 231 | router.map(function () { 232 | this.get('/authorized').filter().bind(function (req, res, params) { 233 | // 234 | // This route will be filtered using the 'authorize' function 235 | // 236 | }); 237 | 238 | this.get('/admin').filter(authorizeAdmin).bind(function (req, res, params) { 239 | // 240 | // This route will be filtered using the 'authorizeAdmin' function 241 | // 242 | }); 243 | }); 244 | 245 | ### Accessing the request object # 246 | 247 | From a bound function, you can access the request object with `this.request`, consider 248 | a request such as `POST /articles`, and a route: 249 | 250 | router.route('/articles').bind(function (req, res) { 251 | this.request.method; // "POST" 252 | res.send("Thanks for your " + this.request.method + " request."); 253 | }); 254 | 255 | license 256 | ------- 257 | 258 | Released under the Apache License 2.0 259 | 260 | See `LICENSE` file. 261 | 262 | Copyright (c) 2010 Alexis Sellier 263 | 264 | 265 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | TODO 2 | 3 | - Implement `bindResource`. 4 | - Ability to bind multiple functions to a route. 5 | - `Router.prototype.push` 6 | - Returning a value from a bound function passes it to the 7 | next matching route. This can function like middle-ware, 8 | or filters. 9 | - Benchmark! 10 | -------------------------------------------------------------------------------- /benchmark/benchmark.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudhead/journey/94a9e9371ce98ef91ed760006348e7b4737d23c5/benchmark/benchmark.js -------------------------------------------------------------------------------- /example/http-server.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | 3 | var journey = require('../lib/journey'); 4 | 5 | // 6 | // Create a Router object with an associated routing table 7 | // 8 | var router = new(journey.Router); 9 | 10 | router.map(function () { 11 | this.root.bind(function (req, res) { // GET '/' 12 | res.send(200, {}, "Welcome"); 13 | }); 14 | this.get('/version').bind(function (req, res) { 15 | res.send(200, {}, { version: journey.version.join('.') }); 16 | }); 17 | }); 18 | 19 | require('http').createServer(function (request, response) { 20 | var body = ""; 21 | 22 | request.addListener('data', function (chunk) { body += chunk }); 23 | request.addListener('end', function () { 24 | // 25 | // Dispatch the request to the router 26 | // 27 | router.handle(request, body, function (result) { 28 | response.writeHead(result.status, result.headers); 29 | response.end(result.body); 30 | }); 31 | }); 32 | }).listen(8080); 33 | 34 | util.puts('journey listening at http://127.0.0.1:8080'); 35 | -------------------------------------------------------------------------------- /lib/journey.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | var http = require("http"), 4 | events = require('events'), 5 | url = require('url'); 6 | 7 | var querystring = require('querystring'); 8 | 9 | var errors = require('./journey/errors'); 10 | 11 | // Escape RegExp characters in a string 12 | var escapeRe = (function () { 13 | var specials = '. * + ? | ( ) [ ] { } \\ ^ ? ! = : $'.split(' ').join('|\\'); 14 | var re = new(RegExp)('(\\' + specials + ')', 'g'); 15 | 16 | return function (str) { 17 | return (typeof(str) === 'string') ? str.replace(re, '\\$1') : str; 18 | }; 19 | })(); 20 | 21 | var journey = exports; 22 | 23 | journey.env = 'development'; 24 | journey.version = [0, 4, 0]; 25 | journey.options = { 26 | strict: false, 27 | strictUrls: true, 28 | ignoreCase: false, 29 | api: 'http' 30 | }; 31 | 32 | // Copy error objects to journey.* 33 | for (var k in errors) { journey[k] = errors[k] } 34 | 35 | // 36 | // The Router 37 | // 38 | journey.Router = function Router(options) { 39 | var that = this; 40 | 41 | this.routes = []; 42 | this.options = mixin({}, journey.options, options || {}); 43 | 44 | if (this.options.extension) { 45 | this.options.extension = this.options.extension.replace('.', '\\.'); 46 | } 47 | }; 48 | 49 | journey.Router.methods = ['GET', 'PUT', 'POST', 'DELETE', 'HEAD']; 50 | 51 | journey.Router.prototype = { 52 | // 53 | // Define the routing table 54 | // 55 | map: function (routes) { 56 | // Calls the function in the context of this instance, 57 | // so it can be used to define routes on `this`. 58 | routes.call(this, this); 59 | }, 60 | paths: [], 61 | required: [], 62 | 63 | filter: function (/* variable arguments */) { 64 | var args = Array.prototype.slice.call(arguments), 65 | map = (typeof(args[args.length - 1]) === 'function') && args.pop(), 66 | filter = args.pop() || this.options.filter; 67 | 68 | this.required.push(filter); 69 | map.call(this, this); 70 | this.required.pop(); 71 | }, 72 | 73 | get: function (pattern, opts) { return this.route('GET', pattern, opts) }, 74 | put: function (pattern, opts) { return this.route('PUT', pattern, opts) }, 75 | post: function (pattern, opts) { return this.route('POST', pattern, opts) }, 76 | del: function (pattern, opts) { return this.route('DELETE', pattern, opts) }, 77 | 78 | route: function (/* variable arguments */) { 79 | if (arguments[0].headers) { throw new(Error)("Router#route method renamed to 'handle'") } 80 | 81 | var that = this, route, 82 | args = Array.prototype.slice.call(arguments).filter(function (a) { return a }), 83 | // Defaults 84 | pattern = this.paths.length ? '' : /.*/, 85 | ignoreCase = this.options.ignoreCase, 86 | method = journey.Router.methods.slice(0), 87 | constraints = [], 88 | extension = this.options.extension ? '(?:' + this.options.extension + ')?' : ''; 89 | 90 | Array.prototype.push.apply(constraints, this.required); 91 | 92 | args.forEach(function (arg) { 93 | if (journey.Router.methods.indexOf(arg) !== -1 || Array.isArray(arg)) { 94 | method = arg; 95 | } else if (typeof(arg) === "string" || arg.exec) { 96 | pattern = arg; 97 | } else { 98 | throw new(Error)("cannot understand route."); 99 | } 100 | }); 101 | 102 | if (typeof(pattern) === "string") { 103 | pattern = escapeRe(pattern); 104 | } else { 105 | // If we're in a nested path, '/i' doesn't mean much, 106 | // as we concatinate strings and regexps. 107 | ignoreCase = this.paths.length || pattern.ignoreCase; 108 | pattern = pattern.source; 109 | } 110 | // Trim trailing and duplicate slashes and add ^$ markers 111 | pattern = '^' + this.paths.concat(pattern ? [pattern] : []) 112 | .join('/') 113 | .match(/^\^?(.*?)\$?$/)[1] // Strip ^ and $ 114 | .replace(/^(\/|\\\/)(?!$)/, '') // Strip root / if pattern != '/' 115 | .replace(/(\/|\\\/)+/g, '/') + // Squeeze slashes 116 | extension; 117 | pattern += this.options.strictUrls ? '$' : '\\/?$'; // Add optional trailing slash if requested 118 | pattern = new(RegExp)(pattern, ignoreCase ? 'i' : ''); 119 | 120 | this.routes.push(route = { 121 | pattern: pattern, 122 | method: Array.isArray(method) ? method : [method], 123 | constraints: constraints 124 | }); 125 | 126 | return { 127 | bind: function (handler) { 128 | route.handler = handler; 129 | return route; 130 | }, 131 | ensure: function (handler) { 132 | route.constraints.push(handler); 133 | return this; 134 | }, 135 | filter: function (handler) { 136 | return this.ensure(handler || that.options.filter); 137 | } 138 | }; 139 | }, 140 | 141 | get root() { 142 | return this.get('/'); 143 | }, 144 | 145 | get any() { 146 | return this.route(/(.*)/); 147 | }, 148 | 149 | path: function (pattern, map) { 150 | this.paths.push(pattern.exec ? pattern.source 151 | : escapeRe(pattern)); 152 | map.call(this, this); 153 | this.paths.pop(); 154 | }, 155 | trail: function (from, to) { 156 | // Logging 157 | }, 158 | 159 | // Called when the HTTP request is 'complete' 160 | // and ready to be processed. 161 | handle: function (request, body, callback) { 162 | var promise = new(events.EventEmitter); 163 | var request = Object.create(request); 164 | var that = this; 165 | 166 | request.url = url.parse(request.url); 167 | 168 | // Call the router asynchronously, so we can return a promise 169 | process.nextTick(function () { 170 | // Dispatch the HTTP request: 171 | // As the last argument, we send the function to be called when the response is ready 172 | // to be sent back to the client -- this allows us to keep our entry and exit point 173 | // in the same spot. `outcome` is an object with a `status`, a `body` and `headers` 174 | that.dispatch(request, body || "", function (outcome) { 175 | outcome.headers["Date"] = new(Date)().toUTCString(); 176 | outcome.headers["Server"] = "journey/" + journey.version.join('.'); 177 | 178 | if (outcome.body) { 179 | if (typeof(outcome.body) !== 'string') { 180 | outcome.headers["Content-Type"] = "application/json;charset=utf-8"; 181 | outcome.body = JSON.stringify(outcome.body); 182 | } 183 | outcome.headers['Content-Length'] = Buffer.byteLength(outcome.body); 184 | } else { 185 | delete(outcome.headers["Content-Type"]); 186 | } 187 | 188 | if (callback) { callback(outcome) } 189 | else { promise.emit("success", outcome) } 190 | 191 | promise.emit("log", { 192 | date: new(Date)(), 193 | method: request.method, 194 | href: request.url.href, 195 | outcome: outcome.status 196 | }); 197 | }); 198 | }); 199 | return promise; 200 | }, 201 | 202 | constraints: [], 203 | 204 | validateRoute: function (route, request, body, allowedMethods, cb) { 205 | var that = this; 206 | 207 | // Match the pattern with the url 208 | var match = (function (pattern) { 209 | var path = request.url.pathname; 210 | 211 | if (! path) { return new(journey.BadRequest) } 212 | 213 | return (path.length > 1 ? path.slice(1) : path).match(pattern); 214 | })(route.pattern); 215 | 216 | // 217 | // Return here if no match to avoid potentially expensive 218 | // async constraint operations. 219 | // 220 | if (!Array.isArray(match)) { 221 | return match === null ? cb(null, false) : cb(match); 222 | } 223 | 224 | // 225 | // Run through the specified constraints, 226 | // asynchronously making sure everything passes. 227 | // 228 | (function checkConstraints(constraints) { 229 | var constraint = constraints.shift(); 230 | 231 | if (constraint) { 232 | // If the constraint is a function then expect it to have a method signature: 233 | // asyncConstraint(request, body, callback); 234 | constraint(request, body, function (err) { 235 | if (err) return cb(err); 236 | checkConstraints(constraints); 237 | }); 238 | } else { 239 | // If there is no handler for this route, return a new NotImplemented exception 240 | if (! ('handler' in route)) { return cb(new(journey.NotImplemented)("unbound route")) } 241 | 242 | // Otherwise, validate the route method, and return accordingly 243 | if ((route.method.indexOf(request.method) !== -1) || !route.method) { 244 | return cb(null, function (res, params) { 245 | var args = []; 246 | 247 | if (that.options.api === 'http') { args.push(request) } 248 | 249 | args.push(res); 250 | args.push.apply(args, match.slice(1).map(function (m) { 251 | return /^\d+$/.test(m) ? parseInt(m) : m; 252 | })); 253 | args.push(params); 254 | return route.handler.apply(this, args); 255 | }); 256 | } else { 257 | for (var i = 0; i < route.method.length; i++) { 258 | if (allowedMethods.indexOf(route.method[i]) === -1) { 259 | allowedMethods.push(route.method[i]); 260 | } 261 | } 262 | return cb(null, false); 263 | } 264 | } 265 | })(route.constraints.slice(0)); 266 | }, 267 | 268 | resolve: function (request, body, dispatcher) { 269 | var that = this, allowedMethods = []; 270 | // 271 | // Return the first matching route 272 | // 273 | (function find(routes, callback) { 274 | var route = routes.shift(); 275 | if (route) { // While there are still routes to process 276 | that.validateRoute(route, request, body, allowedMethods, function (err, found, method) { 277 | if (err) { dispatcher(err) } 278 | else if (found) { dispatcher(null, found) } 279 | else { find(routes, callback) } 280 | }); 281 | } else if (allowedMethods.length) { 282 | dispatcher(new(journey.MethodNotAllowed)(allowedMethods.join(','))); 283 | } else { 284 | dispatcher(null, false); 285 | } 286 | })(this.routes.slice(0)); 287 | }, 288 | 289 | verifyHeaders: function (request, respond) { 290 | var accepts = request.headers.accept; 291 | accepts = accepts && accepts.split(/[,;] */); 292 | 293 | // Journey being a JSON-only server, we expect the 'Accept' header 294 | // to be set accordingly. 295 | if (this.options.strict) { 296 | if (!accepts || accepts.indexOf("application/json") === -1) { return false } 297 | } else { 298 | if (accepts && accepts.indexOf("application/json") === -1 && 299 | accepts.indexOf("*/*") === -1) { return false } 300 | } 301 | return true; 302 | }, 303 | 304 | // This function glues together the request resolver, with the responder. 305 | // It creates a new `route` context, in which the response will be generated. 306 | dispatch: function (request, body, respond) { 307 | var route, parser, that = this, 308 | params = querystring.parse(request.url.query || null); 309 | 310 | if (! this.verifyHeaders(request)) { 311 | return respond(new(journey.NotAcceptable)(request.headers.accept)); 312 | } 313 | 314 | this.resolve(request, body, function (err, resolved) { 315 | if (err) { 316 | if (err.status) { // If it's an HTTP Error 317 | return respond({ 318 | headers: err.headers || {}, 319 | status: err.status, 320 | body: JSON.stringify(err.body) 321 | }); 322 | } else { 323 | throw err; 324 | } 325 | } 326 | 327 | route = that.draw(request, respond); 328 | 329 | if (resolved) { 330 | if (body) { 331 | parser = /^application\/json/.test( 332 | request.headers["content-type"] 333 | ) ? JSON.parse : querystring.parse; 334 | 335 | try { 336 | body = parser(body); 337 | } catch (e) { 338 | return respond(new(journey.BadRequest)("malformed data")); 339 | } 340 | 341 | // If the body is an Array, we want to return params as an array, 342 | // else, an object. The `mixin` function will preserve the type 343 | // of its first parameter. 344 | params = Array.isArray(body) ? mixin(body, params) : mixin(params, body); 345 | } 346 | return route.go(resolved, params); 347 | } else { 348 | return respond(new(journey.NotFound)("request not found")); 349 | } 350 | }); 351 | }, 352 | 353 | // A constructor of sorts, which returns a 'Routing context', in which the response 354 | // status is evaluated. 355 | draw: function (req, respond) { 356 | var that = this; 357 | 358 | return { 359 | request: req, 360 | respond: respond, 361 | baseResponse: { 362 | status: req.method == 'POST' ? 201 : 200, 363 | body: "", 364 | headers: {"Content-Type" : "application/json;charset=utf-8"} 365 | }, 366 | 367 | // A wrapper around `respond()`, it allows us to respond in a variety of 368 | // ways, such as: `201`, `"Hello World"`, `[201, "Hello", {'Content-Type':'text/html'}]`, etc. 369 | // All parameters are optional. 370 | responder: function (response) { 371 | // If more than one argument was received, treat it as if it was an array. 372 | if (arguments.length > 1) { response = Array.prototype.slice.apply(arguments) } 373 | 374 | this.respond((function (baseResponse) { 375 | switch (typeOf(response)) { 376 | case "object": 377 | return mixin({}, baseResponse, { body: response }); 378 | case "string": 379 | return mixin({}, baseResponse, { body: { journey: response } }); 380 | case "number": 381 | return mixin({}, baseResponse, { status: response }); 382 | case "array": 383 | if (response.length === 3) { 384 | return { 385 | status: response[0], 386 | headers: response[1], 387 | body: response[2] 388 | }; 389 | } else { 390 | throw new(Error)("expected 3 elements in response"); 391 | } 392 | default: 393 | throw new(Error)("wrong response type"); 394 | } 395 | })(this.baseResponse)); 396 | }, 397 | 398 | sendBody: function (body) { 399 | this.respond(mixin({}, this.baseResponse, { body: body })); 400 | }, 401 | 402 | sendJSONP: function (name, result) { 403 | this.respond(mixin({}, this.baseResponse, { 404 | status: 200, 405 | headers: { 406 | "Content-Type": "text/javascript" 407 | }, 408 | body: name + "(" + JSON.stringify(result) + ")" 409 | })); 410 | }, 411 | 412 | sendHeaders: function (status, headers) { 413 | this.respond(mixin({}, this.baseResponse, { status: status, headers: headers })); 414 | }, 415 | 416 | go: function (destination, params) { 417 | this.send = this.responder; 418 | 419 | try { 420 | destination.call(this, this, params || {}); 421 | } catch (err) { 422 | this.respond({ 423 | body: { error: err.message || err, 424 | stack: err.stack && err.stack.split('\n') }, 425 | status: err.status || 500, headers: {} 426 | }); 427 | } 428 | } 429 | } 430 | } 431 | }; 432 | 433 | // 434 | // Utility functions 435 | // 436 | function typeOf(value) { 437 | var s = typeof(value), 438 | types = [Object, String, RegExp, Number, Function, Boolean, Date]; 439 | 440 | if (Array.isArray(value)) { 441 | return 'array'; 442 | } else if (s === 'object' || s === 'function') { 443 | if (value) { 444 | types.forEach(function (t) { 445 | if (value instanceof t) { s = t.name.toLowerCase() } 446 | }); 447 | } else { s = 'null' } 448 | } 449 | return s; 450 | } 451 | function mixin(target) { 452 | var args = Array.prototype.slice.call(arguments, 1); 453 | 454 | args.forEach(function (a) { 455 | var keys = Object.keys(a); 456 | for (var i = 0; i < keys.length; i++) { 457 | target[keys[i]] = a[keys[i]]; 458 | } 459 | }); 460 | return target; 461 | } 462 | 463 | -------------------------------------------------------------------------------- /lib/journey/errors.js: -------------------------------------------------------------------------------- 1 | // 2 | // HTTP Error objectst 3 | // 4 | this.BadRequest = function (msg) { 5 | this.status = 400; 6 | this.headers = {}; 7 | this.body = { error: msg }; 8 | }; 9 | this.NotFound = function (msg) { 10 | this.status = 404; 11 | this.headers = {}; 12 | this.body = { error: msg }; 13 | }; 14 | this.MethodNotAllowed = function (allowed) { 15 | this.status = 405; 16 | this.headers = { allow: allowed }; 17 | this.body = { error: "method not allowed." }; 18 | }; 19 | this.NotAcceptable = function (accept) { 20 | this.status = 406; 21 | this.headers = {}; 22 | this.body = { 23 | error: "cannot generate '" + accept + "' response", 24 | only: "application/json" 25 | }; 26 | }; 27 | this.NotImplemented = function (msg) { 28 | this.status = 501; 29 | this.headers = {}; 30 | this.body = { error: msg }; 31 | }; 32 | this.NotAuthorized = function (msg) { 33 | this.status = 401; 34 | this.headers = {}; 35 | this.body = { error: msg || 'Not Authorized' }; 36 | }; 37 | this.Forbidden = function (msg) { 38 | this.status = 403; 39 | this.headers = {}; 40 | this.body = { error: msg || 'Forbidden' }; 41 | }; -------------------------------------------------------------------------------- /lib/journey/mock-request.js: -------------------------------------------------------------------------------- 1 | var url = require('url'), 2 | sys = require('sys'), 3 | events = require('events'); 4 | 5 | var router = null, 6 | defaultHeaders = { "accept" :"application/json", 7 | "content-type":'application/json; charset=UTF-8' }; 8 | var mock = { 9 | mockRequest: function (method, path, headers) { 10 | var uri = url.parse(path || '/'); 11 | 12 | headers = headers || {}; 13 | 14 | for (var k in defaultHeaders) { headers[k] = headers[k] || defaultHeaders[k] } 15 | 16 | return { 17 | listeners: [], 18 | method: method, 19 | headers: headers, 20 | url: uri, 21 | setBodyEncoding: function (e) { this.bodyEncoding = e }, 22 | addListener: function (event, callback) { 23 | this.listeners.push({ event: event, callback: callback }); 24 | if (event == 'data') { 25 | var body = this.body; 26 | this.body = ''; 27 | callback(body); 28 | } else { callback() } 29 | } 30 | }; 31 | }, 32 | request: function (method, path, headers, body) { 33 | var promise = new(events.EventEmitter); 34 | var result = router.handle(this.mockRequest(method, path, headers), 35 | typeof(body) === 'object' ? JSON.stringify(body) : body); 36 | 37 | result.addListener('success', function (res) { 38 | try { 39 | if (res.body) { res.body = JSON.parse(res.body) } 40 | } catch (_) {} 41 | promise.emit('success', res); 42 | }); 43 | return promise; 44 | } 45 | } 46 | 47 | exports.mock = function (instance) { 48 | router = instance; 49 | return this; 50 | }; 51 | exports.mockRequest = mock.mockRequest; 52 | 53 | // Convenience functions to send mock requests 54 | exports.get = function (p, h) { return mock.request('GET', p, h) } 55 | exports.del = function (p, h) { return mock.request('DELETE', p, h) } 56 | exports.post = function (p, h, b) { return mock.request('POST', p, h, b) } 57 | exports.put = function (p, h, b) { return mock.request('PUT', p, h, b) } 58 | 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "journey", 3 | "description" : "liberal JSON-only HTTP request routing for node", 4 | "url" : "http://cloudhead.io/journey", 5 | "keywords" : ["http", "router", "json"], 6 | "author" : "Alexis Sellier ", 7 | "contributors" : [], 8 | "licenses" : ["Apache 2.0"], 9 | "dependencies" : [], 10 | "devDependencies": { 11 | "vows" : "0.6.x" 12 | }, 13 | "lib" : "lib", 14 | "scripts" : { 15 | "test" : "./node_modules/vows/bin/vows" 16 | }, 17 | "main" : "./lib/journey", 18 | "version" : "0.4.0-pre-3", 19 | "directories" : { "test": "./test" }, 20 | "engines" : { "node": "> 0.2.6" } 21 | } 22 | -------------------------------------------------------------------------------- /test/journey-test.js: -------------------------------------------------------------------------------- 1 | var http = require('http'), 2 | assert = require('assert'), 3 | events = require('events'); 4 | 5 | var vows = require('vows'); 6 | 7 | var journey = require('../lib/journey'); 8 | 9 | var resources = { 10 | "home": { 11 | index: function (res) { 12 | res.send("honey I'm home!"); 13 | }, 14 | room: function (res, params) { 15 | assert.equal(params.candles, "lit"); 16 | assert.equal(params.slippers, "on"); 17 | res.send(params); 18 | } 19 | }, 20 | "picnic": { 21 | fail: function () { 22 | throw "fail!"; 23 | } 24 | }, 25 | "kitchen": {}, 26 | "recipies": {} 27 | }; 28 | 29 | // 30 | // Initialize the router 31 | // 32 | var router = new(journey.Router)({ 33 | api: 'basic', 34 | filter: function (request, body, cb) { 35 | return request.headers.authorized === true 36 | ? cb(null) 37 | : cb(new journey.NotAuthorized('Not Authorized')); 38 | } 39 | }); 40 | 41 | router.map(function (map) { 42 | this.route('GET', 'picnic/fail').bind(resources.picnic.fail); 43 | 44 | //map.root.bind(function (res) { res.send("Welcome to the Root") }); 45 | map.get('/home/room').bind(resources.home.room); 46 | map.get('/undefined').bind(); 47 | map.get('/unbound'); 48 | 49 | map.root.bind(function (res) { return resources.home.index(res) }); 50 | 51 | map.get('/').bind(function (res) { res.send(200) }); 52 | map.get('/twice').bind(function (res) { res.send("twice") }); 53 | map.get(/twice/).bind(function (res) { res.send(302) }); 54 | 55 | map.path('/domain', function () { 56 | this.path(/v1/, function () { 57 | this.root.bind(function (res) { res.send({ root: true, version: 1 }) }); 58 | this.get('').bind(function (res) { res.send({ version: 1 }) }); 59 | this.get('/info').bind(function (res) { 60 | res.send(200, {}, ['info']); 61 | }); 62 | this.path('/empty', function () {}); 63 | }); 64 | }); 65 | 66 | map.route(['GET', 'PUT'], /^(\w+)$/). 67 | bind(function (res, r) { return resources[r].index(res) }); 68 | map.route('GET', /^(\w+)\/([0-9]+)$/). 69 | bind(function (res, r, k) { return resources[r].get(res, k) }); 70 | map.route('PUT', /^(\w+)\/([0-9]+)$/). 71 | bind(function (res, r, k) { return resources[r].update(res, k) }); 72 | map.route('POST', /^tuple$/). 73 | bind(function (res, doc) { return resources.tuple(res, doc) }); 74 | map.route('POST', /^(\w+)$/). 75 | bind(function (res, r, doc) { return resources[r].create(res, doc) }); 76 | map.route('DELETE', /^(\w+)\/([0-9]+)$/). 77 | bind(function (res, r, k) { return resources[r].destroy(res, k) }); 78 | 79 | map.put('home/assert').filter(function (res, req, body) { return body.length === 9; }). 80 | bind(function (res) { res.send(200, {"Content-Type":"text/html"}, "OK"); }); 81 | 82 | // 83 | // Setup a secure portion of the router 84 | // 85 | map.get('this_is/secure').filter(). 86 | bind(function (res) { res.send(200, {"Content-Type":"text/html"}, "OK"); }); 87 | 88 | map.filter(function () { 89 | map.get('this_is/still_secure'). 90 | bind(function (res) { res.send(200, {"Content-Type":"text/html"}, "OK"); }); 91 | }); 92 | 93 | map.path('/scoped_auth', function () { 94 | var asyncAuth = function (request, body, cb) { 95 | setTimeout(function () { 96 | return request.headers.admin === true 97 | ? cb(null) 98 | : cb(new journey.NotAuthorized('Not Authorized')); 99 | }, 200); 100 | } 101 | 102 | this.filter(asyncAuth, function () { 103 | this.get('/secure'). 104 | bind(function (res) { res.send(200, {"Content-Type":"text/html"}, "OK"); }); 105 | }); 106 | }); 107 | 108 | map.path('/forbidden', function() { 109 | forbidden_filter = function (request, body, cb) { 110 | cb(new journey.Forbidden()); 111 | } 112 | 113 | this.filter(forbidden_filter, function () { 114 | this.get('/response').bind(function (res) { res.send(200, {"Content-Type":"text/html"}, "OK"); }); 115 | }); 116 | }); 117 | }); 118 | 119 | var mock = require('../lib/journey/mock-request').mock(router); 120 | 121 | var get = mock.get, 122 | del = mock.del, 123 | post = mock.post, 124 | put = mock.put; 125 | 126 | journey.env = 'test'; 127 | 128 | vows.describe('Journey').addBatch({ 129 | // 130 | // SUCCESSFUL (2xx) 131 | // 132 | "A valid HTTP request": { 133 | topic: function () { return get('/', { accept: "application/json" }) }, 134 | 135 | "returns a 200": function (res) { 136 | assert.equal(res.status, 200); 137 | }, 138 | "returns a body": function (res) { 139 | assert.equal(res.body.journey, "honey I'm home!"); 140 | } 141 | }, 142 | 143 | "A valid request with multiple Accept types": { 144 | topic: function () { return get('/', { accept: "text/plain;q=10, application/json" }) }, 145 | 146 | "returns a 200": function (res) { 147 | assert.equal(res.status, 200); 148 | }, 149 | "returns a body": function (res) { 150 | assert.equal(res.body.journey, "honey I'm home!"); 151 | } 152 | }, 153 | 154 | "A request with uri parameters": { 155 | topic: function () { 156 | // URI parameters get parsed into a javascript object, and are passed to the 157 | // function handler like so: 158 | return get('/home/room?slippers=on&candles=lit'); 159 | }, 160 | 161 | "returns a 200": function (res) { 162 | assert.equal(res.status, 200); 163 | }, 164 | "gets parsed into an object": function (res) { 165 | assert.equal(res.body.slippers, 'on'); 166 | assert.equal(res.body.candles, 'lit'); 167 | } 168 | }, 169 | "A request without uri parameters": { 170 | topic: function () { 171 | var promise = new(events.EventEmitter); 172 | router.routes.unshift({ 173 | pattern: /^noparams$/, 174 | method: 'GET', handler: function (res, params) { 175 | promise.emit('success', params); 176 | }, success: undefined, constraints: [] 177 | }); 178 | router.handle(mock.mockRequest('GET', '/noparams', {})); 179 | return promise; 180 | }, 181 | "should pass an empty params object": function (params) { 182 | assert.isObject(params); 183 | assert.equal(Object.keys(params).length, 0); 184 | } 185 | }, 186 | 187 | "A request with two matching routes": { 188 | topic: function () { 189 | return get('/twice'); 190 | }, 191 | 192 | "returns a 200": function (res) { 193 | assert.equal(res.status, 200); 194 | }, 195 | "returns the first matching route": function (res) { 196 | assert.equal(res.body.journey, 'twice'); 197 | } 198 | }, 199 | 200 | // Here, we're sending a POST request; the input is parsed into an object, and passed 201 | // to the function handler as a parameter. 202 | // We expect Journey to respond with a 201 'Created', if the request was successful. 203 | "A POST request": { 204 | "with a JSON body": { 205 | topic: function () { 206 | resources["kitchen"].create = function (res, input) { 207 | res.send("cooking-time: " + (input['chicken'].length + input['fries'].length) + 'min'); 208 | }; 209 | return post('/kitchen', null, JSON.stringify( 210 | {"chicken":"roasted", "fries":"golden"} 211 | )); 212 | }, 213 | "returns a 201": function (res) { 214 | assert.equal(res.status, 201); 215 | }, 216 | "gets parsed into an object": function (res) { 217 | assert.equal(res.body.journey, 'cooking-time: 13min'); 218 | } 219 | }, 220 | "with a JSON Array body": { 221 | topic: function () { 222 | resources.tuple = function (res, input) { 223 | res.send(201, {}, input.join('-')); 224 | }; 225 | return post('/tuple', null, [1, 2, 3]); 226 | }, 227 | "returns a 201": function (res) { 228 | assert.equal(res.status, 201); 229 | }, 230 | "gets parsed into an object": function (res) { 231 | assert.equal(res.body.trim(), '1-2-3'); 232 | } 233 | }, 234 | "with a query-string body": { 235 | topic: function () { 236 | resources["kitchen"].create = function (res, input) { 237 | res.send("cooking-time: " + 238 | (input['chicken'].length + 239 | input['fries'].length) + 'min'); 240 | }; 241 | return post('/kitchen', {'accept': 'application/json', 242 | 'content-type': 'multipart/form-data'}, 243 | "chicken=roasted&fries=golden"); 244 | }, 245 | "returns a 201": function (res) { 246 | assert.equal(res.status, 201); 247 | }, 248 | "gets parsed into an object": function (res) { 249 | assert.equal(res.body.journey, 'cooking-time: 13min'); 250 | } 251 | } 252 | }, 253 | 254 | // 255 | // CLIENT ERRORS (4xx) 256 | // 257 | 258 | // Journey being a JSON only server, asking for text/html returns 'Not Acceptable' 259 | "A request for text/html": { 260 | topic: function () { 261 | return get('/', { accept: "text/html" }); 262 | }, 263 | "returns a 406": function (res) { assert.equal(res.status, 406) } 264 | }, 265 | // This request doesn't have a matching route, it'll therefore return a 404. 266 | "A request which doesn't match anything": { 267 | topic: function () { 268 | return del('/hello/world'); 269 | }, 270 | "returns a 404": function (res) { 271 | assert.equal(res.status, 404); 272 | } 273 | }, 274 | // This request contains malformed JSON data, the server replies 275 | // with a 400 'Bad Request' 276 | "An invalid request": { 277 | topic: function () { 278 | return post('/malformed', null, "{bad: json}"); 279 | }, 280 | "returns a 400": function (res) { 281 | assert.equal(res.status, 400); 282 | } 283 | }, 284 | // Trying to access an undefined function will result in a 500, 285 | // as long as the uri format is valid 286 | "A route bound to an undefined function": { 287 | topic: function () { 288 | return get('/undefined'); 289 | }, 290 | "returns a 500": function (res) { 291 | assert.equal(res.status, 500); 292 | } 293 | }, 294 | // Trying to access an unbound route, will result in a 501 'Not Implemented' 295 | "An unbound route": { 296 | topic: function () { 297 | return get('/unbound'); 298 | }, 299 | "returns a 501": function (res) { 300 | assert.equal(res.status, 501); 301 | } 302 | }, 303 | // Here, we're trying to use the DELETE method on / 304 | // Of course, we haven't allowed this, so Journey responds with a 305 | // 405 'Method not Allowed', and returns the allowed methods 306 | "A request with an unsupported method": { 307 | topic: function () { 308 | return del('/'); 309 | }, 310 | "returns a 405": function (res) { 311 | assert.equal(res.status, 405); 312 | }, 313 | "sets the 'allowed' header correctly": function (res) { 314 | assert.equal(res.headers.allow, 'GET'); 315 | } 316 | }, 317 | // This request is trying to access a non accessible location on the webserver, so Journey responds 318 | // with a 403 'Forbidden' 319 | "A request to a forbidden location": { 320 | topic: function () { 321 | return get('/forbidden/response'); 322 | }, 323 | "returns a 403": function (res) { 324 | assert.equal(res.status, 403); 325 | } 326 | }, 327 | 328 | // 329 | // SERVER ERRORS (5xx) 330 | // 331 | 332 | // The code in `picnic.fail` throws an exception, so we return a 333 | // 500 'Internal Server Error' 334 | "A request to a controller with an error in it": { 335 | topic: function () { 336 | return get('/picnic/fail'); 337 | }, 338 | "returns a 500": function (res) { 339 | assert.equal(res.status, 500); 340 | } 341 | } 342 | }).addBatch({ 343 | "Scoped routes": { 344 | "A request to a scope with no routes": { 345 | topic: function () { 346 | return get('/domain/v1/empty'); 347 | }, 348 | "returns a 404": function (res) { 349 | assert.equal(res.status, 404); 350 | } 351 | }, 352 | "A request to a scoped route's root": { 353 | topic: function () { 354 | return get('/domain/v1/'); 355 | }, 356 | "returns a 200": function (res) { 357 | assert.equal(res.status, 200); 358 | }, 359 | "calls the correct route": function (res) { 360 | assert.equal(res.body.version, 1); 361 | assert.equal(res.body.root, true); 362 | } 363 | }, 364 | "A request to a scoped route's base route": { 365 | topic: function () { 366 | return get('/domain/v1'); 367 | }, 368 | "returns a 200": function (res) { 369 | assert.equal(res.status, 200); 370 | }, 371 | "calls the correct route": function (res) { 372 | assert.equal(res.body.version, 1); 373 | assert.isUndefined(res.body.root); 374 | } 375 | }, 376 | "A request to a scoped route": { 377 | topic: function () { 378 | return get('/domain/v1/info'); 379 | }, 380 | "returns a 200": function (res) { 381 | assert.equal(res.status, 200); 382 | }, 383 | "returns a body": function (res) { 384 | assert.equal(res.body[0], 'info'); 385 | } 386 | } 387 | } 388 | }).addBatch({ 389 | "Secure routes": { 390 | "A request to a secure route": { 391 | "when authorized": { 392 | topic: function () { 393 | return get('/this_is/secure', { authorized: true }); 394 | }, 395 | "returns a 200": function (res) { 396 | assert.equal(res.status, 200); 397 | }, 398 | "returns a body": function (res) { 399 | assert.equal(res.body, 'OK'); 400 | } 401 | }, 402 | "when unauthorized": { 403 | topic: function () { 404 | return get('/this_is/secure'); 405 | }, 406 | "returns a 401": function (res) { 407 | assert.equal(res.status, 401); 408 | }, 409 | "returns a body with 'Not Authorized'": function (res) { 410 | assert.equal(res.body.error, 'Not Authorized'); 411 | } 412 | } 413 | } 414 | } 415 | }).addBatch({ 416 | "Scoped secure routes using secure()": { 417 | "A request to a secure route": { 418 | "when authorized": { 419 | topic: function () { 420 | return get('/this_is/still_secure', { authorized: true }); 421 | }, 422 | "returns a 200": function (res) { 423 | assert.equal(res.status, 200); 424 | }, 425 | "returns a body": function (res) { 426 | assert.equal(res.body, 'OK'); 427 | } 428 | }, 429 | "when unauthorized": { 430 | topic: function () { 431 | return get('/this_is/still_secure'); 432 | }, 433 | "returns a 401": function (res) { 434 | assert.equal(res.status, 401); 435 | }, 436 | "returns a body with 'Not Authorized'": function (res) { 437 | assert.equal(res.body.error, 'Not Authorized'); 438 | } 439 | } 440 | } 441 | } 442 | }).addBatch({ 443 | "Scoped secure routes using path()": { 444 | "A request to a secure route": { 445 | "when authorized": { 446 | topic: function () { 447 | return get('/scoped_auth/secure', { admin: true }); 448 | }, 449 | "returns a 200": function (res) { 450 | assert.equal(res.status, 200); 451 | }, 452 | "returns a body": function (res) { 453 | assert.equal(res.body, 'OK'); 454 | } 455 | }, 456 | "when unauthorized": { 457 | topic: function () { 458 | return get('/scoped_auth/secure'); 459 | }, 460 | "returns a 401": function (res) { 461 | assert.equal(res.status, 401); 462 | }, 463 | "returns a body with 'Not Authorized'": function (res) { 464 | assert.equal(res.body.error, 'Not Authorized'); 465 | } 466 | } 467 | } 468 | } 469 | }).export(module); 470 | 471 | 472 | --------------------------------------------------------------------------------