diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27833da --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/lib +*.log +*.swp +*~ +*.tgz diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bded9f2 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +TESTS = ./test/support/http.js ./test/routers.coffee + +test: + @./node_modules/.bin/mocha --require should --compilers coffee:coffee-script $(TESTS) + +compile: + @./node_modules/.bin/coffee -o lib src + +.PHONY: test diff --git a/package.json b/package.json new file mode 100644 index 0000000..ba9faea --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "connect-routers", + "version": "0.0.1", + "description": "Simple, fast and flexible routing system for connect.\nNice if you need routing middleware without all the other features exposed by express.", + "repository": { + "type": "git", + "url": "" + }, + "keywords": [ + "connect", + "routing", + "router", + "routers", + "dispatch", + "request" + ], + "author": "Thiago de Arruda", + "license": "BSD", + "dependencies": { + "connect": ">=2.4.0" + }, + "devDependencies": { + "coffee-script": "1.3.3", + "should": "*", + "mocha": "*" + } +} diff --git a/src/routers.coffee b/src/routers.coffee new file mode 100644 index 0000000..6c0b666 --- /dev/null +++ b/src/routers.coffee @@ -0,0 +1,103 @@ +path = require('path') +url = require('url') + + +class Compiler + compile: (patternString) -> new RegExp("^#{patternString}/?$", 'i') + + +class Router + constructor: (@compiler) -> + @methodRoutes = + GET: [] + POST: [] + PUT: [] + DELETE: [] + @compiled = false + + # Route an incoming request to the appropriate handler chain + dispatch: (req, res, next) -> + p = path.normalize(url.parse(req.url).pathname) + req.path = p + @compile() + r = @methodRoutes + routeArray = r[req.method] + for route in routeArray + if match = route.pattern.exec(p) + req.params = match.slice(1) + handlerArray = route.handlers + handle = (i) -> + if i is handlerArray.length - 1 + n = next + else + n = -> process.nextTick(-> handle(i + 1)) + current = handlerArray[i] + current(req, res, n) + handle(0) + return + # If not routes were matched, check if the route is matched + # against another http method, if so issue the correct 304 response + allowed = [] + for own method, routeArray of r + if method is req.method then continue + for route in routeArray + if route.pattern.test(p) + allowed.push(method) + if allowed.length + res.writeHead(405, 'Allow': allowed.join(', ')) + res.end() + return + next() + + # Register one of more handler functions to a single route. + register: (methodName, pattern, handlers...) -> + routeArray = @methodRoutes[methodName] + # Only allow routes to be registered before compilation + if @compiled + throw new Error('Cannot register routes after first request') + if not (typeof pattern is 'string' or pattern instanceof RegExp) + throw new Error('Pattern must be string or regex') + # Id used to search for existing routes. That way multiple registrations + # to the same route will append the handler to the same array. + id = pattern.toString() + handlerArray = null + # Check if the route is already registered in this array. + for route in routeArray + if route.id is id + handlerArray = route.handlers + break + # If not registered, then create an entry for this route. + if not handlerArray + handlerArray = [] + routeArray.push + id: id + pattern: pattern + handlers: handlerArray + # Register the passed handlers to the handler array associated with + # this route. + handlerArray.push(handlers...) + + # Compiles each route to a regular expression + compile: -> + if @compiled then return + for own method, routeArray of @methodRoutes + for route in routeArray + if typeof route.pattern isnt 'string' + continue + patternString = route.pattern + if patternString[-1] is '/' + patternString = patternString.slice(0, patternString.length - 1) + route.pattern = @compiler.compile(patternString) + compiled = true + + +module.exports = () -> + r = new Router(new Compiler()) + + return { + middleware: (req, res, next) -> r.dispatch(req, res, next) + get: (pattern, handlers...) -> r.register('GET', pattern, handlers...) + post: (pattern, handlers...) -> r.register('POST', pattern, handlers...) + put: (pattern, handlers...) -> r.register('PUT', pattern, handlers...) + del: (pattern, handlers...) -> r.register('DELETE', pattern, handlers...) + } diff --git a/test/routers.coffee b/test/routers.coffee new file mode 100644 index 0000000..b93af28 --- /dev/null +++ b/test/routers.coffee @@ -0,0 +1,44 @@ +connect = require('connect') + +describe 'router.middleware', -> + router = require('../src/routers')() + app = connect() + app.use(router.middleware) + + router.get '/simple/get/pattern', (req, res) -> + res.write('body1') + res.end() + + router.post('/simple/no-get/pattern', -> res.end()) + + router.del('/simple/no-get/pattern', -> res.end()) + + router.get '/pattern/that/uses/many/handlers', + (req, res, next) -> res.write('part1'); next(), + (req, res, next) -> res.write('part2'); next() + + router.get '/pattern/that/uses/many/handlers', + (req, res) -> res.write('part3'); res.end() + + it 'should match simple patterns', (done) -> + app.request() + .get('/simple/get/pattern') + .end (res) -> + res.body.should.eql('body1') + done() + + it "should return 405 when pattern doesn't match method", (done) -> + app.request() + .get('/simple/no-get/pattern') + .end (res) -> + res.statusCode.should.eql(405) + res.headers['allow'].should.eql('POST, DELETE') + done() + + it 'should pipe request through all handlers', (done) -> + app.request() + .get('/pattern/that/uses/many/handlers') + .end (res) -> + res.body.should.eql('part1part2part3') + done() + diff --git a/test/support/http.js b/test/support/http.js new file mode 100644 index 0000000..913a916 --- /dev/null +++ b/test/support/http.js @@ -0,0 +1,114 @@ +/** +* Module dependencies. +*/ +var EventEmitter = require('events').EventEmitter + , methods = ['get', 'post', 'put', 'delete', 'head'] + , connect = require('connect') + , http = require('http'); + +module.exports = request; + +connect.proto.request = function(){ + return request(this); +}; + +function request(app) { + return new Request(app); +} + +function Request(app) { + var self = this; + this.data = []; + this.header = {}; + this.app = app; + if (!this.server) { + this.server = http.Server(app); + this.server.listen(0, function(){ + self.addr = self.server.address(); + self.listening = true; + }); + } +} + +/** +* Inherit from `EventEmitter.prototype`. +*/ + +Request.prototype.__proto__ = EventEmitter.prototype; + +methods.forEach(function(method){ + Request.prototype[method] = function(path){ + return this.request(method, path); + }; +}); + +Request.prototype.set = function(field, val){ + this.header[field] = val; + return this; +}; + +Request.prototype.write = function(data){ + this.data.push(data); + return this; +}; + +Request.prototype.request = function(method, path){ + this.method = method; + this.path = path; + return this; +}; + +Request.prototype.expect = function(body, fn){ + var args = arguments; + this.end(function(res){ + switch (args.length) { + case 3: + res.headers.should.have.property(body.toLowerCase(), args[1]); + args[2](); + break; + default: + if ('number' == typeof body) { + res.statusCode.should.equal(body); + } else { + res.body.should.equal(body); + } + fn(); + } + }); +}; + +Request.prototype.end = function(fn){ + var self = this; + + if (this.listening) { + var req = http.request({ + method: this.method + , port: this.addr.port + , host: this.addr.address + , path: this.path + , headers: this.header + }); + + this.data.forEach(function(chunk){ + req.write(chunk); + }); + + req.on('response', function(res){ + var buf = ''; + res.setEncoding('utf8'); + res.on('data', function(chunk){ buf += chunk }); + res.on('end', function(){ + res.body = buf; + fn(res); + }); + }); + + req.end(); + } else { + this.server.on('listening', function(){ + self.end(fn); + }); + } + + return this; +};