Monday, 10 November 2008

Rails - Routing

It's been a while since my last post, that's a mixture of being at PDC and battling with Rails routing so that I understand it better and so that I can bend it to my will! I've finally got something working to my liking but it's taken a while.

Routing is a huge part of Rails and, like most things in Rails, I'm not going to be able to do it full justice in this post.

Rails provides a flexible and convenient routing mechanism, which sounds like marketing speak but is true. If the application uses RESTful routes then much of the work is done for it, although only if you want to work exactly the way Rails expects, otherwise you have to understand the routing to get it to fit into your scheme.

Routing information is stored in a file called routes.rb which is in the config directory. The route data 'draws' routes by mapping from resources to HTTP verbs/URLs and vice versa. This means that for a given HTTP verb (GET say) and a given URL ("/blog/foo") there is a map to a specific controller/action pair.

When a controller is generated from a script an entry is automatically placed in /config/routes.rb. For a 'blogs' controller the entry would look like this:

map.resources :blogs

This entry sets up a bunch of routes for the "blogs" controller in this application. There are several ways to see these routes. The generated BlogsController class will have a set of commented methods that show which URLs will be directed to which methods (shown here but shortened for convenience)

  # GET /blogs
  # GET /blogs.xml
  def index

  # GET /blogs/1
  # GET /blogs/1.xml
  def show

  # GET /blogs/new
  # GET /blogs/new.xml
  def new

  # GET /blogs/1/edit
  def edit

  # POST /blogs
  # POST /blogs.xml
  def create

  # PUT /blogs/1
  # PUT /blogs/1.xml
  def update

  # DELETE /blogs/1
  # DELETE /blogs/1.xml
  def destroy

However this does not tell the whole story, for example these routes have names and these names can be used in your code to add references to the URLs.

Another way to look at the roots available is to get Rails to list them. One way of doing this is to execute the rake routes command:

$ rake routes

                    blogs GET    /blogs                       {:controller=>"blogs", :action=>"index"}
          formatted_blogs GET    /blogs.:format               {:controller=>"blogs", :action=>"index"}
                          POST   /blogs                       {:controller=>"blogs", :action=>"create"}
                          POST   /blogs.:format               {:controller=>"blogs", :action=>"create"}
                 new_blog GET    /blogs/new                   {:controller=>"blogs", :action=>"new"}
       formatted_new_blog GET    /blogs/new.:format           {:controller=>"blogs", :action=>"new"}
                edit_blog GET    /blogs/:id/edit              {:controller=>"blogs", :action=>"edit"}
      formatted_edit_blog GET    /blogs/:id/edit.:format      {:controller=>"blogs", :action=>"edit"}
                     blog GET    /blogs/:id                   {:controller=>"blogs", :action=>"show"}
           formatted_blog GET    /blogs/:id.:format           {:controller=>"blogs", :action=>"show"}
                          PUT    /blogs/:id                   {:controller=>"blogs", :action=>"update"}
                          PUT    /blogs/:id.:format           {:controller=>"blogs", :action=>"update"}
                          DELETE /blogs/:id                   {:controller=>"blogs", :action=>"destroy"}
                          DELETE /blogs/:id.:format           {:controller=>"blogs", :action=>"destroy"}

The format is probably screwed as you look at this but hopefully it's still easy to work out what is going on. This lists the VERB/URL mapping to Controller/action, in this case the routes are only shown for the "blogs" resource, the actual listing is much longer and contains all resources as well as the default routing behaviour. The list is fairly straightforward, it shows the name of the route (if there is one), the HTTP verb, the URL and the controller/action these map to. For example the 'blog' named route says that sending a URL of the form blog/[:id] (e.g. /blogs/1) with the HTTP GET verb results in a call to the blogs controller's show method. In this case the value '1' at the end of the URL will be available as the :id value in the params collection. Notice that not all routes are /controller/action style, but that some simply rely on /controller and the HTTP verb to work out the method to call.

The names of the routes are very useful. For example in the above there are routes named 'blogs', 'blog' and 'new_blog' amongst others. These names can be used to display a URL for that route. As an example in views/blogs/index.html.erb there is a line like this:

<%= link_to 'New blog', new_blog_path %>

that says to use the path generated from the new_blog route to get the URL to display.

This is all well and good, and nice and easy to use and if you simply want to use the Rails conventions then you are good. However there are going to be occasions when simply using the defaults does not work. Luckily, for those cases the routing infrastructure is extensible. In the case of this blog I wanted to do two things, I wanted to support multiple blog authors, and I wanted blog IDs to be friendlier. In fact both these things are related, although I've only tackled the first issue at the moment, but the second issue is resolvable using a similar mechanism.

These issues are to do with the way that Rails identifies resources. It does this via the 'id' primary key. So to get to a blog you would send a GET to http://localhost/blogs/1 where blogs is the name of the controller and 1 is the id of the blog. This isn't ideal for a user. The id is an internal representation that the database uses to identify the blog. An end user doesn't want to say 'go to blog 1', they want to say "kevin's blog" or "harry's blog". To support that blogs should have 'nicknames' and these nicknames then get used to identify the blog, meaning that the url becomes http://localhost/blogs/kevin. Similarly with blog entries, the URL to the entry should include the nickname of the blog and maybe the title of the entry. So something like http://localhost/blog/kevin/my-title rather than http://localhost/blog_entry/1/1, where 'blog-entry' is the name of the controller, the first '1' is the id of the blog and the second '1' is the id of the entry. Fixing this requires several steps some of which I'll cover here. At this point I've not fully converted the code to produce URLs like the above but I'm a large step towards it. That step means not relying on the default routes drawn for REST based controllers and rewriting some of the usage of named routes in the controller and views.

The first thing I wanted to prevent was the user having to use the 'blog_entry' name for the controller. This is an internal representation and, as a user, I don't like the name. I wanted something shorter, so I've chosen 'blog' as the alias for the controller, you can guarantee that at some point in the future I'll have come up with something else! I also want to use a 'nickname' as an identifier to show whose blog entries we were looking at. This meant I wanted a URL something like http://localhost/blog/kevin to retrieve all blog entries for "kevin". I did this by modifying routes.rb.

In routes.rb I took out the entry for map.resources blog_entries and added explicit entries for routing. The entries look like this:

  map.blog_entries 'blog/:nickname',
            :controller => "blog_entries",
            :action => "index",
            :conditions => {:method => :get}

  map.connect 'blog/:nickname',
            :controller => "blog_entries",
            :action => "create",
            :conditions => {:method => :post}

  map.new_blog_entry 'blog/:nickname/new',
        :controller => "blog_entries",
        :action => "new",
        :conditions => {:method => :get}

There are many more entries and there are also also entries for formatted output (basically, I did a rake routes, copied and pasted the code and edited it). The above entries show a number of things: there are two named routes (blog_entries and new_blog_entry), and one unnamed route (the call to map.connect). As an aside notice the the named routes are named with a call to map.some_function. This function does not exist and shows the power of Ruby as a language. Internally the map class will override the method_missing method to add this named route. The routes specify the HTTP verb, the controller to call and the action to use on that controller. Notice that all the routes all specify an extra paramter, :nickname. This will be part of the URL and be passed into code in the params collection in much the same way that :id is passed. Once these routes have been defined you can then use them in code.

The nickname is associated with an individual blog and through that with the entries in that blog. This means that to use the nickname we always need to load the blog associated with it. In the BlogEntriesController class I added a "before_filter" entry. before_filters are code that gets executed before the action. The before_filter looks like this:

  before_filter :get_associated_blog

  protected
  def get_associated_blog
    if params[:nickname]
      @blog = Blog.find_by_nickname(params[:nickname])
    else
      @blog = nil
    end
  end

This finds the associated blog (if it can) and stores it as a member variable of the class. The action is then called, and the action can decide what to do if the blog hasn't been found which will typically be to redirect somewhere.

Modifying routing - recreate mapped routes; unnamed and named; use link_to with params to generate url! Needed to add another column: ruby script/generate migration add_usershortname_to_user user_id:string then migrate: rake db:migrate

  def show
    if @blog
      # work here
    else
      redirect_to(:controller => "blogs")
    end
  end

This is nice and easy. However you can also use the nickname to build URLs. For example when we create a new blog entry we want to re-direct back to the blog for this nickname. The code for that looks something like:

  def create
    
    if @blog       
        @blog_entry = BlogEntry.new(params[:blog_entry])

          if @blog_entry.save
            flash[:notice] = 'BlogEntry was successfully created.'
            format.html { redirect_to(:nickname => @blog.nickname) }

Notice that the redirect_to uses the nickname. This ensures that we redirect back to http://localhost/blog/kevin rather than http://localhost/blog.

Another place the nicknames are used are in the views. If we use the standard Rails mapping for routes then we can use the helper functions directly, something like:

<%= link_to 'Edit', edit_blog_path(@blog) %> |
<%= link_to 'Back', blogs_path %>

Where we use the default 'blogs' routing to build URLs for an edit entry and to get to the blogs controller's index page. Now that we've amended the blog_entry routes though we also need to track down and edit the uses of link_to (and url_for and any other use of these paths) in the view files. The primary change is to add the :nickname value to all the links. This is why we made @blog an instance variable in the BlogEntriesController class, so that it would be available in the views. The views will look something like:

<%= link_to 'Show', blog_entry_path(:nickname => blog_entry.blog.nickname, :id => blog_entry.id) %>
<%= link_to 'Edit', edit_blog_entry_path(:nickname => blog_entry.blog.nickname, :id => blog_entry.id) %>
<%= link_to 'Delete', blog_entry_path(:nickname => blog_entry.blog.nickname, 
                        :id => blog_entry.id), :confirm => 'Are you sure?', :method => :delete  %>
<%= link_to 'New Entry', new_blog_entry_path(:nickname => @blog.nickname) %>

Notice that for each of these routes we set the :nickname and the :id values. In fact this was the trickiest part of the entire procedure, figuring out what parameters I needed to pass to the link_to methods. For example there are times when I have to explicitly set :id to an empty string and others where the :id isn't needed at all.

There are some other changes I had to make along the way, notably:

  • Added foreign key constraints to blog table.
  • Change admin interface to add name
  • Change/add a create blog UI

Now that's done I intend to go back and fix up the 'blogs' routing so that it also supports nicknames.

Posted by kevin at 7:39 AM in Ruby

 

[Trackback URL for this entry]

Your comment:

SCode: (*) Generate another code
SCode

Please enter the code as seen in the image above to post your comment.

(not displayed)
 
 
 

Live Comment Preview:

 
« November »
SunMonTueWedThuFriSat
      1
2345678
9101112131415
16171819202122
23242526272829
30