Created
February 21, 2018 08:59
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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