Implementing Basecamp-style URLs in Rails

Here's how anyone can implement the nice tenant URLs for their tenants. No tenant subdomains over here.

Nested tenant resources

I already glossed over some options for tenant URLs, so today I go straight to implementing Basecamp URLs like this one:

https://example.com/customer/projects/8383

The idea is to go from nested resources you find in Business Class 2.0 to ones that drop the resource name from URL (be it /teams/ or /tenants/ ) and keep only the slug.

Here's how it might look in Business Class 2.0 where tenants would manage their books:

# config/routes.rb
Rails.application.routes.draw do
  resources :tenants, param: :slug do
    resources :books
  end
end

To change to Basecamp-style URLs, we change the top enclosing resource to a plain scope :

# config/routes.rb
Rails.application.routes.draw do
  scope ":tenant_slug", as: :tenant do
    resources :books
  end
end

As you can see it's such a simple change when you already scoped things before and something you can do in Business Class today.

Business Class 3.0 will most likely adopt this as a default.

Real Basecamp

If you are curious how this style of URLs are made in real Basecamp, have a look at this DHH gist. They actually parse the URL in Rack, save the tenant in env and then rewrite request.path_info which is used by Rails for routing:

module AccountSlug
  PATTERN = /(\d{7,})/
  FORMAT = '%07d'

  def self.decode(slug) slug.to_i end
  def self.encode(id) FORMAT % id end

  # We're using account id prefixes in the URL path. Rather than namespace
  # all our routes, we're "mounting" the Rails app at this URL prefix.
  #
  # The Extractor middleware yanks the prefix off PATH_INFO, moves it to
  # SCRIPT_NAME, and drops the account id in env['bc3.account.queenbee_id'].
  #
  # Rails routes on PATH_INFO and builds URLs that respect SCRIPT_NAME,
  # so the main app is none the wiser. We look up the current account using
  # env['bc3.account.queenbee_id'] instead of request.subdomains.first
  class Extractor
    PATH_INFO_MATCH = /\A(\/#{AccountSlug::PATTERN})/

    def initialize(app)
      @app = app
    end

    def call(env)
      request = ActionDispatch::Request.new(env)

      # $1, $2, $' == script_name, slug, path_info
      if request.path_info =~ PATH_INFO_MATCH
        request.script_name   = $1
        request.path_info     = $'.empty? ? '/' : $'

        # Stash the account's Queenbee ID.
        env['bc3.account.queenbee_id'] = AccountSlug.decode($2)
      end

      @app.call env
    end
  end

  # Limit session cookies to the slug path.
  class LimitSessionToAccountSlugPath
    def initialize(app)
      @app = app
    end

    def call(env)
      env['rack.session.options'][:path] = env['SCRIPT_NAME'] if env['bc3.account.queenbee_id']
      @app.call env
    end
  end
end
Author
Josef Strzibny
A long time Rails developer from the early Rails 2.0 days. Author of Business Class and books like Kamal Handbook and Test Driving Rails.

© Business Class Blog