Skip to main content

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.

Comments

Popular posts from this blog

Getting started with Ruby on Rails 3.2 and MiniTest - a Tutorial

For fun, I thought I would start a new Ruby on Rails project and use MiniTest instead of Test::Unit. Why? Well MiniTest is Ruby 1.9s testing framework dejour, and I suspect we will see more and more new projects adopt it. It has a built in mocking framework and RSpec like contextual syntax. You can probably get away with fewer gems in your Gemfile because of that. Getting started is always the hardest part - let's jump in with a new rails project rails new tddforme --skip-test-unit Standard stuff. MiniTest sits nicely next to Test::Unit, so you can leave it in if you prefer. I've left it out just to keep things neat and tidy for now. Now we update the old Gemfile: group :development, :test do gem "minitest" end and of course, bundle it all up.....from the command line: $ bundle Note that if you start experiencing strange errors when we get in to the generators later on, make sure you read about rails not finding a JavaScript runtime . Fire up

Getting started with Docker

Docker, in the beginning, can be overwhelming. Tutorials often focus on creating a complex interaction between Dockerfiles, docker-compose, entrypoint scripts and networking. It can take hours to bring up a simple Rails application in Docker and I found that put me off the first few times I tried to play with it. I think a rapid feedback loop is essential for playing with a piece of technology. If you've never used Docker before, then this is the perfect post for you. I'll start you off on your docker journey and with a few simple commands, you'll be in a Docker container, running ruby interactively. You'll need to install Docker. On a Mac, I prefer to install Docker Desktop through homebrew: brew cask install docker If you're running Linux or Windows, read the official docs for install instructions. On your Mac, you should now have a Docker icon in your menu bar. Click on it and make sure it says "Docker desktop is running". Now open a terminal and ty

Rails 3.2, MiniTest Spec and Capybara

What do you do when you love your spec testing with Capybara but you want to veer off the beaten path of Rspec and forge ahead into MiniTest waters? Follow along, and you'll have not one, but two working solutions. The setup Quickly now, let's throw together an app to test this out. I'm on rails 3.2.9. $ rails new minicap Edit the Gemfile to include a test and development block group :development, :test do gem 'capybara' gem 'database_cleaner' end Note the inclusion of database_cleaner as per the capybara documentation And bundle: $ bundle We will, of course, need something to test against, so for the sake of it, lets throw together a scaffold, migrate our database and prepare our test database all in one big lump. If you are unclear on any of this, go read the guides . $ rails g scaffold Book name:string author:string $ rake db:migrate $ rake db:test:prepare Make it minitest To make rails use minitest , we simply add a require