Skip to content

Instantly share code, notes, and snippets.

@wojtha
Created February 21, 2018 08:59
Show Gist options
  • Save wojtha/e0c637065b04d8f5fd58a7b170110221 to your computer and use it in GitHub Desktop.
Save wojtha/e0c637065b04d8f5fd58a7b170110221 to your computer and use it in GitHub Desktop.
Feature flags in 90 LOC for ruby apps (see https://gist.github.com/wojtha/43c68be62757d0b7030485efcf13583b for 20 LOC version)
# Class FeatureFlag defines and stores feature flags
#
# @example Configuration
#
# FEATURE = FeatureFlag.new do |feature|
# feature.define(:new_user_profile) do |user_id:|
# Admin.where(user_id: user_id).exists?
# end
#
# feature.define(:third_party_analytics) do
# not Rails.env.production?
# end
# end
#
# @example Usage
#
# class ProfilesController < ApplicationController
# def show
# FEATURE.with(:new_user_profile, user_id: current_user.id) do
# return render :new_user_profile, locals: { user: NewUserProfilePresenterV2.new(current_user) }
# end
#
# render :show, locals: { user: UserProfilePresenterV1.new(current_user) }
# end
# end
#
# @example Testing with before...after
#
# describe "User profiles" do
# before { FEATURE.override(:new_user_profile, true) }
# after { FEATURE.reset_all_overrides }
# end
#
# @example Testing with inline block
#
# it "shows new user profile" do
# FEATURE.override_with(:new_user_profile, true) do
# expect( FEATURE.active?(:new_user_profile) ).to be_truthy
# end
#
# expect( FEATURE.active?(:new_user_profile) ).to be_falsy
# end
#
# @see http://blog.arkency.com/2015/11/simple-feature-toggle-for-rails-app/
#
class FeatureFlag
FlagAlreadyDefined = Class.new(StandardError)
FlagNotDefined = Class.new(StandardError)
FlagNotOverriden = Class.new(StandardError)
FlagArgumentsMismatch = Class.new(StandardError)
attr_reader :env
def initialize(env: nil)
@env = env
@flags = {}
@overrides = {}
yield self if block_given?
end
def define(name, &block)
raise FlagAlreadyDefined.new("Feature flag `#{name}` is already defined") if flag?(name)
@flags[name] = block
end
def redefine(name, &block)
@flags[name] = block
end
def flags
@flags.keys
end
def with(name, *args, &block)
block.call if active?(name, *args) # rubocop:disable Performance/RedundantBlockCall
end
def override(name, result = true, &block)
raise FlagNotDefined.new("Feature flag `#{name}` is not defined") unless flag?(name)
raise FlagAlreadyDefined.new("Feature flag `#{name}` is already overriden") if overriden?(name)
# We are using proc, not lambda, because proc does not check for number of arguments
original_block = @flags[name]
@overrides[name] = original_block
@flags[name] =
if block_given?
validate_flag_arity(name, original_block.arity, block.arity)
block
else
proc { |*_args| result }
end
original_block
end
def reset_override(name)
raise FlagNotOverriden.new("Feature flag `#{name}` was not overriden") unless overriden?(name)
original_block = @overrides.delete(name)
@flags[name] = original_block
end
def reset_all_overrides
@overrides.each_key { |name| reset_override(name) }
end
def override_with(name, result = true, &block)
raise FlagNotDefined.new("Feature flag `#{name}` is not defined") unless flag?(name)
original_block = @flags[name]
@flags[name] = proc { |*_args| result }
block.call # rubocop:disable Performance/RedundantBlockCall
ensure
@flags[name] = original_block
end
def flag?(name)
@flags.key?(name)
end
def overriden?(name)
@overrides.key?(name)
end
def active?(name, *args)
flag = @flags.fetch(name, proc{ |*_args| false })
validate_flag_arguments(name, flag.arity, args.size)
flag.call(*args)
end
alias :enabled? :active?
def inactive?(name, *args)
!active?(name, *args)
end
alias :disabled? :inactive?
def env?(*args)
[*args].map(&:to_s).include?(env.to_s)
end
private
def validate_flag_arity(flag_name, flag_arity, override_arity)
original_arity = flag_arity < 0 ? flag_arity.abs - 1 : flag_arity
if original_arity != override_arity
raise FlagArgumentsMismatch.new("Flag '#{flag_name}' expects #{flag_arity} arguments, but #{override_arity} arguments were given")
end
end
def validate_flag_arguments(flag_name, flag_arity, args_size)
if flag_arity < 0 && (flag_arity.abs - 1) > args_size
# Contains variable -n-1 arguments
raise FlagArgumentsMismatch.new("Flag '#{flag_name}' expects #{flag_arity.abs - 1} or more arguments, but #{args_size} arguments were given")
elsif flag_arity >= 0 && flag_arity != args_size
# Contains zero or fixed number of arguments
raise FlagArgumentsMismatch.new("Flag '#{flag_name}' expects #{flag_arity} arguments, but #{args_size} arguments were given")
end
end
end
require 'spec_helper'
describe FeatureFlag do
describe '#initialize' do
it 'yields self to allow better setup' do
expect{ |b| described_class.new(&b) }.to yield_with_args(FeatureFlag)
end
it 'allows definition of features within the yielded block' do
features = described_class.new do |feature|
feature.define(:flag1)
feature.define('flag2')
end
expect( features.flags ).to have(2).items
expect( features.flags ).to include :flag1, 'flag2'
end
end
describe '#define' do
let(:flag_result) { double('flag_result') }
it 'defines feature flag' do
subject.define(:flag)
expect( subject.flags ).to include :flag
end
it 'raises FeatureFlag::FlagAlreadyDefined when defines feature flag again' do
subject.define(:flag)
expect{ subject.define(:flag) }.to raise_error(FeatureFlag::FlagAlreadyDefined)
end
it 'defines feature flag as block' do
subject.define(:flag) { flag_result }
expect( subject.active?(:flag) ).to eq flag_result
end
it 'cannot define feature flag as proc' do
expect {
subject.define(:flag, proc { flag_result })
}
.to raise_error(ArgumentError, 'wrong number of arguments (given 2, expected 1)')
end
it 'cannot define feature flag as lambda' do
expect {
subject.define(:flag, -> { flag_result })
}
.to raise_error(ArgumentError, 'wrong number of arguments (given 2, expected 1)')
end
end
describe '#redefine' do
let(:flag_result) { double('flag_result') }
it 'defines feature flag' do
subject.redefine(:flag)
expect( subject.flags ).to include :flag
end
it 'does not raise error when define feature flag again' do
subject.define(:flag)
expect{ subject.redefine(:flag) }.not_to raise_error
end
it 'defines feature flag as block' do
subject.redefine(:flag) { flag_result }
expect( subject.active?(:flag) ).to eq flag_result
end
it 'cannot redefine feature flag as proc' do
expect {
subject.redefine(:flag, proc { flag_result })
}
.to raise_error(ArgumentError, 'wrong number of arguments (given 2, expected 1)')
end
it 'cannot redefine feature flag as lambda' do
expect {
subject.redefine(:flag, -> { flag_result })
}
.to raise_error(ArgumentError, 'wrong number of arguments (given 2, expected 1)')
end
end
describe '#flags' do
context 'when no flag is defined' do
it 'returns empty array' do
expect( subject.flags ).to eq []
end
end
context 'when flags are defined' do
it 'contains defined flag names' do
subject.define(:flag1)
subject.define('flag2')
expect( subject.flags ).to have(2).items
expect( subject.flags ).to include :flag1, 'flag2'
end
end
end
describe '#active?' do
context 'when flag is truthy' do
it 'returns true' do
subject.define(:flag) { true }
expect( subject.active?(:flag) ).to eq true
end
end
context 'when flag is falsy' do
it 'returns false' do
subject.define(:flag) { false }
expect( subject.active?(:flag) ).to eq false
end
end
context 'when flag is not defined' do
it 'returns false' do
expect( subject.active?(:flag) ).to eq false
end
end
context 'when flag is defined with 1 argument' do
before do
subject.define(:flag) { |a| 'a flag' }
end
it 'returns flag result when called with 1 argument' do
expect( subject.active?(:flag, :a) ).to eq 'a flag'
end
it 'raises FlagArgumentsMismatch when called with 0 arguments' do
expect{ subject.active?(:flag) }.to raise_error(FeatureFlag::FlagArgumentsMismatch)
end
it 'raises FlagArgumentsMismatch when called with 2 arguments' do
expect{ subject.active?(:flag, :a, :b) }.to raise_error(FeatureFlag::FlagArgumentsMismatch)
end
end
context 'when flag is defined with 2 required and variable arguments' do
before do
subject.define(:flag) { |a, b, *c| 'a flag' }
end
it 'raises FlagArgumentsMismatch when called with 0 arguments' do
expect{ subject.active?(:flag) }.to raise_error(FeatureFlag::FlagArgumentsMismatch)
end
it 'raises FlagArgumentsMismatch when called with 1 argument' do
expect{ subject.active?(:flag, :a) }.to raise_error(FeatureFlag::FlagArgumentsMismatch)
end
it 'returns flag result when called with 2 arguments' do
expect( subject.active?(:flag, :a, :b) ).to eq 'a flag'
end
it 'returns flag result when called with 3 arguments' do
expect( subject.active?(:flag, :a, :b, :c) ).to eq 'a flag'
end
end
end
describe '#enabled?' do
context 'when flag is truthy' do
it 'returns true' do
subject.define(:flag) { true }
expect( subject.enabled?(:flag) ).to eq true
end
end
context 'when flag is falsy' do
it 'returns false' do
subject.define(:flag) { false }
expect( subject.enabled?(:flag) ).to eq false
end
end
context 'when flag is not defined' do
it 'returns false' do
expect( subject.enabled?(:flag) ).to eq false
end
end
end
describe '#inactive?' do
context 'when flag is truthy' do
it 'returns false' do
subject.define(:flag) { true }
expect( subject.inactive?(:flag) ).to eq false
end
end
context 'when flag is falsy' do
it 'returns true' do
subject.define(:flag) { false }
expect( subject.inactive?(:flag) ).to eq true
end
end
context 'when flag is not defined' do
it 'returns true' do
expect( subject.inactive?(:flag) ).to eq true
end
end
end
describe '#disabled?' do
context 'when flag is truthy' do
it 'returns false' do
subject.define(:flag) { true }
expect( subject.disabled?(:flag) ).to eq false
end
end
context 'when flag is falsy' do
it 'returns true' do
subject.define(:flag) { false }
expect( subject.disabled?(:flag) ).to eq true
end
end
context 'when flag is not defined' do
it 'returns true' do
expect( subject.disabled?(:flag) ).to eq true
end
end
end
describe '#with &block' do
context 'when flag is truthy' do
it 'calls the block' do
subject.define(:flag) { true }
probe = lambda{}
expect( probe ).to receive(:call)
subject.with(:flag, &probe)
end
end
context 'when flag is falsy' do
it 'does not call the block' do
subject.define(:flag) { false }
probe = lambda{}
expect( probe ).not_to receive(:call)
subject.with(:flag, &probe)
end
end
end
describe '#env?' do
let(:features) { described_class.new(env: 'aloha') }
it 'is true when #env and argument are equal' do
expect( features.env?('aloha') ).to be true
end
it 'is false when #env and argument are not equal' do
expect( features.env?('halo') ).to be false
end
it 'accepts both single and multiple argument' do
expect( features.env?('aloha') ).to be true
expect( features.env?('halo', 'aloha') ).to be true
end
it 'accepts both symbols and strings' do
expect( features.env?('aloha') ).to be true
expect( features.env?(:aloha) ).to be true
end
end
describe 'use of #env within the #define block' do
it 'is accessible' do
features = described_class.new(env: 'aloha') do |f|
f.define(:flag) { f.env }
end
expect( features.active?(:flag) ).to eq 'aloha'
end
end
describe '#flag?' do
it 'returns true if flag was defined' do
features = described_class.new do |f|
f.define(:flag) { false }
end
expect( features.flag?(:flag) ).to eq true
end
it 'returns false if flag wasn\'t defined' do
features = described_class.new do |f|
end
expect( features.flag?(:flag) ).to eq false
end
end
describe '#override' do
it 'overrides defined flag with result' do
features = described_class.new do |f|
f.define(:flag) { |a| false }
end
features.override(:flag, true)
expect( features.active?(:flag) ).to be_truthy
end
it 'overrides defined flag with block of same arity' do
features = described_class.new do |f|
f.define(:flag) { |a, b| 'original' }
end
features.override(:flag) { |a, b| 'overriden'}
expect( features.active?(:flag, :a, :b) ).to eq 'overriden'
end
it 'raises exception when overriding defined flag with block of different arity' do
features = described_class.new do |f|
f.define(:flag) { |a, b| false }
end
expect{ features.override(:flag) { |a| 'overriden'} }.to raise_error(FeatureFlag::FlagArgumentsMismatch)
end
it 'raises exception when trying to override non-existing flag' do
features = described_class.new do |f|
end
expect{ features.override(:flag, true) }.to raise_error(FeatureFlag::FlagNotDefined)
end
it 'raises exception when trying to override already overriden flag' do
features = described_class.new do |f|
f.define(:flag) { false }
end
features.override(:flag, true)
expect{ features.override(:flag, true) }.to raise_error(FeatureFlag::FlagAlreadyDefined)
end
end
describe '#reset_override' do
it 'reset overriden flag' do
features = described_class.new do |f|
f.define(:flag) { false }
f.override(:flag, true)
end
features.reset_override(:flag)
expect( features.active?(:flag) ).to be_falsy
end
it 'raises exception when flag was not overriden' do
features = described_class.new do |f|
f.define(:flag) { false }
end
expect{ features.reset_override(:flag) }.to raise_error(FeatureFlag::FlagNotOverriden)
end
end
describe '#reset_all_overrides' do
it 'reset overriden flag' do
features = described_class.new do |f|
f.define(:flag1) { false }
f.define(:flag2) { true }
f.override(:flag1, true)
f.override(:flag2, false)
end
features.reset_all_overrides
expect( features.active?(:flag1) ).to be_falsy
expect( features.active?(:flag2) ).to be_truthy
end
end
describe '#override_with &block' do
it 'overrides defined flag in a block and return to original behaviour immediately' do
features = described_class.new do |f|
f.define(:flag) { false }
end
features.override_with(:flag, true) do
expect( features.active?(:flag) ).to be_truthy
end
expect( features.active?(:flag) ).to be_falsy
end
end
describe '#overriden?' do
it 'returns true if flag was overriden' do
features = described_class.new do |f|
f.define(:flag) { false }
f.override(:flag, true)
end
expect( features.overriden?(:flag) ).to eq true
end
it 'returns false if flag wasn\'t overriden' do
features = described_class.new do |f|
f.define(:flag) { false }
end
expect( features.overriden?(:flag) ).to eq false
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment