Monkey-patching Rails: Extending Routes #1
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!
Reader Comments
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.
20 Oct 2006
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
20 Oct 2006
Neil, that’s a great suggestion. I’ll start looking into how to set that up with mephisto.
20 Oct 2006
Quote Zack.
Did this site get an article redesign? I like it!
20 Oct 2006
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.
26 Oct 2006
Added to del.icio.us! Everyone should.
1 Nov 2006