From 5575eba1dd317291215d4cb2d656adfe6fc789f0 Mon Sep 17 00:00:00 2001 From: Blake Embrey Date: Tue, 3 Feb 2015 21:52:26 +0900 Subject: [PATCH] Add engine documentation --- README.md | 43 +++++++++++ engine.js | 146 ++++++++++++++++++++++++++++--------- index.js | 45 ++---------- lib/layer.js | 6 +- lib/path-to-regexp.js | 24 +------ lib/route.js | 8 +-- test/engine.js | 163 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 331 insertions(+), 104 deletions(-) create mode 100644 test/engine.js diff --git a/README.md b/README.md index d4b5194..32789e6 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,49 @@ curl http://127.0.0.1:8080/such_path > such_path ``` +## Implementing Your Own Router + +Implementing a custom router on top of this logic is as easy as requiring `router/engine`. The engine implements all the runtime logic for the router and can be used to create different path matching semantics. To implement an "exact" path module, we can: + +```js +var Engine = require('router/engine') + +function toFunction (route, options) { + if (!options.end) { + return function (path) { + var matches = path.substr(0, route.length) === route + + return matches ? { path: path } : false + } + } + + return function (path) { + return path === route ? { path: path } : false + } +} + +function ExactRouter (options) { + return Engine.call(this, options) +} + +ExactRouter.prototype = Object.create(Engine.prototype) + +ExactRouter.prototype.use = function () { + var opts = Router.Engine.sanitizeUse.apply(null, arguments) + var match = toFunction(opts.path, { end: false }) + + return Engine.prototype.use.call(this, opts.path, match, opts.callbacks) +} + +ExactRouter.prototype.route = function (path) { + var match = toFunction(path, { end: true }) + + return Engine.prototype.route.call(this, path, match) +} +``` + +Notice that the matching function and the path must be passed into both the `route` and `use` engine methods. This is for debugging purposes, so `path` should be a human-readable path name. `Engine#use` also accepts an array of handlers to immediately include. The match function must return an object of `{ path: string, params: object }` or `false` if it didn't match. + ## License [MIT](LICENSE) diff --git a/engine.js b/engine.js index 7fa24be..6acd5bb 100644 --- a/engine.js +++ b/engine.js @@ -19,6 +19,13 @@ var setPrototypeOf = require('setprototypeof') var Layer = require('./lib/layer') var Route = require('./lib/route') +/** + * Module variables. + * @private + */ + +var slice = Array.prototype.slice + /* istanbul ignore next */ var defer = typeof setImmediate === 'function' ? setImmediate @@ -61,6 +68,35 @@ function Engine(options) { return router } +/** + * Helper for sanitizing `Router.prototype.use` arguments. + */ + +Engine.sanitizeUse = function sanitizeUse(handler) { + var offset = 0 + var path = '/' + + // default path to '/' + // disambiguate router.use([handler]) + if (handler != null && typeof handler !== 'function') { + var arg = handler + + while (Array.isArray(arg) && arg.length !== 0) { + arg = arg[0] + } + + // first arg is the path + if (typeof arg !== 'function') { + offset = 1 + path = handler + } + } + + var callbacks = flatten(slice.call(arguments, offset)) + + return { path: path, callbacks: callbacks } +} + /** * Engine prototype inherits from a Function. */ @@ -210,14 +246,9 @@ Engine.prototype.handle = function handle(req, res, callback) { while (!match && idx < stack.length) { layer = stack[idx++] - match = matchLayer(layer, path) + match = layer.match(path) route = layer.route - if (match instanceof Error) { - // hold on to layerError - layerError = layerError || match - } - if (!match) { continue } @@ -227,12 +258,6 @@ Engine.prototype.handle = function handle(req, res, callback) { continue } - if (layerError) { - // routes do not match with a pending error - match = false - continue - } - var method = req.method; var has_method = route._handles_method(method) @@ -253,6 +278,13 @@ Engine.prototype.handle = function handle(req, res, callback) { return done(layerError) } + var layerPath = match.path + var layerParams = decodeLayerParams(match.params) + + if (layerParams instanceof Error) { + return done(layerParams) + } + // store route for dispatch on change if (route) { req.route = route @@ -260,9 +292,8 @@ Engine.prototype.handle = function handle(req, res, callback) { // Capture one-time layer values req.params = self.mergeParams - ? mergeParams(match.params, parentParams) - : match.params - var layerPath = match.path + ? mergeParams(layerParams, parentParams) + : layerParams // this should be done for the layer self.process_params(match, paramcalled, req, res, function (err) { @@ -442,6 +473,8 @@ Engine.prototype.use = function use(path, match, handlers) { throw new TypeError('argument handler is required') } + var layerMatch = path === '/' ? null : match + handlers.forEach(function (fn) { if (typeof fn !== 'function') { throw new TypeError('argument handler must be a function') @@ -450,10 +483,8 @@ Engine.prototype.use = function use(path, match, handlers) { // add the middleware debug('use %s %s', path, fn.name || '') - var layer = new Layer(path, match, fn) - + var layer = new Layer(path, layerMatch, fn) layer.route = undefined - this.stack.push(layer) }, this) @@ -496,9 +527,19 @@ Engine.prototype.route = function route(path, match) { layer.route = route this.stack.push(layer) + return route } +// create Router#VERB functions +methods.concat('all').forEach(function (method) { + Engine.prototype[method] = function (path) { + var route = this.route(path) + route[method].apply(route, slice.call(arguments, 1)) + return this + } +}) + /** * Generate a callback that will make an OPTIONS response. * @@ -555,22 +596,6 @@ function getProtohost(url) { : undefined } -/** - * Match path to a layer. - * - * @param {Layer} layer - * @param {string} path - * @private - */ - -function matchLayer(layer, path) { - try { - return path != null && layer.match(path); - } catch (err) { - return err; - } -} - /** * Merge params with parent params * @@ -694,3 +719,56 @@ function wrap(old, fn) { fn.apply(this, args) } } + +/** + * Attempt to decode layer parameters + */ + +function decodeLayerParams(params) { + try { + return decodeParams(params) + } catch (err) { + return err + } +} + +/** + * Decode all param values + * + * @param {object} params + * @return {object} + */ + +function decodeParams(params) { + var decodedParams = {} + + if (params) { + Object.keys(params).forEach(function (key) { + decodedParams[key] = decodeParam(params[key]) + }) + } + + return decodedParams +} + +/** + * Decode param value + * + * @param {string} val + * @return {string} + * @api private + */ + +function decodeParam(val){ + if (typeof val !== 'string') { + return val + } + + try { + return decodeURIComponent(val) + } catch (e) { + var err = new TypeError("Failed to decode param '" + val + "'") + err.status = 400 + throw err + } +} diff --git a/index.js b/index.js index fcf8b8f..0b1c8f6 100644 --- a/index.js +++ b/index.js @@ -8,13 +8,6 @@ var flatten = require('array-flatten') var Engine = require('./engine') var pathToRegexp = require('./lib/path-to-regexp') -/** - * Module variables. - * @private - */ - -var slice = Array.prototype.slice - /** * Expose `Router`. */ @@ -29,7 +22,7 @@ module.exports.Route = Engine.Route function Router (options) { if (!(this instanceof Router)) { - return new Router(opts) + return new Router(options) } var opts = options || {} @@ -51,35 +44,16 @@ Router.prototype = Object.create(Engine.prototype) * Create a `path-to-regexp` compatible `.use`. */ -Router.prototype.use = function use(handler) { - var offset = 0 - var path = '/' - - // default path to '/' - // disambiguate router.use([handler]) - if (handler != null && typeof handler !== 'function') { - var arg = handler - - while (Array.isArray(arg) && arg.length !== 0) { - arg = arg[0] - } +Router.prototype.use = function use() { + var opts = Engine.sanitizeUse.apply(null, arguments) - // first arg is the path - if (typeof arg !== 'function') { - offset = 1 - path = handler - } - } - - var callbacks = flatten(slice.call(arguments, offset)) - - var match = pathToRegexp(path, { + var match = pathToRegexp(opts.path, { sensitive: this.caseSensitive, strict: false, end: false }) - return Engine.prototype.use.call(this, path, match, callbacks) + return Engine.prototype.use.call(this, opts.path, match, opts.callbacks) } /** @@ -95,12 +69,3 @@ Router.prototype.route = function route(path) { return Engine.prototype.route.call(this, path, match) } - -// create Router#VERB functions -methods.concat('all').forEach(function (method) { - Router.prototype[method] = function (path) { - var route = this.route(path) - route[method].apply(route, slice.call(arguments, 1)) - return this - } -}) diff --git a/lib/layer.js b/lib/layer.js index 5dd292e..d2bd71c 100644 --- a/lib/layer.js +++ b/lib/layer.js @@ -34,7 +34,7 @@ function Layer(path, match, fn) { this.handle = fn this.name = fn.name || '' - this.match = match + this.match = match || fast_slash } /** @@ -85,3 +85,7 @@ Layer.prototype.handle_request = function handle(req, res, next) { next(err) } } + +function fast_slash () { + return { path: '' } +} diff --git a/lib/path-to-regexp.js b/lib/path-to-regexp.js index eadd568..6c0523b 100644 --- a/lib/path-to-regexp.js +++ b/lib/path-to-regexp.js @@ -31,7 +31,7 @@ function pathRegexp (path, options) { prop = key ? key.name : n++ - val = decode_param(m[i]) + val = m[i] if (val !== undefined || !(hasOwnProperty.call(params, prop))) { params[prop] = val @@ -43,28 +43,6 @@ function pathRegexp (path, options) { } -/** - * Decode param value. - * - * @param {string} val - * @return {string} - * @api private - */ - -function decode_param(val){ - if (typeof val !== 'string') { - return val - } - - try { - return decodeURIComponent(val) - } catch (e) { - var err = new TypeError("Failed to decode param '" + val + "'") - err.status = 400 - throw err - } -} - /** * Always return true. */ diff --git a/lib/route.js b/lib/route.js index 360f441..52f42fd 100644 --- a/lib/route.js +++ b/lib/route.js @@ -177,7 +177,7 @@ Route.prototype.all = function all(handler) { throw new TypeError('argument handler must be a function') } - var layer = new Layer('/', fast_slash, fn) + var layer = new Layer('/', null, fn) layer.method = undefined this.methods._all = true @@ -202,7 +202,7 @@ methods.forEach(function (method) { debug('%s %s', method, this.path) - var layer = new Layer('/', fast_slash, fn) + var layer = new Layer('/', null, fn) layer.method = method this.methods[method] = true @@ -212,7 +212,3 @@ methods.forEach(function (method) { return this } }) - -function fast_slash () { - return { path: '' } -} diff --git a/test/engine.js b/test/engine.js new file mode 100644 index 0000000..9405e4b --- /dev/null +++ b/test/engine.js @@ -0,0 +1,163 @@ + +var after = require('after') +var methods = require('methods') +var Router = require('..') +var utils = require('./support/utils') + +var assert = utils.assert +var createServer = utils.createServer +var request = utils.request + +describe('Engine', function () { + var Engine = Router.Engine + + it('should return a function', function () { + assert.equal(typeof Engine(), 'function') + }) + + it('should return a function using new', function () { + assert.equal(typeof (new Engine()), 'function') + }) + + describe('custom router', function () { + function toFunction (route, options) { + if (!options.end) { + return function (path) { + var matches = path.substr(0, route.length) === route + + return matches ? { path: path } : false + } + } + + return function (path) { + return path === route ? { path: path } : false + } + } + + /** + * Example router implementation. + */ + function SimpleRouter (options) { + return Engine.call(this, options) + } + + SimpleRouter.prototype = Object.create(Engine.prototype) + + SimpleRouter.prototype.use = function () { + var opts = Engine.sanitizeUse.apply(null, arguments) + var match = toFunction(opts.path, { end: false }) + + return Engine.prototype.use.call(this, opts.path, match, opts.callbacks) + } + + SimpleRouter.prototype.route = function (path) { + var match = toFunction(path, { end: true }) + + return Engine.prototype.route.call(this, path, match) + } + + describe('.all(path, fn)', function () { + it('should respond to all methods', function (done) { + var cb = after(methods.length, done) + var router = new SimpleRouter() + var server = createServer(router) + router.all('/', helloWorld) + + methods.forEach(function (method) { + if (method === 'connect') { + // CONNECT is tricky and supertest doesn't support it + return cb() + } + + var body = method !== 'head' + ? 'hello, world' + : '' + + request(server) + [method]('/') + .expect(200, body, cb) + }) + }) + }) + + methods.slice().sort().forEach(function (method) { + if (method === 'connect') { + // CONNECT is tricky and supertest doesn't support it + return + } + + var body = method !== 'head' + ? 'hello, world' + : '' + + describe('.' + method + '(path, ...fn)', function () { + it('should respond to a ' + method.toUpperCase() + ' request', function (done) { + var router = new SimpleRouter() + var server = createServer(router) + + router[method]('/', helloWorld) + + request(server) + [method]('/') + .expect(200, body, done) + }) + + it('should accept multiple arguments', function (done) { + var router = new SimpleRouter() + var server = createServer(router) + + router[method]('/bar', sethit(1), sethit(2), helloWorld) + + request(server) + [method]('/bar') + .expect('x-fn-1', 'hit') + .expect('x-fn-2', 'hit') + .expect(200, body, done) + }) + }) + }) + + describe('.use(...fn)', function () { + it('should invoke function for all requests', function (done) { + var cb = after(3, done) + var router = new SimpleRouter() + var server = createServer(router) + + router.use(saw) + + request(server) + .get('/') + .expect(200, 'saw GET /', cb) + + request(server) + .options('/') + .expect(200, 'saw OPTIONS /', cb) + + request(server) + .post('/foo') + .expect(200, 'saw POST /foo', cb) + }) + }) + }) +}) + +function saw(req, res) { + var msg = 'saw ' + req.method + ' ' + req.originalUrl + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end(msg) +} + +function helloWorld(req, res) { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') +} + +function sethit(num) { + var name = 'x-fn-' + String(num) + return function hit(req, res, next) { + res.setHeader(name, 'hit') + next() + } +}