Tuesday, February 28, 2012

Rails 3 routes and routing constraints with optional parameters

    I was recently faced with a problem where I needed to develop a routing system for a Rails application that handled human and SEO friendly urls.  This article will take you through the various stages of my journey to the final solution.  Just to forewarn you, I don't purport to be a routing expert, and I don't claim there isn't a more elegant solution for this problem set.  But since I was unable to find much help on the interwebs, I thought maybe a person or two might find this useful.
 
   Say, for example, you are tasked with developing a type of taxonomic application that would allow users to navigate a path of increasing levels of specificity (we'll only use 4 of the 7 levels).  Easy enough you say; just define a route for each level your app needs to handle.

get "/:kingdom" => "the_controller#classes"
get "/:kingdom/:class/:family" => "the_controller#species"
get "/:kingdom/:class/:family/:species" => "the_controller#details"

(Don't whine at me because I'm not using RESTful routes here; this is just a simple example.)

So an url such as /animals/mammals/dogs/wolf would be handled by our defined routes (matches the last one).  Ok, so what's the problem?  Well what if one of the requirements of the application was to allow users to SKIP one of these levels.  For example, what if the user is presented with a list of animal classes along with their associated families, AND they are allowed to select either the class or the family to see an associated list of species.
  • Mammals
    • Cats
    • Dogs
    So if they select "Cats", then the associated link would be /animal/mammals/cats and they are taken to the species page.  Well, what if they select "Mammals" and expect to be taken to the species page?  Do we have a route to handle that?  /animals/mammals just takes us back to where we just were.  Could we add another route like so:  /:kingdom/:class/:species?  Well how would Rails differentiate between :species and :family since two routes would have those parameters in the same location in their structure.

    You might be thinking at this stage; well, if :family is optional, let's define it that way: /:kingdom/:class(/:family)/:species But as any decent programmer could tell you, optional parameters only work as expected when placed AT THE END of an arguments list.

     So what do you do?  Static routes that don't map parameters aren't very useful.  You want Rails routing to do it's magic and not have to parse request urls (ewww).  So enter one of the uses of routing CONSTRAINTS.  What constraints allow us to do here is sort of fudge on the rule about optional parameters.  Our final routing solution might look something like this:

get "/:kingdom" => "the_controller#classes"
get "/:kingdom/:class(/:family)" => "the_controller#species", :constraints=>{:family=>Family.all.map(&:name).join('|')}
get "/:kingdom/:class(/:family)/:species" => "the_controller#details", :constraints=>{:family=>Family.all.map(&:name).join('|')}

    Let me explain what this accomplishes.  First you'll notice that :family has now become optional.  But wait, that can't work!  Well what the conditional does is define what :family can match against. (All the extra stuff in there is just pulling together a list of Family instance names into a regular expression.)  Now if an url segment exists in the location defined by :family AND it matches the regexp we defined, it will be recognized as a family.  However, if that segment does NOT match the regexp, routing will say: "Hey, this isn't a family, it must be a species."

Please note: The order in which your routes are defined does matter.  For example. if you swapped the placement of the inner-two routes with each other, then any routes with two segments would always go to the species action.  Let's see if you can tell me why...