The Rails 3 Router: Rack it Up
In my previous post about generic actions in Rails 3, I made reference to significant improvements in the router. Some of those have been covered on other blogs, but the full scope of the improvements hasn't yet been covered.
In this post, I'll cover a number of the larger design decisions, as well as specific improvements that have been made. Most of these features were in the Merb router, but the Rails DSL is more fully developed, and the fuller emphasis on Rack is a strong improvement from the Merb approach.
Improved DSL
While the old map.connect
DSL still works just fine, the new standard DSL is less verbose and more readable.
# old way
ActionController::Routing::Routes.draw do |map|
map.connect "/main/:id", :controller => "main", :action => "home"
end
# new way
Basecamp::Application.routes do
match "/main/:id", :to => "main#home"
end
First, the routes are attached to your application, which is now its own object and used throughout Railties. Second, we no longer need map
, and the new DSL (match/to) is more expressive. Finally, we have a shortcut for controller/action pairs ("main#home"
is {:controller => "main", :action => "home"
).
Another useful shortcut allows you to specify the method more simply than before:
Basecamp::Application.routes do
post "/main/:id", :to => "main#home", :as => :homepage
end
The :as
in the above example specifies a named route, and creates the homepage_url
et al helpers as in Rails 2.
Rack It Up
When designing the new router, we all agreed that it should be built first as a standalone piece of functionality, with Rails sugar added on top. As a result, we used rack-mount
, which was built by Josh Peek as a standalone Rack router.
Internally, the router simply matches requests to a rack endpoint, and knows nothing about controllers or controller semantics. Essentially, the router is designed to work like this:
Basecamp::Application.routes do
match "/home", :to => HomeApp
end
This will match requests with the /home
path, and dispatches them to a valid Rack application at HomeApp. This means that dispatching to a Sinatra app is trivial:
class HomeApp < Sinatra::Base
get "/" do
"Hello World!"
end
end
Basecamp::Application.routes do
match "/home", :to => HomeApp
end
The one small piece of the puzzle that might have you wondering at this point is that in the previous section, I showed the usage of :to => "main#home"
, and now I say that :to
takes a Rack application.
Another improvement in Rails 3 bridges this gap. In Rails 3, PostsController.action(:index)
returns a fully valid Rack application pointing at the index
action of PostsController
. So main#home
is simply a shortcut for MainController.action(:home)
, and it otherwise is identical to providing a Sinatra application.
As I posted before, this is also the engine behind match "/foo", :to => redirect("/bar")
.
Expanded Constraints
Probably the most common desired improvement to the Rails 2 router has been support for routing based on subdomains. There is currently a plugin called subdomain_routes that implements this functionality as follows:
ActionController::Routing::Routes.draw do |map|
map.subdomain :support do |support|
support.resources :tickets
...
end
end
This solves the most common case, but the reality is that this is just one common case. In truth, it should be possible to constrain routes based not just on path segments, method, and subdomain, but also based on any element of the request.
The Rails 3 router exposes this functionality. Here is how you would constrain requests based on subdomains in Rails 3:
Basecamp::Application.routes do
match "/foo/bar", :to => "foo#bar", :constraints => {:subdomain => "support"}
end
These constraints can include path segments as well as any method on ActionDispatch::Request
. You could use a String or a regular expression, so :constraints => {:subdomain => /support\d/}
would be valid as well.
Arbitrary constraints can also be specified in block form, as follows:
Basecamp::Application.routes do
constraints(:subdomain => "support") do
match "/foo/bar", :to => "foo#bar"
end
end
Finally, constraints can be specified as objects:
class SupportSubdomain
def self.matches?(request)
request.subdomain == "support"
end
end
Basecamp::Application.routes do
constraints(SupportSubdomain) do
match "/foo/bar", :to => "foo#bar"
end
end
Optional Segments
In Rails 2.3 and earlier, there were some optional segments. Unfortunately, they were hardcoded names and not controllable. Since we're using a generic router, magical optional segment names and semantics would not do. And having exposed support for optional segments in Merb was pretty nice. So we added them.
# Rails 2.3
ActionController::Routing::Routes.draw do |map|
# Note that :action and :id are optional, and
# :format is implicit
map.connect "/:controller/:action/:id"
end
# Rails 3
Basecamp::Application.routes do
# equivalent
match "/:controller(/:action(/:id))(.:format)"
end
In Rails 3, we can be explicit about the optional segments, and even nest optional segments. If we want the format to be a prefix path, we can do match "(/:format)/home"
and the format is optional. We can use a similar technique to add an optional company ID prefix or a locale.
Pervasive Blocks
You may have noticed this already, but as a general rule, if you can specify something as an inline condition, you can also specify it as a block constraint.
Basecamp::Application.routes do
controller :home do
match "/:action"
end
end
In the above example, we are not required to specify the controller inline, because we specified it via a block. You can use this for subdomains, controller restrictions, request method (get
etc. take a block). There is also a scope
method that can be used to scope a block of routes under a top-level path:
Basecamp::Application.routes do
scope "/home" do
match "/:action", :to => "homepage"
end
end
The above route would match /home/hello/foo
to homepage#foo
.
Closing
There are additional (substantial) improvements around resources, which I will save for another time, assuming someone else doesn't get to it first.