Discovering Rails Routes: Unfamiliar Features

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.