Rails : nested routes, polymorphic associations and controllers

Ruby on Rails is a great framework that allow you to have a great flexibility when writing code.

A friend of mine that just start as Rails developer, recently asked me how to implement controllers that handle polymorphic objects and nested routes.

Today, I’m gonna show you my way of implementing such case with a particular focus on writing DRY controllers that are easy to test and maintain.

Case study

For the purpose of this article, we are going to assume our application can manage categories. Since we are dealing with polymorphic association, let’s say that we also have users and events, and want to make them categorizable.

The following schema shows how to handle this case at the database level :

Models implementation

Let’s first create our Category model, as well as the association model that will allow an object to be categorizable.

app/models/category.rb

class Category < ActiveRecord::Base
has_many :categorizations
end

app/models/categorization.rb

class Categorization < ActiveRecord::Base
belongs_to :category
belongs_to :categorizable, polymorphic: true
end

Now, we need to be able to plug to that, any object that needs to be categorizable. Some may disagree, but Rails concerns come in handy to deal with such case.

app/models/concerns/categorizable.rb

module Categorizable
extend ActiveSupport::Concern
  included do
has_many :categorizations, as: :categorizable
has_many :categories, through: :categorizations
end
end

We can now use this mixin to make our user and event objects categorizable.

app/models/user.rb

class User < ActiveRecord::Base
include Categorizable
end

app/models/event.rb

class Event < ActiveRecord::Base
include Categorizable
end

Routes definitions

We actually want to be able to manage the following urls pattern using one controller only, to avoid repeating common behavior across many controllers.

/categories(/:id)(.:format)
/users/:user_id/categories(/:id)(.:format)
/events/:event_id/categories(/:id)(.:format)

The corresponding routes definition looks like :

Rails.application.routes.draw do
concern :categorizable do
resources :categories
end
  resources :categories
resources :users, concerns: :categorizable
resources :events, concerns: :categorizable
end

Controller

Before implementing the controller, we first need to understand what are the problems when dealing with polymorphic associations. On the top of my head, I can think of two common issues. Our CategoriesController should be able to :

  • Identify which categorizable object is being used (user, event…) on nested scenario(e,g. /users/:user_id/categories/:id), or simply use Category as default if no categorizable object is present (e,g. /categories/:id).
  • Respond with the correct url location dynamically(e,g. http://api.domain.com/categories or http://api.domain.com/users/1/categories)

Mixins with concerns

In order to achieve that, let’s create 2 mixins that can then be injected in any controllers that needs to deal with polymorphic associations.

app/controllers/concerns/behaveable/resource_finder.rb

module Behaveable
module ResourceFinder
# Get the behaveable object.
#
# ==== Returns
# * <tt>ActiveRecord::Model</tt> - Behaveable instance object.
def behaveable
klass, param = behaveable_class
klass.find(params[param.to_sym]) if klass
end
    private

# Lookup behaveable class.
#
# ==== Returns
# * <tt>Response</tt> - Behaveable class or nil if not found.
def behaveable_class
params.each do |name|
if name =~ /(.+)_id$/
model = name.match(%r{([^\/.])_id$})
return model[1].classify.constantize, name
end
end
nil
end
end
end

What this code does is pretty obvious. The behaveable_class loop against the params object in order to detect if we deal with a nested route. If this is indeed the case, it will inflect the related class and pass it to the behaveable method which will find the corresponding object instance (e,g. /users/1/categories ==> User.find(1)).

app/controllers/concerns/behaveable/route_extractor.rb

module Behaveable
module RouteExtractor
def extract(behaveable = nil, resource = nil)
resource_name = resource_name_from(params)
behaveable_name = behaveable_name_from(behaveable)
      location_url = "#{resource_name}_url"
return regular(location_url, resource) unless behaveable
      location_url = "#{behaveable_name}_#{resource_name}_url"
nested(location_url, behaveable, resource)
end
    private
    def regular(location_url, resource)
return send(location_url) unless resource
send(location_url, resource)
end
    def nested(location_url, behaveable, resource)
return send(location_url, behaveable) unless resource
send(location_url, behaveable, resource)
end
    def resource_name_from(params)
inflect = params[:id].present? ? 'singular' : 'plural'
params[:controller].split('/').last.send("#{inflect}ize")
end
    def behaveable_name_from(behaveable)
return unless behaveable
behaveable.class.name.underscore
end
end
end

Let me explain a bit what happen here. When creating routes, Rails generates urls prefix for each of them, in order to be used when linking or redirecting. In our case, those prefix look like that :

# (GET|POST) /categories
=> categories_url
# (GET|PATCH|DELETE) /categories/:id
=> category_url(category)
# (GET|POST) /users/:user_id/categories
=> user_categories_url(user)
# (GET|PATCH|DELETE) /users/:user_id/categories/:id
=> user_category_url(user, category)

The RouteExtractor concern is extracting those prefix dynamically depending on the current context.

CategoriesController

Our CategoriesController is dealing with categories, user’s categories and event’s categories. By using the above mixins, writing it’s code is pretty straight forward. Since the example code cover all the basic REST resource endpoint it ends up being a bit long for be enjoyable to read on Medium. So here is link to the related gist : https://gist.github.com/softmonkeyjapan/6143ff56b25df2d6fd1c

Conclusion

This article does not pretend to show you THE way of handling Polymorphic routes and controller with Rails. Instead, it shows how to leverage the power of mixins to simplify things without too much “magic”.