Extracting a Gem from your Rails app

So you got that big pile of useful code.

Why?

  • Great open-source side project
  • Will simplify your actual codebase
  • Can bring collaborators to add features and fix bugs
  • Fresh and new ideas
  • Useful code for your future (or parallel) projects
  • Reduce your test suite size

First gem?

  • The implementation already exists
  • Focus on the interface
  • You already know the requirements and edge cases
  • You already have an app to test it

Where to find useful code to extract?

  • Custom validations
  • Wrapper around an external API
  • Additions or changes to Rails
  • PORO modelising common things

This talk is a step by step guide to extract a gem from existing code

Based on my recent experience with Has_prerequisite

has_prerequisite

Authorization strategy to limit access to some parts of the application until some prerequisites are met.

Step 1:

Identify and move the key parts of your code.

Tests

Coupling

Dependencies

The tests

 

require 'rails_helper'

RSpec.describe Prerequisites, type: :controller do
  let(:controller) do
    Class.new(ActionController::Base) do
      include Prerequisites

      prerequisite :my_prerequisite, redirection_path: 'path', if: :condition

      def index
        render text: nil
      end

    private

      def my_prerequisite
        true
      end

      def condition
        true
      end
    end.new
  end

  subject { get :index }

  describe 'redirection' do
    it 'does not interfere when the prerequisite is met' do
      expect(controller).to receive(:my_prerequisite) { true }
      expect(subject).to be_success
    end

    it 'it redirects to the path when the prerequisite is not met' do
      expect(controller).to receive(:my_prerequisite) { true }
      expect(subject).to redirect_to 'path'
    end
  end

  describe 'conditionnal' do
    it 'it supports an `if` option to skip the check' do
      expect(controller).to receive(:condition) { false }
      expect(controller).to_not receive(:my_prerequisite)
      expect(subject).to be_success
    end
  end

  context 'is a controller that skips the prerequisites' do
    let(:controller) do
      ActionController::Base.new do
        include Prerequisites

        prerequisite :pre, redirection_path: 'path'
        fulfilling_prerequisite

        def index
          render text: nil
        end
      end
    end

    describe 'fulfilling_prerequisite' do
      it 'skips the checks' do
        expect(controller).to_not receive(:pre)
        expect(subject).to be_success
      end
    end
  end
end
  • They exist!
  • Tests are isolated from existing controllers
  • Rails is all over the place

The code

 

module Prerequisites
  extend ActiveSupport::Concern

  included do
    class_attribute :prerequisites, :skipping_checks
    self.prerequisites = []
    self.skipping_checks = false

    rescue_from Prerequisites::PrerequisiteNotMet, with: :prerequisite_not_met!
  end

  module ClassMethods
    def prerequisite(method, redirection_path: nil, **options)
      prerequisites << { method: method, redirection_path: redirection_path, options: options }
    end

    def fulfilling_prerequisite
      self.skipping_checks = true
    end
  end

  def step_fulfilled!
    redirect_to stored_location_for(:user)
  end

  private

  def perform_checks
    return if self.class.skipping_checks
    return unless failing_preriquisite
    store_location_for(:user, request.fullpath)
    raise Prerequisites::PrerequisiteNotMet
  end

  def failing_preriquisite
    @failing_preriquisite ||= prerequisites.find do |p|
      !send(p[:method]) if p[:options][:if].nil? || send(p[:options][:if])
    end
  end

  def prerequisite_not_met!
    redirect_to redirect_path
  end

  def redirect_path
    return send(failing_preriquisite[:redirection_path]) if failing_preriquisite[:redirection_path].is_a? Symbol
    failing_preriquisite[:redirection_path]
  end

  class PrerequisiteNotMet < StandardError; end
end
  • One module/one file
  • Dependent on Rails and Devise
  • Decoupled from the application's existing controllers

Step 2: (optional)

Refactor your code to move as much as possible to lib/

Decouple

Test in isolation
Break dependencies

Step 3:

Setup of the gem

Bundle gem [gemname]

  • Code of conduct
  • RSpec (minitest available)
  • Travis-CI ready

Rails plugin new [gemname]

  • Dummy rails app
  • Minitest only
  • Rake tasks setup
  • Useful for Rails Engines

VS.

Small number of variations Gemspec, licence, readme, etc

I did both commands

Using an initial commit, a gem creation commit and staging changes step by step to handpick changes and remove duplication

> bundle gem has_prerequisite

> rails plugin new has_prerequisite \
 --skip-test --dummy-path=spec/dummy

Checklist:

  • Is it the licence you want?
  • Code of conduct?
  • Edit the Gemspec with the gems info
  • Tests are running?
  • Git with github/gitlab/bitbucket/whatever

Step 4:

Move the code!

Move the tests first

  • Make sure all dev and test dependencies are in the Gemspec (rspec-rails, shoulda-matchers, its, etc)
  • Some minor changes to the test setup are expected
  • Make sure they run
  • Make sure they fail properly

Move the implementation

  • If step 2 was skipped, you might have broken dependencies
  • Add them to the gemfile and require
  • Implement components you don't want to be dependent on

Decisions

I decided to keep the dependency on Active Support but not Devise.

 

So I have to implement similar methods

Failures:

  1) HasPrerequisite redirection it redirects to the path when the prerequisite is not met
     Failure/Error: store_location_for(:user, request.fullpath)
     
     NoMethodError:
       undefined method `store_location_for' for #<#<Class:0x007f9d98a359e8>:0x007f9d9b931238>
     # ./lib/has_prerequisite.rb:36:in `perform_checks'
     # ./spec/has_prerequisite_spec.rb:43:in `block (2 levels) in <top (required)>'
     # ./spec/has_prerequisite_spec.rb:55:in `block (3 levels) in <top (required)>'

Refactor

Add features

Refactor again

Railties

Hooks for initialisation

Railties

  • Extend core classes
  • Initialisers
  • Set generators

Railties

module HasPrerequisite
  class Railtie > ::Rails::Railtie
    initializer "has_prerequisite.configure_view_controller" do |app|
      ActiveSupport.on_load :action_controller do
        include HasPrerequisite
      end
    end
  end
end

Step 5:

Test locally

Test again?

  • yes.
  • Integration test (I have a dummy app!)
  • Pack the gem locally
  • Reference it from your application

Gemfile

gem 'has_prerequisite', path: '~/dev/has_prerequisite'
> bundle install
=> It will use your gem 

> rspec

Note: If you changed the interface, you'll have to change the usage and maybe some tests

Step 6:

Publish!

Version number

I recommend following Semantic Versioning

MAJOR . MINOR . PATCH

Version number

  • Production ready?
  • Stable API?


When to publish 1.0.0 ?

 

Tip: you can always use the labels (1.0.0.beta, 1.0.0.rc.1, 1.0.0.pre, 1.0.0.racecar-1)

Use Git/Github features

  • Readme for documentation
  • Push hooks to test
  • Issue tracker
  • Projects
  • Releases automatically tagged
  • Use branches to merge and deploy bugfixes across versions

Rubygems.org

  • Where gems magically come from
  • Create an account

Private servers

  • Run your own with `gem server`

  • Gemfurry

  • Note: configure .gemspec to avoid accidental pushes to Rubygems.org

Publish!

→ bundle exec rake release   
has_prerequisite 1.0.0.alpha built to pkg/has_prerequisite-1.0.0.alpha.gem.
Tagged v1.0.0.alpha.
Pushed git commits and tags.
Pushed has_prerequisite 1.0.0.alpha to rubygems.org.

Step 6:

Profit.

Step 6:

Maintenance

Bugs

New features

Pull requests

Never forget about the documentation

Tools

  • CI servers are good friends
  • Contributing guides are your friends
  • Set expectations on PR (fully tested, code style)
  • Use automated review tools (CodeClimate, Hound)

 

Don't burn out

Step 7:

Profit!

Thank you!

Questions?

Extracting a Gem from your Rails app

By Sophie Déziel

Extracting a Gem from your Rails app

Talk I gave for a Montreal.rb meetup

  • 2,270