Dynamic human-readable RESTful routing

I’m slowly becoming a convert to the RESTful routing model in recent versions of Rails, largely because it builds a pile of useful named routes with a tiny amount of code. If I put map.resources :sections in my routes.db file, I instantly get sections_path, edit_section_path, and all their friends. When coding a controller, it’s a lot nicer to write redirect_to section_path than redirect_to :controller => :section, :action => show.

The only difficulty I have with default routes created by map.resources is that, in some cases, they’re ugly and show too much of the application’s internal workings. For example, the application I’m working on now has a Section model, which describes a major section heading in the site (things like About, FAQ, and Help). Instead of having weird routes like /sections/1, I want human-readable URLs like /about. They look nicer, and they’re better for SEO.

To the rescue: dynamic named routes

To create the pretty section names, my routing dynamically creates a set of named routes based on the URL-friendly string stored in each Section’s name attribute. The appropriate part of routes.db looks like this:

  map.resources :sections
  Section.find(:all).each do |section|
    map.named_route("#{section.name}_section",
                    section.name,
                    :controller => 'sections',
                    :action => 'show',
                    :id => section.name,
                    :method => :get)
  end

The first thing this code does is create normal RESTful routes for the sections:

  map.resources :sections

Creating the usual RESTful routes allows administrative actions, like editing and deleting sections, to have all the benefits of regular REST routing. I can still edit a section at /sections/1/edit, or delete one by sending the DELETE HTTP method to /sections/1. I’m okay with most of these URLs showing off section ID numbers, because they’re all tucked away behind an admin interface that requires authentication to access. What I really need is to create new routes for each section’s show action, which is what the next part of the code does.

Next, the code iterates over the sections in the database, using the standard ActiveRecord find method:

  Section.find(:all).each do |section|

Within this each block, the map.named_route method is used to create a named route pointing to each section’s show method. Normally, Rails creates named routes using a little method_missing magic. This is great when you’re making a named route by hand; it lets you create named routes on the fly, instead of having to call a method on the map object (an instance of ActionController::Routing::RouteSet::Mapper). Unfortunately, this bit of syntactic sugar isn’t as nice to code that needs to create named routes.

Fortunately, the Mapper class contains an undocumented named_route method that creates named routes in a standard method call, rather than relying on method_missing to fill in the blanks. The named_route method takes three arguments: the name for the new route, the URL path that will trigger the route, and a hash full of options defining the properties of the route. In my case, the name for the route is composed of the Section object’s name attribute, with _section tacked onto the end. This results in helper methods like about_section_path and faq_section_url. The recognized path is the name of the section itself, meaning that /about will go straight to the section controller’s show method and display the About page.

Putting in the human-readable bit

One other sneaky thing I’m doing is on line 7 of the first code example, with :id => section.name. Because I’m using short, human-readable names in my section URLs, I’ve changed how the Section model generates URLs. By default, ActiveRecord returns the record’s id@ as a String, resulting in the familiar @/sections/1 style of URL. However, if you override the to_param method of an ActiveRecord model, you can change how it generates URLs. Over in my section.rb file, I have the following code:

  def to_param
    name
  end

This ensures that when the Section model is used to generate a URL, it returns the friendly, human-readable name attribute instead of its numeric @id@.

A drawback: rake gets wonky

There is a downside to this method of dynamically generating routes. Recent Rails versions (probably 2.1 and later) changed the rake tasks framework to load up the entire application’s environment before running any rake task. Unfortunately, this includes the routes.rb file. Because my routing queries the database, a normally innocuous invocation of rake db:create causes problems if the database is missing or empty, producing an error like the following:

  > rake db:create
  rake aborted!
  Unknown database 'project_development'

I can’t fathom why something like db:create would need access to the application’s entire environment, and it’s particularly silly in this case that in order to create the database, there must already be a database. I expect that purists would argue that having a database query in my routing breaks MVC separation, but this method makes it simple to make named routes based on changeable data. The workaround is to comment out the Section.find part of the routes.rb file before running things like db:create, but it’s a poor workaround, indeed.

In addition, I’m not positive that this setup works well under the Rails production environment. It’s been fine in development, but caching and other goodness introduced in a production application could mean that I can’t change the routing on the fly like this, instead requiring the application to restart to pick up any changes to the sections data. I recall earlier versions of Rails requiring a restart any time routing was changed, but I haven’t been able to track down if this applies to 2.2 in production.

A different approach?

Another possibility, which I may settle on eventually, would be to add a catch-all route at the bottom of routes.rb that pipes everything through a dispatch method in the sections controller. It might look something like this:

  map.parse_section ':section',
    :controller => 'sections',
    :action => 'parse'

It could perform the database lookup inside controller code and compare it to the string captured by the routing system, and it wouldn’t interfere with rake tasks. It’s important to note that this route should be at the very bottom of routes.db, else it will slurp up any single-component URL and attempt to process it with the section parse method.

One Comment

  1. Johannes
    Posted May 15, 2009 at 11:09 am | Permalink

    Check out the resource_hacks plugin:

    “resource_hacks is a plugin that extends edge Rails’ new restful routes implementation. While the current opinion of the core team is that we should only use member resources with a numeric id, resource_hacks allows Rails to support arbitrary an arbitrary member path.”

    http://archive.jvoorhis.com/articles/2006/08/01/announcing-resource_hacks

    Cheers!

Post a Comment

Your email is never published nor shared. Required fields are marked *

*
*