Jason Codes

Nested resource URLs without controller names or numeric IDs in Rails 3

Posted

Let's say you want your app to have URLs like GitHub's: https://github.com/mojombo/jekyll. There are a number of advantages to URLs of this form over more conventional Rails resource URLs:

There are a couple of ways to implement URLs like GitHub's in Rails. One way is with a bunch of custom routes. This can easily result in a complex routes configuration file, especially if we nested further resources underneath repositories (such as issues or wiki pages). Care would also need to be taken for route helpers to have decent names.

A far nicer way to implement these kind of URLs is to define nested resources and hide the relevant controller names from the generated paths. This is the approach we will take here. Source code for this post's example app is available on GitHub.

Note: This post is for Rails 3. For Rails 2.3, you can use default_routing in combination with FriendlyId. I have a fork of default_routing which adds support for Bundler.

Project setup

Generate new Rails app

Create a new directory and setup our RVM gemset:

mkdir example
cd example
git init
echo rvm --create 1.9.2@example > .rvmrc
cd . # trigger RVM to load the rvmrc file

Generate a new Rails 3 app without Test::Unit or Prototype. We'll use RSpec for testing and we can add jquery-rails later when we want to add client-side scripting.

rails new . --skip-test-unit --skip-prototype

Add Inherited Resources and RSpec to the Gemfile. We'll be using these shortly.

gem 'inherited_resources'

group :development, :test do
  gem 'rspec-rails'
end

Run bundle to ensure all the required gems are installed.

Create models

Generate our model files and migrations. We'll be nesting projects underneath accounts and each model will have their own name.

rails generate model account name:string
rails generate model project name:string account:references

At this point we should add database constraints to the migration and validations to the model. For brevity we'll just add the has_many association for Account here:

class Account < ActiveRecord::Base
  has_many :projects
end

And then run the migrations with rake db:migrate.

Controllers and initial routes

Now that our test models are ready, let's add an initial set of routes to config/routes.rb:

Example::Application.routes.draw do
  resources :accounts do
    resources :projects
  end
end

Add a couple of controllers using Inherited Resources:

class AccountsController < ApplicationController
  inherit_resources
end

class ProjectsController < ApplicationController
  inherit_resources
  belongs_to :account
end

We now have URLs of the classic https://example.com/accounts/3141/projects/59265 format. At this point I have also created specs for the routes which I have omitted here for brevity. You can find these in the controllers commit in the example app repo.

Replacing numeric IDs in URLs with slugs

Revealing our surrogate primary keys in user visible URLs is not overly pretty. Usually there is a name field or other suitable text identifier which we could use. Name fields are rarely suitable for use directly in a URL as they typically contain unsafe characters and are often not unique nor immutable. Luckily for us, there's FriendlyId which normalises our name fields and ensures they are unique by adding a sequence number if required. With FriendlyId, we can easily turn URLs from https://example.com/accounts/3141/projects/59265 into https://example.com/accounts/foocorp/projects/widgets.

Add friendly_id to the Gemfile and run bundle:

gem 'friendly_id', '~> 3.2'

Next, create the slugs table. This is where FriendlyId stores information on all current and previous slugs to allow existing URLs to continue to function even if we rename an account or project.

In a production app you should 301 redirect any old slugs to the latest slug by checking resource.friendly_id_status.best? in a before_filter. This will prevent search engines from seeing the same content at different URLs.

rails generate friendly_id

Add a cached slug column to each model table. This is done primarily for performance reasons. This allows FriendlyId to generate URLs without having to recalculate and verify the slug every time.

rails generate migration add_cached_slug_to_accounts cached_slug:string
rails generate migration add_cached_slug_to_projects cached_slug:string

The cached_slug fields will also be preferred for lookups and thus should be indexed together with any parent scope ID. Ensure you add the relevant indexes to the migration.

class AddCachedSlugToAccounts < ActiveRecord::Migration
  def self.up
    add_column :accounts, :cached_slug, :string
    add_index :accounts, :cached_slug, :unique => true
  end

  def self.down
    remove_column :accounts, :cached_slug
  end
end

class AddCachedSlugToProjects < ActiveRecord::Migration
  def self.up
    add_column :projects, :cached_slug, :string
    add_index :projects, [:account_id, :cached_slug], :unique => true
  end

  def self.down
    remove_column :projects, :cached_slug
  end
end

Add has_friendly_id to the models:

class Account < ActiveRecord::Base
  has_friendly_id :name, :use_slug => true
end
class Project < ActiveRecord::Base
  has_friendly_id :name, :use_slug => true, :scope => :account_id
end

Run the migrations and generate slugs for any existing records:

rake db:migrate
rake friendly_id:make_slugs MODEL=Account
rake friendly_id:make_slugs MODEL=Project

Removing the controller names from URLs

Now that we have URLs like https://example.com/accounts/foocorp/projects/widgets, we need to remove the the controller name segments from the path so we end up with URLs like https://example.com/foocorp/widgets.

Updating the specs

First things first, let's update the routing specs to match the URLs we are after. i.e. /foocorp/widgets instead of /accounts/foocorp/projects/widgets.

spec/routing/accounts_routing_spec.rb:

require 'spec_helper'

describe AccountsController do
  describe "routing" do
    it '/ to Accounts#index' do
      path = accounts_path
      path.should == '/'
      { :get => path }.should route_to(
        :controller => 'accounts',
        :action => 'index'
      )
    end

    it '/new to Account#new' do
      path = new_account_path
      path.should == '/new'
      { :get => path }.should route_to(
        :controller => 'accounts',
        :action => 'new'
      )
    end

    it '/:account_id to Account#show' do
      path = account_path 'foocorp'
      path.should == '/foocorp'
      { :get => path }.should route_to(
        :controller => 'accounts',
        :action => 'show',
        :id => 'foocorp'
      )
    end

    it '/:account_id/edit to Account#edit' do
      path = edit_account_path 'foocorp'
      path.should == '/foocorp/edit'
      { :get => path }.should route_to(
        :controller => 'accounts',
        :action => 'edit',
        :id => 'foocorp'
      )
    end
  end
end

spec/routing/projects_routing_spec.rb:

require 'spec_helper'

describe ProjectsController do
  describe "routing" do
    it '/:account_id/new to Projects#new' do
      path = new_account_project_path('foocorp')
      path.should == '/foocorp/new'
      { :get => path }.should route_to(
        :controller => 'projects',
        :action => 'new',
        :account_id => 'foocorp'
      )
    end

    it '/:account_id/:project_id to Projects#show' do
      path = account_project_path 'foocorp', 'widgets'
      path.should == '/foocorp/widgets'
      { :get => path }.should route_to(
        :controller => 'projects',
        :action => 'show',
        :account_id => 'foocorp',
        :id => 'widgets'
      )
    end

    it '/:account_id/:project_id/edit to Projects#edit' do
      path = edit_account_project_path 'foocorp', 'widgets'
      path.should == '/foocorp/widgets/edit'
      { :get => path }.should route_to(
        :controller => 'projects',
        :action => 'edit',
        :account_id => 'foocorp',
        :id => 'widgets'
      )
    end
  end
end

Updating the routes

We can customise the controller names in routes by using the :path option on the resources block. By setting the path to an empty string, we can remove the controller name segment completely from the generated paths.

With no prefix on the nested resource routes, both the show page for accounts (/accounts/foocorp) and the index page for the nested projects (/accounts/foocorp/projects) will end up with the same URL (/foocorp). Since we can't have both of these at the same URL, we should only generate a route for one of these two actions to prevent confusion and possible bugs. In most cases I've found that disabling the nested index route is best as typically you'd want a normal show page for the parent resource. For example, the project listing makes up only part of the account's show page at /foocorp.

Here's the updated routes entries:

Example::Application.routes.draw do
  resources :accounts, :path => '' do
    resources :projects, :path => '', :except => [:index]
  end
end

A small problem

Unfortunately this does not work quite to plan. If you run the specs, you'll see the member actions on the parent resource are not working. Viewing the output of rake routes confirms why:

account_project GET    /:account_id/:id(.:format)      {:action=>"show", :controller=>"projects"}
   edit_account GET    /:id/edit(.:format)             {:action=>"edit", :controller=>"accounts"}

The nested show route is outputted first and catches all member actions on the parent. If you take a look the implementation of resources in action_dispatch/routing/mapper.rb, you'll see that the child block is yielded before any of the resources own routes are outputted.

An easy solution

The workaround for this is to place any nested resources which have empty paths (:path => '') within a second parent resources block. This second block uses :only => [] to prevent it from generating any routes of its own. Any member or collection blocks for resources :accounts (as well as any only/except constraints) can be added as normal to the first resources :accounts entry.

Example::Application.routes.draw do
  resources :accounts, :path => ''
  resources :accounts, :path => '', :only => [] do
    resources :projects, :path => '', :except => [:index]
  end
end

VoilĂ , all of the specs are now passing.