Thursday, 4 October 2012

Ruby on Rails - mapping a url to a resource

There are always times when you need to override the default RESTful routes in Rails. The most common case for this is mapping a /logout url to a session#destroy controller and action. However, this simple example doesn't really help when you want to achieve something a little more intricate - for example using a completely different url for a resource. Examples include a blog when you want your urls to add blog posts to be /add rather than blogs/new. Or what about a application with a Users model that has different types of users that can be added - perhaps it is a medical database and you have Doctors and Nurses - you want your urls to reflect the kind of person you are adding or editing - so doctor/new or nurse/new rather than users/new.

Mapping urls like this is easy. However there is a little gotcha that you have to be aware of. Let's walk through an example and we'll see how to make it work

New project

We'll create a simple project for this example - a hypothetical book review site. We are going to have a model called BookReview obviously, and we will create it with a couple of properties - book_name and book_review. In a Rails project, run a quick scaffold and migrate to get things up and running fast.

$ rails g scaffold BookReview book_name:string book_review:string
-----
$ rake db:migrate

Decide on our URL structure

Since this is a trivial example and our site will have no input other than book reviews, we want to use /add as our url to add a book review. This is only slightly neater than the default routes of book_reviews/add, but it serves us well for the sample.

Edit your routes

To map a url to a resources, we have to edit config/routes.rb. Currently it looks like this:

BookReviews::Application.routes.draw do
  resources :book_reviews
end

We will start by making Rails aware of our /add url. Edit your routes.rb to look like this:

BookReviews::Application.routes.draw do
  resources :book_reviews
  match 'add' => 'book_reviews#new'
end

Now if you fire up your application with rails s, you can navigate to localhost:3000/add and you should get something similar to this:

This is great - and you might be tempted to stop here, because it all looks good, however, let's add a small constraint to our application - we want to make a field mandatory - book_name for example. Edit app/models/book_review.rb and add in a constraint to ensure book_name is filled in.

class BookReview < ActiveRecord::Base
  attr_accessible :book_name, :book_review
  validates_presence_of :book_name
end

Oh no, our url changes on error

When we visit /add to add a book review and we forget to enter a book_name, our model will not save. Rails neatly handles that for us and provides a flash to tell us that we need to supply a book name. Unfortunately, our url is no longer /add! Try it out - leave off the book name and you'll be redirected to /book_reviews with the flash message!

This is annoying the url that renders is not /add but /book_reviews. Why? Well, the book_review controller responds to actions at /book_reviews - the action to create a book review is a "post" - you can see this if you view source on the /add url or book_reviews/new url (they are both the same thing at the moment). We have a form that is declared like this:

<form accept-charset="UTF-8" action="/book_reviews" class="new_book_review" id="new_book_review" method="post">

When we submit the form, it posts back to /book_reviews. Rails does a little magic and sends the request to our book_review controller's create end point. The create action tries to save the model, fails and decides to render the "new" view - no redirect, no changing of the url....so we are stuck at book_reviews.

Is that so bad?

Well, in and of itself, this isn't so bad really. And to be honest, most users probably wouldn't even notice. However, we are perfectionists and we want our urls to be consistent, if we started on /add and we get an error, we want to return to /add and report the error. And believe me, this is really useful if you've inherited an application and the last programmer jokingly named some resources with less than appropriate names!

Change the url

"Aha" I hear you say "If the form's postback url is causing us problems, let's just change that". Good thinking batman, let's try it out. Edit views/book_reviews/_form.html.erb, changing the form_for helper

<%= form_for(@book_review, :url=> "/add") do |f| %> 

Go back to your browser and try it out - submit a review without a book name. Hmmm that's not quite right is it? When we submit now, we end up at the "/add" url, but we don't get the flash message telling us that we need a book_name anymore. If we fill in book_name, it doesn't actually save it either. Well that makes sense because our route that we defined earlier instructed Rails to render our book_review#new action whenever we visit "/add"- so that's what happens - we never ever hit our create action in the controller!

More routes

Here's the real magic - we can specify our route matching to respond to different http verbs with the handy :via property. Change your config/routes.rb to look like this:

BookReviews::Application.routes.draw do
  resources :book_reviews
  match 'add' => 'book_reviews#new', :via => :get
  match 'add' => 'book_reviews#create', :via => :post
end

We have kept our original route that matched "/add" to our "new" action in our book_review controller, but we have instructed Rails that this should only handle the "get" http verb. We have added in a new route matching "/add" with a "post" http verb and told Rails that when this happens, we should let the book_review controller's create action handle things for us.

Eureka!

Now when we visit "/add" and leave out a book_name, we get sent right back without the saving, as we should, we also get our flash message *and* we remain on the "/add" url!

Your turn

This is a workable solution at the moment. There are a few things to clean up though - note the _form partial will serve up both new and edit actions for book reviews. That means on edit, you'll currently be redirected to "create" (remember, we have manually specified a url). You should tidy that up with a little logic, unless you aren't offering edit functionality. The other little loose end is the nasty somewhat magic string in your _form partial specifying the "/add" url. We can tidy that up in Rails by creating a named route, but I'll cover that in another shorter post.

No comments:

Post a Comment