Blog icon

Five small hacks for your Ruby projects

By Lucas Caton,
Lucas Caton
Scroll down to read

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:

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:

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

The examples below behave as expected:

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

puts valid_date("something else")
#=> false

However, the following example can catch you out:

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:

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:

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:

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

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

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:

"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

<h1><%= brand.title %></h1>

<%= 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 railsbestpractices as well.

Here is an example using travis.yml:

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:

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. 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:

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

To find out how reinteractive can turn your web application vision into reality, get in touch with us through our contact form or call us on +61 2 8019 7252.