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.