Skip to content

Commit

Permalink
Implemented Werkzeug-like syntax for rules
Browse files Browse the repository at this point in the history
  • Loading branch information
tarruda committed Aug 28, 2012
1 parent 88b0f0f commit 1952cd6
Show file tree
Hide file tree
Showing 2 changed files with 202 additions and 45 deletions.
3 changes: 3 additions & 0 deletions Makefile
Expand Up @@ -6,4 +6,7 @@ test:
compile: compile:
@./node_modules/.bin/coffee -o lib src @./node_modules/.bin/coffee -o lib src


link: test compile
npm link

.PHONY: test .PHONY: test
244 changes: 199 additions & 45 deletions src/routers.coffee
Expand Up @@ -2,46 +2,200 @@ path = require('path')
url = require('url') url = require('url')




escapeRegex = (s) -> s.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')

class RegexExtractor
constructor: (@regex) ->

extract: (requestPath) ->
m = @regex.exec(requestPath)
if !m then return null
return m.slice(1)

test: (requestPath) -> @extract(requestPath) != null


class RuleExtractor extends RegexExtractor
constructor: (@parsers) ->
@regexParts = ['^']
@params = []

pushStatic: (staticPart) ->
@regexParts.push(escapeRegex(staticPart))

pushParam: (dynamicPart) ->
@params.push(dynamicPart)
# Actual parsing/validation is done by the parser function,
# so a simple catch-all capture group is inserted
@regexParts.push('(.+)')

compile: ->
@regexParts.push('$')
@regex = new RegExp(@regexParts.join(''))
return @

extract: (requestPath) ->
m = @regex.exec(requestPath)
if !m then return null
params = @params
parsers = @parsers
extractedArgs = []
for i in [1...m.length]
param = params[i - 1]
value = parsers[param.parserName](m[i], param.parserOpts)
if value == null then return null
extractedArgs[i - 1] = extractedArgs[param.name] = value
return extractedArgs


# Class responsible for transforming user supplied rules into RuleExtractor
# objects, which will be used to extract arguments from the request path.
class Compiler class Compiler
compile: (patternString) -> new RegExp("^#{patternString}/?$", 'i') constructor: (parsers) ->
# Default parsers which take care of parsing/validating arguments.
@parsers =
int: (str, opts) ->
str = str.trim()
base = 10
if opts?.base
base = opts.base
rv = parseInt(str, base)
if !isFinite(rv) || rv.toString(base) != str
return null
if opts
if (isFinite(opts.min) && rv < min) ||
(isFinite(opts.max) && rv > max)
return null
return rv

float: (str, opts) ->
str = str.trim()
rv = parseFloat(str)
if !isFinite(rv) || rv.toString() != str
return null
if opts
if (isFinite(opts.min) && rv < min) ||
(isFinite(opts.max) && rv > max)
return null
return rv

# Doesn't accept slashes
str: (str, opts) ->
if str.indexOf('/') != -1
return null
if opts
if (isFinite(opts.len) && rv.length != opts.len) ||
(isFinite(opts.minlen) && rv.length < opts.minlen) ||
(isFinite(opts.maxlen) && rv.length > opts.maxlen)
return null
return str

path: (str) -> str
if parsers
for own k, v in parsers
@parsers[k] = v

# Regexes used to parse user-supplied rules with syntax similar to Flask
# (python web framework).
# Based on the regexes found at
# https://github.com/mitsuhiko/werkzeug/blob/master/werkzeug/routing.py
ruleRe:
///
([^<]+) # Static rule section
| # OR
(?:< # Dynamic rule section:
(?:
([a-zA-Z_][a-zA-Z0-9_]*) # Capture onverter name
(?:\((.+)\))? # Capture parser options
:
)? # Parser/opts is optional
([a-zA-Z_][a-zA-Z0-9_]*) # Capture parameter name
>)
///g

parserOptRe:
///
([a-zA-Z_][a-zA-Z0-9_]*) # Capture option name
\s*=\s* # Delimiters
(?:
(true|false) # Capture boolean literal
| # OR
(\d+\.\d+|\d+\.|\d+) # Capture numeric literal OR
| # OR
(\w+) # Capture string literal
)\s*,?
///g

parseOpts: (rawOpts) ->
rv = {}
while match = @parserOptRe.exec(rawArgs)
name = match[1]
if match[2] # boolean
rv[name] = Boolean(match[2])
else if match[3] # number
rv[name] = parseFloat(match[3])
else # string
rv[name] = match[4]
return rv

compile: (pattern) ->
if pattern instanceof RegExp
return new RegexExtractor(pattern)
extractor = new RuleExtractor(@parsers)
while match = @ruleRe.exec(pattern)
if match[1]
# Static section of rule which must be matched literally
extractor.pushStatic(match[1])
else
ruleParam = {}
if match[2]
# Parser name
ruleParam.parserName = match[2]
if match[3]
# Parser options
ruleParam.parserOptions = @parseOpts(match[3])
# Parameter name
ruleParam.name = match[4]
extractor.pushParam(ruleParam)
return extractor.compile()




class Router class Router
constructor: (@compiler) -> constructor: (@compiler) ->
@methodRoutes = @rules =
GET: [] GET: []
POST: [] POST: []
PUT: [] PUT: []
DELETE: [] DELETE: []
@compiled = false @compiled = false


# Route an incoming request to the appropriate handler chain # Route an incoming request to the appropriate handlers based on matched
# rules.
dispatch: (req, res, next) -> dispatch: (req, res, next) ->
p = path.normalize(url.parse(req.url).pathname) p = path.normalize(url.parse(req.url).pathname)
req.path = p req.path = p
@compile() @compileRules()
r = @methodRoutes ruleArray = @rules[req.method]
routeArray = r[req.method] for route in ruleArray
for route in routeArray if extracted = route.extractor.extract(p)
if match = route.pattern.exec(p) req.params = extracted
req.params = match.slice(1) handlerChain = route.handlers
handlerArray = route.handlers
handle = (i) -> handle = (i) ->
if i is handlerArray.length - 1 if i == handlerChain.length - 1
n = next n = next
else else
n = -> process.nextTick(-> handle(i + 1)) n = -> process.nextTick(-> handle(i + 1))
current = handlerArray[i] current = handlerChain[i]
current(req, res, n) current(req, res, n)
handle(0) handle(0)
return return
# If not routes were matched, check if the route is matched # If no rules were matched, check if the rule is registered
# against another http method, if so issue the correct 304 response # with another http method. If it is, issue the correct 405 response
allowed = [] allowed = [] # Valid methods for this resource
for own method, routeArray of r for own method, ruleArray of @rules
if method is req.method then continue if method == req.method then continue
for route in routeArray for rule in ruleArray
if route.pattern.test(p) if rule.extractor.test(p)
allowed.push(method) allowed.push(method)
if allowed.length if allowed.length
res.writeHead(405, 'Allow': allowed.join(', ')) res.writeHead(405, 'Allow': allowed.join(', '))
Expand All @@ -51,53 +205,53 @@ class Router


# Register one of more handler functions to a single route. # Register one of more handler functions to a single route.
register: (methodName, pattern, handlers...) -> register: (methodName, pattern, handlers...) ->
routeArray = @methodRoutes[methodName] ruleArray = @rules[methodName]
# Only allow routes to be registered before compilation # Only allow rules to be registered before compilation
if @compiled if @compiled
throw new Error('Cannot register routes after first request') throw new Error('Cannot register rules after compilation')
if not (typeof pattern is 'string' or pattern instanceof RegExp) if not (typeof pattern == 'string' || pattern instanceof RegExp)
throw new Error('Pattern must be string or regex') throw new Error('Pattern must be rule string or regex')
# Id used to search for existing routes. That way multiple registrations # Id used to search for existing rules. That way multiple registrations
# to the same route will append the handler to the same array. # to the same rule will append to the same handler array.
id = pattern.toString() id = pattern.toString()
handlerArray = null handlerArray = null
# Check if the route is already registered in this array. # Check if the rule is already registered in this array.
for route in routeArray for rule in ruleArray
if route.id is id if rule.id == id
handlerArray = route.handlers handlerArray = rule.handlers
break break
# If not registered, then create an entry for this route. # If not registered, then create an entry for this rule.
if not handlerArray if not handlerArray
handlerArray = [] handlerArray = []
routeArray.push ruleArray.push
id: id id: id
pattern: pattern pattern: pattern
handlers: handlerArray handlers: handlerArray
# Register the passed handlers to the handler array associated with # Register the passed handlers to the handler array associated with
# this route. # this rule.
handlerArray.push(handlers...) handlerArray.push(handlers...)


# Compiles each route to a regular expression # Compiles all rules
compile: -> compileRules: ->
if @compiled then return if @compiled then return
for own method, routeArray of @methodRoutes for own method, ruleArray of @rules
for route in routeArray for rule in ruleArray
if typeof route.pattern isnt 'string' rule.extractor = @compiler.compile(rule.pattern)
continue
patternString = route.pattern
if patternString[-1] is '/'
patternString = patternString.slice(0, patternString.length - 1)
route.pattern = @compiler.compile(patternString)
compiled = true compiled = true




module.exports = () -> module.exports = (parsers) ->
r = new Router(new Compiler()) if not compiler then compiler = new Compiler()
r = new Router(compiler)


return { return {
middleware: (req, res, next) -> r.dispatch(req, res, next) middleware: (req, res, next) -> r.dispatch(req, res, next)
get: (pattern, handlers...) -> r.register('GET', pattern, handlers...) get: (pattern, handlers...) -> r.register('GET', pattern, handlers...)
post: (pattern, handlers...) -> r.register('POST', pattern, handlers...) post: (pattern, handlers...) -> r.register('POST', pattern, handlers...)
put: (pattern, handlers...) -> r.register('PUT', pattern, handlers...) put: (pattern, handlers...) -> r.register('PUT', pattern, handlers...)
del: (pattern, handlers...) -> r.register('DELETE', pattern, handlers...) del: (pattern, handlers...) -> r.register('DELETE', pattern, handlers...)
all: (pattern, handlers...) ->
for method in ['GET', 'POST', 'PUT', 'DELETE']
r.register(method, pattern, handlers...)
return
} }

0 comments on commit 1952cd6

Please sign in to comment.