Discovering Rails Routes: Unfamiliar Features
Dive into uncommon Rails routes functionalities
Routes are the fundamental part of the Rails framework, as, without them, it won’t be possible to navigate through the app. While all Rails developers are familiarized with the routes DSL less or more, some fewer known features make the routing configuration even more flexible.
This article covers non-standard usage examples that you might find useful when building the next web application with Rails.
All my notes are based on years of hands-on experience at iRonin.IT - a top software development company, where we provide custom software development and IT staff augmentation services for a wide array of technologies.
Multiple configuration files for a large codebase
The routes.rb
file snowballs with the app, so you may end up with many definitions, and it would be harder to maintain them for someone who is not familiarized with the app.
Instead of putting all definitions into the config/routes.rb
file, you can split routes into multiple files. Let’s assume that your app contains a section for the users and administrators:
# config/routes.rb
Rails.application.routes.draw do
namespace :admin do
resources :users
resources :posts
end
resources :posts, only: %i[show index]
resources :users, only: %i[show]
end
If you plan to add many more routes specific to the given group, it may be a good idea to store configuration in the separated files. To do this, create a config/routes
directory and create a file for each group:
# config/routes/admin.rb
namespace :admin do
resources :users
resources :posts
end
and one for the users:
# config/routes/user.rb
resources :posts, only: %i[show index]
resources :users, only: %i[show]
The last step is to load those files into the main config/routes.rb
file and make them available in the app. To do this, we can overwrite the mapper class and add the draw method:
# config/routes.rb
module ActionDispatch
module Routing
class Mapper
def draw(routes_name)
routes_path = Rails.root.join('config', 'routes', (@scope[:shallow_prefix]).to_s, "#{routes_name}.rb")
instance_eval(File.read(routes_path))
end
end
end
end
Rails.application.routes.draw do
draw :admin
draw :user
end
Right now, each time the draw method is invoked, we look for a route file with the given name and evaluate the contents of the file in the context of our routes’ draw block.
Redirect on the routes level instead of controller
Redirection on a controller’s level is a typical pattern, but such action can also be achieved on the route level. The simplest example of a redirection contains hardcoded values for both source and destination path:
Rails.application.routes.draw do
get '/email_us' => redirect('/contact')
end
By default, the 301 response code is returned during the redirection, which means that the resource is moved permanently to the new address. If you would like to change that behavior and use the 302 response code instead (which mean moved temporarily), you have to pass the status option as the second argument:
Rails.application.routes.draw do
get '/email_us' => redirect('/contact', status: 302)
end
More complex redirection logic
If your redirection is more complicated than just a simple replacement of the main path, you can pass a block to the redirect method and manipulate params and the request object inside:
Rails.application.routes.draw do
get '/email_us/:utm_source', to: redirect { |params, request|
utm_path = case params[:utm_source]
when 'facebook', 'twitter' then 'contact/social'
when 'campaign' then 'contact/campaign'
else
'contact/default'
end
"https://#{request.host_with_port}/#{utm_path}"
}
end
We would like to redirect to a different path in the above redirection depending on the visitor’s source. With that syntax, we can also easily redirect the user outside our application.
This approach is flexible but is far from being perfect. We would like to avoid putting the business logic into the routes file. Thankfully we can simply refactor our code and isolate the logic.
Reusable and easy testable redirection logic
The redirect method also accepts any class instance that responds to the call method. This method should return a string that will represent the path to which the visitor should be redirected and accept two arguments: params
and request
.
We can create UtmSourceRedirector
class and isolate our redirection logic there:
class UtmSourceRedirector
def initialize(target_path)
@target_path = target_path
end
def call(params, request)
path = utm_path(params[:utm_source])
"http://#{request.host_with_port}/#{path}"
end
private
def utm_path(utm_source)
case utm_source
when 'facebook', 'twitter' then "#{@target_path}/social"
when 'campaign' then "#{@target_path}/campaign"
else
"#{@target_path}/default"
end
end
end
Now, we can simply create a new instance and pass it to the redirect method to keep the same logic as we had with the block option
Rails.application.routes.draw do
get '/email_us/:utm_source', to: redirect(UtmSourceRedirector.new('contact'))
end
Modifying redirection options
If you don’t need a separated class to deal with redirection, you can also override the redirection request parameters by passing the following options:
protocol
host
port
path
params
For example, if you want to make a redirect with extra params, you can pass the following hash to the redirect method:
Rails.application.routes.draw do
get '/email_us', to: redirect(path: 'contact', params: { utm_source: 'old_form'})
end
Just remember to pass also the path option as you are overriding the request option. Without the path option, you will encounter the endless redirect as Rails will use the current path for which redirection was defined.
Redirection on the routes level is an interesting alternative that allows us not to alter the controller level’s logic when it’s not needed.
Concerns are not reserved only for models and controllers
The concerns concept is mostly known from controllers and models where we can use modules to pack commonly used code and reuse it across other classes. It’s also possible to use concerns with routes. Although the implementation is different, the result is the same: we are not repeating twice the same code.
Let’s consider a typical example to get the idea behind route concerns. If you are building a website, you might want to add the ability to comment on different resources by the website’s users:
Rails.application.routes.draw do
resources :articles do
member do
resources :comments, only: [:create, :index]
end
end
end
The above configuration allows you to create and pull comments for the given article. If you would like to see what routes are available, execute the rake routes
or rails routes
task (rake routes
is no longer available in Rails 6.1).
If you also have documents resources, you can also allow commenting on them:
Rails.application.routes.draw do
resources :articles do
member do
resources :comments, only: [:create, :index]
end
end
resources :documents do
member do
resources :comments, only: [:create, :index]
end
end
end
We had to repeat the configuration to ensure that the comments feature would work the same way on articles and documents. This is a perfect scenario for using concerns:
Rails.application.routes.draw do
concern :commentable do
member do
resources :comments, only: [:create, :index]
end
end
resources :articles, concerns: %i[commentable]
resources :documents, concerns: %i[commentable]
end
You simply create a new concern by setting its name and wrapping it into a block that contains all configuration that belongs to the given concern.
If you would like to be more even more flexible, you can wrap your configuration into an object:
# commentable.rb
class Commentable
def initialize(defaults = {})
@defaults = defaults
end
def call(mapper, options = {})
options = @defaults.merge(options)
commentable_actions = %i[create index]
commentable_actions << :update if options[:editable]
mapper.member do
mapper.resources :comments, only: commentable_actions
end
end
end
Such object allows us to explicitly control the comments editing feature when using concern for given resources:
Rails.application.routes.draw do
concern :commentable, Commentable.new(editable: false)
resources :articles do
concerns :commentable, editable: true
end
resources :documents, concerns: %i[commentable]
end
Our users can comment on documents, but they can’t edit their comments while commenting on articles; they can later edit their comments if needed.
If your routes.rb
file consists of many routes, it’s probably a good idea to refactor the configuration using concerns. If you don’t have many routes defined, such change might be just another abstraction level that you don’t need.
Running another application inside the Rails application
It is possible to mount another Rack-based app as a Rails application route. You can use Hanami, Sinatra, or Grape to build an application and then use it with your existing Rails application.
If you are wondering why would you need to do that instead of adding more code to your application, I have a few reasons that may convince you:
Separated API application - you can expose API access for your application by building a small application with Grape or Sinatra. Such an approach is more performant and provides great isolation for the code
Using an application that doesn’t need Rails - Rails is great, but its great features come with a cost of worse performance and many levels of abstraction. Sidekiq’s web dashboard is a great example of the addon that extends Rails but it doesn’t need its code to provide the value
Here is the example from Sidekiq’s documentation:
Rails.application.routes.draw do
require 'sidekiq/web'
mount Sidekiq::Web => '/sidekiq'
end
You simply call mount, pass the application entry point along with the path where it should be accessible, and you are done.