Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Initial commit

  • Loading branch information...
commit 3246a63f1988ea08d9767b9c2c0d07d778fcd0fc 0 parents
@ckknight authored
15 .gitignore
@@ -0,0 +1,15 @@
+.DS_Store
+pids
+logs
+results
+*.pid
+*.gz
+*.log
+lib-cov
+test/fixtures/foo.bar.baz.css
+test/fixtures/style.css
+test/fixtures/script.js
+test.js
+docs/*.html
+docs/*.json
+
3  .gitmodules
@@ -0,0 +1,3 @@
+[submodule "support/expresso"]
+ path = support/expresso
+ url = git://github.com/visionmedia/expresso.git
22 LICENSE
@@ -0,0 +1,22 @@
+(The MIT License)
+
+Copyright (c) 2011 Cameron Kenneth Knight
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
13 Makefile
@@ -0,0 +1,13 @@
+TEST = support/expresso/bin/expresso
+TESTS ?= test/*.test.js
+SRC = $(shell find lib -type f -name "*.js")
+
+test:
+ @NODE_ENV=test ./$(TEST) \
+ -I lib \
+ $(TEST_FLAGS) $(TESTS)
+
+test-cov:
+ @$(MAKE) test TEST_FLAGS="--cov"
+
+.PHONY: test test-cov
531 Readme.md
@@ -0,0 +1,531 @@
+# Escort
+
+ Escort is a middleware for [Connect](https://github.com/senchalabs/connect) which provides routing and url generation
+ capabilities.
+
+## What makes Escort different from other routing libraries
+
+ * URL generation
+
+ Because routing from URLs to callbacks is only half of the problem, you also need to generate URLs.
+
+ Hardcoding the URLs (in your views, likely) can be a pain to update if ever your URLs. Also, if you have dynamic
+ URLs, knowing the correct and safe syntax to convert the Javascript values to a safe URL can be tricky.
+
+ * Converters
+
+ Converters provide a way to safely and consistently handle dynamic route parameter consumption and generation.
+
+ Rather than defining regular expression yourself or manually converting URL strings to other JavaScript
+ objects, the converter takes care of all of that for you.
+
+ Also, it knows how to convert the JavaScript objects provided back into safe URL components with ease.
+
+ * Submounting
+
+ Logically divide segments of your app into different route submounts.
+
+ * Performance concerns
+
+ Routing tends to be hit every request, since any caching that occurs typically starts inside one's route callback.
+ Thus, making route consumption as efficient as possible is a key priority.
+
+ Routes are separated into static and dynamic routes. Static routes are implicitly efficient,
+ since it is just a quick Object key lookup (which V8) has very optimized.
+
+ Dynamic routes are trickier and Escort has a two-phase approach for consuming them.
+
+ First, every time a route is properly calculated, its callback and generated parameters are stored in an in-memory
+ cache keyed on its URL. That means that if someone were to visit any particular URL more than once (before the
+ cache clears), the routing system only has to do the calculation once rather than each time.
+
+ Secondly, for the actual calculation of the route, it separates out all routes based on their prefixes. Since all
+ prefixes are guaranteed to be static (even if it is just `/`), doing an efficient non-RegExp check for whether the
+ incoming URL matches the prefix will cut down lookups by a significant amount in the general case (varying from app
+ to app). Not having to traverse all the `/pages/` routes for something that we know is under `/forums/` removes a
+ significant amount of work.
+
+## Hello, world!
+
+ This will assume you already have [Connect](https://github.com/senchalabs/connect) installed.
+
+ This should be the absolutely simplest program, a simple app that listens on `localhost:3000` and says
+ `"Hello, world!"` when you visit its root (`/`).
+
+ var connect = require('connect'),
+ escort = require('escort');
+
+ connect(
+ escort(function(routes) {
+ routes.get("/", function(req, res) {
+ res.end("Hello, world!");
+ });
+ })
+ ).listen(3000);
+
+ It will only respond when a `GET` is sent to `/`. If you were to send a `POST`, it would not respond be providing
+ `"Hello, world!"`. Also, if you were to visit any other URL, it wouldn't respond either, since those have not been
+ bound yet.
+
+## Dynamic parameters
+
+ Not every route can or should be statically-defined. After all, what's the point of writing a fluid, dynamic
+ application if we were limited to that?
+
+ Thus, we need to be able to have dynamic parameters in our routes. In Escort, they are specified through the syntax
+ `{param}`.
+
+ connect(
+ escort(function(routes) {
+ routes.get("/", function(req, res) {
+ res.end("Hello, world!");
+ });
+ routes.get("/{name}", function(req, res, params) {
+ res.end("Hello, " + params.name + "!");
+ });
+ })
+ ).listen(3000);
+
+ Visiting `/` still sends you `"Hello, world!"`, but visiting `/ckknight` will now send you `"Hello, ckknight!"`
+ instead of just giving a 404.
+
+## URL generation
+
+ Just as one can visit a URL and have it properly route to a callback to run which powers our apps, often we want the
+ reverse: being able to generate URLs.
+
+ connect(
+ escort(function(routes) {
+ var url = routes.url;
+
+ routes.get("/", function(req, res) {
+ res.end("You are visiting " + url.root());
+ })
+ routes.get("/about", function(req, res) {
+ res.end("You are visiting " + url.about());
+ })
+ routes.get("pageIndex", "/pages", function(req, res) {
+ res.end("You are visiting " + url.pageIndex());
+ })
+ routes.get("page", "/pages/{pageSlug}", function(req, res, params) {
+ res.end("You are visiting " + url.page({ pageSlug: params.pageSlug }));
+ // alternatively, url.page(params.pageSlug)
+ })
+ })
+ ).listen(3000);
+
+ In the first two routes, the `routeName` is generated automatically for each. The system tries to guess, but it's not
+ perfect in every case. In the cases where you either don't like what it guesses or if it is unable to, you can specify
+ your own name for the route, which is done in the latter two cases.
+
+ This `routeName` is then used to generate a function on the `url` object. For static routes, to parameters are
+ required or expected, but for dynamic routes, either an Object must be passed in or the in-order parameters expected.
+
+## Multiple methods
+
+ Unlike some other routing libraries, if you wish to bind a URL to multiple methods, it must all be done so at the same
+ time using the bind function. This is done because every unique route has a unique name, regardless of the methods
+ that it accepts.
+
+ connect(
+ escort(function(routes) {
+ routes.bind("users", "/users", {
+ get: function(req, res) {
+ res.end("Show a list of users");
+ },
+ post: function(req, res) {
+ res.end("Successfully created a new user!");
+ }
+ });
+
+ routes.bind("user", "/users/{username}", {
+ get: function(req, res, params) {
+ res.end("Found user " + params.username);
+ },
+ put: function(req, res, params) {
+ res.end("Updated user " + params.username);
+ }
+ })
+ })
+ ).listen(3000);
+
+ Each route in this case has two methods it listens to. `/users` matches `GET` and `POST`, which RESTfully lists the
+ users and creates a new user, respectively. `/users/{username}` matches `GET` and `PUT`, which RESTfully details the
+ user and updates the existing user, respectively.
+
+### Calling other methods
+
+ One particularly useful usage pattern that comes particularly in handy when making an HTML app (as opposed to a
+ JSON-driven app) is having the `GET` and `POST` (or `PUT`) actions run through the same code in the following manner:
+
+ connect(
+ escort(function(routes) {
+ routes.bind("createPost", "/posts/create", {
+ get: function(req, res) {
+ this.post(req, res);
+ },
+ post: function(req, res) {
+ var form = parseForm(req);
+ if (!form.isValid()) {
+ res.end("Render the form here, show validation errors.");
+ return;
+ }
+ // create the post
+ res.end("Successfully created a new post!");
+ }
+ });
+ })
+ ).listen(3000);
+
+ What this allows is for your `GET` request to render a form (without validation errors, since it's a blank slate), and
+ allow your `POST` request to validate the form, if valid, create, if invalid, re-render the form (with helpful
+ validation errors).
+
+ _Please note that this leaves a lot of code up to the user, such as the form validation and the actual HTML
+ rendering_.
+
+### Multiple methods with the same callback
+
+ If you'd rather not just call `this.post`, you can easily specify that multiple methods are serviced by a single
+ callback.
+
+ connect(
+ escort(function(routes) {
+ routes.bind("createPost", "/posts/create", {
+ "get,post": function(req, res) {
+ res.end(req.method + " /posts/create");
+ }
+ });
+ })
+ ).listen(3000);
+
+ This will bind `/posts/create` to listen on both `GET` and `POST`.
+
+## Converters
+
+ For dynamic routes, different *converters* may be used for each dynamic parameter, each with their own capabilities
+ and options.
+
+ The default is the `string` converter, which is used when one is not specified.
+
+ * `string` - Parses any string that does not have a slash (`/`) in it. Can specify `minLength` and `maxLength`.
+ * `int` - Parses a numeric string. Converts to and from *Number*. Can specify `min`, `max`, and `fixedDigits`.
+ * `path` - Parses any string, even those with slashes (`/`) in them. Useful for wikis.
+ * `any` - Parses one of a specified set of strings.
+
+ ----
+ connect(
+ escort(function(routes) {
+ routes.get("post", "/posts/{postSlug:string}", function(req, res, params) {
+ // exact same as "/posts/{postSlug}"
+ res.end("GET /posts/" + params.postSlug);
+ });
+
+ routes.get("user", "/users/{username:string({minLength: 3, maxLength: 8})}", function(req, res, params) {
+ res.end("GET /users/" + params.username);
+ });
+
+ routes.get("thread", "/thread/{threadID:int({min: 1})}", function(req, res, params) {
+ // params.threadID is a Number, not a String
+ res.end("GET /thread/" + params.threadID);
+ });
+
+ routes.get("archiveYear", "/archive/{year:int({fixedDigits: 4})}", function(req, res, params) {
+ res.end("Archive for year: " + params.year);
+ });
+
+ routes.get("wikiPage", "/wiki/{page:path}", function(req, res, params) {
+ res.end("GET /page/" + params.page);
+ });
+
+ routes.get("info", "/{page:any('about', 'contact')}", function(req, res, params) {
+ res.end("GET /" + params.info);
+ });
+ })
+ ).listen(3000);
+
+ `/posts/some-post` does as expected, since `string` is the default converter anyway. `/posts/some-post/deep` will not,
+ as the slash (`/`) in it makes the route not recognize it properly.
+
+ `/users/hi` will return a 404, won't ever even hit the route, since `hi` is too short. `/users/toolongofaname` will
+ also return a 404, since `toolongofaname` is too long. `/users/ckknight` will work perfectly fine.
+
+ `/thread/some-thread` will return a 404, since `some-thread` isn't a number. `/thread/0` also returns a 404, since 0
+ is less than the specified minimum of 1. `/thread/1` works fine, as does `/thread/1000000000`.
+
+ `/archive/123` will return a 404, since `123` isn't 4 digits. Contrarily, `/archive/0123` will work fine, as well as
+ the expected `/archive/1960`.
+
+ `/wiki/some-page` will work fine, as well as `/wiki/some-page/discussion`.
+
+ Both `/about` and `/contact` will match the *info* route, but no others will.
+
+### Custom converters
+
+ If you are so inclined (95% of apps out there probably aren't) to define your own converter, it's relatively easy.
+
+ You merely have to define a function which returns an object that has the following interface:
+
+ {
+ weight: 100, // optional, if not provided, defaults to 100 regardless
+ regex: "[^/]+", // must be a string, not a RegExp
+ fromUrl: function (value) {
+ // return any _immutable_ value, can be any Javascript element, even Object, Array, or Function (as long as
+ // they're frozen).
+ return value;
+ },
+ toUrl: function (value) {
+ // return a String
+ return encodeURIComponent(value);
+ }
+ }
+
+ If you wish to, you can inherit from `escort.BaseConverter`, but it's not necessary.
+
+ Here is an example converter:
+
+ var BooleanConverter = function (trueName, falseName) {
+ if (!trueName) {
+ trueName = "yes";
+ }
+ if (!falseName) {
+ falseName = "no";
+ }
+ return {
+ regex: "(?:" + trueName + "|" + falseName + ")",
+ fromUrl: function (value) {
+ return value === trueName;
+ },
+ toUrl: function (value) {
+ return value ? trueName : falseName;
+ }
+ };
+ };
+
+ And here it is in action:
+
+ var url;
+ connect(
+ escort({ converters: { bool: BooleanConverter } }, function(routes) {
+ url = routes.url;
+
+ routes.get("check", "/check/{careful}", function(req, res, params) {
+ res.end(params.careful
+ ? "Carefully checking"
+ : "Playing solitaire, not actually checking");
+ });
+
+ routes.get("feed", "/feed/{goodFood:bool('good', 'bad')}", function(req, res, params) {
+ if (params.goodFood) {
+ res.end("Yay, good food!");
+ } else {
+ res.end("Gruel again :(");
+ }
+ })
+ })
+ ).listen(3000);
+
+ url.check(true) === "/check/yes";
+ url.check(false) === "/check/no";
+ url.feed(true) === "/check/good";
+ url.feed(false) === "/check/bad";
+
+ So the param you get back is a **Boolean**, as the converter's `fromUrl` specifies. The `toUrl` function also properly
+ makes the url generation work and provide the reverse result.
+
+ If you want to have the `default` converter not be `escort.StringConverter`, you can provide the `default` key with
+ your own.
+
+## Submounting
+ Often times, your app may have many parts to it that belong in their own route sections. Submounting is the perfect
+ answer for this (assuming you don't want to have multiple apps for each section). Submounting can also be used for
+ more rigorously defining the tree structure of your app.
+
+ There is no performance downside to using submounting, it is merely a configuration nicety.
+
+ connect(
+ escort(function(routes) {
+ url = routes.url;
+
+ routes.namespace("/pages", function(pages) {
+ forums.get("", function(req, res) {
+ res.end("Page listing here");
+ });
+
+ pages.namespace("/{pageSlug}", function(page) {
+ page.get("page", "", function(req, res, params) {
+ res.end("Page details for " + params.pageSlug);
+ });
+
+ page.bind("pageEdit", "/edit", {
+ get: function(req, res, params) {
+ res.end("Editing page " + params.pageSlug);
+ },
+ put: function(req, res, params) {
+ res.end("Updating page " + params.pageSlug);
+ }
+ });
+ });
+ });
+
+ url.pages() === "/pages";
+ url.page("thing") === "/pages/thing";
+ url.pageEdit("thing") === "/pages/thing/edit";
+ })
+ ).listen(3000);
+
+## Optional route segments
+
+ In some cases, you may want to have optional segments as part of your routes, which is easily solvable through one of
+ two ways:
+
+ You can provide two distinct routes to bind to:
+
+ connect(
+ escort(function(routes) {
+ routes.get(["/data", "/data.{format}"], function(req, res, params) {
+ var format = params.format || "html";
+
+ switch (format) {
+ case "html":
+ res.end("<p>Hey there</p>");
+ break;
+ case "json":
+ res.end(JSON.stingify("Hey there"));
+ break;
+ default:
+ res.writeHead(404);
+ res.end();
+ break;
+ }
+ })
+ })
+ ).listen(3000);
+
+ Or you can use the `[]` syntax to denote optionality.
+
+ connect(
+ escort(function(routes) {
+ routes.get("/data[.{format}]", function(req, res, params) {
+ var format = params.format || "html";
+
+ switch (format) {
+ case "html":
+ res.end("<p>Hey there</p>");
+ break;
+ case "json":
+ res.end(JSON.stingify("Hey there"));
+ break;
+ default:
+ res.writeHead(404);
+ res.end();
+ break;
+ }
+ })
+ })
+ ).listen(3000);
+
+ _Note: a better way of determining what format someone wants is to check their Accept header, so I recommend you do
+ that for your non-example apps._
+
+## Not Found (404).
+
+ By default, the `notFound` handler passes to the next middleware, which has an opportunity to handle it.
+
+ If instead of having another middleware handle, you want to handle it yourself, it is quite simple:
+
+ connect(
+ escort(function(routes) {
+ url = routes.url;
+
+ routes.get("/", function(req, res) {
+ res.end("Welcome!");
+ });
+
+ routes.notFound(function(req, res, next) {
+ res.writeHead(404);
+ res.end("Sorry, that cannot be found.");
+ });
+ })
+ ).listen(3000);
+
+ Now visiting `/` will properly tell you `"Welcome!"`, but visiting any other URL will give you your custom 404.
+
+## Method Not Allowed (405).
+
+ By default, the `methodNotAllowed` handler returns a 405 to the user with no body, which would not be appropriate in
+ an HTML application. It might be in a JSON-based app, but that's up to you.
+
+ connect(
+ escort(function(routes) {
+ url = routes.url;
+
+ routes.get("/", function(req, res) {
+ res.end("Welcome!");
+ });
+
+ routes.methodNotAllowed(function(req, res, next) {
+ res.writeHead(405);
+ res.end("The method " + req.method + " is not allowed on " + req.url + ".");
+ });
+ })
+ ).listen(3000);
+
+ Now issuing a `GET` to `/` will properly tell you `"Welcome!"`, but issuing a `POST` or any undefined method will
+ go through the custom handler.
+
+## Code structuring
+
+ For small apps, it's easy to put all your routes inline, but once things get big enough, that can be very troublesome
+ maintenance-wise.
+
+ Here is one way for how you can structure your app and retain your sanity.
+
+ In `main.js`:
+ var connect = require('connect'),
+ escort = require('escort');
+
+ connect(
+ escort(function(routes) {
+ require('./routes/home')(routes);
+
+ routes.submount("/forums", function(forums) {
+ require('./routes/forums')(forums);
+ });
+
+ routes.submount("/pages", function(pages) {
+ require('./routes/forums')(pages);
+ });
+
+ routes.submount("/users", function(users) {
+ require('./routes/users')(users);
+ });
+ })
+ ).listen(3000);
+
+ In `routes/users.js`:
+ module.exports = function(routes) {
+ routes.get("users", "/", function(req, res) {
+ res.end("User listing");
+ });
+
+ routes.get("user", "/{username}", function(req, res, params) {
+ res.end("User details: " + params.username);
+ });
+ };
+
+ Of course, you're free to structure your app however you like.
+
+## Running Tests
+
+first:
+
+ $ git submodule update --init
+
+then:
+
+ $ make test
+
+## License
+
+MIT licensed. See [LICENSE](https://github.com/ckknight/escort/blob/master/LICENSE) for more details.
1  index.js
@@ -0,0 +1 @@
+module.exports = require('./lib/escort');
1,128 lib/escort.js
@@ -0,0 +1,1128 @@
+/*!
+ * Escort
+ * Copyright(c) 2011 Cameron Kenneth Knight
+ * MIT Licensed
+ */
+
+/*jshint node: true evil: true*/
+
+var calculateConverterArgs, generateUrlFunction;
+
+(function () {
+ "use strict";
+
+ /**
+ * the regex used to parse routes.
+ * 1: the name of the parameter.
+ * 2: the converter name.
+ * 3: options provided to the converter.
+ * "/hey" - no match
+ * "/{name}" - ["name", null, null]
+ * "/{name:string}" - ["name", "string", null]
+ * "/{name:string({minLength:1})}" - ["name", "string", "{minLength:1}"]
+ *
+ * @api private
+ */
+ var ROUTE_REGEX = /\{([a-zA-Z][a-zA-Z0-9_]*)(?:\:([a-zA-Z_][a-zA-Z0-9_]*)(?:\((.*?)\))?)?\}/g;
+
+ /**
+ * the amount of time in milliseconds between clears of the dynamic route's cache.
+ * @api private
+ */
+ var DYNAMIC_ROUTE_CACHE_CLEAR_TIME = 60000;
+
+ /**
+ * the array of (lowercase) known HTTP and WebDAV methods that can be provided.
+ * @api private
+ */
+ var ACCEPTABLE_METHODS = [
+ "get",
+ "post",
+ "put",
+ "delete",
+ "connect",
+ "options",
+ "trace",
+ "copy",
+ "lock",
+ "mkcol",
+ "move",
+ "propfind",
+ "proppatch",
+ "unlock",
+ "report",
+ "mkactivity",
+ "checkout",
+ "merge"
+ ];
+
+ /**
+ * a set of (lowercase) known HTTP and WebDAV methods that can be provided.
+ * @api private
+ */
+ var ACCEPTABLE_METHOD_SET = {};
+ ACCEPTABLE_METHODS.forEach(function (method) {
+ ACCEPTABLE_METHOD_SET[method] = true;
+ });
+
+ var freeze = Object.freeze;
+ /**
+ * a simple wrapper around Object.create to easily make new objects without providing property descriptors.
+ *
+ * @param {Object} prototype The prototype to inherit from
+ * @param {Object} properties A map of properties
+ * @api private
+ */
+ var spawn = function (prototype, properties) {
+ var object = Object.create(prototype);
+ Object.keys(properties).forEach(function (key) {
+ object[key] = properties[key];
+ });
+ return object;
+ };
+
+ /**
+ * Compare two values and give their relative position as either -1, 0, or 1. Used in Array.prototype.sort
+ *
+ * @param {any} alpha Any value that can be compared to bravo
+ * @param {any} bravo Any value that can be compared to alpha
+ * @return {Number} -1, 0, or 1 based on the comparison between alpha and bravo
+ * @api private
+ *
+ * @example cmp(1, 2) === -1
+ * @example cmp(2, 1) === 1
+ * @example cmp("hello", "hello") === 0
+ */
+ var cmp = function (alpha, bravo) {
+ if (alpha < bravo) {
+ return -1;
+ } else if (alpha > bravo) {
+ return 1;
+ } else {
+ return 0;
+ }
+ };
+
+ /**
+ * Create a handler for the HTTP OPTIONS method based on the descriptor (which is a method name to callback map).
+ * This is only used of options is not provided by the user.
+ *
+ * @param {Object} descriptor A map of methods to their associated callbacks
+ * @return {Function} A function which will return a proper OPTIONS response
+ * @api private
+ */
+ var createOptionsHandler = function (descriptor) {
+ var methods = [];
+ ACCEPTABLE_METHODS.forEach(function (method) {
+ if (descriptor[method]) {
+ methods.push(method.toUpperCase());
+ }
+ });
+ methods = methods.join(",");
+ return function (request, response) {
+ response.writeHead(200, {
+ "Content-Length": methods.length,
+ "Allow": methods
+ });
+ response.end(methods);
+ };
+ };
+
+ /**
+ * Verify the validity of the provided descriptor and return a sanitized version.
+ * This will add an options handler if none is provided.
+ *
+ * @param {Object} descriptor A map of methods to their associated callbacks
+ * @return {Object} A map of methods to their associated callbacks
+ * @api private
+ *
+ * @example descriptor = sanitizeDescriptor(descriptor);
+ */
+ var sanitizeDescriptor = function (descriptor) {
+ var result = {};
+ for (var key in descriptor) {
+ if (Object.prototype.hasOwnProperty.call(descriptor, key)) {
+ var keys = key.split(',');
+ for (var i = 0, len = keys.length; i < len; i += 1) {
+ var method = keys[i];
+ if (!ACCEPTABLE_METHOD_SET[method]) {
+ throw new Error("Unknown descriptor method " + method);
+ }
+ if (Object.prototype.hasOwnProperty.call(result, method)) {
+ throw new Error("Already specified descriptor method " + method);
+ }
+ result[method] = descriptor[key];
+ }
+ }
+ }
+ if (!result.options) {
+ result.options = createOptionsHandler(result);
+ }
+ return freeze(result);
+ };
+
+ /**
+ * a regex which contains the escape codes used in JavaScript's RegExp.
+ * @api private
+ */
+ var REGEXP_ESCAPE_REGEX = /([\-\[\]\{\}\(\)\*\+\?\.\,\\\^\$\|\#])/g;
+ /**
+ * Escape a string's RegExp escape codes with backslashes.
+ *
+ * @param {String} text The text to escape
+ * @return {String} The escaped text
+ * @api private
+ *
+ * @example regexpEscape("Hello") == "Hello"
+ * @example regexpEscape("thing.txt") == "thing\\.txt"
+ * @example regexpEscape("{value}") == "\\{value\\}"
+ */
+ var regexpEscape = function (text) {
+ return String(text).replace(REGEXP_ESCAPE_REGEX, "\\$1");
+ };
+
+ /**
+ * a regex that will be used to remove slashes (/) at the front of a string.
+ * @api private
+ */
+ var SLASH_PREFIX_REGEX = /^\/+/g;
+ /**
+ * a regex that will be used to remove unexpected characters from a route when trying to guess the route name
+ * @api private
+ */
+ var GUESS_ROUTE_NAME_UNEXPECTED_CHARACTER_REGEX = /[^a-zA-Z0-9\-_\/]/g;
+ /**
+ * a regex that recognizes dashes (-), underscores (_), and slashes (/), and the character afterwards in order to remove the punctuation and turn the character into uppercase.
+ * @api private
+ */
+ var PUNCTUATION_LETTER_REGEX = /[\-_\/](.)/g;
+
+ /**
+ * Guess a route name for the given route. This will strip out any characters and give a best-guess.
+ *
+ * @param {String} route The provided route that it uses to determine a good name for it.
+ * @return {String} A name for the route.
+ * @throws {Error} When unable to guess a route name.
+ * @api private
+ *
+ * @example guessRouteName("/") === "root"
+ * @example guessRouteName("/pages") === "pages"
+ * @example guessRouteName("/pages/view") === "pagesView"
+ * @example guessRouteName("/pages/{name}") // Error
+ */
+ var guessRouteName = function (route) {
+ if (route === "/") {
+ return "root";
+ }
+
+ if (route.indexOf("{") >= 0) {
+ throw new Error("Unable to guess route name for route " + route);
+ }
+ var result = route
+ .replace(SLASH_PREFIX_REGEX, "")
+ .replace(GUESS_ROUTE_NAME_UNEXPECTED_CHARACTER_REGEX, "")
+ .replace(PUNCTUATION_LETTER_REGEX, function (full, character) {
+ return character.toUpperCase();
+ });
+ if (!result) {
+ throw new Error("Unable to guess route name for route " + route);
+ }
+ return result;
+ };
+
+ /**
+ * Run forEach on the provided value or if it lacks that, run the callback immediately on the value itself.
+ *
+ * @param {any} value Either an Array or any other value
+ * @param {Function} callback A function to pass into forEach or call immediately
+ * @api private
+ *
+ * @example forEachOrSingular(["alpha", "bravo"], function(value) { value == "alpha" || value == "bravo"; } );
+ * @example forEachOrSingular("charlie", function(value) { value == "charlie"; } );
+ */
+ var forEachOrSingular = function (value, callback) {
+ if (value.forEach) {
+ value.forEach(callback);
+ } else {
+ callback(value, 0);
+ }
+ };
+
+ /**
+ * Determine whether text starts with value.
+ *
+ * @param {String} text the text to check if value is the beginning part of the string.
+ * @param {String} value the potential value of the start of the string.
+ * @return {Boolean}
+ * @api private
+ *
+ * @example startsWith("hey there", "hey") === true
+ * @example startsWith("hey there", "hello") === true
+ */
+ var startsWith = function (text, value) {
+ var valueLength = value.length;
+ if (text.length < valueLength) {
+ return false;
+ }
+
+ return text.substring(0, valueLength) === value;
+ };
+
+ /**
+ * Add a char to certain elements of an array.
+ *
+ * @param {Array} array An array of strings
+ * @param {String} c A string to add to each string
+ * @param {Number} depth The current binary tree depth to add to.
+ * @api private
+ */
+ var addCharToArray = function (array, c, depth) {
+ var start = array.length - (array.length / Math.pow(2, depth));
+ for (var i = start, len = array.length; i < len; i += 1) {
+ array[i] += c;
+ }
+ };
+
+ /**
+ * Return an array with only distinct elements.
+ * This assumes that the elements of an array have their uniqueness determined by their String value.
+ *
+ * @param {Array} array The array to iterate over.
+ * @return {Array} An array with distinct elements.
+ * @api private
+ *
+ * @example distinct(["a", "b", "c", "a", "a", "c"]) => ["a", "b", "c"]
+ */
+ var distinct = function (array) {
+ var set = {};
+
+ var result = [];
+ for (var i = 0, len = array.length; i < len; i += 1) {
+ var item = array[i];
+
+ if (!Object.prototype.hasOwnProperty.call(set, item)) {
+ set[item] = true;
+ result.push(item);
+ }
+ }
+ return result;
+ };
+
+ /**
+ * Parse all potential optional routes out of the provided String or Array.
+ *
+ * @param {Array} routes an array of routes, or a String which is a single route.
+ * @return {Array} the parsed-out array of optional routes.
+ * @api private
+ *
+ * @example parseOptionalRoutes("/same") => ["/same"]
+ * @example parseOptionalRoutes("/[optional]") => ["/", "/optional"]
+ * @example parseOptionalRoutes("/data[.{format}]") => ["/data", "/data.{format}"]
+ * @example parseOptionalRoutes("/multiple[/optional][/parameters]") => ["/multiple", "/multiple/optional", "/multiple/parameters", "/multiple/optional/parameters"]
+ * @example parseOptionalRoutes("/deep[/optional[/parameters]]") => ["/deep", "/deep/optional", "/deep/optional/parameters"]
+ * @example parseOptionalRoutes(["/data[.{format}]", "/data/page/{num:int}[.{format}]"]) => ["/data", "/data.{format}", "/data/page/{num:int}", "/data/page/{num:int}.{format}"]
+ * @example parseOptionalRoutes("/{name:custom(['a', 'b', 'c'])}") => ["/{name:custom(['a', 'b', 'c'])}"]
+ */
+ var parseOptionalRoutes = function (routes) {
+ if (!Array.isArray(routes)) {
+ routes = [routes];
+ }
+
+ var result = [];
+
+ routes.forEach(function (route) {
+ if (route.indexOf('[') === -1) {
+ result.push(route);
+ return;
+ }
+
+ var immediateResult = [''];
+
+ var depth = 0;
+ var inLiteral = 0;
+
+ for (var i = 0, len = route.length; i < len; i += 1) {
+ var c = route.charAt(i);
+ if (!inLiteral) {
+ if (c === '[') {
+ depth += 1;
+ for (var j = 0, lenJ = immediateResult.length; j < lenJ; j += 1) {
+ immediateResult.push(immediateResult[j]);
+ }
+ } else if (c === ']') {
+ depth -= 1;
+ } else {
+ if (c === '{') {
+ inLiteral += 1;
+ } else if (c === '}') {
+ throw new Error("Found unexpected } in route: " + route);
+ }
+ addCharToArray(immediateResult, c, depth);
+ }
+ } else {
+ if (c === '{') {
+ inLiteral += 1;
+ } else if (c === '}') {
+ inLiteral -= 1;
+ }
+ addCharToArray(immediateResult, c, depth);
+ }
+ }
+
+ for (i = 0, len = immediateResult.length; i < len; i += 1) {
+ result.push(immediateResult[i]);
+ }
+ });
+
+ return distinct(result);
+ };
+
+ var escort;
+
+ /**
+ * Make the submount function for the escort Object.
+ *
+ * @param {Function} bind the bind function for the current escort Object.
+ * @param {String} prefix the route prefix for the submount.
+ * @api private
+ *
+ * @example makeSubmountFunction("/forums")
+ */
+ var makeSubmountFunction = function (bind, prefix) {
+ return function (path, callback) {
+ var prefixedPath = prefix + path;
+ if (prefixedPath.charAt(0) !== "/") {
+ throw new Error("Routes must start with '/', '" + prefixedPath + "' does not.");
+ }
+ var innerMethods = spawn(escort.prototype, {
+ bind: function (routeName, route, descriptor) {
+ if (arguments.length === 2) {
+ descriptor = route;
+ route = routeName;
+ if (Array.isArray(route)) {
+ routeName = guessRouteName(prefixedPath + route[0]);
+ } else {
+ routeName = guessRouteName(prefixedPath + route);
+ }
+ }
+ return bind(routeName, route, descriptor, prefixedPath);
+ },
+ submount: makeSubmountFunction(bind, prefixedPath)
+ });
+
+ callback(innerMethods);
+ };
+ };
+
+ /**
+ * Create the Escort middleware based on the provided options and callback.
+ *
+ * @param {Object} options An options object, optional
+ * @param {Function} fn A callback that is immediately called where the routing configuration is set up.
+ * @api public
+ * @example escort(function(routes) {
+ * routes.get("/", function(req, res) {
+ * res.end("Hello there!");
+ * });
+ * });
+ */
+ escort = function (options, fn) {
+ if (!fn) {
+ fn = options;
+ options = null;
+ }
+ if (!fn) {
+ throw new Error("Callback function is expected");
+ }
+ if (!options) {
+ options = {};
+ }
+
+ /**
+ * A map of static URLs to descriptor
+ * @api private
+ */
+ var staticRoutes = {};
+ /**
+ * A list of dynamic routes
+ * @api private
+ */
+ var dynamicRoutes = [];
+ /**
+ * The url generator object for the current configuration
+ * @api private
+ */
+ var urlGenerators = {};
+ /**
+ * The current handler used when a route is not found
+ * @api private
+ */
+ var notFoundHandler = function (request, response, next) {
+ next();
+ };
+ /**
+ * The current handler used when a route is found but the method accessed is not available
+ * @api private
+ */
+ var methodNotAllowedHandler = function (request, response, next) {
+ response.writeHead(405);
+ response.end();
+ };
+
+ /**
+ * A map of name to converter factory as specified by the options.
+ * @api private
+ */
+ var customConverters = options.converters || {};
+
+ (function () {
+ /**
+ * Bind the provided route to the descriptor specified.
+ *
+ * @param {String} routeName The name of the route. Should be a JavaScript identifier. Optional.
+ * @param {String} route The URL for the route.
+ * @param {Object} descriptor A map of method to callback
+ * @param {String} routePrefix Internally used, provides a prefix for the current route.
+ *
+ * @throws Error the routeName is already specified
+ * @throws Error the route is already specified
+ * @throws Error the route does not start with "/"
+ * @throws Error an unknown route converter was specified
+ *
+ * @api public
+ *
+ * @example routes.bind("/", {
+ * get: function(request, response) {
+ * response.end("GET /");
+ * },
+ * post: function(request, response) {
+ * response.end("POST /");
+ * }
+ * });
+ * @example routes.bind("item", "/{name}", {
+ * get: function(request, response, params) {
+ * response.end("GET /" + params.name);
+ * }
+ * });
+ * @example routes.bind("item", "/{id:int}", {
+ * get: function(request, response, params) {
+ * // params.id is a Number, not a String
+ * response.end("GET /" + params.id);
+ * }
+ * });
+ */
+ var bind = function (routeName, route, descriptor, routePrefix) {
+ if (arguments.length === 2) {
+ descriptor = route;
+ route = parseOptionalRoutes(routeName);
+ routeName = guessRouteName(route[0]);
+ } else {
+ route = parseOptionalRoutes(route);
+ }
+ descriptor = sanitizeDescriptor(descriptor);
+
+ if (urlGenerators[routeName]) {
+ throw new Error("Already defined route named '" + route + "'");
+ }
+
+ var staticRouteUrlGenerator = null;
+ var dynamicRouteUrlGenerator = null;
+
+ forEachOrSingular(route, function (route, routeNum) {
+ route = (routePrefix || "") + route;
+ if (route.charAt(0) !== "/") {
+ throw new Error("Routes must start with '/'");
+ }
+
+ var parts = route.split(ROUTE_REGEX);
+ if (parts.length === 1) {
+ // no dynamic parts
+
+ if (staticRoutes[route]) {
+ throw new Error("Already defined route at " + route);
+ }
+
+ staticRoutes[route] = descriptor;
+ if (staticRouteUrlGenerator === null) {
+ staticRouteUrlGenerator = function () {
+ return route;
+ };
+ }
+ } else {
+ // at least one dynamic part to the route
+
+ var prefix = parts[0];
+ var dynamicRoute = null;
+ for (var i = 0, len = dynamicRoutes.length; i < len; i += 1) {
+ if (dynamicRoutes[i].prefix === prefix) {
+ dynamicRoute = dynamicRoutes[i];
+ break;
+ }
+ }
+ if (dynamicRoute === null) {
+ dynamicRoutes.push(dynamicRoute = {
+ prefix: prefix,
+ routes: [],
+ });
+
+ // sort the routes so that the most specific prefix is first
+ dynamicRoutes.sort(function (a, b) {
+ var aPrefix = a.prefix;
+ var bPrefix = b.prefix;
+ return cmp(bPrefix.length, aPrefix.length) || cmp(aPrefix, bPrefix);
+ });
+ }
+
+ var argumentNames = [];
+ var converters = [];
+ var pattern = "^" + regexpEscape(prefix);
+ var weights = [];
+ weights.push(parts[0].length);
+ for (i = 1, len = parts.length; i < len; i += 4) {
+ argumentNames.push(parts[i]);
+
+ var converterName = parts[i + 1] || "default";
+ var converterFactory = customConverters[converterName] || escort.converters[converterName];
+ if (!converterFactory) {
+ throw new Error("Unknown converter '" + converterName + "'");
+ }
+
+ // represents the arguments in the converter text
+ var args = calculateConverterArgs(parts[i + 2]);
+
+ var converter = converterFactory.apply(Object.create(converterFactory.prototype), args);
+ converters.push(converter);
+ weights.push(converter.weight || 100);
+
+ var regex = converter.regex;
+ if (!regex) {
+ throw new Error("Converter '" + converterName + "' does not specify an appropriate regex.");
+ }
+
+ pattern += "(";
+ pattern += regex;
+ pattern += ")";
+ pattern += regexpEscape(parts[i + 3]);
+ weights.push(parts[i + 3]);
+ }
+ pattern += "$";
+
+ var routes = dynamicRoute.routes;
+ for (i = 0, len = routes.length; i < len; i += 1) {
+ if (routes[i].pattern === pattern) {
+ throw new Error("Already defined route matching " + pattern);
+ }
+ }
+ routes.push({
+ pattern: pattern,
+ regex: new RegExp(pattern),
+ argumentNames: argumentNames,
+ converters: converters.map(function (converter) {
+ return converter.fromUrl.bind(converter);
+ }),
+ descriptor: descriptor,
+ weights: weights
+ });
+
+ // sort the routes (namespaced by the prefix) by their weight, so that more specific routes are tried to be matched first.
+ routes.sort(function (a, b) {
+ var aWeights = a.weights,
+ bWeights = b.weights;
+ var result;
+ for (var i = 0, len = Math.min(aWeights.length, bWeights.length); i < len; i += 1) {
+ result = cmp(bWeights[i], aWeights[i]);
+ if (result) {
+ return result;
+ }
+ }
+ return cmp(bWeights.length, aWeights.length);
+ });
+
+ if (!dynamicRouteUrlGenerator) {
+ // create the url generation function
+ dynamicRouteUrlGenerator = generateUrlFunction(parts, converters.map(function (converter) {
+ return converter.toUrl.bind(converter);
+ }));
+ }
+ }
+ });
+
+ if (staticRouteUrlGenerator && dynamicRouteUrlGenerator) {
+ // two routes were provided, one static and one dynamic.
+ // if arguments are provided, assume it's dynamic, otherwise assume it's static.
+ urlGenerators[routeName] = function () {
+ if (arguments.length === 0) {
+ return staticRouteUrlGenerator();
+ } else {
+ return dynamicRouteUrlGenerator.apply(this, Array.prototype.slice.call(arguments, 0));
+ }
+ };
+ } else {
+ urlGenerators[routeName] = staticRouteUrlGenerator || dynamicRouteUrlGenerator;
+ }
+ };
+
+ /**
+ * The methods which are passed into the callback.
+ */
+ var methods = spawn(escort.prototype, {
+ bind: bind,
+ url: urlGenerators,
+ notFound: function (handler) {
+ if (!handler) {
+ throw new Error("Callback function is expected");
+ }
+ notFoundHandler = handler;
+ },
+ methodNotAllowed: function (handler) {
+ if (!handler) {
+ throw new Error("Callback function is expected");
+ }
+ methodNotAllowedHandler = handler;
+ },
+ submount: makeSubmountFunction(bind, ""),
+ });
+ fn(methods);
+ }());
+
+ return (function () {
+ /**
+ * A map of URL to data containing the descriptor and params for the URL.
+ * @api private
+ */
+ var dynamicRouteCache = null;
+ /**
+ * The amount of time until dynamicRouteCache is to be cleared.
+ * @api private
+ */
+ var dynamicRouteCacheTime = 0;
+
+ /**
+ * An empty, frozen object which will act as the params object for static URLs.
+ * @api private
+ */
+ var emptyParams = freeze({});
+
+ /**
+ * The connect middleware
+ */
+ var router = function (request, response, next) {
+ try
+ {
+ var url = request.url;
+ var questionIndex = url.indexOf("?");
+ if (questionIndex !== -1) {
+ // if there is a querystring, we'll just chop that part off
+ // it's still preserved in request.url, and should be handled
+ // by other middleware.
+ url = url.substring(0, questionIndex);
+ }
+ var lowerMethod = request.method.toLowerCase();
+ var descriptor = staticRoutes[url];
+
+ var params;
+ var callback;
+ if (descriptor) {
+ // we found a static route
+ callback = descriptor[lowerMethod];
+
+ if (!callback) {
+ // the URL exists, but does not have the provided method defined for it
+ methodNotAllowedHandler(request, response, next);
+ return;
+ }
+
+ params = emptyParams;
+ } else {
+ var now = +new Date();
+ if (dynamicRouteCacheTime < now) {
+ // time to reset the dynamic cache cache
+ dynamicRouteCache = {};
+ dynamicRouteCacheTime = now + DYNAMIC_ROUTE_CACHE_CLEAR_TIME;
+ }
+
+ var cachedResult = dynamicRouteCache[url];
+ if (cachedResult) {
+ descriptor = cachedResult.descriptor;
+ callback = descriptor[lowerMethod];
+ if (!callback) {
+ // the URL exists, but does not have the provided method defined for it
+ methodNotAllowedHandler(request, response, next);
+ return;
+ }
+ params = cachedResult.params;
+ }
+ else
+ {
+ searchDynamicRoutes:
+ for (var i = 0, len = dynamicRoutes.length; i < len; i += 1) {
+ var dynamicRoute = dynamicRoutes[i];
+ var prefix = dynamicRoute.prefix;
+
+ if (prefix !== "/" && !startsWith(url, prefix)) {
+ continue;
+ }
+
+ var routes = dynamicRoute.routes;
+ searchWithinPrefix:
+ for (var j = 0, len2 = routes.length; j < len2; j += 1) {
+ var route = routes[j];
+ var match = route.regex.exec(url);
+ if (!match) {
+ continue;
+ }
+
+ descriptor = route.descriptor;
+ callback = descriptor[lowerMethod];
+ if (!callback) {
+ // the URL exists, but does not have the provided method defined for it
+ methodNotAllowedHandler(request, response, next);
+ return;
+ }
+
+ var argumentNames = route.argumentNames;
+ var converters = route.converters;
+ params = {};
+ for (var k = 0, len3 = argumentNames.length; k < len3; k += 1) {
+ var unconvertedValue = match[k + 1];
+ var convertedValue;
+ try {
+ convertedValue = converters[k](unconvertedValue);
+ } catch (ex) {
+ if (ex instanceof escort.ValidationError) {
+ // if the converter explicitly throws a ValidationError, the route doesn't match.
+ // thus, we need to keep searching for the correct route.
+ callback = undefined;
+ continue searchWithinPrefix;
+ } else {
+ throw ex;
+ }
+ }
+ params[argumentNames[k]] = convertedValue;
+ }
+ // params needs to be frozen since it will be potentially used in later requests through the dynamic route caching system
+ freeze(params);
+ dynamicRouteCache[url] = {
+ descriptor: descriptor,
+ params: params
+ };
+ // we're done, so we need to break out of the outer for loop
+ break searchDynamicRoutes;
+ }
+ }
+ }
+ }
+
+ if (!callback) {
+ // route was unable to be located
+ notFoundHandler(request, response, next);
+ return;
+ }
+
+ // set params on request as well as pass it into the callback.
+ request.params = params;
+ // the descriptor is passed in as "this" so that the user can do this.post(request, response) from their get callback.
+ callback.call(descriptor, request, response, params);
+ } catch (err) {
+ next(err);
+ }
+ };
+ router.url = urlGenerators;
+
+ return router;
+ }());
+ };
+ escort.prototype = {};
+
+ // attach all the methods as helper methods onto escort.prototype
+ ACCEPTABLE_METHODS.forEach(function (method) {
+ /**
+ * Bind the provided route with a specific method to the callback provided.
+ * Since you cannot specify a route more than once, it is required to use bind to provide multiple methods.
+ *
+ * @param {String} routeName The name of the route. Should be a JavaScript identifier. Optional.
+ * @param {String} route The URL for the route.
+ * @param {Function} callback The callback to be called when the route is accessed.
+ *
+ * @throws Error the routeName is already specified
+ * @throws Error the route is already specified
+ * @throws Error the route does not start with "/"
+ * @throws Error an unknown route converter was specified
+ *
+ * @api public
+ *
+ * @example routes.get("/", function(request, response) {
+ * response.end("GET /");
+ * });
+ * @example routes.post("item", "/{name}", function(request, response, params) {
+ * response.end("GET /" + params.name);
+ * });
+ */
+ escort.prototype[method] = function (routeName, route, callback) {
+ if (arguments.length === 2) {
+ callback = route;
+ route = routeName;
+ routeName = guessRouteName(route);
+ }
+ var descriptor = {};
+ descriptor[method] = callback;
+ return this.bind(routeName, route, descriptor);
+ };
+ });
+ escort.prototype.del = escort.prototype.delete;
+
+ (function () {
+ var converters = escort.converters = {};
+
+ /**
+ * ValidationError is thrown when converting from a URL to specify that
+ * the route does not match, and thus cannot be converted into a Javascript-
+ * friendly object.
+ */
+ var ValidationError = function () {};
+ ValidationError.prototype = new Error();
+ escort.ValidationError = ValidationError;
+
+ /**
+ * BaseConverter is the base of all the internal converters provided.
+ * When specifying one's own converters, it is not necessary to inherit from this.
+ */
+ var BaseConverter = function () {
+ return {};
+ };
+ BaseConverter.prototype = {
+ weight: 100,
+ regex: "[^/]+",
+ fromUrl: function (value) {
+ return value;
+ },
+ toUrl: function (value) {
+ return encodeURIComponent(value);
+ }
+ };
+ escort.BaseConverter = BaseConverter;
+
+ /**
+ * A converter that accepts any string except those including slashes (/).
+ * This is the default converter if not overridden.
+ *
+ * @example routes.get("/users/{name}", function(req, res, params) { })
+ * @example routes.get("/users/{name:string}", function(req, res, params) { })
+ * @example routes.get("/users/{name:string({minLength: 3, maxLength: 8})}", function(req, res, params) { })
+ *
+ * @param {Object} args An options Object that can contain "minLength" and "maxLength"
+ */
+ var StringConverter = function (args) {
+ if (!args) {
+ args = {};
+ }
+
+ var minLength = args.minLength || 1;
+ var maxLength = args.maxLength || null;
+
+ var regex = "[^/]";
+ if (minLength === 1 && !maxLength) {
+ regex += "+";
+ } else {
+ regex += "{";
+ regex += minLength;
+ if (maxLength !== minLength) {
+ regex += ",";
+ if (maxLength) {
+ regex += maxLength;
+ }
+ }
+ regex += "}";
+ }
+
+ return spawn(StringConverter.prototype, {
+ regex: regex
+ });
+ };
+ StringConverter.prototype = Object.create(BaseConverter.prototype);
+ escort.converters.string = escort.converters.default = escort.StringConverter = StringConverter;
+
+ /**
+ * A converter that accepts any string, even those including slashes (/).
+ * This can be handy for wiki or forum systems or any which have resources that have arbitrary depth.
+ *
+ * @example routes.get("/wiki/{pageName:path}", function(req, res, params) { })
+ */
+ var PathConverter = function () {
+ return spawn(PathConverter.prototype, {
+ regex: "[^/]+(?:/[^/]+)*",
+ });
+ };
+ PathConverter.prototype = Object.create(spawn(BaseConverter.prototype, {
+ weight: 50
+ }));
+ escort.converters.path = escort.PathConverter = PathConverter;
+
+ /**
+ * Pad a value by prepending zeroes until it reaches a specified length.
+ *
+ * @param {String} value the current string or number.
+ * @param {Number} length the size wanted for the value.
+ * @return {String} a string of at least the provided length.
+ * @api private
+ *
+ * @example zeroPad(50, 4) == "0050"
+ * @example zeroPad("123", 4) == "0123"
+ */
+ var zeroPad = function (value, length) {
+ value = String(value);
+ while (value.length < length) {
+ value = "0" + value;
+ }
+ return value;
+ };
+
+ /**
+ * A converter that accepts a numeric string.
+ * This does not support negative values.
+ *
+ * @example routes.get("/users/{id:int}", function(req, res, params) { })
+ * @example routes.get("/archive/{year:int({fixedDigits: 4})}", function(req, res, params) { })
+ * @example routes.get("/users/{id:int({min: 1})}", function(req, res) { })
+ *
+ * @param {Object} args An options Object that can contain "min", "max", and "fixedDigits"
+ */
+ var IntegerConverter = function (args) {
+ if (!args) {
+ args = {};
+ }
+
+ var fixedDigits = args.fixedDigits;
+ var min = args.min;
+ var max = args.max;
+ if (min === undefined) {
+ min = null;
+ }
+ if (max === undefined) {
+ max = null;
+ }
+
+ return spawn(IntegerConverter.prototype, {
+ regex: "\\d+",
+ weight: 150,
+ fromUrl: function (value) {
+ if (fixedDigits && value.length !== fixedDigits) {
+ throw new ValidationError();
+ }
+
+ var result = parseInt(value, 10);
+ if (isNaN(result) || result >= Infinity || result <= -Infinity) {
+ throw new ValidationError();
+ }
+
+ if (min !== null && result < min) {
+ throw new ValidationError();
+ }
+
+ if (max !== null && result > max) {
+ throw new ValidationError();
+ }
+
+ return result;
+ },
+ toUrl: function (value) {
+ var part = (Math.floor(value) || 0).toString();
+ if (fixedDigits) {
+ return zeroPad(part, fixedDigits);
+ } else {
+ return part;
+ }
+ }
+ });
+ };
+ IntegerConverter.prototype = Object.create(BaseConverter.prototype);
+ escort.converters.int = escort.IntegerConverter = IntegerConverter;
+
+ /**
+ * A converter that matches one of the items provided.
+ *
+ * @example routes.get("/pages/{pageName:any('about', 'help', 'contact')}", function(req, res, params) { })
+ */
+ var AnyConverter = function () {
+ var args = Array.prototype.slice.call(arguments, 0);
+ if (args.length < 1) {
+ throw new Error("Must specify at least one argument to AnyConverter");
+ }
+
+ return spawn(AnyConverter.prototype, {
+ regex: "(?:" + args.map(regexpEscape).join("|") + ")",
+ weight: 200
+ });
+ };
+ AnyConverter.prototype = Object.create(BaseConverter.prototype);
+ escort.converters.any = escort.AnyConverter = AnyConverter;
+ }());
+
+ exports = module.exports = escort.escort = escort;
+}());
+
+(function () {
+ "use strict";
+ /**
+ * Calculate the arguments in converter text.
+ * This is segregated due to use of eval.
+ *
+ * @param {String} args A string version of the arguments to the converter.
+ * @return {Array} an array which represents the arguments of the converter.
+ * @api private
+ */
+ calculateConverterArgs = function (args) {
+ if (args) {
+ return eval("[" + args + "]");
+ } else {
+ return [];
+ }
+ };
+
+ /**
+ * Dynamically create a url-generation function.
+ * This is segregated due to use of new Function.
+ *
+ * @param {Array} parts The split result of a route over the ROUTE_REGEX.
+ * @param {Array} converters An array of toUrl functions which represent conversion functions.
+ * @return {Function} A function which will generate a URL.
+ * @api private
+ *
+ * @example generateUrlFunction(["/prefix/", "name", "string", null, ""])({name: "hey"}) === "/prefix/hey"
+ */
+ generateUrlFunction = function (parts, converters) {
+ var fun = "";
+ fun += "return (function generate(params) {\n";
+ fun += " if (arguments.length === 1) {\n";
+ fun += " switch (typeof params) {\n";
+ fun += " case 'object':\n";
+ fun += " return ";
+ fun += JSON.stringify(parts[0]);
+ for (var i = 1, len = parts.length; i < len; i += 4) {
+ fun += "+converters[";
+ fun += (i - 1) / 4;
+ fun += "](params[";
+ fun += JSON.stringify(parts[i]);
+ fun += "])";
+ if (parts[i + 3]) {
+ fun += "+";
+ fun += JSON.stringify(parts[i + 3]);
+ }
+ }
+ fun += ";\n";
+ fun += " }\n";
+ fun += " }\n";
+ fun += " return generate({";
+
+ for (i = 1, len = parts.length; i < len; i += 4) {
+ fun += JSON.stringify(parts[i]);
+ fun += ":arguments[";
+ fun += (i - 1) / 4;
+ fun += "]";
+ }
+
+ fun += "});\n";
+ fun += "});\n";
+ return new Function("converters", fun)(converters);
+ };
+}());
1  support/expresso
@@ -0,0 +1 @@
+Subproject commit e8f44768a9553cfcb75b219287bc498d077dd0b9
796 test/escort.test.js
@@ -0,0 +1,796 @@
+/*jshint strict: false */
+
+var connect = require("connect"),
+ assert = require("assert"),
+ escort = require("../index");
+
+var methods = ["get", "post", "put", "delete"];
+var exampleNames = ["neil", "bob", "windsor"];
+
+module.exports = {
+ "methods static": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ methods.forEach(function (method) {
+ routes[method]("home_" + method, "/" + method, function (req, res) {
+ res.end(method.toUpperCase() + " /" + method);
+ });
+ });
+ })
+ );
+
+ methods.forEach(function (method) {
+ assert.response(app,
+ { url: "/" + method, method: method.toUpperCase() },
+ { body: method.toUpperCase() + " /" + method });
+
+ assert.strictEqual("/" + method, url["home_" + method]());
+
+ methods.forEach(function (otherMethod) {
+ if (method !== otherMethod) {
+ assert.response(app,
+ { url: "/" + method, method: otherMethod.toUpperCase() },
+ { statusCode: 405 });
+ }
+ });
+ });
+ },
+ "bind static": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ var descriptor = {};
+ methods.forEach(function (method) {
+ descriptor[method] = function (req, res) {
+ res.end(method.toUpperCase() + " /");
+ };
+ });
+ routes.bind("home", "/", descriptor);
+ })
+ );
+
+ assert.strictEqual("/", url.home());
+
+ methods.forEach(function (method) {
+ assert.response(app,
+ { url: "/", method: method.toUpperCase() },
+ { body: method.toUpperCase() + " /" });
+ });
+ },
+ "methods dynamic": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ methods.forEach(function (method) {
+ routes[method]("name_" + method, "/{name}/" + method, function (req, res, params) {
+ res.end(method.toUpperCase() + " /" + params.name + "/" + method);
+ });
+ });
+ })
+ );
+
+ exampleNames.forEach(function (name) {
+ methods.forEach(function (method) {
+ assert.response(app,
+ { url: "/" + name + "/" + method, method: method.toUpperCase() },
+ { body: method.toUpperCase() + " /" + name + "/" + method });
+
+ assert.strictEqual("/" + name + "/" + method, url["name_" + method](name));
+ assert.strictEqual("/" + name + "/" + method, url["name_" + method]({ name: name }));
+
+ methods.forEach(function (otherMethod) {
+ if (method !== otherMethod) {
+ assert.response(app,
+ { url: "/" + name + "/" + method, method: otherMethod.toUpperCase() },
+ { statusCode: 405 });
+ }
+ });
+ });
+ });
+ },
+ "bind dynamic": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ var descriptor = {};
+ methods.forEach(function (method) {
+ descriptor[method] = function (req, res, params) {
+ res.end(method.toUpperCase() + " /" + params.name);
+ };
+ });
+ routes.bind("name", "/{name}", descriptor);
+ })
+ );
+
+ exampleNames.forEach(function (name) {
+ assert.strictEqual("/" + name, url.name(name));
+ assert.strictEqual("/" + name, url.name({ name: name }));
+ });
+
+ methods.forEach(function (method) {
+ exampleNames.forEach(function (name) {
+ assert.response(app,
+ { url: "/" + name, method: method.toUpperCase() },
+ { body: method.toUpperCase() + " /" + name });
+ });
+ });
+ },
+ "calling other methods": function () {
+ var app = connect(
+ escort(function (routes) {
+ routes.bind("doSomething", "/do-something", {
+ get: function (req, res) {
+ this.post(req, res);
+ },
+ post: function (req, res) {
+ res.end(req.method + " /do-something");
+ }
+ });
+ })
+ );
+
+ assert.response(app,
+ { url: "/do-something", method: "GET" },
+ { body: "GET /do-something" });
+ assert.response(app,
+ { url: "/do-something", method: "POST" },
+ { body: "POST /do-something" });
+ },
+ "guessed route names": function () {
+ var routesToExpectedNames = {
+ "/do-something": "doSomething",
+ "/posts": "posts",
+ "/": "root",
+ };
+
+ Object.keys(routesToExpectedNames).forEach(function (route) {
+ var name = routesToExpectedNames[route];
+
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+ routes.get(route, function (req, res) {
+ res.end("GET " + route);
+ });
+ })
+ );
+ assert.strictEqual(route, url[name]());
+ });
+ },
+ "int converter": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ routes.get("post", "/posts/{id:int({min: 1, max: 99})}", function (req, res, params) {
+ assert.strictEqual("number", typeof params.id);
+
+ res.end("GET /posts/" + params.id);
+ });
+ })
+ );
+
+ assert.response(app,
+ { url: "/posts/0", method: "GET" },
+ { statusCode: 404 });
+ assert.response(app,
+ { url: "/posts/100", method: "GET" },
+ { statusCode: 404 });
+
+ for (var i = 1; i <= 99; i += 1) {
+ assert.strictEqual("/posts/" + i, url.post(i));
+ assert.strictEqual("/posts/" + i, url.post({ id: i }));
+
+ assert.response(app,
+ { url: "/posts/" + i, method: "GET" },
+ { body: "GET /posts/" + i });
+ }
+ },
+ "int converter (fixedDigits)": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ routes.get("post", "/posts/{id:int({fixedDigits: 4})}", function (req, res, params) {
+ assert.strictEqual("number", typeof params.id);
+
+ res.end("GET /posts/" + params.id);
+ });
+ })
+ );
+
+ assert.response(app,
+ { url: "/posts/0", method: "GET" },
+ { statusCode: 404 });
+ assert.response(app,
+ { url: "/posts/100", method: "GET" },
+ { statusCode: 404 });
+
+ for (var i = 1; i <= 9; i += 1) {
+ assert.strictEqual("/posts/000" + i, url.post(i));
+ assert.strictEqual("/posts/000" + i, url.post({ id: i }));
+
+ assert.response(app,
+ { url: "/posts/000" + i, method: "GET" },
+ { body: "GET /posts/" + i });
+ }
+ },
+ "string converter": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ routes.get("post", "/posts/{id:string({minLength: 3, maxLength: 8})}", function (req, res, params) {
+ assert.strictEqual("string", typeof params.id);
+
+ res.end("GET /posts/" + params.id);
+ });
+ })
+ );
+
+ assert.response(app,
+ { url: "/posts/hi", method: "GET" },
+ { statusCode: 404 });
+ assert.response(app,
+ { url: "/posts/howdypartner", method: "GET" },
+ { statusCode: 404 });
+ for (var i = 0; i < 20; i += 1) {
+ assert.response(app,
+ { url: "/posts/" + "howdypartner".substr(0, i), method: "GET" },
+ { statusCode: i < 3 || i > 8 ? 404 : 200 });
+ }
+
+ for (i = 1; i <= 9; i += 1) {
+ assert.strictEqual("/posts/hey" + i, url.post("hey" + i));
+ assert.strictEqual("/posts/hey" + i, url.post({ id: "hey" + i }));
+
+ assert.response(app,
+ { url: "/posts/hey" + i, method: "GET" },
+ { body: "GET /posts/hey" + i });
+ }
+ },
+ "path converter": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ routes.get("post", "/posts/{id:path}", function (req, res, params) {
+ assert.strictEqual("string", typeof params.id);
+
+ res.end("GET /posts/" + params.id);
+ });
+ })
+ );
+
+ for (var i = 1; i < "howdy/partner/how/are/you".length; i += 1) {
+ var part = "howdy/partner/how/are/you".substr(0, i);
+ if (part.charAt(part.length - 1) !== "/") {
+ assert.response(app,
+ { url: "/posts/" + part, method: "GET" },
+ { body: "GET /posts/" + part });
+ }
+ }
+ },
+ "any converter": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ routes.get("post", "/posts/{id:any('alpha', 'bravo', 'charlie')}", function (req, res, params) {
+ assert.strictEqual("string", typeof params.id);
+
+ res.end("GET /posts/" + params.id);
+ });
+ })
+ );
+
+ assert.response(app,
+ { url: "/posts/alpha", method: "GET" },
+ { body: "GET /posts/alpha" });
+
+ assert.response(app,
+ { url: "/posts/bravo", method: "GET" },
+ { body: "GET /posts/bravo" });
+
+ assert.response(app,
+ { url: "/posts/charlie", method: "GET" },
+ { body: "GET /posts/charlie" });
+
+ assert.response(app,
+ { url: "/posts/delta", method: "GET" },
+ { statusCode: 404 });
+ },
+ "custom converter": function () {
+ var CustomConverter = function () {
+ return {
+ regex: "(?:yes|no)",
+ fromUrl: function (value) {
+ return value === "yes";
+ },
+ toUrl: function (value) {
+ return value ? "yes" : "no";
+ }
+ };
+ };
+
+ var url;
+ var app = connect(
+ escort({ converters: { custom: CustomConverter } }, function (routes) {
+ url = routes.url;
+
+ routes.get("post", "/posts/{id:custom}", function (req, res, params) {
+ assert.strictEqual("boolean", typeof params.id);
+
+ res.end("GET /posts/" + (params.id ? "yes" : "no"));
+ });
+ })
+ );
+
+ assert.response(app,
+ { url: "/posts/yes", method: "GET" },
+ { body: "GET /posts/yes" });
+
+ assert.response(app,
+ { url: "/posts/no", method: "GET" },
+ { body: "GET /posts/no" });
+
+ assert.response(app,
+ { url: "/posts/maybe", method: "GET" },
+ { statusCode: 404 });
+
+ assert.strictEqual("/posts/yes", url.post(true));
+ assert.strictEqual("/posts/no", url.post(false));
+ },
+ "notFound and methodNotAllowed handlers": function () {
+ var app = connect(
+ escort(function (routes) {
+ routes.get("/", function (req, res) {
+ res.end("Found the root");
+ });
+
+ routes.notFound(function (req, res, next) {
+ res.writeHead(404);
+ res.end("Not found, oh noes!");
+ });
+
+ routes.methodNotAllowed(function (req, res, next) {
+ res.writeHead(405);
+ res.end("No such method, nuh-uh.");
+ });
+ })
+ );
+
+ assert.response(app,
+ { url: "/", method: "GET" },
+ { body: "Found the root" });
+
+ assert.response(app,
+ { url: "/", method: "POST" },
+ { body: "No such method, nuh-uh.", statusCode: 405 });
+
+ assert.response(app,
+ { url: "/other", method: "GET" },
+ { body: "Not found, oh noes!", statusCode: 404 });
+ },
+ "dynamic caching": function () {
+ var doneParts = {};
+ var CustomConverter = function () {
+ return {
+ regex: "[a-z]+",
+ fromUrl: function (value) {
+ if (doneParts[value]) {
+ throw new Error("Already seen " + value);
+ }
+ return value;
+ },
+ toUrl: function (value) {
+ return value;
+ }
+ };
+ };
+
+ var app = connect(
+ escort({ converters: { custom: CustomConverter } }, function (routes) {
+ routes.bind("user", "/users/{name:custom}", {
+ get: function (req, res, params) {
+ res.end("GET /users/" + params.name);
+ },
+ post: function (req, res, params) {
+ res.end("POST /users/" + params.name);
+ },
+ });
+ })
+ );
+
+ for (var i = 0; i < 100; i += 1) {
+ for (var j = 0, len = exampleNames.length; j < len; j += 1) {
+ var name = exampleNames[j];
+
+ assert.response(app,
+ { url: "/users/" + name, method: "GET" },
+ { body: "GET /users/" + name });
+
+ assert.response(app,
+ { url: "/users/" + name, method: "POST" },
+ { body: "POST /users/" + name });
+ }
+ }
+ },
+ "submounting": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ routes.submount("/users", function (users) {
+ users.get("user", "/{name}", function (req, res, params) {
+ res.end("GET /users/" + params.name);
+ });
+ });
+ })
+ );
+
+ exampleNames.forEach(function (name) {
+ assert.response(app,
+ { url: "/users/" + name, method: "GET" },
+ { body: "GET /users/" + name });
+ });
+ },
+ "dynamic submounting": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ routes.submount("/users/{name}", function (users) {
+ users.get("userInfo", "/info", function (req, res, params) {
+ res.end("GET /users/" + params.name + "/info");
+ });
+ });
+ })
+ );
+
+ exampleNames.forEach(function (name) {
+ assert.response(app,
+ { url: "/users/" + name + "/info", method: "GET" },
+ { body: "GET /users/" + name + "/info" });
+ });
+ },
+ "submount within submount": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ routes.submount("/alpha", function (alpha) {
+ alpha.submount("/bravo", function (bravo) {
+ bravo.submount("/charlie", function (charlie) {
+ charlie.get("item", "/{name}", function (req, res, params) {
+ res.end("GET /alpha/bravo/charlie/" + params.name);
+ });
+ });
+ });
+ });
+ })
+ );
+
+ exampleNames.forEach(function (name) {
+ assert.response(app,
+ { url: "/alpha/bravo/charlie/" + name, method: "GET" },
+ { body: "GET /alpha/bravo/charlie/" + name });
+ });
+ },
+ "conflicts": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ routes.submount("/forums", function (forums) {
+ forums.get("forum", "/{forumSlug}", function (req, res, params) {
+ res.end("GET /forums/" + params.forumSlug);
+ });
+ forums.get("thread", "/{threadID:int}", function (req, res, params) {
+ res.end("GET /forums/" + params.threadID + " (thread)");
+ });
+ });
+ })
+ );
+
+ for (var i = 1; i < 10; i += 1) {
+ assert.response(app,
+ { url: "/forums/" + i, method: "GET" },
+ { body: "GET /forums/" + i + " (thread)" });
+ }
+
+ exampleNames.forEach(function (name) {
+ assert.response(app,
+ { url: "/forums/" + name, method: "GET" },
+ { body: "GET /forums/" + name });
+ });
+ },
+ "multiple routes per callback": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ routes.get("home", ["/", "/home"], function (req, res, params) {
+ res.end("GET " + req.url);
+ });
+ })
+ );
+
+ assert.strictEqual("/", url.home());
+
+ assert.response(app,
+ { url: "/", method: "GET" },
+ { body: "GET /" });
+
+ assert.response(app,
+ { url: "/home", method: "GET" },
+ { body: "GET /home" });
+
+ assert.response(app,
+ { url: "/ho", method: "GET" },
+ { statusCode: 404 });
+ },
+ "multiple routes per callback with [] syntax": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ routes.get("home", "/[home]", function (req, res, params) {
+ res.end("GET " + req.url);
+ });
+ })
+ );
+
+ assert.strictEqual("/", url.home());
+
+ assert.response(app,
+ { url: "/", method: "GET" },
+ { body: "GET /" });
+
+ assert.response(app,
+ { url: "/home", method: "GET" },
+ { body: "GET /home" });
+
+ assert.response(app,
+ { url: "/ho", method: "GET" },
+ { statusCode: 404 });
+ },
+ "submounted multiple routes per callback": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ routes.submount("/forums", function (forums) {
+ forums.get("forum", ["", "/home"], function (req, res, params) {
+ res.end("GET " + req.url);
+ });
+ });
+ })
+ );
+
+ assert.strictEqual("/forums", url.forum());
+
+ assert.response(app,
+ { url: "/forums", method: "GET" },
+ { body: "GET /forums" });
+
+ assert.response(app,
+ { url: "/forums/home", method: "GET" },
+ { body: "GET /forums/home" });
+
+ assert.response(app,
+ { url: "/forums/ho", method: "GET" },
+ { statusCode: 404 });
+ },
+ "submounted multiple routes per callback with [] syntax": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ routes.submount("/forums", function (forums) {
+ forums.get("forum", "[/home]", function (req, res, params) {
+ res.end("GET " + req.url);
+ });
+ });
+ })
+ );
+
+ assert.strictEqual("/forums", url.forum());
+
+ assert.response(app,
+ { url: "/forums", method: "GET" },
+ { body: "GET /forums" });
+
+ assert.response(app,
+ { url: "/forums/home", method: "GET" },
+ { body: "GET /forums/home" });
+
+ assert.response(app,
+ { url: "/forums/ho", method: "GET" },
+ { statusCode: 404 });
+ },
+ "dynamic multiple routes per callback": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ routes.get("page", ["/", "/page/{pageNum:int({min: 1})}"], function (req, res, params) {
+ var pageNum = params.pageNum || 1;
+ res.end("Viewing page #" + pageNum);
+ });
+ })
+ );
+
+ assert.strictEqual("/", url.page());
+ assert.strictEqual("/page/2", url.page(2));
+ assert.strictEqual("/page/2", url.page({pageNum: 2}));
+
+ assert.response(app,
+ { url: "/", method: "GET" },
+ { body: "Viewing page #1" });
+
+ assert.response(app,
+ { url: "/page/1", method: "GET" },
+ { body: "Viewing page #1" });
+
+ assert.response(app,
+ { url: "/page/2", method: "GET" },
+ { body: "Viewing page #2" });
+ },
+ "dynamic multiple routes per callback with [] syntax": function () {
+ var url;
+ var app = connect(
+ escort(function (routes) {
+ url = routes.url;
+
+ routes.get("page", "/[page/{pageNum:int({min: 1})}]", function (req, res, params) {
+ var pageNum = params.pageNum || 1;
+ res.end("Viewing page #" + pageNum);
+ });
+ })
+ );
+
+ assert.strictEqual("/", url.page());
+ assert.strictEqual("/page/2", url.page(2));
+ assert.strictEqual("/page/2", url.page({pageNum: 2}));
+
+ assert.response(app,
+ { url: "/", method: "GET" },
+ { body: "Viewing page #1" });
+
+ assert.response(app,
+ { url: "/page/1", method: "GET" },
+ { body: "Viewing page #1" });
+
+ assert.response(app,
+ { url: "/page/2", method: "GET" },
+ { body: "Viewing page #2" });
+ },
+ "error handling": function () {
+ var app = connect(
+ escort(function (routes) {
+ routes.get("/", function (req, res, params) {
+ throw new Error("fake error");
+ });
+ }),
+ function (err, req, res, next) {
+ res.writeHead(500);
+ res.end(err.toString());
+ }
+ );
+
+ assert.response(app,
+ { url: "/", method: "GET" },
+ { statusCode: 500, body: "Error: fake error" });
+ },
+ "escaping regexp characters": function () {
+ var app = connect(
+ escort(function (routes) {
+ routes.get("blah", "/blah.txt", function (req, res, params) {
+ res.end("Blah!");
+ });
+ routes.get("name", "/{name}.txt", function (req, res, params) {
+ res.end("Blah: " + params.name + "!");
+ });
+ })
+ );
+
+ assert.response(app,
+ { url: "/blah.txt", method: "GET" },
+ { body: "Blah!" });
+
+ assert.response(app,
+ { url: "/blahxtxt", method: "GET" },
+ { statusCode: 404 });
+
+ exampleNames.forEach(function (name) {
+ assert.response(app,
+ { url: "/" + name + ".txt", method: "GET" },
+ { body: "Blah: " + name + "!" });
+
+ assert.response(app,
+ { url: "/" + name + "xtxt", method: "GET" },