diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..fac70aa --- /dev/null +++ b/Readme.md @@ -0,0 +1,154 @@ +Barista is a simple URL router for nodejs. + +Getting Barista +=============== + +Install via npm, thusly: + +```javascript +npm install barista +``` + +Using Barista +------------- + +```javascript +var Router = require('barista').Router; + +var router = new Router; +``` + + +### Adding routes +```javascript +// a basic example +router.match( '/products', 'GET' ) + .to( 'products.index' ) + +// Rails-style variables +router.match( '/products/:id', 'GET' ) + .to( 'products.show' ) + +// optional parts +router.match( '/products/:id(.:format)', 'GET' ) + .to( 'products.show' ) + +// convenience methods +router.get( '/products/:id(.:format)' ) + .to( 'products.show' ) + +router.put( '/products/:id(.:format)' ) + .to( 'products.update' ) + +router.post( '/products' ) + .to( 'products.create' ) + +router.delete( '/products' ) + .to( 'products.destroy' ) +``` +### REST Resources + +```javascript +router.resource( 'products' ) +``` + +is equivalent to: + +```javascript +router.get( '/products(.:format)' ) + .to( 'products.index' ) + +router.get( '/products/add(.:format)' ) + .to( 'products.add' ) + +router.get( '/products/:id(.:format)' ) + .to('products.show' ) + +router.get('/products/:id/edit(.:format)' ) + .to( 'products.edit' ) + +router.post('/products(.:format)' ) + .to( 'products.create' ) + +router.put('/products/:id(.:format)' ) + .to( 'products.update' ) + +router.delete('/products/:id(.:format)' ) + .to( 'products.destroy' ) +``` + +Resolution & dispatching +------------------------ + +The `router.all( url, method [, callback] )` method can be used in two ways: + +```javascript +var params = router.first( '/products/15', 'GET' ) +``` +OR +```javascript +router.first( '/products/15', 'GET', function( params ){ + // dispatch the request or something +}) +``` + +You can get all the matching routes like so: +```javascript +var params = router.all( '/products/15', 'GET' ) + +//=> [params, params, params....] +``` + +Route generation +---------------- + +Pass in a params hash, get back a tasty string: +```javascript +router.url( { + controller: 'products', + action: 'show', + id: 5 +} ) +//=> '/products/5' + +router.url( { + controller: 'products', + action: 'show', + id: 5, + format: 'json' +} ) +//=> '/products/5.json' +``` +Set the optional second parameter to `true` if you want +extra params appended as a query string: +```javascript +router.url({ + controller: 'products', + action: 'show', + id: 5, + format: 'json', + love: 'cheese' +}, true ) +//=> '/products/5.json?love=cheese' +``` + +Things I forgot... +------------------ +...might be in the `/docs` folder... + +...or might not exist at all. + + +It's broken! +------------ +Shit happens. + +Write a test that fails and add it to the tests folder, +then create an issue! + +Patches welcome :-) + + +Who are you? +------------ +I'm [Kieran Huggins](mailto:kieran@refactory.ca), partner at [Refactory](http://refactory.ca) in Toronto, Canada. \ No newline at end of file diff --git a/docs/docco.css b/docs/docco.css new file mode 100644 index 0000000..5aa0a8d --- /dev/null +++ b/docs/docco.css @@ -0,0 +1,186 @@ +/*--------------------- Layout and Typography ----------------------------*/ +body { + font-family: 'Palatino Linotype', 'Book Antiqua', Palatino, FreeSerif, serif; + font-size: 15px; + line-height: 22px; + color: #252519; + margin: 0; padding: 0; +} +a { + color: #261a3b; +} + a:visited { + color: #261a3b; + } +p { + margin: 0 0 15px 0; +} +h1, h2, h3, h4, h5, h6 { + margin: 0px 0 15px 0; +} + h1 { + margin-top: 40px; + } +#container { + position: relative; +} +#background { + position: fixed; + top: 0; left: 525px; right: 0; bottom: 0; + background: #f5f5ff; + border-left: 1px solid #e5e5ee; + z-index: -1; +} +#jump_to, #jump_page { + background: white; + -webkit-box-shadow: 0 0 25px #777; -moz-box-shadow: 0 0 25px #777; + -webkit-border-bottom-left-radius: 5px; -moz-border-radius-bottomleft: 5px; + font: 10px Arial; + text-transform: uppercase; + cursor: pointer; + text-align: right; +} +#jump_to, #jump_wrapper { + position: fixed; + right: 0; top: 0; + padding: 5px 10px; +} + #jump_wrapper { + padding: 0; + display: none; + } + #jump_to:hover #jump_wrapper { + display: block; + } + #jump_page { + padding: 5px 0 3px; + margin: 0 0 25px 25px; + } + #jump_page .source { + display: block; + padding: 5px 10px; + text-decoration: none; + border-top: 1px solid #eee; + } + #jump_page .source:hover { + background: #f5f5ff; + } + #jump_page .source:first-child { + } +table td { + border: 0; + outline: 0; +} + td.docs, th.docs { + max-width: 450px; + min-width: 450px; + min-height: 5px; + padding: 10px 25px 1px 50px; + overflow-x: hidden; + vertical-align: top; + text-align: left; + } + .docs pre { + margin: 15px 0 15px; + padding-left: 15px; + } + .docs p tt, .docs p code { + background: #f8f8ff; + border: 1px solid #dedede; + font-size: 12px; + padding: 0 0.2em; + } + .pilwrap { + position: relative; + } + .pilcrow { + font: 12px Arial; + text-decoration: none; + color: #454545; + position: absolute; + top: 3px; left: -20px; + padding: 1px 2px; + opacity: 0; + -webkit-transition: opacity 0.2s linear; + } + td.docs:hover .pilcrow { + opacity: 1; + } + td.code, th.code { + padding: 14px 15px 16px 25px; + width: 100%; + vertical-align: top; + background: #f5f5ff; + border-left: 1px solid #e5e5ee; + } + pre, tt, code { + font-size: 12px; line-height: 18px; + font-family: Monaco, Consolas, "Lucida Console", monospace; + margin: 0; padding: 0; + } + + +/*---------------------- Syntax Highlighting -----------------------------*/ +td.linenos { background-color: #f0f0f0; padding-right: 10px; } +span.lineno { background-color: #f0f0f0; padding: 0 5px 0 5px; } +body .hll { background-color: #ffffcc } +body .c { color: #408080; font-style: italic } /* Comment */ +body .err { border: 1px solid #FF0000 } /* Error */ +body .k { color: #954121 } /* Keyword */ +body .o { color: #666666 } /* Operator */ +body .cm { color: #408080; font-style: italic } /* Comment.Multiline */ +body .cp { color: #BC7A00 } /* Comment.Preproc */ +body .c1 { color: #408080; font-style: italic } /* Comment.Single */ +body .cs { color: #408080; font-style: italic } /* Comment.Special */ +body .gd { color: #A00000 } /* Generic.Deleted */ +body .ge { font-style: italic } /* Generic.Emph */ +body .gr { color: #FF0000 } /* Generic.Error */ +body .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +body .gi { color: #00A000 } /* Generic.Inserted */ +body .go { color: #808080 } /* Generic.Output */ +body .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +body .gs { font-weight: bold } /* Generic.Strong */ +body .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +body .gt { color: #0040D0 } /* Generic.Traceback */ +body .kc { color: #954121 } /* Keyword.Constant */ +body .kd { color: #954121; font-weight: bold } /* Keyword.Declaration */ +body .kn { color: #954121; font-weight: bold } /* Keyword.Namespace */ +body .kp { color: #954121 } /* Keyword.Pseudo */ +body .kr { color: #954121; font-weight: bold } /* Keyword.Reserved */ +body .kt { color: #B00040 } /* Keyword.Type */ +body .m { color: #666666 } /* Literal.Number */ +body .s { color: #219161 } /* Literal.String */ +body .na { color: #7D9029 } /* Name.Attribute */ +body .nb { color: #954121 } /* Name.Builtin */ +body .nc { color: #0000FF; font-weight: bold } /* Name.Class */ +body .no { color: #880000 } /* Name.Constant */ +body .nd { color: #AA22FF } /* Name.Decorator */ +body .ni { color: #999999; font-weight: bold } /* Name.Entity */ +body .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ +body .nf { color: #0000FF } /* Name.Function */ +body .nl { color: #A0A000 } /* Name.Label */ +body .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ +body .nt { color: #954121; font-weight: bold } /* Name.Tag */ +body .nv { color: #19469D } /* Name.Variable */ +body .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ +body .w { color: #bbbbbb } /* Text.Whitespace */ +body .mf { color: #666666 } /* Literal.Number.Float */ +body .mh { color: #666666 } /* Literal.Number.Hex */ +body .mi { color: #666666 } /* Literal.Number.Integer */ +body .mo { color: #666666 } /* Literal.Number.Oct */ +body .sb { color: #219161 } /* Literal.String.Backtick */ +body .sc { color: #219161 } /* Literal.String.Char */ +body .sd { color: #219161; font-style: italic } /* Literal.String.Doc */ +body .s2 { color: #219161 } /* Literal.String.Double */ +body .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ +body .sh { color: #219161 } /* Literal.String.Heredoc */ +body .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ +body .sx { color: #954121 } /* Literal.String.Other */ +body .sr { color: #BB6688 } /* Literal.String.Regex */ +body .s1 { color: #219161 } /* Literal.String.Single */ +body .ss { color: #19469D } /* Literal.String.Symbol */ +body .bp { color: #954121 } /* Name.Builtin.Pseudo */ +body .vc { color: #19469D } /* Name.Variable.Class */ +body .vg { color: #19469D } /* Name.Variable.Global */ +body .vi { color: #19469D } /* Name.Variable.Instance */ +body .il { color: #666666 } /* Literal.Number.Integer.Long */ \ No newline at end of file diff --git a/docs/helpers.html b/docs/helpers.html new file mode 100644 index 0000000..a41acb1 --- /dev/null +++ b/docs/helpers.html @@ -0,0 +1,41 @@ + helpers.js
Jump To …

helpers.js

CamelCase +var camelize = function(str,capitalize){ + var ret = str.replace( /[^a-zA-Z][a-zA-Z]/g, function(str){ + return str[1].toUpperCase(); + }); + if (capitalize) return ret.replace(/^./,function(str){ + return str.toUpperCase(); + }); + return ret; +}

snake_case

exports.snakeize = function(str){
+  return str.replace( /[A-Z]|\d+/g, function(str){
+    return '_'+str.toLowerCase();
+  }).replace(/^[^a-zA-Z0-9]|[^a-zA-Z0-9]$/,'');
+}

deep object mixer

exports.mixin = function(){
+  var args = Array.prototype.slice.call(arguments)
+
+  for ( i=1; i < args.length; i++ ) {
+    
+    for ( var prop in args[i] ) {
+      if ( exports.kindof(args[i][prop]) == 'object' ) {

deep copy

        args[0][prop] = mixin( {}, args[i][prop] )
+      } else {

shallow copy

        args[0][prop] = args[i][prop]
+      }
+    }
+  }
+  return args[0]
+}

escapes a string on its way in to a regex pattern

exports.regExpEscape = (function() {
+  var specials = [ '/', '.', '*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '\\' ]
+  sRE = new RegExp('(\\' + specials.join('|\\') + ')', 'g')
+  return function (text) { return text.replace(sRE, '\\$1') }
+})();
+
+exports.kindof = function(o) {
+  if (typeof(o) != "object") return typeof(o)
+  if (o === null) return "null"
+  if (o.constructor == (new Array).constructor) return "array"
+  if (o.constructor == (new Date).constructor) return "date"
+  if (o.constructor == (new RegExp).constructor) return "regex"
+  return "object"
+}
+
+
\ No newline at end of file diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..da9626e --- /dev/null +++ b/docs/index.html @@ -0,0 +1,3 @@ + index.js

index.js

exports.Router = require('./lib/router').Router;
+
+
\ No newline at end of file diff --git a/docs/key.html b/docs/key.html new file mode 100644 index 0000000..e2dea35 --- /dev/null +++ b/docs/key.html @@ -0,0 +1,65 @@ + key.js
Jump To …

key.js

new Key( name, optional )

+ +

those variable thingies

+ +
key = new Key('name')
+key = new Key('name', true)
+
var Key = function( name, optional ) {
+
+  var self = this
+
+  self.name = name
+  self.optional = (optional===true) ? true : false
+  self.regex = /[\w\-]+/ // default url-friendly regex

special defaults for controllers & actions, which will always be function-name-safe

  if (self.name == 'controller' || self.name == 'action') {
+    self.regex = /[a-zA-Z_][\w\-]*/
+  }

key.regexString()

+ +

makes a regex string of the key - used by key.test()

+ +

returns a string of this keys regex

  this.regexString = function() {
+    var ret = String(self.regex).replace(/^\//, '').replace(/\/[gis]?$/, '')
+    if (self.optional) {
+      return '(' + ret + ')?'
+    }
+    return '(' + ret + ')'
+  };

key.test( string )

+ +

validates a string using the key's regex pattern

+ +

returns true/false if the string matches

  this.test = function( string ) {
+    return new RegExp('^'+self.regexString()+'$').test(string)
+  };

key.url( string )

+ +

returns a string for buulding the url +if it matches the key conditions

  this.url = function( string ) {
+    if (self.test(string)) {
+      /*
+      -- no longer needed 
+      snake_caseify the controller, if there is one
+      if (self.name == 'controller') return snakeize(string)
+      */
+      return string
+    }
+    return false // doesn't match, let it go
+  };
+  

key.where( conditions )

+ +

adds conditions that the key must match

+ +

returns the key... because it can?

  this.where = function( conditions ) {
+
+    var condition = conditions[self.name]
+
+    if (condition instanceof RegExp) self.regex = condition //  e.g. /\d+/
+
+    if (condition instanceof String) self.regex = new RegExp(condition) //  e.g. "/\d+/"

an array of allowed values, e.g. ['stop','play','pause']

    if (condition instanceof Array) self.regex = new RegExp('/'+condition.join('|')+'/')
+
+    return self
+  }
+
+  return self // just in case we forgot the new operator
+};
+
+exports.Key = Key
+
+
\ No newline at end of file diff --git a/docs/route.html b/docs/route.html new file mode 100644 index 0000000..033b87f --- /dev/null +++ b/docs/route.html @@ -0,0 +1,239 @@ + route.js
Jump To …

route.js

var Key           = require('./key').Key,
+    regExpEscape  = require('./helpers').regExpEscape,
+    mixin         = require('./helpers').mixin,
+    kindof        = require('./helpers').kindof

new Route( path [, method] )

+ +

turns strings into magical ponies that come when you call them

+ +
route = new Route('/:controller/:action/:id(.:format)')
+route = new Route('/:controller/:action(/:id)(.:format)', 'GET')
+route = new Route('/:controller/:action(/:id)(.:format)',
+route = new Route('/:controller/:action/:id(.:format)', 'GET')
+
+ +

Pretty familiar to anyone who's used Merb/Rails - called by Router.match()

var Route = function( path, method ) {
+
+  var self = this,

!x! regexen crossing !x! +matches keys

      KEY = /:([a-zA-Z_][\w\-]*)/,

optional group (the part in parens)

      OGRP = /\(([^)]+)\)/,

breaks a string into atomic parts: ogrps, keys, then everything else

      PARTS = /\([^)]+\)|:[a-zA-Z_][\w\-]*|[\w\-_\\\/\.]+/g

is this a nested, optional url segment like (.:format)

  self.optional = false

uppercase the method name

  if (typeof(method) == 'string') self.method = method.toUpperCase()

base properties

  self.params = {}
+  self.parts = []
+  self.route_name = null
+  self.path = path
+  /*self.regex = null // for caching of test() regex MAYBE*/

route.regexString()

+ +

returns a composite regex string of all route parts

  this.regexString = function() {
+    var ret = ''

a route regex is a composite of its parts' regexe(s|n)

    for (var i in self.parts) {
+      var part = self.parts[i]
+      if (part instanceof Key) {
+        ret += part.regexString()
+      } else if (part instanceof Route) {
+        ret += part.regexString()
+      } else { // string
+        ret += regExpEscape(part)
+      }
+    }
+    return '('+ret+')'+(self.optional ? '?' : '')
+  };

route.test( string )

+ +

builds & tests on a full regex of the entire path

+ +
route.test( '/products/19/edit' ) 
+ => true
+
+ +

returns true/false depending on whether the url matches

  this.test = function( string ) {
+    /*
+    TODO cache this if it makes sense, code below:
+    if(self.regex == null) self.regex = RegExp('^' +  self.regexString() + '(\\\?.*)?$')
+    return self.regex.test(string)
+    */
+    return RegExp('^' +  self.regexString() + '(\\\?.*)?$').test(string)
+  };

route.to( endpoint [, extra_params ] )

+ +

defines the endpoint & mixes in optional params

+ +
route.to( 'controller.action' )
+
+route.to( 'controller.action', {lang:'en'} )
+
+ +

returns the route for chaining

  this.to = function( endpoint, extra_params ) {
+
+    if ( !extra_params && typeof endpoint != 'string' ) {
+      extra_params = endpoint
+      endpoint = undefined
+    }
+
+    /* 
+      TODO: make endpoint optional, since you can have the 
+      controller & action in the URL utself, 
+      even though that's a terrible idea...
+    */
+
+    if ( endpoint ){
+      endpoint = endpoint.split('.')
+      if( kindof(endpoint) == 'array' && endpoint.length != 2 ) throw 'syntax should be in the form: controller.action'
+      this.params.controller = endpoint[0]
+      this.params.action = endpoint[1]
+    }
+
+    extra_params = kindof(extra_params) == 'object' ? extra_params : {}
+    mixin(self.params, extra_params)
+
+    return this // chainable
+  };

route.name( name )

+ +

just sets the route name - NAMED ROUTES ARE NOT CURRENTLY USED

+ +
route.name( 'login' )
+route.name( 'homepage' ) // etc...
+
+ +

returns: the route for chaining

  this.name = function( name ) {
+    self.route_name = name
+    return self // chainable
+  };

route.where( conditions )

+ +

sets conditions that each url variable must match for the URL to be valid

+ +
route.where( { id:/\d+/, username:/\w+/ } )
+
+ +

returns: the route for chaining

  this.where = function( conditions ) {
+
+    var self = this
+
+    if ( kindof(conditions) != 'object' ) throw 'conditions must be an object'
+
+    for (var i in self.parts) {
+      if (self.parts[i] instanceof Key || self.parts[i] instanceof Route) {

recursively apply all conditions to sub-parts

        self.parts[i].where(conditions)
+      }
+    }
+
+    return self // chainable
+  };
+  

route.stringify( params )

+ +

builds a string url for this Route from a params object

+ +

returns: [ "url", [leftover params] ]

+ +

this is meant to be called & modified by router.url()

  this.stringify = function( params ) {
+    var url = [] // urls start life as an array to enble a second pass
+    
+    for (var i in self.parts) {
+      var part = self.parts[i]
+      if (part instanceof Key) {
+        if (typeof(params[part.name]) != 'undefined' &&
+            part.regex.test(params[part.name])) {

there's a param named this && the param matches the key's regex

          url.push(part.url(params[part.name])); // push it onto the stack 
+          delete params[part.name] // and remove from list of params              
+        } else if (self.optional) {

(sub)route doesn't match, move on

          return false
+        }
+      } else if (part instanceof Route) {

sub-routes must be handled in the next pass +to avoid leftover param duplication

        url.push(part)
+      } else { // string
+        url.push(part)
+      }
+    }
+    

second pass, resolve optional parts

    for (var i in url) {
+      if (url[i] instanceof Route) { 
+        url[i] = url[i].stringify(params) // recursion is your friend

it resolved to a url fragment!

        if (url[i]) {

replace leftover params hash with the new, smaller leftover params hash

          params = url[i][1]

leave only the string for joining

          url[i] = url[i][0]
+        } else {
+          delete url[i] // get rid of these shits
+        }
+      }
+    }
+    
+    for (var i in self.params) {

remove from leftovers, they're implied in the to() portion of the route

      delete params[i]
+    }
+
+    return [ url.join(''), params ]
+  }; 
+  

route.keysAndRoutes()

+ +

just the parts that aren't strings. basically

+ +

returns an array of Key and Route objects

  this.keysAndRoutes = function() {
+    var knr = []
+    for (var i in self.parts) {
+      if (self.parts[i] instanceof Key || self.parts[i] instanceof Route) {
+        knr.push(self.parts[i])
+      }
+    }
+    return knr
+  };

route.keys()

+ +

just the parts that are Keys

+ +

returns an array of aforementioned Keys

  this.keys = function() {
+    var keys = []
+    for (var i in self.parts) {
+      if (self.parts[i] instanceof Key) {
+        keys.push(self.parts[i])
+      }
+    }
+    return keys;
+  };

route.parse( url, method )

+ +

parses a URL into a params object

+ +
route.parse( '/products/15/edit', 'GET' )
+ => { controller:'products', action:'edit', id:15 }
+
+ +

returns: a params hash || false (if the route doesn't match)

+ +

this is meant to be called by Router.first() && Router.all()

  this.parse = function( urlParam, method ) {
+    

parse the URL with the regex & step along with the parts, +assigning the vals from the url to the names of the keys as we go (potentially stoopid)

    

let's chop off the QS to make life easier

    var url = require('url').parse(urlParam)
+    var path = url.pathname
+    var params = {method:method}
+    
+    for (var key in self.params) { params[key] = self.params[key] } 

if the method doesn't match, gtfo immediately

    if (typeof self.method != 'undefined' && self.method != params.method) return false
+
+    /* TODO: implement substring checks for possible performance boost */

if the route doesn't match the regex, gtfo

    if (!self.test(path)) {
+      return false
+    }

parse the URL with the regex

    var parts = new RegExp('^' + self.regexString() + '$').exec(path)
+    var j = 2; // index of the parts array, starts at 2 to bypass the entire match string & the entire match
+
+    var keysAndRoutes = self.keysAndRoutes()
+
+    for (var i in keysAndRoutes) {        
+      if (keysAndRoutes[i] instanceof Key) {
+        if (keysAndRoutes[i].test(parts[j])) {
+          params[keysAndRoutes[i].name] = parts[j]
+        }
+      } else if (keysAndRoutes[i] instanceof Route) {
+        if (keysAndRoutes[i].test(parts[j])) {

parse the subroute

          var subparams = keysAndRoutes[i].parse(parts[j], method)
+          mixin(params, subparams)

advance the parts pointer by the number of submatches

          j+= parts[j].match(keysAndRoutes[i].regexString()).length-2 || 0
+        } else {
+          j++; 
+        }
+      }
+      j++;
+    }
+
+    return params
+  };

path parsing

  while (part = PARTS.exec(path)) {
+    self.parts.push(part)
+  }
+  

have to do this in two passes due to RegExp execution limits

  for (var i in self.parts) {
+    if (OGRP.test(self.parts[i])) { // optional group
+      self.parts[i] = new Route(OGRP.exec(self.parts[i])[1], true)
+      self.parts[i].optional = true
+
+    } else if(KEY.test(self.parts[i])) { // key
+      var keyname = KEY.exec(self.parts[i])[1]
+      self.parts[i] = new Key(keyname)
+    } else { // string
+      self.parts[i] = String(self.parts[i])
+    }
+  }  
+
+  return self
+
+}; // Route
+
+
+exports.Route = Route
+
+
\ No newline at end of file diff --git a/docs/router.html b/docs/router.html new file mode 100644 index 0000000..2faa784 --- /dev/null +++ b/docs/router.html @@ -0,0 +1,215 @@ + router.js
Jump To …

router.js

var Route     = require('./route').Route,
+    snakeize  = require('./helpers').snakeize

new Router()

+ +

Simple router for Node -- setting up routes looks like this:

+ +
 router = new Router();
+
+ router.match('/')
+  .to('application.index')
+  .name('main');
+
+ router.match('/users/:user_id/messages/:message_id')
+  .to('users.read_message');
+
+ +

Pretty familiar to anyone who's used Merb/Rails

var Router = function() {
+
+  var METHODS = /^(GET|POST|PUT|DELETE)$/i,
+      self = this
+
+  this.routes = []
+  

router.match( path [, method] )

+ +
router.match('/:controller/:action(/:id)(.:format)', 'GET')
+ .to(......)
+
+ +

path is mandatory (duh) +method is optional, routes without a method will apply in all cases

+ +

returns the route (for chaining)

  this.match = function( path, method ) {
+
+    if ( typeof path != 'string' ) throw 'path must be a string'
+
+    if ( typeof method != 'undefined' && !METHODS.test(method)) throw 'method must be one of: get, post, put, delete'
+
+    var route = new Route(path, method)
+    self.routes.push(route)
+
+    return route
+  };

convenience methods

router.get( path )

+ +

equivalent to

+ +
router.match( path, 'GET' )
+
  this.get = function( path ){
+    return this.match(path, 'GET')
+  }

router.put( path )

+ +

equivalent to

+ +
router.match( path, 'PUT' )
+
  this.put = function( path ){
+    return this.match(path, 'PUT')
+  }

router.post( path )

+ +

equivalent to

+ +
router.match( path, 'POST' )
+
  this.post = function( path ){
+    return this.match(path, 'POST')
+  }

router.delete( path )

+ +

equivalent to

+ +
router.match( path, 'DELETE' )
+
  this.delete = function( path ){
+    return this.match(path, 'DELETE')
+  }

router.resource( controller )

+ +

generates standard resource routes for a controller name

+ +
router.resource('products')
+
+ +

returns an array of routes (for now, this may change)

  this.resource = function( controller ) {
+    var controller_slug = snakeize(controller)
+    return [
+      self.get('/'+controller_slug+'(.:format)', 'GET').to(controller+'.index'),
+      self.get('/'+controller_slug+'/add(.:format)', 'GET').to(controller+'.add'),
+      self.get('/'+controller_slug+'/:id(.:format)', 'GET').to(controller+'.show'),
+      self.get('/'+controller_slug+'/:id/edit(.:format)', 'GET').to(controller+'.edit'),
+      self.post('/'+controller_slug+'(.:format)', 'POST').to(controller+'.create'),
+      self.put('/'+controller_slug+'/:id(.:format)', 'PUT').to(controller+'.update'),
+      self.delete('/'+controller_slug+'/:id(.:format)', 'DELETE').to(controller+'.destroy')
+    ];
+  };
+  
+  

router.first( path, method, callback )

+ +

find the first route that match the path & method

+ +
router.first('/products/5', 'GET')
+=> { controller: 'products', action: 'show', id: 5, method: 'GET' }
+
+ +

find & return a params hash from the first route that matches. If there's no match, this will return false

+ +

If the options callback function is provided, it will be fired like so:

+ +
callback( error, params )
+
  this.first = function( path, method, cb ) {
+    var params = false
+    
+    for (var i in self.routes) {
+      

attempt the parse

      params = self.routes[i].parse(path, method)
+      if (params) {

fire the callback if given

        if (typeof cb == 'function') cb(false, params)

may as well return this

        return params
+      }
+      
+    }
+    if (typeof cb == 'function') cb('No matching routes found')
+    return false
+  };
+  

router.all( path [, method] )

+ +

find & return a params hash from ALL routes that match

+ +
router.all( '/products/5' ) 
+
+  => [
+    { controller: 'products', action: 'show', id: 5, method: 'GET' },
+    { controller: 'products', action: 'update', id: 5, method: 'PUT' },
+    { controller: 'products', action: 'destroy', id: 5, method: 'DELETE' },
+  ]
+
+ +

if there ares no matches, returns an empty array

  this.all = function( path, method ) {
+    var ret = [],
+        params = false
+
+    for (var i in self.routes) {
+      params = self.routes[i].parse(path, method)  // TODO: use call or apply (I forget which) to make 'method' optional
+      if (params) ret.push(params)
+    }
+    return ret;
+  };
+  

router.url( params[, add_querystring=false] )

+ +

generates a URL from a params hash

+ +
router.url( { 
+  controller: 'products', 
+  action: 'show', 
+  id: 5
+} )
+=> '/products/5'
+
+router.url( {
+  controller: 'products', 
+  action: 'show', 
+  id: 5, 
+  format: 'json'
+} )
+=> '/products/5.json'
+
+router.url({ 
+  controller: 'products', 
+  action: 'show', 
+  id: 5, 
+  format: 'json', 
+  love: 'cheese' 
+}, true )
+=> '/products/5.json?love=cheese'
+
+ +

returns false if there are no suitable routes

  this.url = function( params, add_querystring ) {
+    var url = false
+        

iterate through the existing routes until a suitable match is found

    for (var i in self.routes) {

do the controller & acton match?

      if (typeof(self.routes[i].params.controller) != 'undefined' &&
+          self.routes[i].params.controller != params.controller) {
+        continue
+      }
+      if (typeof(self.routes[i].params.action) != 'undefined' &&
+          self.routes[i].params.action != params.action) {
+        continue
+      }
+      url = self.routes[i].stringify(params);
+      if (url) {
+        break;
+      }
+    } 
+
+    if (!url) return false  // no love? return false
+
+    var qs = require('querystring').stringify(url[1])  // build the possibly empty query string
+
+    if (add_querystring && qs.length > 0) return url[0] + '?' + qs  // if there is a query string...
+
+    return url[0]  // just return the url
+  };

router.defer( testfn() )

+ +
router.defer( test( path, method ) )
+
+ +

test should be a function that examines non-standard URLs

+ +

path and method will be passed in - expects a params hash back OR false on a non-match

+ +

returns: DeferredRoute (for... reference? I dunno.)

+ +

THIS IS CURRENTLY COMPLETELY UNTESTED. IT MIGHT NOT EVEN WORK. SERIOUSLY.

  this.defer = function( fn ) {
+    if ( typeof(fn) != 'function' ) throw 'Router.defer requires a function as the only argument'
+     
+    var route = new Route('deferred')
+    route.parse = fn // add the custom parser
+    delete route.test // = function(){return false};
+    delete route.stringify //= function(){ throw 'Deferred routes are NOT generatable'};
+    self.routes.push(route)
+    return route
+  }
+
+}; // Router
+
+exports.Router = Router
+
+
\ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..e23ff94 --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +exports.Router = require('./lib/router').Router; \ No newline at end of file diff --git a/lib/helpers.js b/lib/helpers.js new file mode 100644 index 0000000..593f841 --- /dev/null +++ b/lib/helpers.js @@ -0,0 +1,52 @@ +// CamelCase +// var camelize = function(str,capitalize){ +// var ret = str.replace( /[^a-zA-Z][a-zA-Z]/g, function(str){ +// return str[1].toUpperCase(); +// }); +// if (capitalize) return ret.replace(/^./,function(str){ +// return str.toUpperCase(); +// }); +// return ret; +// } + +// snake_case +exports.snakeize = function(str){ + return str.replace( /[A-Z]|\d+/g, function(str){ + return '_'+str.toLowerCase(); + }).replace(/^[^a-zA-Z0-9]|[^a-zA-Z0-9]$/,''); +} + +// deep object mixer +exports.mixin = function(){ + var args = Array.prototype.slice.call(arguments) + + for ( i=1; i < args.length; i++ ) { + + for ( var prop in args[i] ) { + if ( exports.kindof(args[i][prop]) == 'object' ) { + // deep copy + args[0][prop] = mixin( {}, args[i][prop] ) + } else { + // shallow copy + args[0][prop] = args[i][prop] + } + } + } + return args[0] +} + +// escapes a string on its way in to a regex pattern +exports.regExpEscape = (function() { + var specials = [ '/', '.', '*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '\\' ] + sRE = new RegExp('(\\' + specials.join('|\\') + ')', 'g') + return function (text) { return text.replace(sRE, '\\$1') } +})(); + +exports.kindof = function(o) { + if (typeof(o) != "object") return typeof(o) + if (o === null) return "null" + if (o.constructor == (new Array).constructor) return "array" + if (o.constructor == (new Date).constructor) return "date" + if (o.constructor == (new RegExp).constructor) return "regex" + return "object" +} diff --git a/lib/key.js b/lib/key.js new file mode 100644 index 0000000..f753e3a --- /dev/null +++ b/lib/key.js @@ -0,0 +1,80 @@ +// new Key( name, optional ) +// ================================= +// those variable thingies +// +// key = new Key('name') +// key = new Key('name', true) +// +var Key = function( name, optional ) { + + var self = this + + self.name = name + self.optional = (optional===true) ? true : false + self.regex = /[\w\-]+/ // default url-friendly regex + + // special defaults for controllers & actions, which will always be function-name-safe + if (self.name == 'controller' || self.name == 'action') { + self.regex = /[a-zA-Z_][\w\-]*/ + } + + // key.regexString() + // ----------------- + // makes a regex string of the key - used by key.test() + // + // returns a string of this keys regex + this.regexString = function() { + var ret = String(self.regex).replace(/^\//, '').replace(/\/[gis]?$/, '') + if (self.optional) { + return '(' + ret + ')?' + } + return '(' + ret + ')' + }; + + // key.test( string ) + // ----------------- + // validates a string using the key's regex pattern + // + // returns true/false if the string matches + this.test = function( string ) { + return new RegExp('^'+self.regexString()+'$').test(string) + }; + + // key.url( string ) + // ----------------- + // returns a string for buulding the url + // if it matches the key conditions + this.url = function( string ) { + if (self.test(string)) { + /* + -- no longer needed + snake_caseify the controller, if there is one + if (self.name == 'controller') return snakeize(string) + */ + return string + } + return false // doesn't match, let it go + }; + + // key.where( conditions ) + // ----------------- + // adds conditions that the key must match + // + // returns the key... because it can? + this.where = function( conditions ) { + + var condition = conditions[self.name] + + if (condition instanceof RegExp) self.regex = condition // e.g. /\d+/ + + if (condition instanceof String) self.regex = new RegExp(condition) // e.g. "/\d+/" + // an array of allowed values, e.g. ['stop','play','pause'] + if (condition instanceof Array) self.regex = new RegExp('/'+condition.join('|')+'/') + + return self + } + + return self // just in case we forgot the new operator +}; + +exports.Key = Key \ No newline at end of file diff --git a/lib/route.js b/lib/route.js new file mode 100644 index 0000000..55a1ac1 --- /dev/null +++ b/lib/route.js @@ -0,0 +1,321 @@ +var Key = require('./key').Key, + regExpEscape = require('./helpers').regExpEscape, + mixin = require('./helpers').mixin, + kindof = require('./helpers').kindof + + +// new Route( path [, method] ) +// ================= +// turns strings into magical ponies that come when you call them +// +// route = new Route('/:controller/:action/:id(.:format)') +// route = new Route('/:controller/:action(/:id)(.:format)', 'GET') +// route = new Route('/:controller/:action(/:id)(.:format)', +// route = new Route('/:controller/:action/:id(.:format)', 'GET') +// +// Pretty familiar to anyone who's used Merb/Rails - called by Router.match() +var Route = function( path, method ) { + + var self = this, + // !x! regexen crossing !x! + // matches keys + KEY = /:([a-zA-Z_][\w\-]*)/, + // optional group (the part in parens) + OGRP = /\(([^)]+)\)/, + // breaks a string into atomic parts: ogrps, keys, then everything else + PARTS = /\([^)]+\)|:[a-zA-Z_][\w\-]*|[\w\-_\\\/\.]+/g + + // is this a nested, optional url segment like (.:format) + self.optional = false + + // uppercase the method name + if (typeof(method) == 'string') self.method = method.toUpperCase() + + // base properties + self.params = {} + self.parts = [] + self.route_name = null + self.path = path + /*self.regex = null // for caching of test() regex MAYBE*/ + + // route.regexString() + // ------------------- + // + // returns a composite regex string of all route parts + this.regexString = function() { + var ret = '' + // a route regex is a composite of its parts' regexe(s|n) + for (var i in self.parts) { + var part = self.parts[i] + if (part instanceof Key) { + ret += part.regexString() + } else if (part instanceof Route) { + ret += part.regexString() + } else { // string + ret += regExpEscape(part) + } + } + return '('+ret+')'+(self.optional ? '?' : '') + }; + + + // route.test( string ) + // ----------- + // builds & tests on a full regex of the entire path + // + // route.test( '/products/19/edit' ) + // => true + // + // returns true/false depending on whether the url matches + this.test = function( string ) { + /* + TODO cache this if it makes sense, code below: + if(self.regex == null) self.regex = RegExp('^' + self.regexString() + '(\\\?.*)?$') + return self.regex.test(string) + */ + return RegExp('^' + self.regexString() + '(\\\?.*)?$').test(string) + }; + + // route.to( endpoint [, extra_params ] ) + // ------------------------------------------------------------------------------------ + // defines the endpoint & mixes in optional params + // + // route.to( 'controller.action' ) + // + // route.to( 'controller.action', {lang:'en'} ) + // + // returns the route for chaining + this.to = function( endpoint, extra_params ) { + + if ( !extra_params && typeof endpoint != 'string' ) { + extra_params = endpoint + endpoint = undefined + } + + /* + TODO: make endpoint optional, since you can have the + controller & action in the URL utself, + even though that's a terrible idea... + */ + + if ( endpoint ){ + endpoint = endpoint.split('.') + if( kindof(endpoint) == 'array' && endpoint.length != 2 ) throw 'syntax should be in the form: controller.action' + this.params.controller = endpoint[0] + this.params.action = endpoint[1] + } + + extra_params = kindof(extra_params) == 'object' ? extra_params : {} + mixin(self.params, extra_params) + + return this // chainable + }; + + // route.name( name ) + // ------------------ + // just sets the route name - NAMED ROUTES ARE NOT CURRENTLY USED + // + // route.name( 'login' ) + // route.name( 'homepage' ) // etc... + // + // returns: the route for chaining + this.name = function( name ) { + self.route_name = name + return self // chainable + }; + + // route.where( conditions ) + // --------------------- + // sets conditions that each url variable must match for the URL to be valid + // + // route.where( { id:/\d+/, username:/\w+/ } ) + // + // returns: the route for chaining + this.where = function( conditions ) { + + var self = this + + if ( kindof(conditions) != 'object' ) throw 'conditions must be an object' + + for (var i in self.parts) { + if (self.parts[i] instanceof Key || self.parts[i] instanceof Route) { + // recursively apply all conditions to sub-parts + self.parts[i].where(conditions) + } + } + + return self // chainable + }; + + // route.stringify( params ) + // ------------------------- + // builds a string url for this Route from a params object + // + // returns: [ "url", [leftover params] ] + // + // **this is meant to be called & modified by router.url()** + this.stringify = function( params ) { + var url = [] // urls start life as an array to enble a second pass + + for (var i in self.parts) { + var part = self.parts[i] + if (part instanceof Key) { + if (typeof(params[part.name]) != 'undefined' && + part.regex.test(params[part.name])) { + // there's a param named this && the param matches the key's regex + url.push(part.url(params[part.name])); // push it onto the stack + delete params[part.name] // and remove from list of params + } else if (self.optional) { + // (sub)route doesn't match, move on + return false + } + } else if (part instanceof Route) { + // sub-routes must be handled in the next pass + // to avoid leftover param duplication + url.push(part) + } else { // string + url.push(part) + } + } + + // second pass, resolve optional parts + for (var i in url) { + if (url[i] instanceof Route) { + url[i] = url[i].stringify(params) // recursion is your friend + // it resolved to a url fragment! + if (url[i]) { + // replace leftover params hash with the new, smaller leftover params hash + params = url[i][1] + // leave only the string for joining + url[i] = url[i][0] + } else { + delete url[i] // get rid of these shits + } + } + } + + for (var i in self.params) { + // remove from leftovers, they're implied in the to() portion of the route + delete params[i] + } + + return [ url.join(''), params ] + }; + + + // route.keysAndRoutes() + // --------------------- + // just the parts that aren't strings. basically + // + // returns an array of Key and Route objects + this.keysAndRoutes = function() { + var knr = [] + for (var i in self.parts) { + if (self.parts[i] instanceof Key || self.parts[i] instanceof Route) { + knr.push(self.parts[i]) + } + } + return knr + }; + + // route.keys() + // --------------------- + // just the parts that are Keys + // + // returns an array of aforementioned Keys + this.keys = function() { + var keys = [] + for (var i in self.parts) { + if (self.parts[i] instanceof Key) { + keys.push(self.parts[i]) + } + } + return keys; + }; + + + // route.parse( url, method ) + // -------------------------- + // parses a URL into a params object + // + // route.parse( '/products/15/edit', 'GET' ) + // => { controller:'products', action:'edit', id:15 } + // + // returns: a params hash || false (if the route doesn't match) + // + // **this is meant to be called by Router.first() && Router.all()** + this.parse = function( urlParam, method ) { + + // parse the URL with the regex & step along with the parts, + // assigning the vals from the url to the names of the keys as we go (potentially stoopid) + + // let's chop off the QS to make life easier + var url = require('url').parse(urlParam) + var path = url.pathname + var params = {method:method} + + for (var key in self.params) { params[key] = self.params[key] } + + // if the method doesn't match, gtfo immediately + if (typeof self.method != 'undefined' && self.method != params.method) return false + + /* TODO: implement substring checks for possible performance boost */ + + // if the route doesn't match the regex, gtfo + if (!self.test(path)) { + return false + } + + // parse the URL with the regex + var parts = new RegExp('^' + self.regexString() + '$').exec(path) + var j = 2; // index of the parts array, starts at 2 to bypass the entire match string & the entire match + + var keysAndRoutes = self.keysAndRoutes() + + for (var i in keysAndRoutes) { + if (keysAndRoutes[i] instanceof Key) { + if (keysAndRoutes[i].test(parts[j])) { + params[keysAndRoutes[i].name] = parts[j] + } + } else if (keysAndRoutes[i] instanceof Route) { + if (keysAndRoutes[i].test(parts[j])) { + // parse the subroute + var subparams = keysAndRoutes[i].parse(parts[j], method) + mixin(params, subparams) + // advance the parts pointer by the number of submatches + j+= parts[j].match(keysAndRoutes[i].regexString()).length-2 || 0 + } else { + j++; + } + } + j++; + } + + return params + }; + + // path parsing + while (part = PARTS.exec(path)) { + self.parts.push(part) + } + + // have to do this in two passes due to RegExp execution limits + for (var i in self.parts) { + if (OGRP.test(self.parts[i])) { // optional group + self.parts[i] = new Route(OGRP.exec(self.parts[i])[1], true) + self.parts[i].optional = true + + } else if(KEY.test(self.parts[i])) { // key + var keyname = KEY.exec(self.parts[i])[1] + self.parts[i] = new Key(keyname) + } else { // string + self.parts[i] = String(self.parts[i]) + } + } + + return self + +}; // Route + + +exports.Route = Route \ No newline at end of file diff --git a/lib/router.js b/lib/router.js new file mode 100644 index 0000000..bb5eb64 --- /dev/null +++ b/lib/router.js @@ -0,0 +1,242 @@ +var Route = require('./route').Route, + snakeize = require('./helpers').snakeize + + +// new Router() +// ============ +// Simple router for Node -- setting up routes looks like this: +// +// router = new Router(); +// +// router.match('/') +// .to('application.index') +// .name('main'); +// +// router.match('/users/:user_id/messages/:message_id') +// .to('users.read_message'); +// +// Pretty familiar to anyone who's used Merb/Rails +var Router = function() { + + var METHODS = /^(GET|POST|PUT|DELETE)$/i, + self = this + + this.routes = [] + + // router.match( path [, method] ) + // ------------------------------- + // + // router.match('/:controller/:action(/:id)(.:format)', 'GET') + // .to(......) + // + // path is mandatory (duh) + // method is optional, routes without a method will apply in all cases + // + // returns the route (for chaining) + this.match = function( path, method ) { + + if ( typeof path != 'string' ) throw 'path must be a string' + + if ( typeof method != 'undefined' && !METHODS.test(method)) throw 'method must be one of: get, post, put, delete' + + var route = new Route(path, method) + self.routes.push(route) + + return route + }; + + // convenience methods + // ------------------- + + // ### router.get( path ) + // equivalent to + // + // router.match( path, 'GET' ) + this.get = function( path ){ + return this.match(path, 'GET') + } + // ### router.put( path ) + // equivalent to + // + // router.match( path, 'PUT' ) + this.put = function( path ){ + return this.match(path, 'PUT') + } + // ### router.post( path ) + // equivalent to + // + // router.match( path, 'POST' ) + this.post = function( path ){ + return this.match(path, 'POST') + } + // ### router.delete( path ) + // equivalent to + // + // router.match( path, 'DELETE' ) + this.delete = function( path ){ + return this.match(path, 'DELETE') + } + + // router.resource( controller ) + // ----------------------------- + // generates standard resource routes for a controller name + // + // router.resource('products') + // + // returns an array of routes (for now, this may change) + this.resource = function( controller ) { + var controller_slug = snakeize(controller) + return [ + self.get('/'+controller_slug+'(.:format)', 'GET').to(controller+'.index'), + self.get('/'+controller_slug+'/add(.:format)', 'GET').to(controller+'.add'), + self.get('/'+controller_slug+'/:id(.:format)', 'GET').to(controller+'.show'), + self.get('/'+controller_slug+'/:id/edit(.:format)', 'GET').to(controller+'.edit'), + self.post('/'+controller_slug+'(.:format)', 'POST').to(controller+'.create'), + self.put('/'+controller_slug+'/:id(.:format)', 'PUT').to(controller+'.update'), + self.delete('/'+controller_slug+'/:id(.:format)', 'DELETE').to(controller+'.destroy') + ]; + }; + + + // router.first( path, method, callback ) + // ---------------------------- + // find the first route that match the path & method + // + // router.first('/products/5', 'GET') + // => { controller: 'products', action: 'show', id: 5, method: 'GET' } + // + // find & return a params hash from the first route that matches. If there's no match, this will return false + // + // If the options callback function is provided, it will be fired like so: + // + // callback( error, params ) + this.first = function( path, method, cb ) { + var params = false + + for (var i in self.routes) { + + // attempt the parse + params = self.routes[i].parse(path, method) + if (params) { + // fire the callback if given + if (typeof cb == 'function') cb(false, params) + // may as well return this + return params + } + + } + if (typeof cb == 'function') cb('No matching routes found') + return false + }; + + + // router.all( path [, method] ) + // -------------------------- + // find & return a params hash from ALL routes that match + // + // router.all( '/products/5' ) + // + // => [ + // { controller: 'products', action: 'show', id: 5, method: 'GET' }, + // { controller: 'products', action: 'update', id: 5, method: 'PUT' }, + // { controller: 'products', action: 'destroy', id: 5, method: 'DELETE' }, + // ] + // + // if there ares no matches, returns an empty array + this.all = function( path, method ) { + var ret = [], + params = false + + for (var i in self.routes) { + params = self.routes[i].parse(path, method) // TODO: use call or apply (I forget which) to make 'method' optional + if (params) ret.push(params) + } + return ret; + }; + + // router.url( params[, add_querystring=false] ) + // -------------------------------------------- + // generates a URL from a params hash + // + // router.url( { + // controller: 'products', + // action: 'show', + // id: 5 + // } ) + // => '/products/5' + // + // router.url( { + // controller: 'products', + // action: 'show', + // id: 5, + // format: 'json' + // } ) + // => '/products/5.json' + // + // router.url({ + // controller: 'products', + // action: 'show', + // id: 5, + // format: 'json', + // love: 'cheese' + // }, true ) + // => '/products/5.json?love=cheese' + // + // returns false if there are no suitable routes + this.url = function( params, add_querystring ) { + var url = false + + // iterate through the existing routes until a suitable match is found + for (var i in self.routes) { + // do the controller & acton match? + if (typeof(self.routes[i].params.controller) != 'undefined' && + self.routes[i].params.controller != params.controller) { + continue + } + if (typeof(self.routes[i].params.action) != 'undefined' && + self.routes[i].params.action != params.action) { + continue + } + url = self.routes[i].stringify(params); + if (url) { + break; + } + } + + if (!url) return false // no love? return false + + var qs = require('querystring').stringify(url[1]) // build the possibly empty query string + + if (add_querystring && qs.length > 0) return url[0] + '?' + qs // if there is a query string... + + return url[0] // just return the url + }; + + + // router.defer( testfn() ) + // ------------------------ + // + // router.defer( test( path, method ) ) + // + // test should be a function that examines non-standard URLs + // + // path and method will be passed in - expects a params hash back OR false on a non-match + // + // returns: DeferredRoute (for... reference? I dunno.) + // + // **THIS IS CURRENTLY COMPLETELY UNTESTED. IT MIGHT NOT EVEN WORK. SERIOUSLY.** + // + this.defer = function( fn ) { + if ( typeof(fn) != 'function' ) throw 'Router.defer requires a function as the only argument' + + var route = new Route('deferred') + route.parse = fn // add the custom parser + delete route.test // = function(){return false}; + delete route.stringify //= function(){ throw 'Deferred routes are NOT generatable'}; + self.routes.push(route) + return route + } + +}; // Router + +exports.Router = Router \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..cb5e5cc --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "barista", + "description": "URL router / generator", + "version": "0.0.1", + "author": "Kieran Huggins ", + "repository": "git://github.com/kieran/barista", + "main": "./index.js", + "engines": { "node": ">= 0.3.0" } +} \ No newline at end of file diff --git a/tests/barista.test.js b/tests/barista.test.js new file mode 100644 index 0000000..c099c77 --- /dev/null +++ b/tests/barista.test.js @@ -0,0 +1,496 @@ +var util = require ('util'); +var assert = require('assert'); +var Router = require('../index').Router; + + +RouterTests = { + //pass and fail messages to be used in reporting success or failure + + //basic test setup + setup : function(opts) { + return function() { + router = new Router(); + }(); + }, + + //tear down must be run at the completion of every test + teardown : function(test) { + util.puts("\033[0;1;32mPASSED :: \033[0m" + test); + return function() { + process.addListener("exit", function () { + assert.equal(0, exitStatus); + })(); + } + }, + + // create a router + 'test Create Router' : function() { + assert.ok(router, this.fail); + }, + + // create a simple route + 'test Create Static Route' : function() { + var route = router.match('/path/to/thing'); + assert.ok(route, this.fail); + bench(function(){ + router.match('/path/to/thing'); + }); + }, + + // create a simple route + 'test Create Simple Route' : function() { + var route = router.match('/:controller/:action/:id'); + assert.ok(route, this.fail); + bench(function(){ + router.match('/:controller/:action/:id'); + }); + }, + + // create a route with optional segments + 'test Create Optional Route' : function() { + var route = router.match('/:controller/:action/:id(.:format)') + assert.ok(route, this.fail) + bench(function(){ + router.match('/:controller/:action/:id(.:format)') + }); + }, + + // create a route with multiple optional segments + 'test Create Multiple Optional Route' : function() { + var route = router.match('/:controller/:id(/:action)(.:format)') + assert.ok(route, this.fail) + bench(function(){ + router.match('/:controller/:id(/:action)(.:format)') + }); + }, + + // create a resource + 'test Create Resource' : function() { + var routes = router.resource('snow_dogs'); + assert.ok(routes.length === 7, this.fail) + for ( var i in routes ) { + assert.ok(routes[i], this.fail) + } + bench(function(){ + router.resource('snow_dogs'); + }); + }, + + // create a static route with fixed params + 'test Route With Params' : function() { + var route = router.match('/hello/there').to( 'applicaton.index' ); + assert.ok(route, this.fail) + bench(function(){ + router.match('/hello/there').to( 'applicaton.index' ); + }); + }, + + // create a static route with extra fixed params + 'test Route With Extra Params' : function() { + var route = router.match('/hello/there').to( 'applicaton.index', { language: 'english' } ); + assert.ok(route, this.fail) + }, + + // create a static route with extra fixed params + 'test Route With Extra Params And Route-Implied Endpoint' : function() { + var route = router.match('/:controller/:action').to( { language: 'english' } ); + assert.ok(route, this.fail) + }, + + // create a static route with a specific request method + 'test Route With Method' : function() { + var route = router.match('/:controller/:action', 'GET'); + assert.ok(route, this.fail) + }, + + // create a static route with key regex match requirements + 'test Route With Regex Reqs' : function() { + var route = router.match('/:controller/:action/:id').where( { id: /\d+/ } ); + assert.ok(route, this.fail) + }, + + // create a static route with key match requirements as a regex string + 'test Route With String Regex Reqs' : function() { + var route = router.match('/:controller/:action/:id').where( { id: '\\d+' } ); + assert.ok(route, this.fail) + }, + + // create a static route with key match requirements AND a method + 'test Route With Reqs And Method' : function() { + var route = router.match('/:controller/:action/:id', 'GET').where( { id: /\d+/ } ); + assert.ok(route, this.fail) + }, + + // create a static route with key match requirements AND a method in reverse order + 'test Route With Name' : function() { + var route = router.match('/:controller/:action/:id', 'GET').where( { id: /\d+/ } ).name('awesome'); + assert.ok(route, this.fail) + }, + + +// ok - let's start doing things with these routes + + // test that the router matches a URL + 'test Simple Route Parses' : function() { + var route = router.match('/:controller/:action/:id'); + var params = router.first('/products/show/1','GET'); + assert.ok(params, this.fail); + assert.equal(params.controller, 'products', this.fail); + assert.equal(params.action, 'show', this.fail); + assert.equal(params.id, 1, this.fail); + assert.equal(params.method, 'GET', this.fail); + + bench(function(){ + router.first('/products/show/1','GET'); + }); + }, + + // test that the route accepts a regexp parameter + 'test Simple Route Parses with conditions' : function() { + var route = router.match('/:controller/:action/:id').where( { id: /\d+/ } ); + var params = router.first('/products/show/1','GET'); + assert.ok(params, this.fail); + assert.equal(params.controller, 'products', this.fail); + assert.equal(params.action, 'show', this.fail); + assert.equal(params.id, 1, this.fail); + assert.equal(params.method, 'GET', this.fail); + + bench(function(){ + router.first('/products/show/1','GET'); + }); + }, + + // test that the route rejects a bad regexp parameter + 'test Simple Route fails to Parse with bad conditions' : function() { + var route = router.match('/:controller/:action/:id').where( { id: /\d+/ } ); + var params = router.first('/products/show/bob','GET'); + assert.equal(params, false, this.fail); + + bench(function(){ + router.first('/products/show/1','GET'); + }); + }, + + // test that the callback fires with the right args + 'test Callback Fires With Params' : function() { + var route = router.match('/:controller/:action/:id'); + router.first('/products/show/1','GET',function(err,params){ + assert.ok(params, this.fail); + assert.equal(params.controller, 'products', this.fail); + assert.equal(params.action, 'show', this.fail); + assert.equal(params.id, 1, this.fail); + assert.equal(params.method, 'GET', this.fail); + }); + }, + + // create a static route with extra fixed params + 'test Route With Extra Params And Route-Implied Endpoint Parses' : function() { + var route = router.match('/:controller/:action').to( { language: 'english' } ); + var params = router.first('/products/show','GET'); + assert.ok(params, this.fail); + assert.equal(params.controller, 'products', this.fail); + assert.equal(params.action, 'show', this.fail); + assert.equal(params.method, 'GET', this.fail); + assert.equal(params.language, 'english', this.fail); + }, + + // test that the router matches a URL + 'test Simple Route Parses With Optional Segment' : function() { + var route = router.match('/:controller/:action/:id(.:format)'); + var params = router.first('/products/show/1.html','GET'); + assert.ok(params, this.fail); + assert.equal(params.controller, 'products', this.fail); + assert.equal(params.action, 'show', this.fail); + assert.equal(params.id, 1, this.fail); + assert.equal(params.method, 'GET', this.fail); + assert.equal(params.format, 'html', this.fail); + + bench(function(){ + router.first('/products/show/1.html','GET'); + }); + }, + + 'test Simple Route Parses With Optional Segment Missing' : function() { + var route = router.match('/:controller/:action/:id(.:format)','GET'); + var params = router.first('/products/show/1','GET'); + assert.ok(params, this.fail); + assert.equal(params.controller, 'products', this.fail); + assert.equal(params.action, 'show', this.fail); + assert.equal(params.id, 1, this.fail); + assert.equal(params.method, 'GET', this.fail); + assert.equal(typeof(params.format), 'undefined', this.fail); + + bench(function(){ + router.first('/products/show/1','GET'); + }); + }, + + 'test Simple Route Failing Due To Bad Method' : function() { + var route = router.match('/:controller/:action/:id(.:format)','GET'); + var params = router.first('/products/show/1','POST'); + assert.equal(params, false, this.fail); + + bench(function(){ + router.first('/products/show/1','POST'); + }); + }, + + 'test Simple Route With Two Optional Segments' : function() { + var route = router.match('/:controller/:action(/:id)(.:format)','GET'); + var params = router.first('/products/show','GET'); + assert.ok(params, this.fail); + assert.equal(params.controller, 'products', this.fail); + assert.equal(params.action, 'show', this.fail); + assert.equal(typeof(params.id), 'undefined', this.fail); + assert.equal(typeof(params.format), 'undefined', this.fail); + assert.equal(params.method, 'GET', this.fail); + + bench(function(){ + router.first('/products/show','GET'); + }); + }, + + 'test Simple Route With Two Optional Segments With First Used' : function() { + var route = router.match('/:controller/:action(/:id)(.:format)','GET'); + var params = router.first('/products/show/1','GET'); + assert.ok(params, this.fail); + assert.equal(params.controller, 'products', this.fail); + assert.equal(params.action, 'show', this.fail); + assert.equal(params.id, 1, this.fail); + assert.equal(typeof(params.format), 'undefined', this.fail); + assert.equal(params.method, 'GET', this.fail); + + bench(function(){ + router.first('/products/show/1','GET'); + }); + }, + + 'test Simple Route With Two Optional Segments With Second Used' : function() { + var route = router.match('/:controller/:action(/:id)(.:format)','GET'); + var params = router.first('/products/show.html','GET'); + assert.ok(params, this.fail); + assert.equal(params.controller, 'products', this.fail); + assert.equal(params.action, 'show', this.fail); + assert.equal(typeof(params.id), 'undefined', this.fail); + assert.equal(params.format, 'html', this.fail); + assert.equal(params.method, 'GET', this.fail); + + bench(function(){ + router.first('/products/show.html','GET'); + }); + }, + + 'test Simple Route With Two Optional Segments With Both Used' : function() { + var route = router.match('/:controller/:action(/:id)(.:format)','GET'); + var params = router.first('/products/show/1.html','GET'); + assert.ok(params, this.fail); + assert.equal(params.controller, 'products', this.fail); + assert.equal(params.action, 'show', this.fail); + assert.equal(params.id, 1, this.fail); + assert.equal(params.format, 'html', this.fail); + assert.equal(params.method, 'GET', this.fail); + + bench(function(){ + router.first('/products/show/1.html','GET'); + }); + }, + +// fuck, how repetitive. how about methods for a bit? + + 'test GET' : function() { + var route = router.match('/:controller/:action(/:id)(.:format)','GET'); + var params = router.first('/products/show/1.html','GET'); + assert.ok(params, this.fail); + assert.equal(params.method, 'GET', this.fail); + }, + + 'test POST' : function() { + var route = router.match('/:controller/:action(/:id)(.:format)','POST'); + var params = router.first('/products/show/1.html','POST'); + assert.ok(params, this.fail); + assert.equal(params.method, 'POST', this.fail); + }, + + 'test PUT' : function() { + var route = router.match('/:controller/:action(/:id)(.:format)','PUT'); + var params = router.first('/products/show/1.html','PUT'); + assert.ok(params, this.fail); + assert.equal(params.method, 'PUT', this.fail); + }, + + 'test DELETE' : function() { + var route = router.match('/:controller/:action(/:id)(.:format)','DELETE'); + var params = router.first('/products/show/1.html','DELETE'); + assert.ok(params, this.fail); + assert.equal(params.method, 'DELETE', this.fail); + }, + + 'test GET Shorthand' : function() { + var route = router.get('/:controller/:action(/:id)(.:format)'); + var params = router.first('/products/show/1.html','GET'); + assert.ok(params, this.fail); + assert.equal(params.method, 'GET', this.fail); + }, + + 'test POST Shorthand' : function() { + var route = router.post('/:controller/:action(/:id)(.:format)'); + var params = router.first('/products/show/1.html','POST'); + assert.ok(params, this.fail); + assert.equal(params.method, 'POST', this.fail); + }, + + 'test PUT Shorthand' : function() { + var route = router.put('/:controller/:action(/:id)(.:format)'); + var params = router.first('/products/show/1.html','PUT'); + assert.ok(params, this.fail); + assert.equal(params.method, 'PUT', this.fail); + }, + + 'test DELETE Shorthand' : function() { + var route = router.delete('/:controller/:action(/:id)(.:format)'); + var params = router.first('/products/show/1.html','DELETE'); + assert.ok(params, this.fail); + assert.equal(params.method, 'DELETE', this.fail); + assert.equal(params.action, 'show', this.fail); + }, + + +// that was fun. Let's do a little resource testing + + 'test Resource Matches' : function() { + var routes = router.resource('snow_dogs'); + + // index + assert.ok( router.first('/snow_dogs','GET'), this.fail); + assert.ok( router.first('/snow_dogs.html','GET'), this.fail); + assert.equal( router.first('/snow_dogs','GET').action, 'index', this.fail); + // show + assert.ok( router.first('/snow_dogs/1','GET'), this.fail); + assert.ok( router.first('/snow_dogs/1.html','GET'), this.fail); + assert.equal( router.first('/snow_dogs/1','GET').action, 'show', this.fail); + // add form + assert.ok( router.first('/snow_dogs/add','GET'), this.fail); + assert.ok( router.first('/snow_dogs/add.html','GET'), this.fail); + assert.equal( router.first('/snow_dogs/add','GET').action, 'add', this.fail); + // edit form + assert.ok( router.first('/snow_dogs/1/edit','GET'), this.fail); + assert.ok( router.first('/snow_dogs/1/edit.html','GET'), this.fail); + assert.equal( router.first('/snow_dogs/1/edit','GET').action, 'edit', this.fail); + // create + assert.ok( router.first('/snow_dogs','POST'), this.fail); + assert.ok( router.first('/snow_dogs.html','POST'), this.fail); + assert.equal( router.first('/snow_dogs','POST').action, 'create', this.fail); + // update + assert.ok( router.first('/snow_dogs/1','PUT'), this.fail); + assert.ok( router.first('/snow_dogs/1.html','PUT'), this.fail); + assert.equal( router.first('/snow_dogs/1','PUT').action, 'update', this.fail); + // delete + assert.ok( router.first('/snow_dogs/1','DELETE'), this.fail); + assert.ok( router.first('/snow_dogs/1.html','DELETE'), this.fail); + assert.equal( router.first('/snow_dogs/1','DELETE').action, 'destroy', this.fail); + }, + +// url generation time nao + + 'test Resource Url Generation' : function() { + var routes = router.resource('snow_dogs'); + // index + assert.equal( router.url( { controller:'snow_dogs', action:'index' } ), '/snow_dogs', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'index', format: 'html' } ), '/snow_dogs.html', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'index', format: 'json' } ), '/snow_dogs.json', this.fail); + // show + assert.equal( router.url( { controller:'snow_dogs', action:'show', id:1 } ), '/snow_dogs/1', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'show', id:1, format: 'html' } ), '/snow_dogs/1.html', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'show', id:1, format: 'json' } ), '/snow_dogs/1.json', this.fail); + // add form + assert.equal( router.url( { controller:'snow_dogs', action:'add' } ), '/snow_dogs/add', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'add', format: 'html' } ), '/snow_dogs/add.html', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'add', format: 'json' } ), '/snow_dogs/add.json', this.fail); + // edit form + assert.equal( router.url( { controller:'snow_dogs', action:'edit', id:1 } ), '/snow_dogs/1/edit', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'edit', id:1, format: 'html' } ), '/snow_dogs/1/edit.html', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'edit', id:1, format: 'json' } ), '/snow_dogs/1/edit.json', this.fail); + // create + assert.equal( router.url( { controller:'snow_dogs', action:'create' } ), '/snow_dogs', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'create', format: 'html' } ), '/snow_dogs.html', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'create', format: 'json' } ), '/snow_dogs.json', this.fail); + // update + assert.equal( router.url( { controller:'snow_dogs', action:'update', id:1 } ), '/snow_dogs/1', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'update', id:1, format: 'html' } ), '/snow_dogs/1.html', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'update', id:1, format: 'json' } ), '/snow_dogs/1.json', this.fail); + // delete + assert.equal( router.url( { controller:'snow_dogs', action:'destroy', id:1 } ), '/snow_dogs/1', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'destroy', id:1, format: 'html' } ), '/snow_dogs/1.html', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'destroy', id:1, format: 'json' } ), '/snow_dogs/1.json', this.fail); + + bench(function(){ + router.url( { controller:'snow_dogs', action:'destroy', id:1, format: 'json' } ) + }); + }, + + 'test Route Url Generation' : function() { + var route = router.match('/:controller/:action(/:id)(.:format)'); + assert.equal( router.url( { controller:'snow_dogs', action:'pet' } ), '/snow_dogs/pet', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'pet', id:5 } ), '/snow_dogs/pet/5', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'pet', id:5, format:'html' } ), '/snow_dogs/pet/5.html', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'pet', id:5, format:'json' } ), '/snow_dogs/pet/5.json', this.fail); + assert.equal( router.url( { controller:'snow_dogs', action:'pet', format:'html' } ), '/snow_dogs/pet.html', this.fail); + + bench(function(){ + router.url( { controller:'snow_dogs', action:'pet', id:5, format:'html' } ) + }); + }, + + 'test Route Url Generates Route With QueryString Params' : function() { + var route = router.match('/:controller/:action(/:id)(.:format)'); + // test with QS params ON + assert.equal( router.url( { controller:'snow_dogs', action:'pet', awesome:'yes' }, true ), '/snow_dogs/pet?awesome=yes', this.fail); + }, + + 'test Route Url Generates Route Without QueryString Params' : function() { + var route = router.match('/:controller/:action(/:id)(.:format)'); + // test with QS params OFF (default behaviour) + assert.equal( router.url( { controller:'snow_dogs', action:'pet', awesome:'yes' }, false ), '/snow_dogs/pet', this.fail); + }, + + 'test Creating a route without a string path will throw an error' : function() { + assert.throws( function() { + var route = router.match(5) + }, /path must be a string/, + this.fail ); + assert.throws( function() { + var route = router.match(/bob/) + }, /path must be a string/, + this.fail ); + assert.throws( function() { + var route = router.match({}) + }, /path must be a string/, + this.fail ); + }, + + +} + +function bench(fn){ + return true + var start = new Date().getTime(); + for ( var i=0; i<1000; i++ ) { + fn(); + } + util.puts('\navg time: '+(new Date().getTime() - start) / 1000 + 'ms for the following test:'); +} + + +// Run tests -- additionally setting up custom failure message and calling setup() and teardown() +for(e in RouterTests) { + if (e.match(/test/)) { + RouterTests.fail = "\033[0;1;31mFAILED :: \033[0m" + e; + try { + RouterTests.setup(); + RouterTests[e](); + RouterTests.teardown(e); + } catch (e) { + util.puts(RouterTests.fail) + } + } +}