Imagine, if you will, that you’re a bookseller. You sell books. Big books, small books, serious books, silly books; if it’s got pages and a cover you’ll sell it. Times being what they are you’ve decided to harness the power of the intertubes to sell your books (a novel idea; ho ho ho). In fact, you’ve decided to build a website, and to expose an API with which your business partners can sell books through their websites. Huzzah.
As it turns out you’re an accomplished Rails developer as well as a thriving bibliophile, so you get to work. Fortunately, you thought ahead and already have information for all of your books in a database. Being well read as you are, you choose to make a RESTful Books resource to show off your books. Any customer can check out a book of their choosing by navigating their browser thusly:
Huzzah again. Sort of.
You’ve heard about this SEO thing, and you hate how ugly that URL is, so you override #to_param on your Book model to return a nice looking slug. Now that URL from above looks like this:
You go about your business, quite pleased with yourself, until you receive a phone call from one of the business partners who use your API; it seems they can no longer look at books through your web service.
Here’s the problem: they’re using ActiveResource to consume your RESTful interface. To get a catalog of books they call Book#find(:all), which executes a books#index request. This returns some XML looking like this:
Now, if they’re interested in Stickwick Stapers by Farles Wickens they call Book#find(1), which returns a 404 error. Oops, of course it does, you’re not looking up books by their database ID any more, you’re looking them up by their URL slug. Your customer needs to call Book#find(’stickwick-stapers’).
Unfortunately, your book XML doesn’t include the URL slug, so your partners are in a bind. Back to work. You change the #to_xml method for your Book model to return something that looks like this:
After all, the consumers of your API aren’t really interested in the database ID; or, they shouldn’t be. All is well again, until you get another phone call. It seems now your partners can no longer purchase books through your service.
You’ve exposed the Purchases resource for your partners who want to buy books. A purchase involves simply POSTing to this resource with the ID of the book you want to buy and a quantity (you handle payment offline using a complicated barter system). The POST body looks like this:
OOPS! ActiveRecord doesn’t expect the URL slug for the book, it wants the database ID.
Well, crap. This is a big problem, and one that has no particularly satisfying solution. Here are the candidates:
Send both the database ID and the URL slug in the API, and try to educate all of your API consumers about when to use one vs. the other. Get ready for some serious customer support time.
Override the #book_id= method in the Purchase model to expect a URL slug for the book. Unfortunately, the web site you developed, at great expense, has all sorts of drop-downs and the like stuffed chock full of book IDs. Changing all of that would be a significant expense, never mind the bugs guaranteed to creep in as developers consistently forget that #book_id= doesn’t actually take an ID.
Write the #book_slug= on the Purchase model, and ask your API uses to start using this method instead. Unfortunately, this means changing the web sites that they have developed, at great expense. You just cost them money, never mind the bugs guaranteed to creep in as developers consistently forget that the method to set the book ID is #book_slug=, not #book_id=.
Stop using those silly slugs and just go back to database IDs. Integers are really quite beautiful, aren’t they?
This little ditty is just an example of a fairly serious problem with Rails:
Sometimes we reference domain objects by their database ID (when creating associations), sometimes we reference domain objects by their URL representation (when finding objects in a controller), but in both cases we call the reference that we use the ID.
ActiveResource is an obvious example of the problem. It expects that the XML it receives for an object will have the <id> attribute, and it uses this attribute to build the URL for that object.
Rails routing codifies the problem with its URL parameter naming convention:
map.resources :books # => /books/:id (show/update/destroy)
Is there any surprise this leads to code that looks like this?
@book = Book.find_by_id(params[:id])
it "should succeed" do
get :show, :id => @book.id
ActiveRecord, whether by intention or not, further enforces this fallacy with the unfortunate convenience that the default implementation of #to_param is simply id.to_s, and that #find_by_id will accept an integer, or a string, or even a string that starts with an integer. So, oftentimes when a project chooses to start using something other than database IDs for URLs the code has a confusing mishmash of methods that use the two interchangeably. Have fun picking that apart.
So, what to do about it? The Rails conventions are largely set in stone, after all, it’s not likely the names of these references will ever change. But, we can be smarter about how we use them:
Stop using #find_by_id in controllers. After all, you’re more than likely not looking for anything there by the database ID. I like the find_by_param plugin as a nice little helper for this. It gives you the #find_by_param and #find_by_param! methods, which you should use in your controllers. It also gives you methods for easily creating URL slugs, but you don’t need to use those until you want them.
Stop writing broken tests. Every time you pass an ID to a routing parameter in a functional test you’re testing a lie. Your tests will pass with the default ActiveRecord behavior, but if you ever decide to override #to_param (most likely after you’ve written about 700 tests like this), they’ll break. My experiences dealing with just this problem on client projects was no small part of the reason I wrote the Wapcaplet plugin and this Rails patch.
Know what you mean and say what you mean. The fact that Rails got this wrong just means that you have to pay closer attention when referencing anything by ID.
Let me know if you come up with any clever solutions.
 Rails will treat any string that starts with a database ID the same as the database ID itself in many cases:
purchase.book_id = "11-thirty-days-in-the-samarkind-desert-with-the-duchess-of-kent"
genre.book_ids = ["13-how-to-start-a-fight", "16-blogging-for-dummies"]
This fixes the symptom in many cases, but really just further conflates the IDs. And, if you want something without that ugly integer on the front you’re out of luck.