Posted over 4 years ago. Visible to the public.

Rails routes: Extracting collection actions into their own controllers

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:

Copy
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:

Copy
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:

Copy
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:

Copy
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:

Copy
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:

Copy
namespace :users do resources :sign_ups, only: [:new] end # GET /users/sign_ups/new => Users::SignUpsController#new
Copy
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:

Copy
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:

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

makandra has been working exclusively with Ruby on Rails since 2007. Our laser focus on a single technology has made us a leader in this space.

Owner of this card:

Avatar
Henning Koch
Last edit:
about 1 month ago
by Jakob Scholz
About this deck:
We are makandra and do test-driven, agile Ruby on Rails software development.
License for source code
Posted by Henning Koch to makandra dev
This website uses cookies to improve usability and analyze traffic.
Accept or learn more