Rails routes: Extracting collection actions into their own controllers

Updated . Posted . Visible to the public.

Let's say you have two screens:

  1. Show a given project
  2. Show a report for all projects

Ideally you want both screens to be handled by different controllers like this:

GET /projects/:id        => ProjectsController#show
GET /projects/report     => Projects::ReportsController#show

What seems like a simple requirement is a little awkward to configure in your routes.
Obviously the report should be a singleton resource, but how can we nest it into the Projects:: namespace?

What does not work is this:

resources :projects, only: :show do
  resource :report, only: :show
end

If we defined the routes above we would get a report for each project, instead of one report across all projects:

GET /projects/:id        => ProjectsController#show
GET /projects/:id/report => Projects::ReportsController#show

Hacks to solve this include renaming the report route to something like /projects_report, or defining
a new collection action ProjectsController#report. But these are all unsatisfying.

What you can do is wrap the sub-resource in a collection block like you would do with custom collection actions:

resources :projects, only: :show do
  collection do
    resource :report, only: :show, controller: 'projects/report'
    resources :members, only: [:index, :show]
  end
end

Run rake routes and you get what you wanted in the first place:

GET /projects/:id              => ProjectsController#show
GET /projects/report           => Projects::ReportsController#show
GET /projects/members          => MembersController#index
GET /projects/members/:id      => MembersController#show

Note how /projects/report, /projects/members and /projects/members/:id do not take a :project_id.

Controllers

Take care when defining nested resources, as they don't behave identical to namespaced resources:

namespace :users do
  resources :sign_ups, only: [:new]
end
# GET /users/sign_ups/new      => Users::SignUpsController#new

resources :users, only: [] do
  resources :sign_ups, only: [:new]
end
# GET /users/sign_ups/new      => SignUpsController#new

Both solutions will generate the same routes, the controller paths will be different, though. In the first example rails will expect to find a Users::SignUpsController in the subdirectory controllers/users/sign_ups_controller.rb, but there has to be a SignUpsController in controllers/sign_ups_controller.rb in order to make the second example work.
You can alter this behaviour with a custom controller path, as shown in the very first example of this card (resource :report, only: :show, controller: 'projects/report').

Solution for legacy Rails versions

What you can do instead is simple re-use the :projects symbol as both a resource and a namespace:

namespace :projects, as: :project do
  resource :report, only: :show
end
resources :projects, only: :show

Note that the routes must appear in this order so projects/report won't match the :projects resource.

Run rake routes and you get what you wanted in the first place:

GET /projects/report     => Projects::ReportsController#show
GET /projects/:id        => ProjectsController#show
Henning Koch
Last edit
Jakob Scholz
License
Source code in this card is licensed under the MIT License.
Posted by Henning Koch to makandra dev (2014-08-29 09:59)