Modular Routes

Dedicated controllers for each of your Rails route actions.

gem version CI Coverage Maintainability

If you've ever used Hanami routes or already use dedicated controllers for each route action, this gem might be useful.

Disclaimer: There's no better/worse nor right/wrong approach, it's up to you to decide how you prefer to organize the controllers and routes of your application.

Docs: Unreleased, v0.2.0, v0.1.1

Motivation

Let's imagine that you have to design a full RESTful resource named articles with some custom routes like the table below

HTTP Verb Path
GET /articles
GET /articles/new
POST /articles
GET /articles/:id
GET /articles/:id/edit
PATCH/PUT /articles/:id
DELETE /articles/:id
GET /articles/stats
POST /articles/:id/archive

How would you organize the controllers and routes of this application?

The most common approach is to have all the actions (RESTful and customs) in the same controller.

# routes.rb

resources :articles do
  get  :stats,   on: :collection
  post :archive, on: :member
end

# articles_controller.rb

class ArticlesController
  def index
    # ...
  end

  def create
    # ...
  end

  # other actions...

  def stats
    # ...
  end

  def archive
    # ...
  end
end

The reason I don't like this approach is that you can end up with a lot of code that are not related to each other in the same file. You can still have it all organized but I believe that it could be better.

DHH prefers to keep the RESTful actions (index, new, edit, show, create, update, destroy) inside the same controller and the custom ones in dedicated controllers but represented as RESTful actions.

One way of representing that would be

# routes.rb

resources :articles do
  get  :stats,   on: :collection, to: 'articles/stats#show'
  post :archive, on: :member,     to: 'articles/archive#create'
end

# articles_controller.rb

class ArticlesController
  def index
    # ...
  end

  def create
    # ...
  end

  # other actions...
end

# articles/archive_controller.rb

class Articles::ArchiveController
  def create
  end
end

# articles/stats_controller.rb

class Articles::StatsController
  def show
  end
end

This approach is better than the previous one because it restricts the main controller file to contain only the RESTful actions. Additional routes would require you to create a dedicated controller to handle that individually.

Another approach (and what I personally prefer) is to have one controller per route. What it was done for archive and stats routes would also be applied to all the RESTful routes.

The files would be organized inside articles/ folder that would act as a namespace

app/
└── controllers/
    └── articles/
        ├── archive_controller.rb
        ├── create_controller.rb
        ├── destroy_controller.rb
        ├── edit_controller.rb
        ├── index_controller.rb
        ├── new_controller.rb
        ├── show_controller.rb
        ├── stats_controller.rb
        └── update_controller.rb

And the controllers would have one single action named call like

# articles/index_controller.rb

class Articles::IndexController
  def call
  end
end

# articles/archive_controller.rb

class Articles::ArchiveController
  def call
  end
end

Here are two ways of representing what was explained above:

scope module: :articles, path: '/articles' do
  get    '/',        to: 'index#call', as: 'articles'
  post   '/',        to: 'create#call'

  get    'new',      to: 'new#call',  as: 'new_article'
  get    ':id/edit', to: 'edit#call', as: 'edit_article'
  get    ':id',      to: 'show#call', as: 'article'
  patch  ':id',      to: 'update#call'
  put    ':id',      to: 'update#call'
  delete ':id',      to: 'destroy#call'

  post 'stats',       to: 'stats#call',   as: 'stats_articles'
  post ':id/archive', to: 'archive#call', as: 'archive_article'
end

or

resources :articles, module: :articles, only: [] do
  collection do
    get  :index,  to: 'index#call'
    post :create, to: 'create#call'
    post :stats,  to: 'stats#call'
  end

  new do
    get :new, to: 'new#call'
  end

  member do
    get    :edit,    to: 'edit#call'
    get    :show,    to: 'show#call'
    patch  :update,  to: 'update#call'
    put    :update,  to: 'update#call'
    delete :destroy, to: 'destroy#call'
    post   :archive, to: 'archive#call'
  end
end

This is the best approach in my opinion because your controller will contain only code related to that specific route action. It will also be easier to test and maintain the code.

If you've decided to go with the last approach, unless you organize your routes in separated files, your config/routes.rb might get really messy as your application grows due to verbosity.

So, what if we had a simpler way of doing all of that? Let's take a look at how modular routes can help us.

Installation

Add this line to your application's Gemfile:

gem "modular_routes"

And then execute:

$ bundle install

Or install it yourself as:

$ gem install modular_routes

Usage

modular_routes uses Rails route helpers behind the scenes. So you can pretty much use everything except for a few limitations that will be detailed later.

For the same example used in the motivation, using modular routes we now have

# routes.rb

modular_routes do
  resources :articles do
    collection do
      post :stats
    end

    member do
      post :archive
    end
  end
end

or to be shorter

# routes.rb

modular_routes do
  resources :articles do
    post :stats,   on: :collection
    post :archive, on: :member
  end
end

The output routes for the code above would be

HTTP Verb Path Controller#Action Named Route Helper
GET /articles articles/index#call articles_path
GET /articles/new articles/new#call new_article_path
POST /articles articles/create#call articles_path
GET /articles/:id articles/show#call articles_path(:id)
GET /articles/:id/edit articles/edit#call edit_articles_path(:id)
PATCH/PUT /articles/:id articles/update#call articles_path(:id)
DELETE /articles/:id articles/destroy#call articles_path(:id)
POST /articles/stats articles/stats#call stats_articles_path
POST /articles/:id/archive articles/archive#call archive_article_path(:id)

Restricting routes

You can restrict resource RESTful routes with :only and :except similar to what you can do in Rails.

modular_routes do
  resources :articles, only: [:index, :show]

  resources :comments, except: [:destroy]
end

Renaming paths

As in Rails you can use :path to rename route paths.

modular_routes do
  resources :articles, path: 'posts'
end

is going to produce

HTTP Verb Path Controller#Action Named Route Helper
GET /posts articles/index#call articles_path
GET /posts/new articles/new#call new_article_path
POST /posts articles/create#call articles_path
GET /posts/:id articles/show#call article_path(:id)
GET /posts/:id/edit articles/edit#call edit_article_path(:id)
PATCH/PUT /posts/:id articles/update#call article_path(:id)
DELETE /posts/:id articles/destroy#call article_path(:id)

Nesting

As of version 0.2.0, modular routes supports nesting just like Rails.

modular_routes do
  resources :books, only: [] do
    resources :reviews
  end
end

The output routes for that would be

HTTP Verb Path Controller#Action Named Route Helper
GET /books/:book_id/reviews books/reviews/index#call book_reviews_path
GET /books/:book_id/reviews/new books/reviews/new#call new_book_review_path
POST /books/:book_id/reviews books/reviews/create#call book_reviews_path
GET /books/:book_id/reviews/:id books/reviews/show#call book_review_path(:id)
GET /books/:book_id/reviews/:id/edit books/reviews/edit#call edit_book_review_path(:id)
PATCH/PUT /books/:book_id/reviews/:id books/reviews/update#call book_review_path(:id)
DELETE /books/:book_id/reviews/:id books/reviews/destroy#call book_review_path(:id)

Non-resourceful routes (standalone)

Sometimes you want to declare a non-resourceful routes and its straightforward without modular routes:

  get :about, to: "about/show#call"

Even being pretty simple, with modular routes you can omit the #call action like

modular_routes do
  get :about, to: "about#show"
end

It expects About::IndexController to exist in controllers/about/index_controller.rb.

If to doesn't match controller#action pattern, it falls back to Rails default behavior.

Scope

scope falls back to Rails default behavior, so you can use it just like you would do it outside modular routes.

modular_routes do
  scope :v1 do
    resources :books
  end

  scope module: :v1 do
    resources :books
  end
end

In this example it recognizes /v1/books and /books expecting BooksController and V1::BooksController respectively.

Namespace

As scope, namespace also falls back to Rails default behavior:

modular_routes do
  namespace :v1 do
    resources :books
  end
end
HTTP Verb Path Controller#Action Named Route Helper
GET /v1/books v1/books/index#call v1_books_path
GET /v1/books/new v1/books/new#call new_v1_book_path
POST /v1/books v1/books/create#call v1_books_path
GET /v1/books/:id v1/books/show#call v1_book_path(:id)
GET /v1/books/:id/edit v1/books/edit#call edit_v1_book_path(:id)
PATCH/PUT /v1/books/:id v1/books/update#call v1_book_path(:id)
DELETE /v1/books/:id v1/books/destroy#call v1_book_path(:id)

Routing concerns

When you want to reuse route declarations that are usually associated with a common behavior, you can use concerns declaring blocks like:

concern :commentable do
  resource :comments
end

concern :activatable do
  member do
    put :activate
    put :deactivate
  end
end

To use it you can pass it through resource(s) options or calling concerns helper inside of a resource(s) block:

resources :articles, concerns: :commentable

resources :articles, concerns: [:activatable]

# or

resources :articles, concerns: :activatable do
  concerns :commentable
end

The output of that would be:

HTTP Verb Path Controller#Action Named Route Helper
GET /articles/:id/activate articles/activate#call activate_article_path
GET /articles/:id/deactivate articles/deactivate#call deactivate_article_path
GET /articles/:article_id/comments articles/comments/index#call article_comments_path(:article_id)
GET /articles/:article_id/comments/new articles/comments/new#call new_article_comment_path (:article_id)
POST /articles/:article_id/comments articles/comments/create#call article_comments_path(:article_id)
GET /articles/:article_id/comments/:id articles/comments/show#call article_comment_path(:article_id, :id)
GET /articles/:article_id/comments/:id/edit articles/comments/edit#call edit_article_comment_path(:article_id, :id)
PATCH/PUT /articles/:article_id/comments/:id articles/comments/update#call article_comment_path(:article_id, :id)
DELETE /articles/:article_id/comments/:id articles/comments/destroy#call article_comment_path(:article_id, :id)

API mode

When config.api_only is set to true, :edit and :new routes won't be applied for resources.

Limitations

  • constraints are supported via scope :constraints and options
  • concerns are not supported inside modular_routes block but can be declared outside and used as options

Let us know more limitations by creating a new issue.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/vitoravelino/modular_routes. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

Code of Conduct

Everyone interacting in the ModularRoutes project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

Licensing

Modular Routes is licensed under the Apache License, Version 2.0. See LICENSE for the full license text.