20121119

subdomains in rails apps: a current brief

Subdomains (or second level domains) are still a nice way to present separate interfaces to a single web app — commonly to give a certain kind of customers the feel-n-touch of a dedicated app install.

Here are most of the related aspects based on a particular Rails3 project:

  • some model Group, representing a customer, or rather a group of users, has a string column domain (migration not shown, but don’t forget to index by that column)

    in app/models/group.rb:

    has_many :users
    before_validation :downcase_domain, :if => :domain_changed?
    validates :domain, presence: true, uniqueness: true, length: {maximum: 255}, format: /^[0-9a-z-]+$/
    
    
    def host
      "#{domain}.#{ROOT_DOMAIN}"
    end
    
    
    protected
    def downcase_domain
      self.domain = domain.to_s.downcase
    end
    
  • under assumption that you’re using devise and your (group’s) user identity model is User (updated for everchanging devise 2.0.4)

    in app/models/user.rb:

    belongs_to :group
    devise :database_authenticatable, ..., :request_keys => [:app_subdomain]
    
    
    def self.find_for_authentication(conditions={})
      group = Group.find_by_domain(conditions.delete(:app_subdomain))
      return nil unless group.present?
      conditions[:group_id] = group.id
      super
    end
    
  • you’ll have some admin interface where the groups are managed (e.g. active_admin, not recommended)

    in app/admin/groups.rb:

    link_to group.domain, root_url(host: group.host)
    
  • you will certainly want to do something specific in config/routes.rb

  • you’ll want some handy helper method, to know which guvnor you’re serving

    in app/controllers/application_controller.rb:

    helper_method :current_group
    def current_group
      return @current_group if defined? @current_group
      @current_group = request.app_subdomain && Group.find_by_domain(request.app_subdomain)
    end
    
  • you’ll need an initializer of some sort to set a constant and monkey-patch the request class, so…

    in config/initializers/subdomain.rb:

    ROOT_DOMAIN ||= ENV['ROOT_DOMAIN'] or raise "ROOT_DOMAIN must be set"
    
    
    # (): paranoid monkey patching :()
    class ActionDispatch::Request
      def app_subdomain
        return @app_subdomain if defined? @app_subdomain
        @@app_hostname_regex ||= /^(?:([0-9a-z-]+).)?#{Regexp.escape(ROOT_DOMAIN)}$/
        raise 'Wrong domain' unless host.downcase =~ @@app_hostname_regex
        @app_subdomain = $1
      end
    end
    
  • then, for your production (and staging) environment on, say, heroku, you’ll have to setup your lovely app domain name (with wildcard subdomains) and set the environment variable ROOT_DOMAIN to it

  • for test environment, which is also good for the handy circleci

    in config/environments/test.rb:

    ROOT_DOMAIN = 'lvh.me'  # yes, it's a magic domain for 127.0.0.1 //smackaho.st RIP
    
  • for other environment cases, be sure to set either ROOT_DOMAIN or ENV['ROOT_DOMAIN'] as appropriate

  • if you use factories (and girls, factory_girl)

    in spec/factories.rb:

    factory :group do
      sequence(:domain) {|n| "dom-#{n}"}  # or better still, use `forgery` with some smart randomness
      ...
    end
    
  • if you use capybara (2.0.0 at least, recommended) and rspec (rspec-rails 2.12.0 at least)

    in spec/spec_helper.rb: (in Spork.prefork block if you use spork, recommended)

    Capybara.always_include_port = true  # unless you `visit` external sites in your feature specs
    

    and then in some spec/features/..._spec.rb:

    visit("http://some_domain.#{ROOT_DOMAIN}/some_path")
    # or
    visit(some_url host: @group.host)  # if you're playing dirty, using pre-fabricated data and route helpers, recommended
    
  • in some spec/controllers/..._spec.rb you’ll have to include something like this:

    before(:each) do
      request.host = @group.host
    end
    
  • don’t forget the specs for domains in spec/models/group_spec.rb and other relevant places

May your sub-domains be obedient to their master.