Monkey-patching Rails: Extending Routes #1

Posted by Jamis on October 20, 2006 @ 08:00 AM

Assuming you’ve been reading this blog for the last couple of weeks, you’ve followed as we’ve explored Rails’ routing DSL implementation, peeked into the nooks and crannies of the route recognition code, and (most recently) went spelunking into the very bowels of route generation.

It’s time to begin putting all that reading to some practical, hands-on use.

We’ll start with something almost trivial: adding a “redirect” feature to routing, such that you can have any request to a particular route automatically respond with a 302 that sends the caller to another route.

Why would we want this? Well, besides the obvious pedagogical application, consider the situation of RESTful routes:

1
2
3
ActionController::Routing::Routes.draw do |map|
  map.resources :people
end

The map.resources command installs a whole slew of routes for you. However, it does not do anything with the root URI (the one that’s just a slash, ”/”). For most RESTful applications, you typically want that ”/” URI to map to one of your primary resources, like ”/people” in the above example. Normally, you’d just add another route like:


map.connect "/", :controller => "people", :action => "index"

That feels a little less-than-DRY. It would be nicer to just say something like:


map.redirect "/", :people

Here’s how we’ll do it.

map.redirect

This trick actually requires something of a hack, because of the way routes work. As you may (or may not) recall from the recognition overview, when a route is recognized (via RouteSet#recognize), the actual controller class is returned. The #process method is immediately invoked on that class, which will instantiate the class and call down into the action that was requested. Thus, you can’t just have a route do a redirect, because it still has to return a controller, and specify an action to be performed.

1
2
3
# excerpted from railties/lib/dispatcher.rb, Dispatcher#dispatch
controller = ActionController::Routing::Routes.recognize(request)
controller.process(request, response).out(output)

So, whatever our implementation, it must return a controller, and specify an action to invoke, even if all we want to do is redirect. (You’ll frequently find yourself having to work around issues like this when you try to monkey-patch routes.)

In this case, we will work our magic by creating a simple “tricks” controller. It will have a single action, do_redirect, which expects the requested route to be in the params hash (as :destination) and emits a redirect to send the client to it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
module JamisBuck
module Routing

  # A custom controller that we'll use for routing trickiness.
  class TricksController < ActionController::Base

    # A simple action that simple accepts a destination route, and emits a
    # redirect to it.
    def do_redirect
      params.delete(:controller)
      params.delete(:action)
      route = params.delete(:destination)

      redirect_to send(:"#{route}_url", params)
    end

  end

end
end

Then, we define a module that we’ll mix into the Mapper class (ActionController::Routing::RouteSet::Mapper) which (as the DSL article described) is where the routing DSL is defined:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
module JamisBuck
module Routing

  module DSL
    module MapperExtensions

      # The implementation of the "redirect" DSL syntax. It takes a path
      # string, and a destination symbol naming the route to redirect to.
      # Any additional options are merged into the route definition and will
      # be passed to the destination route.
      def redirect(path, destination, options={})
        options = options.merge(:controller => "jamis_buck/routing/tricks",
          :action => "do_redirect", :destination => destination)
        @set.add_route(path, options)
      end

    end
  end

end
end

As you can see, calling map.redirect just adds a new route that points to our custom controller and action, and also sets the :destination option to the target route.

This works, simply because when a route gets recognized, the options associated with that route get merged into the request’s parameters. Thus, the :destination key gets added to the params hash in the controller, and the do_redirect action can then use it to build the target of the redirect.

There’s an added side-effect to this. Because the options from the recognized route are added to params, you can do something like this:


map.redirect "/:id", :person, :id => /\d+/

This will make ”/15” redirect to ”/people/15” (person_url(id)). The :id parameter gets “transferred” from the first route to the second, because of that params hash. Pretty slick!

So, now that it is all implemented, it’s just a matter of installing everything, via init.rb for our plugin:

1
2
3
4
5
require 'jamis_buck/routing/dsl'
require 'action_controller/routing'

ActionController::Routing::RouteSet::Mapper.send :include,
  JamisBuck::Routing::DSL::MapperExtensions

Voila! As I said, almost trivial, but it’s a good reinforcement of the basics. You can check out the entire plugin from my svn repository.

I’ve got more planned for this, to take us further into techniques for monkey-patching routes, so stay tuned!

Lastly and leastly, did you find this article helpful? These take a fair bit of time and effort to compose, and while I do enjoy doing it, any encouragement at all is appreciated. You are (of course) never under any obligation to do so, but if you wish to, a few dollars via PayPal (to jamis@jamisbuck.org) would be wonderful. Thank-you!

Posted in Under the Hood

Comments

Have something to add? Click here to leave a comment.

20 Oct 2006

1. Zack Chandler said...

Another great article! I think I can speak for the whole community when I says you are a great teacher of ruby and rails. Thanks.

2. Neil Wilson said...

Jamis,

Your articles are fabulous and we need more of them from the Rails ‘old guard’ – who all seem to have disappeared off doing things for money nowadays.

However you might want to consider an index page before too long. Blogs are all very well, but good stuff gets lost in them over time.

Rgs

NeilW

3. Jamis said...

Neil, that’s a great suggestion. I’ll start looking into how to set that up with mephisto.

4. Mislav said...

Quote Zack.

Did this site get an article redesign? I like it!

26 Oct 2006

5. Lewis said...

Jamis, I would just like to thank you for your last few posts about rails routing. Your blog has easily become the best rails blog out there.

Keep up the good work.

01 Nov 2006

6. Clint Pachl said...

Added to del.icio.us! Everyone should.