Last week was my first week of working from home, which meant two things: spending a lot of time with my computer and limited time with other human beings and, more importantly, debugging things on my own without having anyone nearby to ask for help. The latter of the two actually ended up reaffirming the fact that I actually can debug a decent amount of things on my own if I just power through and am stubborn enough to not give up.
I also learned something interesting about the debugging process: while we’re learning, we often solve the same problem again and again. At least, this was the case for one of the features I was working on which involved using an object’s slug to generate a url. As I started thinking through how to approach solving this, I immediately had the feeling that I had done something similar in another project. Digging through another repository’s source code confirmed my suspicions, and I rediscovered the stringex
gem and its multiple libraries, including acts_as_url
!
All of this begs the question: why didn’t I remember that this gem existed — or that I had already used it? My guess is that it’s because I neither wrote about it nor understood how it worked until a few days ago. This week, it’s time to rectify that situation and dive into the acts_as_url
library and find a solution for all slug problems, once and for all!
The Other Kind of Slug
We all are familiar with the Rails mantra of convention over configuration, aka making the lives of developers easier by eliminating the need for them to make decisions about how to structure their code. Now, this design paradigm is pretty fantastic, particularly when we’re first learning a new framework. But eventually, there comes a time when we need to tweak our code’s conventions just a tiny little bit.
What’s an example of this? Well, take Rails convention of finding an object by it’s id
. This standardization pops up all over the place, but the one that we’re particularly concerned with is the generation of a url. By default, Rails applications will build a URL path for the show
action of any given controller based on the primary key (aka the id
column in our database) of the object that we’re trying to “show”.
Let’s put this in context of our bookstore application. We have a bunch of Book
objects, and we want to iterate through their titles and then link to their individual “show” pages. A very basic, not-at-all-fancy template might look something like this:
1 2 3 |
|
It’s important to note that we’re actually passing in the book
instance here — an ActiveRecord
object, and not the book
’s id
. Why is this important? Because there’s a method that Rails is using to convert the Book
object into a URL in order to generate the correct address for our book_path
. That’s why we need to pass it an ActiveRecord
object, because the method that’s being called expects an object and returns the id
as parameters in the url. What does this look like, exactly? Well, right now our book_path
takes a Book
instance and creates a path that looks like this: localhost:3000/books/25
.
Which is fine! Actually, it’s more than fine: it’s the expected behavior given our Rails mantra of convention over configuration. But, what if we actually want to configure this a little bit more. What if, instead of using the primary key of our Book
instances, we wanted to use the title of the book? It would be lovely if we could link someone to a particular book’s page with a more human-readable url (for example, something like awesomebookstore.com/books/the-bell-jar
in production).
There’s a solution for this problem, and it’s called slugs. Slugs are a solution for semantic URL generation, which is also sometimes referred to as “RESTful” or “SEO-friendly” URL generation. As our application grows, not only would we want our URLs to be user-friendly, but we probably also will want them to be optimized for search engine results. So, we need to change our application’s configuration to use a slug.
Protip: if anyone ever pop quizzes you about where the term “slug” comes from, you can totally school them with the following interesting fact: a “slug” used to be a shorter name given to a newspaper article while it was in production; during the editing process, the article would be labeled by its slug, which would more specifically indicate the content of the story to the editors and reporters. The more you know, amirite?
There are obviously a lot of ways to approach this, but why reinvent the wheel by writing a bunch of methods that someone else has already written? Let’s make use of someone’s open source work and use the acts_as_url
library to get the job done for us!
Don’t Let Slugs Slow You Down
The acts_as_url
library is actually part of the stringex
gem, which adds some useful extensions to Ruby’s String
class. After we add this gem to our Gemfile
(gem "stringex"
) and run bundle install
, we can get started doing a quick setup.
The documentation for this library is fairly straightforward, and a quick read-through gives us a good idea of what we need to do in order to make it work properly. The basic implementation of this library is four-fold:
- We need a column in our database that will map to the attribute used in generating our url.
- We need to call the
acts_as_url
method in our model using the attribute name that we want to use for generating our url. - We need to override Rails’
to_param
method (Confused? Hang tight, we’ll get there in a second!) - We need to
find_by
our new url attribute inside of our controllers.
Let’s take it step by step. First, we’ll write a migration that will add a slug
column to our books
database. This is going to be the column that will map to a slug
attribute on our Book
objects:
1 2 3 4 5 |
|
Once we run rake db:migrate
and add this attribute, we will want to add the acts_as_url
class method to our Book
model.
1 2 3 |
|
The default behavior of this method expects that we have a column and attribute called url
on our object. Since we aren’t using the default attribute name, we need to specify the name of the attribute that we’re using to store the generated url string. Thankfully, acts_as_url
takes a bunch of options, including url_attribute
, which is what we’re using here. There are some other useful options worth checking out in the documentation, including scope
, limit
, truncate_words
, and blacklist
.
Next, we’ll need to override Rail’s to_param
method in order to actually use our generated url attribute. Basically, we’ll just want to write our own to_param
method and return our slug
attribute from inside of it.
1 2 3 4 5 6 7 |
|
And finally, we need to make sure that we’re finding our object using the appropriate attribute from within the context of our controller. The documentation suggests we use
the find_by_url
method, but we could also use the find_by
method in our controller as well.
1 2 3 4 5 |
|
Nothing changes about how our controller works and we can still do all the fancy things we were doing before, like use a decorator (Remember those?). The only thing that happens now is that our original book_path
helper will now use the book
instance we passed it to generate a url with a slug instead of the primary key!
Success! We’ve done it! Actually, we’ve almost done it. One tiny little thing that I always forget is all of the books that already exist in our database. What about them? They all have a slug
attribute, sure, and a slug
column – but there’s a slight problem: the column is empty! So we can’t find_by
the slug
attribute for those books, can we? In fact, if we try to call to_param
on any of our preexisting Book
instances right now, all we’ll get is nil
!
No worries, we just need to call a method inside of our console:
1 2 3 |
|
Now all of the Book
instances that had empty slug
attributes have ben initialized, and we’re good to go! Right? Wrong. Because I haven’t explained the whole to_param
situation yet, and I promised that I would get to it. Now’s the time to figure out the magic behind that!
Rails Non-Sluggish Solution: to_param
The Rails solution to generating params for an object’s url path comes from its elegant to_param
method. By default, this method just calls to_s
on a Plain Old Ruby Object, and converts it to an instance of Ruby’s String
class. However, there are plenty of places where Rails itself overrides this method (which explains why we also have to do it in the context of our own controller)!
In fact, the Rails documentation even explains when and how to go about redefining the implementation of this method:
“Notably, the Rails routing system calls
to_param
on models to get a value for the:id
placeholder.ActiveRecord::Base#to_param
returns theid
of a model, but you can redefine that method in your models.”
1 2 3 4 5 |
|
Of course, we could have easily redefined this in the context of each of our models, but using the acts_as_url
library reduces the amount of duplicated code that we need in each of our models, and is pretty sophisticated in that it allows us to use different attributes across different models to generate our url path.
Interestingly, the source code for Rails’ to_param
method reveals some elegant checks as well. This method first checks whether the object as been persisted to the database, and then returns a string representing the object’s key. We can actually see how the to_key
method is being called from inside of to_param
, and how the default return value of an unpersisted object’s param will be nil
. This is the magic that goes on under the hood when we were trying to find the slug
attributes for all of those Book
instances before we called initialize_urls
on them!
So, even though slugs have a reputation of being slow, now we know how to speed through this problem with an elegant and quick solution! I can’t say the same for this poor guy, though:
tl;dr?
- The
acts_as_url
library expects anurl
attribute on a model, and uses that to generate the path for an object. You need to override Rails’to_param
method that, by default, will use theid
of an object to generate its path. - This awesome gist by Jeff Casimir is the best write-up on slugs and Rails’ url generation out there. Give it a read!
- The original
to_param
method used to be defined inside ofActiveRecord::Base
, but has since moved to theActiveModel::Conversion
module, which handles default conversions, includingto_model
,to_key
, andto_partial_path
. Read more about how these methods work in the Conversion module documentation.