rails

A 6-post collection

Introducing KittyEvents

During the Christmas break me and Mike were discussing a new feature at Product Hunt. The feature required scheduling an ActiveJobs when a user signs up, votes or submits a comment.

There is SignUp object which handles user registration. So scheduling a new background job there is quite simple:

module SignUp  
  # ... handle user sign up

  def after_sign_up(user)
    WelcomeEmailWorker.perform_later(user)
    WelcomeTweetWorker.perform_later(user)
    SyncProfileImageWorker.perform_later(user)
    NewFancyFeatureWorker.perform_later(user) # <- new worker
  end
end  

Unfortunately after_sign_up method was becoming quite large ☹️
Now imagine having to add NewFancyFeatureWorker to 10 other places 😫

Those issues pushed us to create a simple wrapper around ActiveJobs, which we call KittyEvents.

Now in SignUp there is just one trigger for an "event":

module SignUp  
  # ... handle user sign up

  def after_sign_up(user)
    ApplicationEvents.trigger(:user_signup, user)
  end
end  

And there is a central place, where events are mapped to ActiveJobs Workers:

# config/initializers/application_events.rb
module ApplicationEvents  
  extend KittyEvents

  event :user_signup, [
    WelcomeEmailWorker,
    WelcomeTweetWorker,
    SyncProfileImageWorker,
    NewFancyFeatureWorker, # <- new worker
  ]

  # ... other events
end  

When an event is triggered, all ActiveJobs Workers for this events are scheduled and executed.

Another bonus, is when using KittyEvents, you only make a single Redis call to trigger any number of events. This shaves off precious milliseconds when using KittyEvents in request.

Rails 5 features, I'm excited about

Ruby on Rails 5 has arrived. Though, I don't care much about the big features. I'm more excited about the smaller features, which would not change my life, but will make it a bit easier.

API mode improvements

Most of my work in Rails projects, these days is just using Rails as an api endpoint. There are lots of improvements.

I'm really glad we finally got ActionController::API.

Easier conversion of errors to JSON

Returning consistent and usable error messages is vital when designing and API.

Previously getting usable validation errors from rails was quite painful. Since you can only have:

user.errors.messages # => {:email=>["can't be blank"]}  

This error message is it quite hard for clients. In several projects, I have defined a json locale where things like "can't be blank" is just "blank" and used that for the api.

But this is no more! Since errors.details exists now:

user.errors.details # => {:email=>[{:error=>:blank}]}  
Better exceptions in development

Speaking about errors. There is a new option debugexceptionresponse_format:

# config/environments/development.rb
config.debug_exception_response_format = :api  

You will get exception information in JSON format during development, instead of HTML.

ActiveJob improvements

In a previous post - Retrying ActiveJob, there was a glue code depending on a Rails 5 feature - ActiveJob::Base#deserialize. Now that can be removed.

Also having the ApplicationJob makes including the ActiveJobRetriesCount from the post easier:

class ApplicationJob < ActiveJob::Base  
  include ActiveJobRetriesCount
end  

ActiveRecord improvements

And even more

There is, even more, stuff like redirect_back or ActiveModel::AttributeAssignment. But for more information I encourage you to check:

Overall I think this is a solid release.

I have heard on The Bike Shed that there are discussions about changing the release process and to smaller and more frequent release cycle. I think this would be great. Especially since the features, I'm mostly excited usually are smaller and don't require a major version change.

Retrying ActiveJob

I like ActiveJob. The one missing feature is the ability to retry when a job fails. Fortunately, this is quite easy to add:

module ActiveJobRetriesCount  
  extend ActiveSupport::Concern

  included do
    attr_accessor :retries_count
  end

  def initialize(*arguments)
    super
    @retries_count ||= 0
  end

  def deserialize(job_data)
    super
    @retries_count = job_data['retries_count'] || 0
  end

  def serialize
    super.merge('retries_count' => retries_count || 0)
  end

  def retry_job(options)
    @retries_count = (retries_count || 0) + 1
    super(options)
  end
end  
class ImageDownloader::Worker < ActiveJob::Base  
  include ActiveJobRetriesCount

  rescue_from ImageDownloader::TimeoutError do |exception|
    if exception.code == 0
      # just retry the download in 5 minutes
      # tolarate up to 5 failures
      retry_job wait: 5.minutes if retries_count > 5
    else
      fail exception
    end
  end

  def perform(url)
    ImageDownloader.download(url)
  end
end  

Here is a simple test for the ActiveJobRetriesCount.

require 'spec_helper'

describe ActiveJobRetriesCount do  
  class RetriesTestClass
  end

  class RetriesTestError < StandardError
  end

  class RetriesTestWorker < ActiveJob::Base
    include ActiveJobRetriesCount

    rescue_from RetriesTestError do
      retry_job wait: 5.minutes if retries_count < 5
    end

    def perform
      RetriesTestClass.do_something(retries_count)
    end
  end

  it 'handles retries counts', active_job: :inline do
    allow(RetriesTestClass).to receive(:do_something).and_raise RetriesTestError

    expect { RetriesTestWorker.perform_later }.not_to raise_error

    expect(RetriesTestClass).to have_received(:do_something).with(5)
    expect(RetriesTestClass).not_to have_received(:do_something).with(6)
  end
end  

If you are not on Rails 5, you would need the following code:

if ActiveJob::Base.method_defined?(:deserialize)  
  fail 'This no longer needed.'
else  
  module ActiveJob
    class Base
      def self.deserialize(job_data)
        job = job_data['job_class'].constantize.new
        job.deserialize(job_data)
        job
      end

      def deserialize(job_data)
        self.job_id               = job_data['job_id']
        self.queue_name           = job_data['queue_name']
        self.serialized_arguments = job_data['arguments']
      end
    end
  end
end  

For more advanced features – check out ActiveJob::Retry.

Rails Anti-Pattern: Setting View Variables In Before Actions

I see a lot of the following pattern in Rails projects:

class ProductsController < ApplicationController  
  before_action :assign_post, only: %i(show edit)

  def index
    @posts = Post.paginate(params[:page])
  end

  def show
  end

  def edit
  end

  private

  def assign_post
    @post = Post.find(params[:id])
  end
end  

Most people do this, because for them @post = Post.find(params[:id]) is duplication. Having assign_post is more DRY.

I don't buy the duplication argument. The definition for DRY is:

“Every piece of knowledge must have a single, unambiguous, authoritative representation within a system."

Unfortunately, most people confused it with - "I don't want to have the same sequence of characters more than once". David Chelimsky have an excellent talk on that subject.

But, my biggest issue with the approach is that it "hides" all variables passed to the view. Especially if there are only or except passed to before_action. Then you have to become an interpreter to figure out what gets into the view. I can recall several situations when during a code review I found code passing unwanted variables to a view.

I prefer to think the following, when I read a controller:

ok, show sends @post and @comments to the view

Instead of:

So, if show is called then assign_posts is called before it, which sets @posts, then show is not included in this other action filter, but is not excluded for this third one...

Also, it is easier for a developer to spot, that too many variables are passed to the view if all of them are listed together.

Because of that, I prefer to use the following pattern:

class ProductsController < ApplicationController  
  def show
    @post = find_post
  end

  def edit
    @post = find_post
  end

  private

  def find_post
    Post.find(params[:id])
  end
end  

In this way, the duplication is moved into finder methods. So if I have to add a scope like visible to Post.find, I just need to apply it in find_post.

Introducing MiniForm

My last blog post - Dealing with form objects, got me thinking about form objects. I also had a 12 hours flight to San Francisco, where I was meeting my teammates at Product Hunt.

During that flight, I went back and checked all of my custom form implementations. I gathered them together in a folder and started combining and extracting the common parts. By the end of the flight I almost had what now is MiniForm. (I had to change its name several times since I'm not very good with names and most of the decent names are not available).

MiniForm allows the following code:

 class RegistrationForm  
  include ActiveModel::Model

  attr_reader :user, :account

  attr_accessor :first_name, :last_name, :email, :name, :plan, :terms_of_service

  # user validation
  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :email, presence: true, email: true

  # account validation
  validates :account_name, presence: true

  # form custom validation
  validates :plan, inclusion: {in AccountPlan::VALUES}
  validates :terms_of_service, acceptance: true

  # ensure uniqueness
  validate :ensure_unique_user_email
  validate :ensure_unique_account_name

  def initialize
    @user    = User.new
    @account = Account.new owner: @user
  end

  def update(attributes)
    attributes.each do |name, value|
      public_send "#{name}=", value
    end

    if valid? & user.update(user_attributes) && account.update(account_attributes)
      account.users << user
      AccountWasCreated.perform_later(account)
    else
      false
    end
  end

  private

  def ensure_unique_user_email
    errors.add :email, 'already taken' if User.where(email: email).any?
  end

  def ensure_unique_account_name
    errors.add :name, 'already taken' if Account.where(name: name).any?
  end

  def user_attributes
    {first_name: first_name, last_name: last_name, email: email}
  end

  def account_attributes
    {plan: plan, name: name}
  end
end  

To become:

class RegistrationForm  
  include MiniForm::Model

  # `model` delegates attributes to models 
  # and copies the validations from them
  model :user, attributes: %i(first_name last_name email), save: true
  model :account, attributes: %i(name plan), save: true

  attributes :terms_of_service

  validates :plan, inclusion: {in AccountPlan::VALUES}
  validates :terms_of_service, acceptance: true

  def initialize
    @user    = User.new
    @account = Account.new owner: @user
  end

  # `update` calls `perform` if model is valid
  # and models are saved
  def perform
    account.users << user
    AccountWasCreated.perform_later(account)
  end
end