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