Friday, May 14, 2010

User-Friendly RESTful Routes in Rails

RESTful routes created via map.resources use the :id field for URLs for individual resources.  I.e. if user 'joebob' has an id of 42 then the URL for his resource is

I find those URLs rather user-unfriendly.  Wouldn't be nicer?

If you plan for this from the start you can fake out rails by using a non-standard id column.  I.e.

    create_table :users, :id => false do |t|
      t.string :id, :null => false
    add_index :users, :id, :unique => true

Judging by blog posts there are at least a few folks out there who have taken this approach.  Here are a couple that point out issues you might run into if your user names (or whatever identifier you use for id) contain special characters:  here and here

But what if you already have a project up and running and don't want to rearrange your database?  Or just think that faking out Rails that way is kinda ugly?

It is possible you can do this somehow with map.resources and its :requirements parameter, but I haven't been able to figure out a syntax that works.  It seems that map.resources really expects to map the identifier to the id column.

So for now I'm settling with a partial solution.  In routes.rb:

  map.connect 'users/:name', :controller => 'users', :action => 'show', :name => /[a-z]+[a-z0-9]*/
  map.resources :users

The trick with the connect route is that the regex for name must not match ids.  I.e. you want /users/joebob to use the connect route, but /users/42 to use the RESTful route.  Of course if your usernames are alpha only this is easier.  Note that Rails does not allow anchor characters in the regex, so a regex like /^[a-z][a-z0-9]*/ doesn't work.

OK, but if you have a standard RESTful controller the first line of the show method is going to look like:

    @user = User.find(params[:id]

But that method is going to get passed params[:name] for calls using the connect route.  So you'll need to replace that line with something like:

    @user = nil
    if params[:id]
      @user = User.find(params[:id])
    elsif params[:name]
      @user = User.find(:first, :conditions => ['name = ?', params[:name]])

At this point at least the basics are working, but I'd welcome feedback and suggestions for improving this.

Update:  See