Monkey-patching Rails: Extending Routes #2
Last Friday, I introduced my routing tricks plugin for Rails, by walking through the implementation of a routing extension that let you specify HTTP redirections via routes. Today’s article extends that plugin with a new feature: recognition by host, domain, or subdomain.
Suppose, for instance, that I wanted my blog’s admin feature to live at its own subdomain, admin.jamisbuck.org. Currently, that means I’d need the action that maps to ”/” to determine how to proceed based on the subdomain. Using this plugin, though, I could simply define my routes like this:
1 2 3 4 5 6 |
ActionController::Routing::Routes.draw do |map| map.connect "/", :controller => "admin", :conditions => { :subdomain => "admin" } map.connect "/", :controller => "blog" # ... end |
Note that I’ve defined two routes that map to ”/”, but the first is constrained by the subdomain. Any request for ”/” that comes in with a subdomain of “admin” will be routed to the admin controller. If the subdomain is not “admin”, the “blog” controller will be used instead.
You can do the same thing with :host
and :domain
, and any three of them may be regexes if you want that kind of flexibility.
Nifty! However, let’s get to the point of this article: how does this extension work its magic? Though not as trivial as the last article, it’s still remarkably simple.
The first thing we need to do, is record what the host, domain, and subdomain are for every request we’re asked to recognize. As you may or may not recall from the lesson on route recognition, one of the first things route recognition does is extract the request method from the request, via the extract_request_environment
method. Our task, then, is to extend that method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# jamisbuck/routing/routeset.rb module JamisBuck module Routing module RouteSetExtensions def self.included(base) base.alias_method_chain :extract_request_environment, :host end def extract_request_environment_with_host(request) env = extract_request_environment_without_host(request) env.merge :host => request.host, :domain => request.domain, :subdomain => request.subdomains.first end end end end |
As you’ll see shortly, we’ll extend the RouteSet
class with the RouteSetExtensions
module. (That alias_method_chain
method is a handy trick, defined in ActiveSupport: it just aliases extract_request_environment
to extract_request_environment_without_host
, and then aliases extract_request_method_with_host
to extract_request_method
. It’s a very common idiom in Rails—you’ll see it everywhere.)
Once we’ve extracted that data from the request, we need to alter the routines that generate the route recognition code, such that they now need to take into consideration the new data. This is easily done, as it happens. We just need to extend the Route#recognition_conditions
method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
# jamis_buck/routing/route.rb module JamisBuck module Routing module RouteExtensions def self.included(base) base.alias_method_chain :recognition_conditions, :host end def recognition_conditions_with_host result = recognition_conditions_without_host result << "conditions[:host] === env[:host]" if conditions[:host] result << "conditions[:domain] === env[:domain]" if conditions[:domain] result << "conditions[:subdomain] === env[:subdomain]" if conditions[:subdomain] result end end end end |
This bit simply appends up to three new comparisons to the results
array. That array (as you may or may not recall) gets joined together with “&&”, and set in an “if” statement at the top of the generated recognition method. We can see this by looking at the Route#recognize
method that gets generated for the “admin” route:
1 2 3 4 5 6 |
def recognize(path, env={}) if (match = /\A\/?\Z/.match(path)) && conditions[:subdomain] === env[:subdomain] params = parameter_shell.dup params end end |
It just checks the path, to see that it is ”/” or ””, and then compares the subdomain of the request (in the env
variable) to the subdomain in the conditions for the route (in the conditions
variable). If all matches, the parameter shell for the route is returned. Beautiful!
Ok, one last thing: we can’t forget to tie the bits together and plug them into the routing code itself.
1 2 3 4 5 6 7 8 9 10 |
# init.rb require 'jamis_buck/routing/routeset' require 'jamis_buck/routing/route' require 'action_controller/routing' ActionController::Routing::RouteSet.send :include, JamisBuck::Routing::RouteSetExtensions ActionController::Routing::Route.send :include, JamisBuck::Routing::RouteExtensions |
And thus, gentle readers, does the proverbial “fat lady” sing. Note, though, that this is really only half of the solution, as it doesn’t handle route generation at all. However, it does demonstrate how simple it can be to extend route recognition to include aspects of the request and its environment.
As before, you can check out the entire plugin from my subversion repository.
Reader Comments
Thank you so much for these write-ups Jamis. I’m resisting the urge to say “why isn’t this in rails core?”, but thank you all the same. My donation to the cause is on it’s way :)
26 Oct 2006
Cool stuff!
Probably a bit off topic, but I was doing some routes stuff the other day. I wanted to generate routes depending on certain queries. It look something like:
if Section.count > 0 map.something = else # bla end
What suprised me was that when I re-created the DB and did a rake:schema:import rails threw an error saying the table “sections” doesn’t exist. I wonder why rails loads the routes on a schema:load
I’ll have to look at the rakefile and see if this can be fixed, but probably setting up routes is just part of the same bootstrapping, no matter whether you start a console, server or rake script.
Anyways, keep up the good work!
Jeroen
27 Oct 2006
Justin, thank-you! Regarding why this isn’t in core: this is only half of the solution. You’ll notice, if you actually try to use it, that route generate is a little wonky now, unless you use named routes exclusively. Route generation is hard to make work with the host/domain/subdomain thing, because route generation has no access to the request. In fact, route generation does not generate the host portion of the URL; it generates only the path. It’s a hard problem.
Jeroen, yah, routing is loaded by
Rails::Initializer
on bootstrap, so it’ll get loaded by simple rake tasks. Note that you probably don’t want to put queries in the routes.rb itself, though, since that gets loaded (in production) only once. If you want your routes to depend on info in the database, you’ll need to dig in and actually monkeypatch the route recognition/generation routines themselves.27 Oct 2006
Thanks for that Jamis. I realised the fact the it only gets loaded once in production, but I kind of found that interesting. Say you developed some commercial blogging service and customers have to pay more to publish under multiple topics. If you only have one topic, you don’t want example.com/defaulttopic/entries – you just want example.com/entries or even just example.com/
If you know the customer hasn’t paid for multiple topics, you only have to find out once and generate those short routes and never have to query the database again to fetch the topics (there’s only one).
Downside is you have to restart the app when a customer upgrades his plan.
28 Oct 2006
Wonderful! I was procrastinating on more routing research, and I suppose it was all for a good reason in the end. Thanks for this lovely gem Jamis!
31 Oct 2006
Hmm, your plugin gives me the following error and I am not able to start the server. What could cause this? (ruby1.8.4/rails1.1.6)
13 Nov 2006
ijin, this plugin (and all the routing stuff I’ve blogged about recently) requires edge rails (or rails 1.2, when it is released). The routing code changed significantly since 1.1.6, which is the primary reason I’ve been writing about it. Sorry I wasn’t clearer about that.
13 Nov 2006
Jamis, thanks for the response. I’ll give edge rails a try.
13 Nov 2006
Hi Jamis, Thanks for this write up, it’s very useful. One question—how would I access the value of the subdomain from the controller that I’ve routed to? For example, let’s say I connected ”/” to the “admin” controller, but I wanted to use the value of the subdomain in a function within that “admin” controller after the connect. Any ideas?
Thanks, Graham
16 Nov 2006
Graham, the request object gives you the subdomains you need. Just do “request.subdomains.first” in your controller or view to get the first subdomain. (There can be multiple, as in “jamis.projects.domain.com”.)
16 Nov 2006