221: Subdomains in Rails 3
(view original Railscast)
It has been nearly two years since the topic of subdomains was covered. Rails 3 introduces some added support for subdomains and we’ll look into what it offers in this episode.
The application we’ll be using is the same one used in the other episode on subdomains. It’s a simple blogging application that supports multiple blogs. Below is the homepage which lists all the blogs. Each blog has a set of articles associated with it.
If we click on the edit link for a blog we’ll see that each one has two attributes: a name
and a subdomain
. We want to use the subdomain
attribute as a subdomain in the URL for that blog.
Configuring Subdomains In Development Mode
The first thing we need to set up is a way to manage the subdomains in development mode. Our application has the URL http://localhost:3000 which doesn’t allow for subdomains so we need to use an alternative method.
One way to do this would be to set up our application to use Passenger. We could then give it a domain name like http://blog.local
and edit the /etc/hosts
file to set up subdomains like this:
/etc/hosts
127.0.0.1 personal.blog.local company.blog.local
Ideally we’d like to use a wildcard character that would match any subdomain so that we don’t have to continually change the hosts file as we develop our application. Unfortunately we can’t do that so we’ll have to find a different solution.
One good solution is offered by Tim Pope on his blog. He has bought the domain name smackaho.st
and made it a localhost wildcard. One of the people who commented on that blog posting has sone something similar with a shorter domain name, lvh.me
, and we’re going to use that instead.
If we go to http://lvh.me:3000/ we’ll see the homepage of our application because lvh.me
resolves to the IP address 127.0.0.1. The difference is that we can now prepend any subdomain we like to the URL and it will still point to the same application.
Now that we can use subdomains in our application we can configure its routes so that the personal subdomain will go to the Personal blog’s show action. Here’s what our application’s routes file currently looks like:
/config/routes.rb
Bloggit::Application.routes.draw do |map| resources :comments resources :articles resources :blogs root :to => "blogs#index" end
Currently the root route goes to the blogs/index
action. We only want that to be the case if no subdomain has been specified; where there is a subdomain the blogs/show
action should be shown instead. In Rails 3 we can do this by adding a constraint.
/config/routes.rb
Bloggit::Application.routes.draw do |map| resources :comments resources :articles resources :blogs match '/' => 'blogs#show', :constraints => { :subdomain => /.+/ } root :to => "blogs#index" end
In a Rails 2 application we would have needed to use a plugin to do this, but in Rails 3 this is built in. The :subdomain
option takes either a string or a regular expression and here we’ve used a regular expression that matches at least one character so any subdomain will match the route. Note that it is important that the route root comes after the subdomain route. If not, the root route will catch the subdomain routes and the subdomain route will never be called. As a general rule of thumb the more specific a route is the higher up in the list it should be.
If we visit the personal
subdomain now we’ll see an error, but that’s to be expected as we’re on the BlogsController
’s show
action and haven’t supplied an id
.
This is easy to fix. We just need to modify the show
action so that it finds a blog by its subdomain rather than by its id
.
/app/controllers/blogs_controller.rb
def show @blog = Blog.find_by_subdomain!(request.subdomain) end
When we reload the page now we’ll see the articles for our “Personal” blog.
If we remove the subdomain we’ll see the home page we had before.
There’s still a small problem with the routes, though. If we visit http://www.lvh.me:3000 then our application will look for a blog with the subdomain www
when this should redirect to the blogs index page. We could try to fix this by doing something clever with the regular expression in the constraints in the routes file but in order to give us more flexibility and control over the subdomains we’ll do this in Ruby code instead. This is possible in Rails 3 by writing a class and moving the constraint code into there.
First we’ll modify the routes file so that it uses a new class called Subdomain
. We do this by using a constraints
method and passing the class as an argument.
/config/routes.rb
Bloggit::Application.routes.draw do |map| resources :comments resources :articles resources :blogs constraints(Subdomain) do match '/' => 'blogs#show' end root :to => "blogs#index" end
Next we’ll need to create that Subdomain
class which will go in the /lib
directory. This class will need to have a class method called matches?
that takes a request
object as a parameter. This request object is the same object that we have access to in our controllers and views so we can use the same methods on it that we use there. This method needs to return a boolean value whose value depends on whether the given route matches the request. In our case we want the method to return true
if the request has a subdomain as long as that subdomain isn’t www
so our class will look like this:
/lib/subdomain.rb
class Subdomain def self.matches?(request) request.subdomain.present? && request.subdomain != 'www' end end
When we visit http://www.lvh.me:3000 now we’ll see the home page just as we want.
Fixing Links
Next we’ll fix the links to each blog on the home page so that they point to the correct subdomain instead of the normal show
action for that blog as that will throw an error because the show action now expects a subdomain.
In the view code for the index
action we have a standard link to each blog between h2
tags.
/app/views/blogs/index.html.erb
<% title "Blogs" %> <% for blog in @blogs %> <div> <h2><%= link_to blog.name, blog %></h2> <div class="actions"> <%= link_to "Edit", edit_blog_path(blog) %> | <%= link_to "Destroy", blog, :confirm => 'Are you sure?', :method => :delete %> </div> <% end %>
We’ll change this link so that it points to the root URL with the appropriate subdomain. Unfortunately we can’t pass a subdomain
option to root_url
like we can with subdomain_fu. Instead we have to create the entire host name from scratch and include the subdomain in it. The host name will be created from the blog’s subdomain
and the current domain
and port_string
properties of the request
object.
/app/views/blogs/index.html.erb
<h2><%= link_to blog.name, root_url(:host => blog.subdomain + '.' + request.domain + request.port_string) %></h2>
When we reload the home page now the links to the blogs will have the correct subdomain in them.
Cleaning Up The Code To Change The Subdomain
Everything is now working well but the code above that creates the link to the other subdomain could be a little cleaner, especially if we were going to make a lot of use of it. We’ll do this by moving it into a separate helper method that we’ll call with_subdomain
and which takes the subdomain
as an argument. We’ll change the code in the view first so that it calls the method we’re about to write.
/app/views/blogs/index.html.erb
<h2><%= link_to blog.name, root_url(:host => with_subdomain(blog.subdomain)) %></h2>
We’ll put the helper method inside its own module, in a file called url_helper.rb
.
/app/helpers/url_helper.rb
module UrlHelper def with_subdomain(subdomain) subdomain = (subdomain || "") subdomain += "." unless subdomain.empty? [subdomain, request.domain, request.port_string].join end end
The code in the module first sets the subdomain
to an empty string if the value passed in is nil
then appends a full stop to it if a non-empty subdomain
has been supplied. Finally it joins the subdomain
, domain
and port_string
and returns that value.
We’ll add the module to the ApplicationController
too so that all of our application’s controllers can use the method.
/app/controllers/application_controller.rb
class ApplicationController < ActionController::Base include UrlHelper protect_from_forgery layout 'application' end
Even though we have cleaned up the code in the view quite a bit it could be even cleaner if we could just add a :subdomain
option to the root_url
method and this is possible if we override the url_for
method. All we need to do is add the following method to our UrlHelper
module.
/app/helpers/url_helper.rb
def url_for(options = nil) if options.kind_of?(Hash) && options.has_key?(:subdomain) options[:host] = with_subdomain(options.delete(:subdomain)) end super end
The overriding url_for
method checks to see if the options
hash has a key called :subdomain
and, if so, sets the :host
option to be the value of with_subdomain
for that subdomain. Finally it calls super
so that the method’s default code is also executed and the rest of the URL is generated properly. There is no need to use alias_method_chain
here, just calling super
is enough.
We can now update our view code to use the :subdomain
option.
/app/views/blogs/index.html.erb
<h2><%= link_to blog.name, root_url(:subdomain => blog.subdomain) %></h2>
When we reload the index page and click one of the blog links the link still redirects to the correct URL with a subdomain.
It would be good to have a link on each blog’s page back to the index page and we can do this by calling root_url
with a :subdomain
value of false.
/app/views/blogs/show.html.erb
<p><%= link_to "All Blogs", root_url(:subdomain => false) %></p>
This will give us the link we want back to the index page.
Handling Different Top-Level Domains
One thing that we’ve not covered yet is how to handle domain names with more than two parts. So, while our subdomain code will work for .com domains it wouldn’t work for domain that ends with .co.uk. To make it work with a .co.uk or similar domain we’ll need to change our application everywhere it calls request.domain
or request.subdomain
so that we specify the number of dots that the domain name (without any subdomain) has. Rails assumes a default value of 1 but for domains like .co.uk we’ll need to set a value of 2.
We’ll have make changes to our application in two places: in the UrlHelper
method and in the Subdomain
class we use in our routing.
/app/helpers/url_helper.rb
def with_subdomain(subdomain) subdomain = (subdomain || "") subdomain += "." unless subdomain.empty? [subdomain, request.domain(2), request.port_string].join end
/lib/subdomains.rb
class Subdomain def self.matches?(request) request.subdomain(2).present? && request.subdomain(2) != "www" end end
Obviously we wouldn’t really want the value to be hard-coded. We’ll need to make it dynamic so that in development mode we can set a value of 1 (for a domain like lvh.me
) while in development mode we use 2. This value could be set in an external configuration file that loads the environment correctly.
Cookies
There’s one final thing we’ll cover in this episode. If we look at our browser’s cookies and filter it by the domain name we’ve been using for this application we’ll see that a separate session is stored for each different subdomain we’ve visited. We don’t want this as it will mean that sessions aren’t shared across subdomains.
A solution to this was presented in episode 123, but now in Rails 3 there is a better way to do it. In the application’s /config/initializers/session_store.rb
we just need to append the :domain
option to the Rails.application.config.session_store
method, giving it a value of :all
.
/config/initializers/sesion_store.rb
Rails.application.config.session_store :cookie_store, :key => '_bloggit_session', :domain => :all
This won’t work quite yet, however. The :domain
option has been added after Rails 3.0 beta 4 which at the time of writing is the current release. We’ll need to run Edge Rails or wait for the release candidate before this will work. Once we’re using a version of Rails that supports this option then our application will be able to share sessions across subdomains. The :all
option assumes that our application has a top-level domain size of 1. If not then we can specify a domain name instead and that will be used as the base domain for the session.
That’s it for this episode. Being able to handle subdomains without a plugin is a great addition to Rails 3 and will have many uses in applications.