User profile permalinks with Ruby on Rails (and Authlogic)

There are many different ways to implement pretty permalinks (aka SEO URL aka Nice URL…) in Rails. August Lilleaas wrote an excellent post explaining the most common solutions a few weeks ago. Ryan Bates created a screencast back in 2007 called Model name in URL.

This article is a little bit more specific. Its goal is to demonstrate how to create permalinks for user profiles based on Authlogic. If you don't know about Authlogic, it is an emerging Authentication solution for Rails available as plugin/GEM.

Why am I mentioning Authlogic? This article doesn't require your application to be based on Authlogic, however it makes some assumption that I'm sure all Authlogic users would met. This is the reason why, if you don't use Authlogic, make sure your application provides at least the features required to follow this article.

If you are expecting a step-by-step tutorial, sorry to disappoint you. This is more like a summary including lots of code snippets and some feedback useful if you need to implement a similar solution on your own.

Scenario

As you can see from August's article, there's more than one way to create pretty URLs with Rails. For this specific project, I wanted to completely replace the ID-based system so that any URL pointing to a profile path always used the user login name instead of the database record ID.

In other words, I want the following URLs

http://projecturl.com/users/12
http://projecturl.com/users/12/comments
http://projecturl.com/users/12/articles

to become something like

http://projecturl.com/users/weppos
http://projecturl.com/users/weppos/comments
http://projecturl.com/users/weppos/articles

Are you ready? Let's start.

Do you have tests?

If the answer is no, I'm sorry, but this article is probably too much complex for your application. If you really want to do this architectural change, you must ensure your application contains a reasonable test suite.

In order to gain the "reasonable" flag, you must have at least one functional test for each UserController action and one unit test for any sensible User model action. Don't you have them? What are you waiting for? Go back and write them!

Here's a few functional tests extracted from the application I want to convert.

class UsersControllerTest < ActionController::TestCase
  test "index should paginate users and render page" do
    get :index
    assert_response :success
    assert_template "users/index"
    assert_equal User.paginate(:page => 1, :order => 'login'), assigns(:users)
  end

  test "latest should paginate users order by :activated_at and render page" do
    get :latest
    assert_response :success
    assert_template "users/latest"
    assert_equal User.paginate(:page => 1, :order => 'activated_at DESC'), assigns(:users)
  end

  test "show should load user, latest articles and render page" do
    get :show, :id => users(:quentin).to_param
    assert_response :success
    assert_template "users/show"
    assert_equal users(:quentin), assigns(:user)
    assert_instance_of Array, assigns(:latest_articles)
  end

  test "articles should load user, articles and render page" do
    get :articles, :id => users(:quentin).to_param
    assert_response :success
    assert_template "users/articles"
    assert_equal users(:quentin), assigns(:user)
    assert_kind_of Array, assigns(:articles)
  end

  test "new should initialize user and render page" do
    get :new
    assert_response :success
    assert_template "users/new"
    user = User.new(:secret => "foo")
    assert_equal user.attributes, assigns(:user).attributes
  end

  test "create should create user and redirect" do
    assert_difference "User.count" do
      post :create, :user => { :login => "superpippo", :password => "noccioline", :password_confirmation => "noccioline", :email => "superpippo@example.com" }
      assert_redirected_to root_path
    end
    assert_equal "superpippo", assigns(:user).login
    assert !assigns(:user).active?
  end

  # .. many other tests

end

As you can see, there are a lot of behaviors you should test. It's also really important to test all the methods that partially interacts with the user controller. For example, if you have an ArticleController action that loads the user before searching for the appropriate article, you should write a test for it. This is really important because this refactoring activity is likely to influence that action as well.

Overriding to_param method

At a first glance, the solution seems to be really straightforward. The User model contains a login field used to store the username. Just ensure to_param returns the username value is enough to convert the entire application behavior.

There are at least a couple of issues we must be ready to handle. First, we are working (or at least I was at the time I implemented that feature) on a live application with thousand of users, so we have to be sure all existing accounts would work with the new implementation as well. Furthermore, the application doesn't apply any kind of normalization when the user is created, thus the database contains records in the following formats

username
UserName
username
user-name

The first step is to create and test the method that returns a valid slug for the User record. It's like a kind of unique key, except for being a virtual attribute and a string.

class User
  def slug
    login.downcase if login?
  end
end

Let's write some tests to make sure it works as expected.

class UserTest < ActiveSupport::TestCase
  # ... other tests

  LoginToSlug = [
    ["username", "username"],
    ["username", "UserName"],
    [nil, ""],
    [nil, nil],
  ]

  test "slug" do
    LoginToSlug.each do |expected, login|
      assert_equal expected, User.new(:login => login).slug
    end
  end
end

Now let's map this field to the to_param method. Here's an example, I'm skipping to copy here the tests because they are really straightforward. You should not skip them in your code!

# Overwrites the default to_param method to force
# the use of custom permalink.
def to_param
  slug || super
end

Custom finder

Did you remember the beginning of the article? I told you the application is live and working.

We decided to go ahead with a virtual attribute instead of a database field, thus we don't have any find_by_slug method available. This doesn't mean we can't created it. Personally I prefer the bang! version rather than the classic one, therefore I'm going to create only this version of the finder.

The main difference between a find_by_* and a find_by_*! method is that the latter raises an exception when no record is found. We must focus our unit tests on that behavior too.

# app/models/user.rb
def self.find_by_slug!(slug, options = {})
  with_scope(:find => { :conditions => ["LOWER(login) = ?", slug.to_s.downcase] }) do
    first(options) || raise(ActiveRecord::RecordNotFound)
  end
end

# test/unit/user_test.rb
test "self.find_by_slug!" do
  user = Factory(:user, :login => "UserName100")
  assert_equal user, User.find_by_slug!("UserName100")
  assert_equal user, User.find_by_slug!("username100")
  assert_equal user, User.find_by_slug!("USERNAME100")
end

test "self.find_by_slug! should raise ActiveRecord::RecordNotFound" do
  assert_raise(ActiveRecord::RecordNotFound) { User.find_by_slug!("UserName100") }
  assert_raise(ActiveRecord::RecordNotFound) { User.find_by_slug!("username100") }
  assert_raise(ActiveRecord::RecordNotFound) { User.find_by_slug!("USERNAME100") }
end

Before going ahead, let's stop by this test for a minute. Where does this Factory method come from? Have a rest and check out the FactoryGirl GEM.

Update User#find calls

Our refactoring is almost done. We just need to make sure our controllers are going to use the new finder instead of the following one

User.find(params[:id])

Let's open the UserController controller and replace any User.find call with the following one

User.find_by_slug!(params[:id])

Make sure to update your code everywhere in all your Controllers. If you are using Authlogic, you are likely to need to update just a few methods because the Authlogic authentication system would not be affected by that changes. It will continue to store the user id in the user cookie and lazy-load it on every request.

Run your tests

If you have a comprehensive test suite, you should be able to spot any bug immediately just running the test suite. Run all your tests and ensure they passes. If not, go back and investigate the problem.

Also make sure to run the test suite before starting to change the code. In this way you can safely assume your changes invalidated the application.

Hey, my username is foo@bar.com

Ok, I cheated you. The article is not finished yet, unless you have been smarter than me when you installed Authlogic

By default, Authlogic validates usernames against the following regular expression.

/Aw[w.+-_@ ]+z/

It means the following usernames are allowed:

username
UserName
user@name.com
user.name
user-name
user_name

Unfortunately, by default Rails doesn't support paths including the following characters

@.

It means we must allow them as well. Also, we can't simply remove these characters from the database because existing users might have been used them already.

First we have to update the routing rule with a requirement for the :id parameter. The value must match the same regular expression used to validate the User model. You might want to reference Authlogic regexp or use a constant. I ended-up with the latter choice because I don't want my routing file to be coupled with my authentication system.

# app/models/user.rb
# The regular expression pattern to validate the format of login fied.
LOGIN_PATTERN = 'w[w.+-_@]+'

# config/routes.rb
map.resources :users,
              :requirements => { :id => /#{User::LOGIN_PATTERN}/ }

Now let's write some tests to make sure the code works as expected.

ValidUsernames = [
  "UserName"      ,
  "user@name.com" ,
  "user.name"     ,
  "user-name"     ,
  "user_name"     ,
]

ValidUsernames.each do |username|
  test "show can handle login #{username}" do
    user = Factory(:user, :login => username)
    get :show, :id => username
    assert_response :success
    assert_equal user, assigns(:user)
  end
end

How can I redirect all my old URLs?

This seems to be a really good candidate to demonstrate how Rails Metal feature works. However, I'll write about this in an other post.

Conclusions

In this article we went through the creation of pretty permalinks for a Rails model. Although I mentioned Authlogic and a User model, you can apply this tutorial to any of your Rails model, it's not limited to user profiles or the Authlogic authentication system.

This article also demonstrates you should always write tests along with your features. Do not let features to be implemented without tests or refactoring them will be a pain in the ass.