Monkey-patching Rails: Extending Routes #2

Posted by Jamis on October 26, 2006 @ 08:03 AM

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.

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.

26 Oct 2006

1. Justin French said...

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 :)

27 Oct 2006

2. jeroen@supercool.nl said...

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

3. Jamis said...

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.

28 Oct 2006

4. jeroen@supercool.nl said...

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.

31 Oct 2006

5. Chuck said...

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!

13 Nov 2006

6. ijin said...

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)

  1. ruby script/plugin install http://svn.jamisbuck.org/rails-plugins/routing_tricks/ + ./routing_tricks/README + ./routing_tricks/Rakefile + ./routing_tricks/init.rb + ./routing_tricks/lib/jamis_buck/routing/dsl.rb + ./routing_tricks/lib/jamis_buck/routing/route.rb + ./routing_tricks/lib/jamis_buck/routing/routeset.rb + ./routing_tricks/test/host_test.rb + ./routing_tricks/test/redirect_test.rb + ./routing_tricks/test/test_helper.rb
  2. ruby script/server => Booting WEBrick… /usr/local/lib/ruby/gems/1.8/gems/activesupport-1.3.1/lib/active_support/dependencies.rb:123:in `const_missing’: uninitialized constant Mapper (NameError) from /usr/local/lib/ruby/gems/1.8/gems/activesupport-1.3.1/lib/active_support/dependencies.rb:133:in `const_missing’ from script/../config/../vendor/plugins/routing_tricks/init.rb:6:in `load_plugin’ from /usr/local/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/initializer.rb:348:in `load_plugin’ from /usr/local/lib/ruby/gems/1.8/gems/activesupport-1.3.1/lib/active_support/core_ext/kernel/reporting.rb:11:in `silence_warnings’ from /usr/local/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/initializer.rb:348:in `load_plugin’ from /usr/local/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/initializer.rb:158:in `load_plugins’ from /usr/local/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/initializer.rb:158:in `load_plugins’ from /usr/local/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/initializer.rb:102:in `process’ ... 7 levels… from /usr/local/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/commands/server.rb:30 from /usr/local/lib/ruby/site_ruby/1.8/rubygems/custom_require.rb:21:in `require’ from /usr/local/lib/ruby/gems/1.8/gems/activesupport-1.3.1/lib/active_support/dependencies.rb:147:in `require’ from script/server:3

7. Jamis said...

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.

8. ijin said...

Jamis, thanks for the response. I’ll give edge rails a try.

16 Nov 2006

9. Graham said...

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

10. Jamis said...

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”.)