Controllers
Friendly-URLs
By default, Rails applications build URLs based on the primary key – the id
column from the database. Imagine we have a Person
model and associated controller. We have a person record for Bob Martin
that has id
number 6
. The URL for his show page would be:
1
|
|
But, for aesthetic or SEO purposes, we want Bob’s name in the URL. The last segment, the 6
here, is called the "slug". Let’s look at a few ways to implement better slugs.
Simple Approach
The simplest approach is to override the to_param
method in the Person
model. Whenever we call a route helper like this:
1
|
|
Rails will call to_param
to convert the object to a slug for the URL. If your model does not define the to_param
method then Rails will use the implementation in ActiveRecord::Base
, which just returns the id
.
For the to_param
method to succeed, it is critical that all links use the ActiveRecord
object rather than calling id
. Don’t do this:
1
|
|
Instead, always pass the object:
1
|
|
Slug Generation with to_param
In the model, we can override to_param
to include a parameterized version of the person’s name:
1 2 3 4 5 |
|
The parameterize
method from ActiveSupport
will turn any string into characters valid for in a URL.
For our user Bob Martin
with id
number 6
, the to_param
will generate a slug 6-bob_martin
. The full path would be:
1
|
|
Object Lookup
What do we need to change about our finders? Nothing!
When we call Person.find(x)
, the parameter x
is converted to an integer to perform the SQL lookup. Check out how to_i
deals with strings which have a mix of letters and numbers:
IRB
2.1.1 :001> 2.1.1 :002> 2.1.1 :003> 2.1.1 :004> |
|
The to_i
method will stop interpreting the string as soon as it hits a non-digit. Since our implementation of to_param
always has the id
at the front followed by a hyphen, it will always do lookups based on just the id
and discard the rest of the slug.
Benefits / Limitations
We’ve added content to the slug which will improve SEO and make our URLs more readable.
One limitation is that the users cannot manipulate the URL in any meaningful way. Knowing the url 6-bob-martin
doesn’t allow you to guess the url 7-russ-olsen
, you still need to know the ID.
Another limitation is that the numeric ID is still in the URL. If the ID is something you want to obfuscate, simple slug generation by overriding to_param
doesn’t help.
Using a Non-ID Field
Sometimes you want to get away from the ID all together and use another attribute in the database for lookups. Imagine we have a Tag
object that has a name
column. The name would be something like ruby
or rails
.
Link Generation
We can again override to_param
for creating the links:
1 2 3 4 5 6 7 |
|
Now when we call tag_path(@tag)
we’d get a path like /tags/ruby
.
Object Lookup
The lookup is harder, though. When a request comes in to /tags/ruby
the ruby
will be stored in params[:id]
by the router.
A typical controller will call Tag.find(params[:id])
, essentially Tag.find("ruby")
, and it will fail.
Option 1: Query Name from Controller
Instead, we can modify the controller to use Tag.find_by_name(params[:id])
. It will work, but it is bad object-oriented design. We’re breaking the encapsulation of the Tag
class.
The DRY Principle says that a piece of knowledge should have a single representation in a system. In this implementation of tags, the idea of "A tag can be found by its name" has now been represented in the to_param
of the model and the controller lookup. That’s a maintenance headache.
Option 2: Custom Finder
In our model we could define a custom finder:
1 2 3 4 5 6 7 8 9 10 11 |
|
Then in the controller call Tag.find_by_param(params[:id])
. This layer of abstraction means that only the model knows exactly how a Tag
is converted to and from a parameter. The encapsulation is restored.
But we have to remember to use Tag.find_by_param
instead of Tag.find
everywhere. Especially if you’re retrofitting the friendly ID onto an existing system, this can be a significant effort.
Option 3: Overriding Find
Instead of implementing the custom finder, we could override the find
method:
1 2 3 4 5 6 |
|
It will work when you pass in a name slug, but will break when a numeric ID is passed in. How could we handle both?
The first temptation is to do some type switching:
1 2 3 4 5 6 7 8 9 10 |
|
That will work, but checking type is very against the Ruby ethos. Writing is_a?
should always make you ask "Is there a better way?"
And there is a better way, based on these two facts:
- Databases give the
id
of1
to the first record - Ruby converts strings starting with a letter to
0
1 2 3 4 5 6 7 8 9 10 |
|
Or, condensed down with a ternary:
1 2 3 4 5 6 |
|
Our goal is achieved, but we have introduced a possible bug: if a name starts with a digit it will look like an ID. Let’s add a validation that names cannot start with a digit:
1 2 3 4 5 6 7 |
|
Now everything should work great!
Using the FriendlyID Gem
Does implementing two additional methods seem like a pain? Or, more seriously, are you going to implement this kind of functionality in multiple models of your application? If so, it may be worth checking out the FriendlyID gem: https://github.com/norman/friendly_id
Setup
Add the gem to your Gemfile
:
1
|
|
Then run bundle
from the command line.
Simple Usage
The minimum configuration required in your model is:
1 2 3 4 |
|
This will allow you to use the name
column or the id
for lookups using find
, just like we did before.
Dedicated Slug
The library does a great job of maintaining a dedicated slug column for you. If we were dealing with articles, for instance, we don’t want to generate the slug every request. More importantly, we’ll want to store the slug in the database to be queried directly.
The library defaults to a String
column named slug
. If you have that column, you can use the :slugged
option to automatically generate and store the slug:
1 2 3 4 |
|
Usage
You can see it in action here:
IRB
2.1.1 :001> |
|
We can use .find
with an ID or the slug transparently. When the object is converted to a parameter for links, we’ll get the slug with no ID number. We get good encapsulation, easy usage, improved SEO, and easy to read URLs.
Exercises
Use the Blogger sample application to complete the exercises in this section. See the Setup Instructions for help.
- Implement a
to_param
method inArticle
so URLs include theid
and article title like4-hello-world
- Change the
to_param
inArticle
so the output does not include theid
- Try to modify the
show
action ofArticlesController
so the lookup with work with the no-id
-having slug from exercise 2. Why is this impossible to implement efficiently? - Implement FriendlyID, as described above, so tags use only their name in URLs.
- Implement FriendlyID so article URLs no longer use the
id
, only the article’s parameterizedtitle
.