Blog

Five Small Hacks for your Ruby Projects

Placeholder Avatar
Lucas Caton
May 26, 2018

Here are a few small hacks I have discovered over the years to streamline your Ruby projects:

1. Valid Date Method

Many projects require a method that takes a date as a parameter and returns the date if it is valid and false if it is not. This method should also not raise an exception. Regex is the obvious go to but that is not a great idea. Here is an example of the sort of code you would need to write to check a date:

Ruby


def valid_date(potential_date)
  potential_date.match?(
    /\A(?:(?:(?:(?:0?[13578])|(1[02]))/31/(19|20)?
\d\d)|(?:(?:(?:0?[13-9])|(?:1[0-2]))/(?:29|30)/(?:
19|20)?\d\d)|(?:0?2/29/(?:19|20)(?:(?:[02468]
[048])|(?:[13579][26])))|(?:(?:(?:0?[1-9])|(?:
1[0-2]))/(?:(?:0?[1-9])|(?:1\d)|(?:2[0-8]))/(?:19|
20)?\d\d))\Z/
  ) ? potential_date : false
end

Once upon a time, I would have used the Date.parse method to validate dates. However, I soon learned that it comes with a number of gotchas, as illustrated below:

Ruby


def valid_date(potential_date)
  Date.parse(potential_date)
rescue ArgumentError
  false
end

The examples below behave as expected:

sh


puts valid_date("2018-05-04")
#=> <Date: 2018-05-04>
puts valid_date("something else")
#=> false

However, the following example can catch you out:

sh


Date.parse "hel10 world"
#=> #<Date: 2018-05-10>

This is because Ruby sees the 10 and assumes that you mean the tenth day of the current month and year. Go figure!

You really want to pass in a format, but there are many valid date formats. This can be achieved by providing a list of formats using ||=. As we loop through each format, Ruby may raise an exception if the format is not a match. We need to capture that exception and find a way to resume the code. This is achieved by the following method:

Ruby


def valid_date(potential_date)
  formats ||= ['%Y/%m/%d', '%d/%m/%Y', '%m/%d', '%B %d', '%b %d', '%d %b']
  format = formats.shift
  Date.strptime(potential_date, format).tap do |date|
    confirmation = date.strftime(format)
    raise ArgumentError if potential_date != confirmation
  end
rescue ArgumentError
  formats.any? ? retry : false
end

2. Using hashes in RSpec to check different variable/value combinations

Often in RSpec, you want to check all different combinations of values for a number of variables, such as in the following example:

Ruby


RSpec.describe RealStateWorker do
  context "when active is true and rented is true" do
    it { expect(something).to eq(something) }
  end
  context "when active is true and rented is false" do
    it { expect(something).to eq(something) }
  end
  context "when active is false and rented is true" do
    it { expect(something).to eq(something) }
  end
  context "when active is false and rented is false" do
    it { expect(something).to eq(something) }
  end
end

Because each variable has two possible values, there are four combinations to test. But, what if we had more variables? This can very quickly grow out of hand. Using hashes is a concise way of managing this:

Ruby


RSpec.describe RealStateWorker do
  scenarios = [
    { active: true,   rented: true,  expected_result: :something      },
    { active: true,   rented: true,  expected_result: :something_else },
    { active: true,   rented: false, expected_result: :something      },
    { active: true,   rented: false, expected_result: :something_else },
    { active: false,  rented: true,  expected_result: :something      },
    { active: false,  rented: true,  expected_result: :something_else },
    { active: false,  rented: false, expected_result: :something      },
    { active: false,  rented: false, expected_result: :something_else }
  ]
  scenarios.each do |scenario|
    context "when active is #{scenario[:active]} and rented is #{scenario[:rented]}" do
      it "is #{expected_result}" do
        expect(something).to eq(scenarios[:expected_result])
      end
    end
  end
end

3. Manage White Labelling a Website Using a YAML file

White labelling a website can get very messy very quickly. I streamline the process by creating a Brandable concern (see below) and storing the details for each website in a YAML file.

app/controllers/concerns/brandable.rb

Ruby


module Brandable
  extend ActiveSupport::Concern
  included do
    helper_method :brand
  end
  private
  def brand
    OpenStruct.new(YAML.load_file("config/brands.yml")[request.url])
  end
end

Then I include it in the ApplicationController so it will be available throughout the project:

app/controllers/application_controller.rb

Ruby


class ApplicationController < ActionController::Base
  include Brandable
end

The brand method returns an OpenStruct object from the data you have in config/brands.yml. Here is an example of config/brands.yml:

Yaml


"https://reinteractive.com/":
  title: "reinteractive"
  blog_url: "https://reinteractive.com/blog"
"https://envisage.io/":
  title: "Envisage"
  blog_url: "https://envisage.io/blog_posts"

Within any view, you can call either brand.title or brand.address and it is correctly rendered according to the domain you are accessing:

app/views/pages/index.html.erb

erb


<%= brand.title %>

<%= link_to 'Blog', brand.blog_url %>

4. Customise your CI using the massa gem

When it comes to CI, sometimes you simply want to check only Rubocop and RSpec pass, and other times you want to go the whole way and check erblint, brakeman, and rails_best_practices as well. Here is an example using travis.yml:

Ruby


language: ruby
rvm: 2.5.1
sudo: false
cache: bundler
services:
  - postgresql
addons:
  chrome: stable
  postgresql: "9.6"
before_install:
- export TZ=Australia/Sydney
- gem update --system
- gem install bundler
before_script:
  - bin/setup
script:
  - bundle exec rubocop && bundle exec erblint app/views/**/*.html{+*,}.erb && bundle exec brakeman -Aqz5 && bundle exec rails_best_practices && RAILS_ENV=test bundle exec rspec

If anyone of these fail, the build won’t pass. To switch the different tools on or off involves modifying the line bundle exec rubocop && bundle exec erblint app/views/**/*.html{+*,}.erb && bundle exec brakeman -Aqz5 && bundle exec rails_best_practices && RAILS_ENV=test bundle exec rspec over and over again. I got tired of having to do this, so I have begun working on a gem to manage this more simply. Using the massa gem, you can define a YAML file that will specify all the tools you want:

Ruby


rubocop:
  description: 'Rubocop'
  command:     'bundle exec rubocop'
  required:    true
haml_lint:
  description: 'HAML lint'
  command:     'bundle exec haml-lint app/views'
  required:    true
brakeman:
  description: 'Brakeman (security vulnerability scanner)'
  command:     'bundle exec brakeman -Aqz5'
  required:    true
rails_best_practices:
  description: 'Rails Best Practices'
  command:     'bundle exec rails_best_practices'
  required:    true
i18n-tasks:
  description: 'I18n translations'
  command:     'bundle exec i18n-tasks missing'
  required:    false
rspec-rails:
  description: 'RSpec'
  command:     'RAILS_ENV=test bundle exec rspec'
  required:    true

You can turn your tools on or off as required, by simply changing required: true to required: false in the config/massa.yml file.

5. Testing your gems against different versions of Ruby/Rails

When you are maintaining a gem, it is a good idea to test it against many different versions of Ruby and Rails. I have combined a couple of things that allow me to maintain my gems with ease.

The first part of the solution comes from Travis: a feature called [Build Matrix] (https://docs.travis-ci.com/user/customizing-the-build#Build-Matrix). When it’s set up, it runs specs for each combination of Ruby/Rails/whatever you want to check. If any fails, the build fails.

The second part comes from Thoughtbot: they created an awesome gem called Appraisal (https://github.com/thoughtbot/appraisal), which integrates with Travis Build Matrix. > Appraisal integrates with bundler and rake to test your library against different versions of dependencies in repeatable scenarios called “appraisals.”

The last part is my hack: Instead of writing a static Appraisals file, I created a dynamic one. This is possible because Appraisals is interpreted as a normal Ruby file.

Here is an example of how I do it in the enumerate_it gem:

Ruby


require 'net/http'
require 'json'
rails_versions = JSON.parse(Net::HTTP.get(URI('https://rubygems.org/api/v1/versions/rails.json')))
  .group_by { |version| version['number'] }.keys.reject { |key| key =~ /rc|racecar|beta|pre/ }
%w[3.0 3.1 3.2 4.0 4.1 4.2 5.0 5.1 5.2].each do |version|
  appraise "rails_#{version}" do
    current_version = rails_versions
      .select { |key| key.match(/\A#{version}/) }
      .sort { |a, b| Gem::Version.new(a) <=> Gem::Version.new(b) }
      .last
    gem 'activesupport', "~> #{current_version}"
    gem 'activerecord',  "~> #{current_version}"
    gem 'sqlite3'
  end
end