Keep application controller clean

Search icon
Search Book

As a project adds more features, the application_controller.rb file tends to gather more and more code and soon it becomes very big and very messy.

Take a look at this application_controller.rb before the refactoring was done.

As we can see many disjoint entities exist within that file. In the first look itself we can see that it doesn't adhere to the DRY principle.

One method has nothing to do with another method. All the after_action declarations are physically too far from the implementation of the method. For example method set_honeybadger_context is 67 lines apart from the implementation of that method.

Using concerns to keep sanity

Concerns in Rails are like Ruby modules that extend the ActiveSupport::Concern module. Rails controllers come with concerns directory. All modules that reside inside concerns directory are automatically loaded by Rails. It's created by Rails team so that we can put related stuff together as a concern in that directory. So let's try to use it.

Using concerns is another form of keeping code DRY.

Moving functionality into a concern

Let's extract the helper methods for sending back a response for API requests from ApplicationController class and move it inside the api_responders.rb concern.

Let's try the same for authorization related code:

Run the following command to create the api_responders.rb concern:

1touch app/controllers/concerns/api_responders.rb

Now add the following lines of code to the api_responders.rb file:

1module ApiResponders
2  extend ActiveSupport::Concern
4  private
6    def respond_with_error(message, status = :unprocessable_entity, context = {})
7      is_exception = message.kind_of?(StandardError)
8      error_message = is_exception ? message.record&.errors_to_sentence : message
9      render status: status, json: { error: error_message }.merge(context)
10    end
12    def respond_with_success(message, status = :ok, context = {})
13      render status: status, json: { notice: message }.merge(context)
14    end
16    def respond_with_json(json = {}, status = :ok)
17      render status: status, json: json
18    end

Similarly let us extract the exception rescuing and its corresponding handler methods out of the ApplicationController to the api_exceptions.rb concern.

Run the following command to create the api_exceptions.rb concern:

1touch app/controllers/concerns/api_exceptions.rb

Now add the following lines of code to the api_exceptions.rb file:

1module ApiExceptions
2  extend ActiveSupport::Concern
4  included do
5    protect_from_forgery
7    rescue_from StandardError, with: :handle_api_exception
9    def handle_api_exception(exception)
10      case exception
11      when -> (e) { e.message.include?("PG::") || e.message.include?("SQLite3::") }
12        handle_database_level_exception(exception)
14      when Pundit::NotAuthorizedError
15        handle_authorization_error
17      when ActionController::ParameterMissing
18        respond_with_error(exception, :internal_server_error)
20      when ActiveRecord::RecordNotFound
21        respond_with_error(t("not_found", entity: exception.model), :not_found)
23      when ActiveRecord::RecordNotUnique
24        respond_with_error(exception)
26      when ActiveModel::ValidationError, ActiveRecord::RecordInvalid, ArgumentError
27        error_message = exception.message.gsub("Validation failed: ", "")
28        respond_with_error(error_message, :unprocessable_entity)
30      else
31        handle_generic_exception(exception)
32      end
33    end
35    def handle_database_level_exception(exception)
36      handle_generic_exception(exception, :internal_server_error)
37    end
39    def handle_authorization_error
40      respond_with_error(t("authorization.denied"), :forbidden)
41    end
43    def handle_generic_exception(exception, status = :internal_server_error)
44      log_exception(exception) unless Rails.env.test?
45      error = Rails.env.production? ? t("generic_error") : exception
46      respond_with_error(error, status)
47    end
49    def log_exception(exception)
50 exception.class.to_s
51 exception.to_s
52 exception.backtrace.join("\n")
53    end
54  end

There are a couple of key points to note here:

  • Helper methods declared inside the ApiResponders module are used inside the ApiExceptions module. We can say that the ApiExceptions module is dependent on ApiResponders module but we do not need to include ApiResponders inside ApiExceptions.

    We can do so because, since ActiveSupport::Concern dependencies are gracefully resolved.

  • We have used an included block inside the ApiExceptions module. The included block is available because we are extending the ActiveSupport::Concern module.

    When a class includes a module, the code present inside the included block will be executed within the scope of the including class.

    In the above code, when a class includes the ApiExceptions class, the rescue_from callbacks will be executed within the scope of that class. Whenever an exception occurs inside that class or it's child class, the rescue_from callback will be called.

    The included block usually contains code like callbacks and macros.

Let us now move the authentication functionality out of the ApplicationController to the authenticable.rb concern.

Run the following command to create the authenticable.rb concern:

1touch app/controllers/concerns/authenticable.rb

Open app/controllers/concerns/authenticable.rb and add following code:

1module Authenticable
2  extend ActiveSupport::Concern
4  included do
5    before_action :authenticate_user_using_x_auth_token
6  end
8  private
10    def authenticate_user_using_x_auth_token
11      user_email = request.headers["X-Auth-Email"].presence
12      auth_token = request.headers["X-Auth-Token"].presence
13      user = user_email && User.find_by!(email: user_email)
14      is_valid_token = user && auth_token && ActiveSupport::SecurityUtils.secure_compare(user.authentication_token, auth_token)
15      if is_valid_token
16        @current_user = user
17      else
18        respond_with_error(t("session.could_not_auth"), :unauthorized)
19      end
20    end

Let's also modify our TasksController to invoke verify_authorized and verify_policy_scoped methods after certain specific actions:

1class TasksController < ApplicationController
2  after_action :verify_authorized, except: :index
3  after_action :verify_policy_scoped, only: :index
4  #------previous code -------
5  end

verify_authorized raises an error if pundit authorization has not been performed in specified actions. That is why we invoke it as an after_action hook. It is used to prevent the programmer from forgetting to call authorize from specified action methods.

Like verify_authorized, Pundit also adds verify_policy_scoped to our controller. It tracks and makes sure that policy_scope is used in the specified actions. This is mostly useful for controller actions like index which find collections with a scope and don't authorize individual instances.

Sanitized version

To make the application_controller even thinner and neater, we just need to include the necessary concerns, rather than defining the functionality with the controller.

For example, in a fully fledged application, once all the code is moved to concerns, then the application_controller.rb would look something like this (no need to add the following changes):

1class ApplicationController < ActionController::Base
2  include Authenticable
3  include Authorizable
4  include ApiException
5  include SetHoneybadgerContext
6  include RedirectHttpToHttps
7  include EnsureTermsOfServiceIsAccepted
8  include EnsureUserOnboarded
9  include DataLoader

Now let's modify our current application_controller and include the concerns we created in the previous section. Fully replace the contents of application_controller.rb file like so:

1class ApplicationController < ActionController::Base
2  include ApiResponders
3  include ApiExceptions
4  include Authenticable
5  include Pundit::Authorization
7  private
9    def current_user
10      @current_user
11    end

The @current_user is assigned when the authenticate_user_using_x_auth_token inside the Authenticable concern is invoked when the server receives an API request.

By convention, we prefer to name concerns with able suffix. It suggests the addition of ability.

An important point to keep in mind is that we need to use concerns only when the logic has to be shared within multiple controllers or related files.

If the logic is only specific to a controller, then we can either write it in the private section or move it inside a helper.

Now let's commit these changes, where we moved pundit related functionalities into Authorizable concern:

1git add -A
2git commit -m "Moved functionality from application controller to concerns"