DEV Community

Cover image for Simple Command
Rafał Piekara
Rafał Piekara

Posted on

Simple Command

Who ever worked in a rails project must have seen the so-called fat controllers, where the logic of individual actions is packed directly into controllers that automatically have hundreds of private methods and thousands of lines of code, and the actions look like this:

  def update
    @template.update_attributes(page_template_params)
    page_levels_ids = @template.page_levels.map(&:id)
    saved_levels_ids =  if params[:page_levels_ids]
                          params[:page_levels_ids].map{|id| id.to_i }
                        else
                          []
                        end
    ids_to_save = saved_levels_ids - page_levels_ids
    ids_to_remove = page_levels_ids - saved_levels_ids
    ids_to_remove.each { |id| PageLevel.find(id).update(page_template_id: nil) }
    ids_to_save.each { |id| PageLevel.find(id).update(page_template_id: @template.id) }

    @template.reload
    PageProcessor::TemplateService.update_template(@template)
    redirect_to edit_page_template_path(@template)
  end
Enter fullscreen mode Exit fullscreen mode

I have such good practice that I transfer all logic from controllers to external domain classes of services, commands, use cases - whatever you call it. When testing such controllers, I only care about the shape of the responses that return individual actions and whether the appropriate website was called with the appropriate parameters. That's all.

To use such a solution, PORO (Plain Old Ruby Object) is enough, while I like to use a small, nice library called Simple Command, which extends our PORO with a few things that improve the convenience and testing of such classes.

Link to library

From the documentation of the gem, we can read that:

  • to use SimpleCommand in your class you should (here I have a bit of a problem with the proper translation of it into Polish), precede it with the SimpleCommand class
prepend SimpleCommand
Enter fullscreen mode Exit fullscreen mode

If you want to know what prepend is all about, check here.

  • we will be able to call our class through the .call method defined in it

  • our class will have two attributes that indicate the success of the operation: success? and failure?

  • if the operation is successful, the value returned by the call method will be available under the result attribute

  • if the operation fails, the errors from this operation can be read from the errors attribute

Testing such a class is very simple. We really only test 3 cases:

  • if the call will be successful if the correct parameters are given

  • if we get an error when entering incorrect parameters

  • whether the command will return the expected result on a positive end of the operation

Of course, we can dive in and see if there are any operations on other classes inside the command, or if array errors contain information about specific errors, or if the command throws exceptions suitable for our test cases. However, these three points above are enough to feel comfortable with the implementation of the logic contained in the command.

Now let's move on to the specific case of user login to the API. The code that I prepared is the agnostic framework, it shows that SimpleCommand and the command pattern it supports can also be used outside of Ruby on Rails, in scripts, console applications, automations, etc.

Classic example according to rails way:

require_relative './auth_token'

class BadAuthController < ApplicationController

  def login
    if email.nil? || email.length.zero?
      render json: { error: 'Email not valid' }, status: 422
    end

    if user && user.authenticate(auth_params[:password])
      render json: { user: user, auth_token: AuthToken.encode({ user_id: user.id }) }, status: :ok
    elsif user.blank?
      render json: { error: 'User not found' }, status: 404
    elsif !user.authenticate(auth_params[:password])
      render json: { error: 'Invalid password' }, status: 401
    end
  end

  private

  def user
    @user ||= User.find_by(email: auth_params[:email])
  end

  def auth_params
    params.require(:auth).permit(:email, :password)
  end
end
Enter fullscreen mode Exit fullscreen mode

The login method is quite long. There are some ifs, some complex conditions, some renders. What if we add, for example, 2 Factor Authentication to the application? This logic will have to be tied down somewhere between the ifs. Distributing this code to private methods won't quite work. What if another action uses the same method and in the meantime, we modify the method to meet the new requirements?

Let's move all the logic to the LoginUser command. For the purposes of the example, the User and AuthToken classes are plugs, we are not interested in their implementation.

require 'simple_command'
require_relative './user'
require_relative './auth_token'

class LoginUser
  prepend SimpleCommand

  class InvalidEmail < StandardError; end
  class InvalidPassword < StandardError; end
  class UserNotFound < StandardError; end

  def initialize(auth_params)@email = auth_params[:email]
    @password = auth_params[:password]
  end

  def call
    raise InvalidEmail unless email_valid?
    raise UserNotFound if user.nil?
    raise InvalidPassword if password.nil? || !user_authenticated?
    logged_user_payload
  rescue InvalidEmail
    errors.add 422, 'Invalid email given'
  rescue InvalidPassword
    errors.add 422, 'Invalid password'
  rescue UserNotFound
    errors.add 422, 'User not found'
  end

  private

  attr_reader :email, :password

  def logged_user_payload
    {
      user: user,
      auth_token: auth_token
    }
  end

  def user_authenticated?
    user.authenticate(password)
  end

  def user
    @user ||= User.find_by(email: email)
  end

  def email_valid?
    !email.nil? && email.length
  end

  def auth_token
    AuthToken.encode({ user_id: user.id })
  end
end
Enter fullscreen mode Exit fullscreen mode

What do we have here? The command accepts the parameters needed to log in. As logic is enforced, it throws its own exceptions, catches those exceptions, and sets error codes and error responses accordingly. There are a few private methods dedicated to make the code clearer.

Now let's see a test of such a command:

require_relative '../lib/login_user'
require 'spec_helper'

describe LoginUser do
  it 'is defined' do
    expect(described_class).not_to be nil
  end

  describe 'logging' do
    let(:auth_params) {
      {
        email: 'email@example.com',
        password: 'password'
      }
    }

    subject { described_class.call(auth_params) }


    it 'succeeds' do
      expect(subject).to be_success
    end

    it 'logs in user' do
      expect(subject.result).to have_key :user
      expect(subject.result).to have_key :auth_token
    end

    context 'when no email given' do
      let(:auth_params) {
        {
          email: nil,
          password: 'password'
        }
      }

      it 'fails' do
        expect(subject).to be_failure
      end

      it 'raises error' do
        expect(subject.errors.size).to be > 0
        expect(subject.errors.first[1]).to eq 'Invalid email given'
      end
    end

    context 'when no password given' do
      let(:auth_params) {
        {
          email: 'email@example.com',
          password: nil
        }
      }

      it 'fails' do
        expect(subject).to be_failure
      end

      it 'raises error' do
        expect(subject.errors.size).to be > 0
        expect(subject.errors.first[1]).to eq 'Invalid password'
      end
    end

    context 'when user not found' do
      before do
        allow(User).to receive(:find_by).and_return(nil)
      end

      it 'fails' do
        expect(subject).to be_failure
      end

      it 'raises error' do
        expect(subject.errors.size).to be > 0
        expect(subject.errors.first[1]).to eq 'User not found'
      end
    end

    context 'when invalid password' do
      before do
        allow_any_instance_of(User).to receive(:authenticate).and_return(false)
      end

      it 'fails' do
        expect(subject).to be_failure
      end

      it 'raises error' do
        expect(subject.errors.size).to be > 0
        expect(subject.errors.first[1]).to eq 'Invalid password'
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The tests are very simple. We check the happy path first, then check how the command behaves in the event of invalid credentials. Only this and so much.

And this is how invoking the command in the controller and testing for the login action will look like:

require_relative './command_helpers'

class AuthController
include CommandHelpers

  def login
    login = LoginUser.call(auth_params)
    if login.success?
      return { data: login.result, status: 200 }
    else
      return { errors: login.errors, status: 422 }
    end
  end

  private

  def auth_params
    param.require(:auth).permit(:email, :password)
  end
end
Enter fullscreen mode Exit fullscreen mode

And here is the test:

require 'spec_helper'
require_relative '../lib/auth_controller'
require_relative '../lib/login_user'

describe AuthController do
  it 'is defined' do
    expect(described_class).not_to be nil
  end

  let(:auth_params) {
    {
      email: 'email@example.com',
      password: 'password'
    }
  }

  subject { described_class.new(auth_params) }
  let(:login) { instance_double(LoginUser)}
  before do
    allow(LoginUser).to receive(:call).with(auth_params).and_return(login)
    allow(login).to receive(:success?).and_return(true)
    allow(login).to receive(:result).and_return(anything)
    subject.login
  end

  it 'logs user via command' do
    expect(LoginUser).to have_received(:call).with(auth_params).once
  end

  it 'checks for command status' do
    expect(login).to have_received(:success?).once
  end

  it 'gets command result' do
    expect(login).to have_received(:result).once
  end

  it 'returns command result' do
    expect(subject.login[:data]).to eq login.result
  end

  context 'when login failed' do
    before do
      allow(login).to receive(:success?).and_return(false)
      allow(login).to receive(:failure?).and_return(true)
      allow(login).to receive(:errors).and_return(anything)
      subject.login
    end

    it 'checks for failure' do
      expect(login).to have_received(:failure?).once
    end

    it 'checks for errors' do
      expect(login).to have_received(:errors).once
    end

    it 'returns command errors' do
      expect(subject.login[:errors]).to eq login.errors
    end
  end
end

Enter fullscreen mode Exit fullscreen mode

Simple scenario. At the controller level, we only check the correct execution of the command with the appropriate parameters. And the responsibility for the logic test and its correctness is delegated to another layer.

Well, what now? The moment we have more of these actions that use commands, we will have a good idea. We can grasp it through auxiliary methods. For the sake of example, I have thrown them into the included CommandHelpers module. The command is caught by handle, and it is already responsible for handling its responses or errors properly. This handler is always more expandable and customizable. However, I present to you the minimum version of the implementation.

module CommandHelpers

  private

  def handle(command)
    command_result(command) do |result|
      render json: result
    end
  end

  def command_result(command)yield({ data: command.result }) if command.success?
    yield({ errors: command.errors }) if command.failure?
  end
end
Enter fullscreen mode Exit fullscreen mode

Then something like this is enough in the controller:

def login_with_handler
  handle LoginUser.call(auth_params)
end
Enter fullscreen mode Exit fullscreen mode

And that’s it. 😃

As a result, we have the encapsulation of logic in a pleasant class that additionally contains information about the success or interruption of the action as well as clean and transparent controllers.

Attention! In the SimpleCommand repository on Github, you may notice that there have been no new commits there for several years. The library appears to be unmaintained, so you may be concerned about using it. Do not worry. Just look at the gem code and see that the entire SimpleCommand is really pure PORO without any dependencies. Equally, instead of installing the gem, we can include this code in some helper class in a rails application.

Discussion (0)