Routing in Ruby on Rails 3


Be thee warned, this is a *long* article. However, it covers a lot of ground, is written in plain english, and comes with tons of code samples to make the subject matter as easy to digest as possible.

Routing has changed in Rails 3. It’s very different from 2.x, but it’s not actually difficult. This is one of those things where new users are almost better off, because they’re learning the simple way right off the bat, with no baggage. Whether you’re new to all of Rails or just Rails 3, this will get you up and running.

Static-y routes

Starting off with a fresh rails app, let’s generate our main static controller:

rails3_routing$ rails g controller home index about
      create  app/controllers/home_controller.rb
       route  get "home/about"
       route  get "home/index"
      invoke  erb
      create    app/views/home
      create    app/views/home/index.html.erb
      create    app/views/home/about.html.erb
      invoke  test_unit
      create    test/functional/home_controller_test.rb
      invoke  helper
      create    app/helpers/home_helper.rb
      invoke    test_unit
      create      test/unit/helpers/home_helper_test.rb

We can clean up our routes file so it looks something like this:

Rails3Routing::Application.routes.draw do
  get "home/index"
  get "home/about"

  root :to => "home#index"
end

The controller generator created the two static routes for us, and I made home/index the root (default) route for the application. Now I need to get rid of the default index file in the public directory:

rails3_routing$ ls public
404.html	500.html	images		javascripts	stylesheets
422.html	favicon.ico	index.html	robots.txt
rails3_routing$ rm public/index.html
rails3_routing$ ls public
404.html	500.html	images		robots.txt
422.html	favicon.ico	javascripts	stylesheets

If we don’t remove this, the app will show the public/index.html file as the home page. So far so good! If you fire up the server, you’ll see our home/index action is being called as our home page.

RESTful Routes

Let’s use the example of a blog application, and create the Post model, views, and controller to look at RESTful routing in Rails 3. We’ll use the scaffold generator to create the entire MVC (Model-View-Controller) stack in one shot:

rails3_routing$ rails g scaffold post title:string content:text
      invoke  active_record
      create    db/migrate/20101202051750_create_posts.rb
      create    app/models/post.rb
      invoke    test_unit
      create      test/unit/post_test.rb
      create      test/fixtures/posts.yml
       route  resources :posts
      invoke  scaffold_controller
      create    app/controllers/posts_controller.rb
      invoke    erb
      create      app/views/posts
      create      app/views/posts/index.html.erb
      create      app/views/posts/edit.html.erb
      create      app/views/posts/show.html.erb
      create      app/views/posts/new.html.erb
      create      app/views/posts/_form.html.erb
      invoke    test_unit
      create      test/functional/posts_controller_test.rb
      invoke    helper
      create      app/helpers/posts_helper.rb
      invoke      test_unit
      create        test/unit/helpers/posts_helper_test.rb
      invoke  stylesheets
      create    public/stylesheets/scaffold.css

Looking at our routes.rb file, you can see we now have our first RESTful route, added automatically by Rails:

Rails3Routing::Application.routes.draw do
  resources :posts

  get "home/index"
  get "home/about"

  root :to => "home#index"
end

This doesn’t look like much – it only added a line to our routes file – but it packs a punch. For starters, it’s defining the seven basic RESTful routes:

  • GET index
  • GET show
  • GET new
  • POST create
  • GET edit
  • PUT update
  • DELETE destroy

These are the seven routes needed to list, view, add, update, and remove posts in our application. But what if we want to add custom routes to this controller? Maybe we want a couple actions called recent that shows only the latest 5 posts, and popular that shows the top 5 popular posts. Let’s also add an action called publish that will activate a post once it’s ready, so people can see it. Let’s add these to our routes:

  resources :posts do
    get 'recent', :on => :collection
    get 'popular', :on => :collection
    
    put 'publish', :on => :member
  end

Both recent and popular use http GET requests, because we’re not changing any data on the server. The publish action uses the http PUT request, to denote a route that does changes records on the server.

The publish action is a member action, meaning when we call this action, the route will have to contain the id of the record we want to work with, like this: “/posts/1/publish”. The other two actions are collection-oriented, meaning they work with the collection as a whole and we don’t need to pass a specific id to them. The recent route would look like “/posts/recent”.

When you start adding routes, the notation above may get tedious. Luckily, there’s a shortcut:

  resources :posts do
    collection do
      get 'recent'
      get 'popular'
    end
    
    member do
      put 'publish'
    end
  end

This is the preferred way to notate custom routes, whether you have many or just a couple. It’s easier to read, and easier to update with more routes in the future.

Nested Routes

Now let’s say we want to add comments to our application. Obviously every comment will be attached to a specific post, and we’d like the routes to reflect this. It’s not absolutely necessary, but it does add much needed structure in these trying and uncertain times. Let’s generate our comments:

rails3_routing$ rails g scaffold comment title:string content:text post_id:integer
      invoke  active_record
      create    db/migrate/20101202054608_create_comments.rb
      create    app/models/comment.rb
      invoke    test_unit
      create      test/unit/comment_test.rb
      create      test/fixtures/comments.yml
       route  resources :comments
      invoke  scaffold_controller
      create    app/controllers/comments_controller.rb
      invoke    erb
      create      app/views/comments
      create      app/views/comments/index.html.erb
      create      app/views/comments/edit.html.erb
      create      app/views/comments/show.html.erb
      create      app/views/comments/new.html.erb
      create      app/views/comments/_form.html.erb
      invoke    test_unit
      create      test/functional/comments_controller_test.rb
      invoke    helper
      create      app/helpers/comments_helper.rb
      invoke      test_unit
      create        test/unit/helpers/comments_helper_test.rb
      invoke  stylesheets
   identical    public/stylesheets/scaffold.css

Now our routing file will have our comments routes, but they’re not nested inside our posts routes. Let’s fix that:

  resources :posts do
    resources :comments

    collection do
      get 'recent'
      get 'popular'
    end
    
    member do
      put 'publish'
    end
  end

Easy! We just moved the resource line into our posts block. In order to complete the process, I’ve added the necessary relationships in the models, before filter in the comments controller (to lookup the parent post) and updated the comments controller to perform all comment lookups from the perspective of the parent post. Feel free to view the source code for this article (link at the top of the page) to see how I did this.

Now if you’re editing a comment with id 100 that belongs to post 20, the route will look like: “/posts/20/comments/100/edit”.

Namespaced Routes

After a while, you might realize that your posts and comments need to be treated differently depending on the context. One way to do this is with namespacing. For example, you might want to create an admin section of the site where posts are shown with more options (re-ordering, editing, etc). While I won’t get into the permissions issues in this article, this is easy to do from a routing perspective.

First, we’ll create the namespaced controller:

rails3_routing$ rails g controller admin/posts index show new create edit update destroy
      create  app/controllers/admin/posts_controller.rb
       route  get "posts/destroy"
       route  get "posts/update"
       route  get "posts/edit"
       route  get "posts/create"
       route  get "posts/new"
       route  get "posts/show"
       route  get "posts/index"
      invoke  erb
      create    app/views/admin/posts
      create    app/views/admin/posts/index.html.erb
      create    app/views/admin/posts/show.html.erb
      create    app/views/admin/posts/new.html.erb
      create    app/views/admin/posts/create.html.erb
      create    app/views/admin/posts/edit.html.erb
      create    app/views/admin/posts/update.html.erb
      create    app/views/admin/posts/destroy.html.erb
      invoke  test_unit
      create    test/functional/admin/posts_controller_test.rb
      invoke  helper
      create    app/helpers/admin/posts_helper.rb
      invoke    test_unit
      create      test/unit/helpers/admin/posts_helper_test.rb

As you can see, calling the controller “admin/posts” caused all the newly created files to be put in their own admin directory. If we look in the routes file, we’ll see this ugliness:

  get "posts/index"

  get "posts/show"

  get "posts/new"

  get "posts/create"

  get "posts/edit"

  get "posts/update"

  get "posts/destroy"

The controller generator doesn’t know that we want RESTful, namespaced routes, so we’ll drop all those lines in favor of this:

  namespace :admin do
    resources :posts
  end

Note that we’re adding posts resources again, but this time they’re namespaced. They won’t interfere with our regular posts routes, because these routes will be prefixed with “/admin”. So editing the post with id “100” in the admin section will have a route like this: “/admin/posts/100/edit”.

Things You Should Know

While our initial routes file is pretty simple and straightforward, it would quickly grow if this were a real app. How does the Rails 3 routing engine choose from conflicting or overlapping routes? It does this the same way Rails 2.x does – it works top to bottom. It does not try to find the *best* match (the way a google search might) – instead, it starts at the top of the routes file and stops when it finds the first acceptable match. Therefore, default routes should be pushed to the bottom.

While we’re on the subject, don’t use default routes. Named routes form a much more secure and stable application. There shouldn’t be routes you’re NOT expecting. One exception can be short urls. If you want members to have a short url the way Twitter does (http://twitter.com/kconrails for instance) then you’ll need a default route, placed at the bottom of the routes file, to be tried after all other routes have been checked. If you had a members controller, and the member name should route to the show action, the route might look like this:

match ':member' => 'members#show', :as => :shorturl

Finally, when in doubt, check your existing routes with this rake task:

rails3_routing$ rake routes
(in /Users/bellmyer/Desktop/bellmyer/blog/rails3_routing)
      admin_posts GET    /admin/posts(.:format)                      {:action=>"index", :controller=>"admin/posts"}
      admin_posts POST   /admin/posts(.:format)                      {:action=>"create", :controller=>"admin/posts"}
   new_admin_post GET    /admin/posts/new(.:format)                  {:action=>"new", :controller=>"admin/posts"}
  edit_admin_post GET    /admin/posts/:id/edit(.:format)             {:action=>"edit", :controller=>"admin/posts"}
       admin_post GET    /admin/posts/:id(.:format)                  {:action=>"show", :controller=>"admin/posts"}
       admin_post PUT    /admin/posts/:id(.:format)                  {:action=>"update", :controller=>"admin/posts"}
       admin_post DELETE /admin/posts/:id(.:format)                  {:action=>"destroy", :controller=>"admin/posts"}
    post_comments GET    /posts/:post_id/comments(.:format)          {:action=>"index", :controller=>"comments"}
    post_comments POST   /posts/:post_id/comments(.:format)          {:action=>"create", :controller=>"comments"}
 new_post_comment GET    /posts/:post_id/comments/new(.:format)      {:action=>"new", :controller=>"comments"}
edit_post_comment GET    /posts/:post_id/comments/:id/edit(.:format) {:action=>"edit", :controller=>"comments"}
     post_comment GET    /posts/:post_id/comments/:id(.:format)      {:action=>"show", :controller=>"comments"}
     post_comment PUT    /posts/:post_id/comments/:id(.:format)      {:action=>"update", :controller=>"comments"}
     post_comment DELETE /posts/:post_id/comments/:id(.:format)      {:action=>"destroy", :controller=>"comments"}
     recent_posts GET    /posts/recent(.:format)                     {:action=>"recent", :controller=>"posts"}
    popular_posts GET    /posts/popular(.:format)                    {:action=>"popular", :controller=>"posts"}
     publish_post PUT    /posts/:id/publish(.:format)                {:action=>"publish", :controller=>"posts"}
            posts GET    /posts(.:format)                            {:action=>"index", :controller=>"posts"}
            posts POST   /posts(.:format)                            {:action=>"create", :controller=>"posts"}
         new_post GET    /posts/new(.:format)                        {:action=>"new", :controller=>"posts"}
        edit_post GET    /posts/:id/edit(.:format)                   {:action=>"edit", :controller=>"posts"}
             post GET    /posts/:id(.:format)                        {:action=>"show", :controller=>"posts"}
             post PUT    /posts/:id(.:format)                        {:action=>"update", :controller=>"posts"}
             post DELETE /posts/:id(.:format)                        {:action=>"destroy", :controller=>"posts"}
       home_index GET    /home/index(.:format)                       {:controller=>"home", :action=>"index"}
       home_about GET    /home/about(.:format)                       {:controller=>"home", :action=>"about"}
             root        /(.:format)                                 {:controller=>"home", :action=>"index"}

It will give you a list of all available routes that have been setup in your application. It also gives you the route names. The first word of each line is the route name. The first named route in our example is “admin_posts”. You can append “_url” or “_path” in views and controllers to get either that action’s url with or without the hostname, respectively.

Also note that in the routes listed above, any symbols that are NOT in parentheses are required. For example, on line 10 above, post_comments_path would require post_id to be passed in. You can do this with either the id, or the object itself. For example

post_comments_path(@post)
post_comments_path(@post.id)

This is a lot to play with, which is why I like including code samples with articles like this. Download (link at the top of the page) and play around with the routes until you're comfortable. After and hour or so, you won't need to lookup the Rails specs every time you want to make a routing change.

About these ads

Tags: , , , , , , , ,

4 Responses to “Routing in Ruby on Rails 3”

  1. Ogredude Says:

    When you’re using nested routes, make sure to change all form_for(@comment) to form_for([@post, @comment]) and all redirect_to(@comment) to redirect_to([@post, @comment])

    It’s a bit different because we’ve got to pass a post object along with a comment object.

    Also, link_to paths might need to be changed up, because we don’t have a comments_path, instead we have a post_comments_path, etc.

  2. Andy Says:

    Hey bellmyer. First of all thank you very much for this post.
    But as you may have guessed, almost nobody writes if everything ist fine. So to get it out: I looked at your github code and found out, that your controllers are structured in this nested way but your views are not.
    Maybe this was your intention, anyway, i modified my comment’s index view to something like %= link_to ‘Show’, post_comment_path(comment.post.id, comment.id) % which is working… is this the way it should be done or am i wrong? i mean… sure i am showing a comment which is unique even without post.id, but then (with comment_path(comment.id)) there is an exeption “no controller found”, which is justified i guess.
    i would be happy if you could clear my thoughts up a bit

  3. Andy Says:

    ok this is not a problem anymore.
    if anybody get the same troubles, you’ll have to edit your views to the new routes structure presented here.
    eg. you’d have to write %= link_to ‘Destroy’, post_comment_path(comment.post.id, comment.id), :confirm => ‘Are you sure?’, :method => :delete % instead of %= link_to ‘Destroy’, comment, :confirm => ‘Are you sure?’, :method => :delete %
    *** Disclaimer: I’m like 90% noob and may be 100% wrong ;)

  4. saathi Says:

    Thanks. Helpful article

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s


Follow

Get every new post delivered to your Inbox.