diff --git a/Gemfile.lock b/Gemfile.lock index 21c6269..80b3c45 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,10 @@ PATH remote: . specs: - toggleable (0.1.5) + toggleable (0.1.7) activesupport (>= 4.0.0) + json + rest-client (= 1.8.0) GEM remote: https://rubygems.org/ @@ -12,27 +14,43 @@ GEM i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) + addressable (2.5.2) + public_suffix (>= 2.0.2, < 4.0) ast (2.4.0) codecov (0.1.10) json simplecov url concurrent-ruby (1.0.5) + crack (0.4.3) + safe_yaml (~> 1.0.0) diff-lcs (1.3) docile (1.3.1) + domain_name (0.5.20180417) + unf (>= 0.0.5, < 1.0.0) dotenv (2.4.0) + hashdiff (0.3.7) + http-cookie (1.0.3) + domain_name (~> 0.5) i18n (1.0.1) concurrent-ruby (~> 1.0) jaro_winkler (1.5.1) json (2.1.0) + mime-types (2.99.3) minitest (5.11.3) + netrc (0.11.0) parallel (1.12.1) parser (2.5.1.0) ast (~> 2.4.0) powerpack (0.1.2) + public_suffix (3.0.2) rainbow (3.0.0) rake (10.5.0) redis (3.3.5) + rest-client (1.8.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 3.0) + netrc (~> 0.7) rspec (3.7.0) rspec-core (~> 3.7.0) rspec-expectations (~> 3.7.0) @@ -57,6 +75,7 @@ GEM ruby-progressbar (~> 1.7) unicode-display_width (~> 1.0, >= 1.0.1) ruby-progressbar (1.9.0) + safe_yaml (1.0.4) simplecov (0.16.1) docile (~> 1.1) json (>= 1.8, < 3) @@ -65,8 +84,15 @@ GEM thread_safe (0.3.6) tzinfo (1.2.5) thread_safe (~> 0.1) + unf (0.1.4) + unf_ext + unf_ext (0.0.7.5) unicode-display_width (1.4.0) url (0.3.2) + webmock (3.4.2) + addressable (>= 2.3.6) + crack (>= 0.3.2) + hashdiff PLATFORMS ruby @@ -82,6 +108,7 @@ DEPENDENCIES rubocop simplecov (>= 0.16.1) toggleable! + webmock BUNDLED WITH 1.16.1 diff --git a/lib/toggleable/base.rb b/lib/toggleable/base.rb index 96a1151..0e62628 100644 --- a/lib/toggleable/base.rb +++ b/lib/toggleable/base.rb @@ -18,19 +18,21 @@ module Base # it will generate these methods into included class. module ClassMethods def active? - return to_bool(toggle_active.to_s) unless toggle_active.nil? + toggle_status = toggle_active + return toggle_status.to_s == 'true' unless toggle_status.nil? # Lazily register the key - Toggleable.configuration.storage.set_if_not_exist(key, DEFAULT_VALUE, namespace: Toggleable.configuration.namespace) + set_status = Toggleable.configuration.storage.set_if_not_exist(key, DEFAULT_VALUE, namespace: Toggleable.configuration.namespace) + Toggleable::FeatureToggler.instance.toggle_key(key, DEFAULT_VALUE, actor: 'key initialization') if Toggleable.configuration.enable_palanca && set_status DEFAULT_VALUE end - def activate!(actor: nil) - toggle_key(true, actor) + def activate!(actor: nil, email: nil) + toggle_key(true, actor, email) end - def deactivate!(actor: nil) - toggle_key(false, actor) + def deactivate!(actor: nil, email: nil) + toggle_key(false, actor, email) end def key @@ -48,9 +50,10 @@ def process private - def toggle_key(value, actor) - Toggleable.configuration.logger&.log(key: key, value: value, actor: actor) + def toggle_key(value, actor, email) Toggleable.configuration.storage.set(key, value, namespace: Toggleable.configuration.namespace) + Toggleable.configuration.logger&.log(key: key, value: value, actor: actor) + Toggleable::FeatureToggler.instance.toggle_key(key, value, actor: (email || actor)) if Toggleable.configuration.enable_palanca end def toggle_active @@ -62,12 +65,6 @@ def toggle_active def read_expired? @_last_read_at < Time.now.localtime - Toggleable.configuration.expiration_time end - - def to_bool(value) - return true if value =~ /^(true|t|yes|y|1)$/i - return false if value.empty? || value =~ /^(false|f|no|n|0)$/i - raise ArgumentError, "invalid value for Boolean: \"#{value}\"" - end end end end diff --git a/lib/toggleable/configuration.rb b/lib/toggleable/configuration.rb index dda1678..c9a1ce9 100644 --- a/lib/toggleable/configuration.rb +++ b/lib/toggleable/configuration.rb @@ -4,6 +4,10 @@ module Toggleable # Toggleable::Configuration yields the configuration of toggleable. class Configuration attr_accessor :expiration_time ## expiration time for memoization. default: 5 minutes + attr_accessor :enable_palanca ## enable palanca api call + attr_accessor :palanca_host + attr_accessor :palanca_user + attr_accessor :palanca_password attr_accessor :storage ## storage used. default: memory store attr_accessor :namespace ## required for prefixing the keys. default: `toggleable`` attr_accessor :logger ## optional, it will not log if not configured. diff --git a/lib/toggleable/feature_toggler.rb b/lib/toggleable/feature_toggler.rb index 72c9f14..bcaa7fc 100644 --- a/lib/toggleable/feature_toggler.rb +++ b/lib/toggleable/feature_toggler.rb @@ -1,12 +1,17 @@ # frozen_string_literal: true require 'singleton' +require 'rest-client' +require 'active_support/inflector' +require 'json' module Toggleable # Toggleable::FeatureToggler provides an instance to manage all toggleable keys. class FeatureToggler include Singleton + MAX_ATTEMPT = 3 + attr_reader :features def initialize @@ -17,14 +22,81 @@ def register(key) features << key end + def get_key(key) + @_toggle_active ||= {} + @_last_key_read_at ||= {} + return @_toggle_active[key] if !@_toggle_active[key].nil? && !read_key_expired?(key) + + @_last_key_read_at[key] = Time.now.localtime + response = '' + attempt = 1 + url = "#{Toggleable.configuration.palanca_host}/_internal/toggle-features?feature=#{key}" + resource = RestClient::Resource.new(url, Toggleable.configuration.palanca_user, Toggleable.configuration.palanca_password) + + while response.empty? + begin + response = resource.get timeout: 2, open_timeout: 1 + response = ::JSON.parse(response) + @_toggle_active[key] = response['data']['status'] + rescue StandardError => e + if attempt >= MAX_ATTEMPT + Toggleable.configuration.logger.error(message: "GET #{key} TIMEOUT") + raise e + end + attempt += 1 + end + end + @_toggle_active[key] + end + + def toggle_key(key, value, actor: nil) + response = '' + attempt = 1 + url = "#{Toggleable.configuration.palanca_host}/_internal/toggle-features" + payload = { feature: key, status: value, user_id: actor }.to_json + resource = RestClient::Resource.new(url, Toggleable.configuration.palanca_user, Toggleable.configuration.palanca_password) + + while response.empty? + begin + response = resource.put payload, timeout: 2, open_timeout: 1 + rescue StandardError => e + if attempt >= MAX_ATTEMPT + Toggleable.configuration.logger.error(message: "TOGGLE #{key} TIMEOUT") + raise e + end + attempt += 1 + end + end + end + def available_features(memoize: Toggleable.configuration.use_memoization) available_features = memoize ? memoized_keys : keys available_features.slice(*features) end - def mass_toggle!(mapping, actor: nil) + def mass_toggle!(mapping, actor:, email:) log_changes(mapping, actor) if Toggleable.configuration.logger Toggleable.configuration.storage.mass_set(mapping, namespace: Toggleable.configuration.namespace) + mass_set_palanca!(mapping, actor: email) if Toggleable.configuration.enable_palanca + end + + def mass_set_palanca!(mapping, actor:) + response = '' + attempt = 1 + url = "#{Toggleable.configuration.palanca_host}/_internal/toggle-features/bulk-update" + payload = { mappings: mapping, user_id: actor }.to_json + resource = RestClient::Resource.new(url, Toggleable.configuration.palanca_user, Toggleable.configuration.palanca_password) + while response.empty? + begin + response = resource.post payload, timeout: 2, open_timeout: 1 + rescue StandardError => e + if attempt >= MAX_ATTEMPT + Toggleable.configuration.logger.error(message: 'MASS TOGGLE TIMEOUT') + raise e + end + attempt += 1 + end + end end private @@ -34,19 +106,21 @@ def keys end def memoized_keys - return @_memoized_keys if defined?(@_memoized_keys) && !read_expired? + return @_memoized_keys if defined?(@_memoized_keys) && !read_all_keys_expired? @_last_read_at = Time.now.localtime @_memoized_keys = Toggleable.configuration.storage.get_all(namespace: Toggleable.configuration.namespace) end - def read_expired? + def read_all_keys_expired? @_last_read_at < Time.now.localtime - Toggleable.configuration.expiration_time end + def read_key_expired?(key) + @_last_key_read_at[key] < Time.now.localtime - Toggleable.configuration.expiration_time + end + def log_changes(mapping, actor) - previous_values = available_features mapping.each do |key, val| - next if previous_values[key].to_s == val.to_s Toggleable.configuration.logger.log(key: key, value: val, actor: actor) end end diff --git a/lib/toggleable/storage/memory_store.rb b/lib/toggleable/storage/memory_store.rb index 6b55bc4..e7daae7 100644 --- a/lib/toggleable/storage/memory_store.rb +++ b/lib/toggleable/storage/memory_store.rb @@ -8,25 +8,25 @@ class MemoryStore < ActiveSupport::Cache::MemoryStore ## the self you provide must implement these methods ## namespace parameter is optional, only if you provide namespace configuration - def get(key, namespace:) + def get(key, namespace: nil) read(key, namespace: namespace) end - def get_all(namespace:) + def get_all(namespace: nil) read_multi(*keys, namespace: namespace) end - def set(key, value, namespace:) + def set(key, value, namespace: nil) write(key, value, namespace: namespace) end - def set_if_not_exist(key, value, namespace:) + def set_if_not_exist(key, value, namespace: nil) fetch(key, namespace: namespace) do value end end - def mass_set(mappings, namespace:) + def mass_set(mappings, namespace: nil) write_multi(mappings, namespace: namespace) end diff --git a/lib/toggleable/version.rb b/lib/toggleable/version.rb index cbca12f..34548c8 100644 --- a/lib/toggleable/version.rb +++ b/lib/toggleable/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Toggleable - VERSION = '0.1.5' + VERSION = '0.1.9' end diff --git a/spec/class_initializer.rb b/spec/class_initializer.rb index 821db76..3fd03f9 100644 --- a/spec/class_initializer.rb +++ b/spec/class_initializer.rb @@ -24,4 +24,7 @@ def log(key:, value:, actor:) Toggleable.configure do |t| t.logger = logger + t.palanca_host = 'localhost:8027' + t.use_memoization = false + t.enable_palanca = true end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e05de23..f6538ee 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,12 +5,7 @@ SimpleCov.start do add_filter '/spec/' -end - -require 'codecov' - -SimpleCov.start do - add_filter '/spec/' + add_filter '/lib/toggleable/storage' end require 'codecov' @@ -22,4 +17,5 @@ SimpleCov::Formatter::HTMLFormatter end +require 'webmock/rspec' require 'class_initializer' diff --git a/spec/toggleable/feature_toggler_spec.rb b/spec/toggleable/feature_toggler_spec.rb index 2de57fa..d64fc78 100644 --- a/spec/toggleable/feature_toggler_spec.rb +++ b/spec/toggleable/feature_toggler_spec.rb @@ -28,8 +28,8 @@ } before do - allow(subject).to receive(:keys).and_return(keys) allow(subject).to receive(:features).and_return(['active_key', 'inactive_key']) + allow(subject).to receive(:keys).and_return(keys) end it { expect(subject.available_features).to eq({ 'active_key' => 'true', 'inactive_key' => 'false' }) } @@ -52,8 +52,8 @@ } before do - Toggleable.configuration.storage.mass_set(keys, namespace: Toggleable.configuration.namespace) allow(subject).to receive(:features).and_return(['active_key', 'inactive_key']) + Toggleable.configuration.storage.mass_set(keys, namespace: Toggleable.configuration.namespace) end it do @@ -64,33 +64,43 @@ end end - describe '#mass_toggle! with memory store' do - let(:mapping_before) { - { - 'key' => 'true', - 'other_key' => 'false' - } - } + describe 'get key' do + let(:key) { 'sample_key' } + let(:data) { { status: true } } + let(:response) { { data: data }.to_json } + + context 'successful' do + before do + stub_request(:get, "http://localhost:8027/_internal/toggle-features?feature=#{key}") + .to_return(status: 200, body: response) + end + it { expect(subject.get_key(key)).to be_truthy } + end + end + + before do + stub_request(:post, "http://localhost:8027/_internal/toggle-features/bulk-update").to_return(status: 200, body: 'success') + end + + describe '#mass_toggle! with memory store' do let(:mapping_after) { { - 'key' => 'true', 'other_key' => 'true' } } - let(:actor_id) { 1 } + let(:actor_id) { 1 } + let(:actor_email) { 'admin@toggle.com' } before do subject.register('key') subject.register('other_key') - subject.mass_toggle!(mapping_before, actor: actor_id) end it do expect(Toggleable.configuration.logger).to receive(:log).with(key: 'other_key', value: 'true', actor: actor_id).and_return(true) - subject.mass_toggle!(mapping_after, actor: actor_id) - expect(subject.available_features).to include(mapping_after) + subject.mass_toggle!(mapping_after, actor: actor_id, email: actor_email) end end @@ -99,32 +109,23 @@ allow(Toggleable.configuration).to receive(:storage).and_return(redis_storage) end - let(:mapping_before) { - { - 'key' => 'true', - 'other_key' => 'false' - } - } - let(:mapping_after) { { - 'key' => 'true', 'other_key' => 'true' } } - let(:actor_id) { 1 } + let(:actor_id) { 1 } + let(:actor_email) { 'admin@toggle.com' } before do subject.register('key') subject.register('other_key') - subject.mass_toggle!(mapping_before, actor: actor_id) end it do expect(Toggleable.configuration.logger).to receive(:log).with(key: 'other_key', value: 'true', actor: actor_id).and_return(true) - subject.mass_toggle!(mapping_after, actor: actor_id) - expect(subject.available_features).to include(mapping_after) + subject.mass_toggle!(mapping_after, actor: actor_id, email: actor_email) end end end diff --git a/spec/toggleable/toggleable_spec.rb b/spec/toggleable/toggleable_spec.rb index 5eaa39a..d8af8c6 100644 --- a/spec/toggleable/toggleable_spec.rb +++ b/spec/toggleable/toggleable_spec.rb @@ -21,15 +21,15 @@ class SampleFeature it { is_expected.to respond_to(:key) } it { is_expected.to respond_to(:description) } + before(:each) do + allow(Toggleable::FeatureToggler.instance).to receive(:toggle_key).and_return(true) + end + describe 'active? before key exist should create the key also' do context 'with memory store' do - it { expect(subject.active?).to be_falsy } - end - - context 'with redis store' do before do allow(subject).to receive(:toggle_active).and_return(nil) - allow(Toggleable.configuration).to receive(:storage).and_return(redis_storage) + allow(Toggleable::FeatureToggler.instance).to receive(:get_key).and_return(false) end it { expect(subject.active?).to be_falsy } @@ -53,16 +53,8 @@ def description let(:actor_id) { 1 } context 'activation' do - it do - expect(Toggleable.configuration.logger).to receive(:log).with(key: SampleFeature.key, value: true, actor: actor_id).and_return(true) - subject.activate!(actor: actor_id) - expect(subject.active?).to be_truthy - end - end - - context 'activation with redis' do before do - allow(Toggleable.configuration).to receive(:storage).and_return(redis_storage) + allow(Toggleable::FeatureToggler.instance).to receive(:get_key).and_return(true) end it do @@ -73,6 +65,10 @@ def description end context 'deactivation' do + before do + allow(Toggleable::FeatureToggler.instance).to receive(:get_key).and_return(false) + end + it do expect(Toggleable.configuration.logger).to receive(:log).with(key: SampleFeature.key, value: false, actor: actor_id).and_return(true) subject.deactivate!(actor: actor_id) @@ -82,6 +78,7 @@ def description context 'deactivation without namespace' do before do + allow(Toggleable::FeatureToggler.instance).to receive(:get_key).and_return(false) allow(Toggleable.configuration).to receive(:namespace).and_return(nil) end @@ -95,6 +92,7 @@ def description context 'processing class when inactive will do nothing' do before do subject.deactivate! + allow(Toggleable::FeatureToggler.instance).to receive(:get_key).and_return(false) end it do @@ -107,6 +105,7 @@ def description context 'processing class when active' do before do + allow(Toggleable::FeatureToggler.instance).to receive(:get_key).and_return(false) subject.activate! end @@ -117,15 +116,5 @@ def description expect(subject.active?).to be_falsy end end - - context 'wrong argument type for to bool' do - let(:wrong_args) { 'wrong args' } - - before do - allow(subject).to receive(:toggle_active).and_return(wrong_args) - end - - it { expect { subject.active? }.to raise_error(ArgumentError) } - end end end diff --git a/toggleable.gemspec b/toggleable.gemspec index 8d70a87..e78624e 100644 --- a/toggleable.gemspec +++ b/toggleable.gemspec @@ -32,12 +32,15 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_runtime_dependency "activesupport", ">= 4.0.0" + spec.add_runtime_dependency "rest-client", "1.8.0" + spec.add_runtime_dependency "json" spec.add_development_dependency "bundler", "~> 1.14" spec.add_development_dependency "rake", "~> 10.0" spec.add_development_dependency "simplecov", ">= 0.16.1" spec.add_development_dependency "redis", "~> 3.0" spec.add_development_dependency "dotenv", ">= 2.4.0" spec.add_development_dependency "rspec", "~> 3.0" + spec.add_development_dependency "webmock" spec.add_development_dependency "rspec_junit_formatter" spec.add_development_dependency "rubocop" spec.add_development_dependency "codecov"