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
Profile picture of Henning Koch
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)