Pages

Sunday, November 22, 2009

Request Routing With URI Templates in Node.JS

I've been playing around with node.js, an asynchronous JavaScript server built on V8.

Node.js itself is pretty bare bones.  It's not a framework like Rails, but rather plain request-response handling.  It's sort of like Python's Twisted framework, from what I gather.

There are more full-featured frameworks for node.js if you look around on github, but I'm bored and feel like committing the sin of writing yet more framework code.

This morning I started with a request router for Node.JS that leverages URI templates*.  You specify the application as a series of request templates, paired with the functions that handle them.

For instance if you take the typical blogging application example, you might have a path like /posts/1234 - and the URI template would look like /posts/{postId}.

The magic is in turning {param}s in the URI template into parameters in the handler call.

Here's an example app that routes blog-like requests:

var handlers = {
  '/posts/{postId}' : {
      GET : function(postId) {
        this.response.sendHeader(200, {"Content-Type": "text/plain"});
        this.response.sendBody("GET Post ID: " + postId);
        this.response.finish();
      },
      POST : function(postId) {
        this.response.sendHeader(200, {"Content-Type": "text/plain"});
        this.response.sendBody("POST Post ID: " + postId);
        this.response.finish();        
      }
  },
  '/comments/{postId}/{commentId}' : {
      GET : function(postId, commentId) {
        this.response.sendHeader(200, {"Content-Type": "text/plain"});
        this.response.sendBody("GET Post ID: " + postId + " Comment ID: " + commentId);    
        this.response.finish();
      }
  }
};

As you can see the individual handler functions are further distinguished by HTTP method.

I'm not sure how to pass POST bodies to the handlers. They could just be attached to the handler's this I suppose.

Here's the full source:

var sys = require("sys"), http = require("http");

var handlers = {
  '/posts/{postId}' : {
      GET : function(postId) {
        this.response.sendHeader(200, {"Content-Type": "text/plain"});
        this.response.sendBody("GET Post ID: " + postId);
        this.response.finish();
      },
      POST : function(postId) {
        this.response.sendHeader(200, {"Content-Type": "text/plain"});
        this.response.sendBody("POST Post ID: " + postId);
        this.response.finish();        
      }
  },
  '/comments/{postId}/{commentId}' : {
      GET : function(postId, commentId) {
        this.response.sendHeader(200, {"Content-Type": "text/plain"});
        this.response.sendBody("GET Post ID: " + postId + " Comment ID: " + commentId);    
        this.response.finish();
      }
  }
};

var Route = function(uriTemplate) {
 this.uriTemplate = uriTemplate;
 var nameMatcher = new RegExp('{([^}]+)}', 'g');
 
 this.paramNames = this.uriTemplate.match(nameMatcher);
 // the regex keeps the {} on the param names for some reason. TODO: fix this.
 for (var i = 0; i < this.paramNames.length; i++) {
  this.paramNames[i] = this.paramNames[i].replace('{', '').replace('}', '');
 }

 this.matcherRegex = this.uriTemplate.replace('?', "\\?").replace(/{([^}]+)}/g, '([^/?&]+)');
 this.matcher = new RegExp(this.matcherRegex);
};

Route.prototype.parse = function(path) {
 if (this.matcher.test(path)) {
  var result = {};
  var paramValues = this.matcher.exec(path);
  // assert: paramValues.length == paramNames.length
  for (var i = 1; i < paramValues.length; i++) {
      result[this.paramNames[i-1]] = paramValues[i];
    }
  return result;
 }
 return null; //throw exception?
};

http.createServer(function (request, response) {
   var handled = false;

   for (pathTemplate in handlers) {
     var route = new Route(pathTemplate);
     var params = route.parse(request.uri.full);
     if (params) {
       // Convert the results to an array so we can pass them in via apply().
       var values = [];
       for (name in params) {
         values[values.length] = params[name];
       }

       var handler = handlers[pathTemplate][request.method];
       // So you can call this.request and this.response in the handlers.
       handler.apply({'request' : request, 'response' : response}, values);
       handled = true;
     }
   }

   if (!handled) {
     response.sendHeader(404, {"Content-Type": "text/plain"});
     var output = "Couldn't route: " + request.uri.full + "\n";
     for (name in request) {
       output += name + ": " + request[name] + "\n";
     }
     response.sendBody(output);
     response.finish();
   }
}).listen(8000);

sys.puts("Server running at http://127.0.0.1:8000/");
The route lookup in the http.createServer could be a lot more efficient, like memoizing Route objects for instance.

Anyways, NodeJS looks pretty exciting. Combined with CouchDB you could have a full JavaScript application stack: from storage to app server to client.

*Yes, I realize this is not a full implementation of the URI template spec.  It's just a proof of concept.

4 comments:

  1. Hm. It just occurred to me that the URI template parameter-to-function-parameter mapping is positional, so that the names in the template must be in the same order as the names in the handler function. So you could't change /comments/ handler to take (commentId, postId) and have it still work. This code would produce the reverse of what would would expect for those two parameter values.

    Instead of calling apply() with the parameter array, it could just pass in the Route.parse() results as a single parameter to the handler.

    ReplyDelete
  2. Hmm. But it's still ends up as a regex. What's the point (besides somehow clearer syntax)? Regex-only URI (like the ones implemented in `nerve`) will be much more flexible.

    On the other hand, for simple tasks as this, I think clear syntax will outweight other concerns.

    ReplyDelete
  3. Clearer syntax is the point. How often do you need the full power of RegExp for path parsing? Rarely. And you end up with typos, unintentionally greedy patterns that match other handlers' request paths. Really RegExp is overkill.

    PLUS- this method uses the variable {names} from the URI template as the actual variable names returned in a match. So you don't have to iterate through the RegExp match groups manually looking for a parameter from the URI.

    ReplyDelete
  4. I'd check if the request.method exists in the handler and return a 405 otherwise.

    ReplyDelete