arturo 2.1.0 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +90 -0
  3. data/README.md +142 -93
  4. data/app/controllers/arturo/features_controller.rb +6 -13
  5. data/app/helpers/arturo/features_helper.rb +1 -0
  6. data/app/views/arturo/features/_feature.html.erb +1 -0
  7. data/app/views/arturo/features/_form.html.erb +1 -0
  8. data/app/views/arturo/features/edit.html.erb +1 -0
  9. data/app/views/arturo/features/forbidden.html.erb +1 -0
  10. data/app/views/arturo/features/index.html.erb +1 -0
  11. data/app/views/arturo/features/new.html.erb +1 -0
  12. data/app/views/arturo/features/show.html.erb +1 -0
  13. data/config/locales/en.yml +1 -1
  14. data/config/routes.rb +4 -12
  15. data/lib/arturo/controller_filters.rb +3 -2
  16. data/lib/arturo/engine.rb +2 -8
  17. data/lib/arturo/feature_availability.rb +2 -1
  18. data/lib/arturo/feature_caching.rb +118 -23
  19. data/lib/arturo/feature_factories.rb +3 -2
  20. data/lib/arturo/feature_management.rb +1 -0
  21. data/lib/arturo/feature_methods.rb +95 -0
  22. data/lib/arturo/feature_params_support.rb +6 -4
  23. data/lib/arturo/middleware.rb +1 -0
  24. data/lib/arturo/no_such_feature.rb +1 -0
  25. data/lib/arturo/null_logger.rb +8 -0
  26. data/lib/arturo/special_handling.rb +6 -6
  27. data/lib/arturo/test_support.rb +3 -2
  28. data/lib/arturo/version.rb +4 -0
  29. data/lib/arturo.rb +28 -6
  30. data/lib/generators/arturo/assets_generator.rb +1 -0
  31. data/lib/generators/arturo/feature_model_generator.rb +14 -0
  32. data/lib/generators/arturo/initializer_generator.rb +1 -0
  33. data/lib/generators/arturo/migration_generator.rb +10 -10
  34. data/lib/generators/arturo/routes_generator.rb +1 -0
  35. data/lib/generators/arturo/templates/feature.rb +9 -0
  36. data/lib/generators/arturo/templates/initializer.rb +1 -0
  37. data/lib/generators/arturo/templates/migration.erb +17 -0
  38. metadata +36 -200
  39. data/lib/arturo/feature.rb +0 -90
  40. data/lib/generators/arturo/templates/migration.rb +0 -17
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Arturo
2
3
 
3
4
  # A mixin that provides #feature_enabled? and #if_feature_enabled
@@ -24,7 +25,7 @@ module Arturo
24
25
  end
25
26
 
26
27
  # By default, returns current_user.
27
- #
28
+ #
28
29
  # If you would like to change this implementation, it is recommended
29
30
  # you do so in config/initializers/arturo_initializer.rb
30
31
  # @return [Object] the recipient of features.
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'arturo/no_such_feature'
2
3
 
3
4
  module Arturo
@@ -18,48 +19,63 @@ module Arturo
18
19
  # to use a shared cache like Memcached.
19
20
  module FeatureCaching
20
21
 
22
+ module PrependMethods
23
+ # Wraps Arturo::Feature.to_feature with in-memory caching.
24
+ def to_feature(feature_or_symbol)
25
+ if !caches_features?
26
+ super
27
+ elsif feature_or_symbol.kind_of?(Arturo::Feature)
28
+ feature_or_symbol
29
+ else
30
+ symbol = feature_or_symbol.to_sym
31
+ feature_caching_strategy.fetch(feature_cache, symbol) { super(symbol) }
32
+ end
33
+ end
34
+ end
35
+
21
36
  def self.extended(base)
22
37
  class << base
23
- alias_method_chain :to_feature, :caching
38
+ prepend PrependMethods
24
39
  attr_accessor :cache_ttl, :feature_cache, :feature_caching_strategy
40
+ attr_writer :extend_cache_on_failure
25
41
  end
26
42
  base.send(:after_save) do |f|
27
43
  f.class.feature_caching_strategy.expire(f.class.feature_cache, f.symbol.to_sym) if f.class.caches_features?
28
44
  end
29
45
  base.cache_ttl = 0
46
+ base.extend_cache_on_failure = false
30
47
  base.feature_cache = Arturo::FeatureCaching::Cache.new
31
48
  base.feature_caching_strategy = AllStrategy
32
49
  end
33
50
 
34
- def caches_features?
35
- self.cache_ttl.to_i > 0
51
+ def extend_cache_on_failure?
52
+ !!@extend_cache_on_failure
36
53
  end
37
54
 
38
- # Wraps Arturo::Feature.to_feature with in-memory caching.
39
- def to_feature_with_caching(feature_or_symbol)
40
- if !caches_features?
41
- to_feature_without_caching(feature_or_symbol)
42
- elsif feature_or_symbol.kind_of?(Arturo::Feature)
43
- feature_or_symbol
44
- else
45
- symbol = feature_or_symbol.to_sym
46
- feature_caching_strategy.fetch(feature_cache, symbol) { to_feature_without_caching(symbol) }
47
- end
55
+ def caches_features?
56
+ self.cache_ttl.to_i > 0
48
57
  end
49
58
 
50
59
  def warm_cache!
51
- warn "Deprecated, no longer necessary!"
60
+ to_feature(:fake_feature_to_force_cache_warming)
52
61
  end
53
62
 
54
63
  class AllStrategy
55
64
  class << self
65
+ ##
66
+ # @param cache [Arturo::Cache] cache backend
67
+ # @param symbol [Symbol] arturo identifier
68
+ # @return [Arturo::Feature, Arturo::NoSuchFeature]
69
+ #
56
70
  def fetch(cache, symbol, &block)
57
- features = cache.read("arturo.all")
58
-
59
- unless cache_is_current?(cache, features)
60
- features = Hash[Arturo::Feature.all.map { |f| [f.symbol.to_sym, f] }]
61
- mark_as_current!(cache)
62
- cache.write("arturo.all", features, :expires_in => Arturo::Feature.cache_ttl * 10)
71
+ existing_features = cache.read("arturo.all")
72
+
73
+ features = if cache_is_current?(cache, existing_features)
74
+ existing_features
75
+ else
76
+ arturos_from_origin(fallback: existing_features).tap do |updated_features|
77
+ update_and_extend_cache!(cache, updated_features)
78
+ end
63
79
  end
64
80
 
65
81
  features[symbol] || Arturo::NoSuchFeature.new(symbol)
@@ -69,17 +85,96 @@ module Arturo
69
85
  cache.delete("arturo.all")
70
86
  end
71
87
 
88
+ def register_cache_update_listener(&block)
89
+ cache_update_listeners << block
90
+ end
91
+
72
92
  private
73
93
 
94
+ def cache_update_listeners
95
+ @cache_update_listeners ||= []
96
+ end
97
+
98
+ ##
99
+ # @param fallback [Hash] features to use on database failure
100
+ # @return [Hash] updated features from origin or fallback
101
+ # @raise [ActiveRecord::ActiveRecordError] on database failure
102
+ # without cache extension option
103
+ #
104
+ def arturos_from_origin(fallback:)
105
+ Arturo::Feature.all.to_h { |f| [f.symbol.to_sym, f] }
106
+ rescue ActiveRecord::ActiveRecordError
107
+ raise unless Arturo::Feature.extend_cache_on_failure?
108
+
109
+ if fallback.blank?
110
+ log_empty_cache
111
+ raise
112
+ else
113
+ log_stale_cache
114
+ fallback
115
+ end
116
+ end
117
+
118
+ ##
119
+ # @return [Boolean] whether the current cache has to be updated from origin
120
+ # @raise [ActiveRecord::ActiveRecordError] on database failure
121
+ # without cache extension option
122
+ #
74
123
  def cache_is_current?(cache, features)
75
124
  return unless features
76
125
  return true if cache.read("arturo.current")
77
- return false if features.values.map(&:updated_at).compact.max != Arturo::Feature.maximum(:updated_at)
126
+
127
+ begin
128
+ return false if origin_changed?(features)
129
+ rescue ActiveRecord::ActiveRecordError
130
+ raise unless Arturo::Feature.extend_cache_on_failure?
131
+
132
+ if features.blank?
133
+ log_empty_cache
134
+ raise
135
+ else
136
+ log_stale_cache
137
+ update_and_extend_cache!(cache, features)
138
+ end
139
+
140
+ return true
141
+ end
78
142
  mark_as_current!(cache)
79
143
  end
80
144
 
145
+ def formatted_log(namespace, msg)
146
+ "[Arturo][#{namespace}] #{msg}"
147
+ end
148
+
149
+ def log_empty_cache
150
+ Arturo.logger.error(formatted_log('extend_cache_on_failure', 'Fallback cache is empty'))
151
+ end
152
+
153
+ def log_stale_cache
154
+ Arturo.logger.warn(formatted_log('extend_cache_on_failure', 'Falling back to stale cache'))
155
+ end
156
+
157
+ ##
158
+ # @return [True]
159
+ #
81
160
  def mark_as_current!(cache)
82
- cache.write("arturo.current", true, :expires_in => Arturo::Feature.cache_ttl)
161
+ cache.write("arturo.current", true, expires_in: Arturo::Feature.cache_ttl)
162
+ end
163
+
164
+ ##
165
+ # The Arturo origin might return a big payload, so checking for the latest
166
+ # update is a cheaper operation.
167
+ #
168
+ # @return [Boolean] if origin has been updated since the last cache update.
169
+ #
170
+ def origin_changed?(features)
171
+ features.values.map(&:updated_at).compact.max != Arturo::Feature.maximum(:updated_at)
172
+ end
173
+
174
+ def update_and_extend_cache!(cache, features)
175
+ mark_as_current!(cache)
176
+ cache.write("arturo.all", features, expires_in: Arturo::Feature.cache_ttl * 10)
177
+ cache_update_listeners.each(&:call)
83
178
  end
84
179
  end
85
180
  end
@@ -89,7 +184,7 @@ module Arturo
89
184
  if feature = cache.read("arturo.#{symbol}")
90
185
  feature
91
186
  else
92
- cache.write("arturo.#{symbol}", yield || Arturo::NoSuchFeature.new(symbol), :expires_in => Arturo::Feature.cache_ttl)
187
+ cache.write("arturo.#{symbol}", yield || Arturo::NoSuchFeature.new(symbol), expires_in: Arturo::Feature.cache_ttl)
93
188
  end
94
189
  end
95
190
 
@@ -1,8 +1,9 @@
1
- FactoryGirl.define do
1
+ # frozen_string_literal: true
2
+ FactoryBot.define do
2
3
  sequence(:feature_symbol) { |n| "feature_#{n}".to_sym }
3
4
 
4
5
  factory :feature, :class => Arturo::Feature do
5
6
  symbol { generate(:feature_symbol) }
6
7
  deployment_percentage { rand(101) }
7
8
  end
8
- end
9
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Arturo
2
3
 
3
4
  # A mixin that is included by Arturo::FeaturesController and is declared
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'active_support'
5
+ require 'active_support/core_ext/hash/indifferent_access'
6
+
7
+ module Arturo
8
+ module FeatureMethods
9
+ extend ActiveSupport::Concern
10
+ include Arturo::SpecialHandling
11
+
12
+ SYMBOL_REGEX = /^[a-zA-z][a-zA-Z0-9_]*$/
13
+ DEFAULT_ATTRIBUTES = { :deployment_percentage => 0 }.with_indifferent_access
14
+
15
+ included do
16
+ attr_readonly :symbol
17
+
18
+ validates_presence_of :symbol, :deployment_percentage
19
+ validates_uniqueness_of :symbol, :allow_blank => true, :case_sensitive => false
20
+ validates_numericality_of :deployment_percentage,
21
+ :only_integer => true,
22
+ :allow_blank => true,
23
+ :greater_than_or_equal_to => 0,
24
+ :less_than_or_equal_to => 100
25
+ end
26
+
27
+ class_methods do
28
+ # Looks up a feature by symbol. Also accepts a Feature as input.
29
+ # @param [Symbol, Arturo::Feature] feature_or_symbol a Feature or the Symbol of a Feature
30
+ # @return [Arturo::Feature, Arturo::NoSuchFeature] the Feature if found, else Arturo::NoSuchFeature
31
+ def to_feature(feature_or_symbol)
32
+ return feature_or_symbol if feature_or_symbol.kind_of?(self)
33
+
34
+ symbol = feature_or_symbol.to_sym.to_s
35
+ self.where(:symbol => symbol).first || Arturo::NoSuchFeature.new(symbol)
36
+ end
37
+
38
+ # Looks up a feature by symbol. Also accepts a Feature as input.
39
+ # @param [Symbol, Arturo::Feature] feature_or_symbol a Feature or the Symbol of a Feature
40
+ # @return [Arturo::Feature, nil] the Feature if found, else nil
41
+ def find_feature(feature_or_symbol)
42
+ feature = to_feature(feature_or_symbol)
43
+ feature.is_a?(Arturo::NoSuchFeature) ? nil : feature
44
+ end
45
+
46
+ def last_updated_at
47
+ maximum(:updated_at)
48
+ end
49
+ end
50
+
51
+ # Create a new Feature
52
+ def initialize(*args, &block)
53
+ args[0] = DEFAULT_ATTRIBUTES.merge(args[0].try(:to_h) || {})
54
+ super(*args, &block)
55
+ end
56
+
57
+ # @param [Object] feature_recipient a User, Account,
58
+ # or other model with an #id method
59
+ # @return [true,false] whether or not this feature is enabled
60
+ # for feature_recipient
61
+ # @see Arturo::SpecialHandling#whitelisted?
62
+ # @see Arturo::SpecialHandling#blacklisted?
63
+ def enabled_for?(feature_recipient)
64
+ return false if feature_recipient.nil?
65
+ return false if blacklisted?(feature_recipient)
66
+ return true if whitelisted?(feature_recipient)
67
+ passes_threshold?(feature_recipient, deployment_percentage || 0)
68
+ end
69
+
70
+ def name
71
+ return I18n.translate("arturo.feature.nameless") if symbol.blank?
72
+
73
+ I18n.translate("arturo.feature.#{symbol}", :default => symbol.to_s.titleize)
74
+ end
75
+
76
+ def to_s
77
+ "Feature #{name}"
78
+ end
79
+
80
+ def to_param
81
+ persisted? ? "#{id}-#{symbol.to_s.parameterize}" : nil
82
+ end
83
+
84
+ def inspect
85
+ "<Arturo::Feature #{name}, deployed to #{deployment_percentage}%>"
86
+ end
87
+
88
+ # made public so as to allow for thresholds stored outside of the model
89
+ def passes_threshold?(feature_recipient, threshold)
90
+ return true if threshold == 100
91
+ return false if threshold == 0 || !feature_recipient.id
92
+ (((feature_recipient.id + (self.id || 1) + 17) * 13) % 100) < threshold
93
+ end
94
+ end
95
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Arturo
2
3
 
3
4
  # Mix in to FeaturesController. Provides the logic for getting parameters
@@ -18,14 +19,16 @@ module Arturo
18
19
  PERMITTED_ATTRIBUTES = [ :symbol, :deployment_percentage ]
19
20
 
20
21
  def feature_params
21
- params.permit(:feature => PERMITTED_ATTRIBUTES)[:feature]
22
+ if feature = params[:feature]
23
+ feature.permit(PERMITTED_ATTRIBUTES)
24
+ end
22
25
  end
23
26
 
24
27
  def features_params
25
- permitted = PERMITTED_ATTRIBUTES
26
28
  features = params[:features]
27
29
  features.each do |id, attributes|
28
- features[id] = ActionController::Parameters.new(attributes).permit(*permitted)
30
+ attributes = attributes.to_unsafe_h if attributes.respond_to?(:to_unsafe_h)
31
+ features[id] = ActionController::Parameters.new(attributes).permit(*PERMITTED_ATTRIBUTES)
29
32
  end
30
33
  end
31
34
  end
@@ -35,4 +38,3 @@ module Arturo
35
38
  end
36
39
 
37
40
  end
38
-
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Arturo
2
3
  # A Rack middleware that requires a feature to be present. By default,
3
4
  # checks feature availability against an `arturo.recipient` object
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Arturo
2
3
 
3
4
  # A Null-Object stand-in for a Feature.
@@ -0,0 +1,8 @@
1
+ module Arturo
2
+ class NullLogger
3
+ %w[info debug error fatal warn].each do |level|
4
+ define_method level do |_message|
5
+ end
6
+ end
7
+ end
8
+ end
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+
1
5
  module Arturo
2
6
 
3
7
  # Adds whitelist and blacklist support to individual features by name
@@ -24,13 +28,9 @@ module Arturo
24
28
  # This is particularly important if your application runs in several
25
29
  # different processes or on several servers.
26
30
  module SpecialHandling
31
+ extend ActiveSupport::Concern
27
32
 
28
- def self.included(base)
29
- base.extend Arturo::SpecialHandling::ClassMethods
30
- end
31
-
32
- module ClassMethods
33
-
33
+ class_methods do
34
34
  def whitelists
35
35
  @whitelists ||= []
36
36
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  Arturo.instance_eval do
2
3
 
3
4
  # Enable a feature; create it if necessary.
@@ -9,7 +10,7 @@ Arturo.instance_eval do
9
10
  def enable_feature!(name)
10
11
  if feature = Arturo::Feature.find_feature(name)
11
12
  feature = feature.class.find(feature.id) if feature.frozen?
12
- feature.update_attributes(:deployment_percentage => 100)
13
+ feature.update(:deployment_percentage => 100)
13
14
  else
14
15
  Arturo::Feature.create!(:symbol => name, :deployment_percentage => 100)
15
16
  end
@@ -24,7 +25,7 @@ Arturo.instance_eval do
24
25
  def disable_feature!(name)
25
26
  if feature = Arturo::Feature.find_feature(name)
26
27
  feature = feature.class.find(feature.id) if feature.frozen?
27
- feature.update_attributes(:deployment_percentage => 0)
28
+ feature.update(:deployment_percentage => 0)
28
29
  end
29
30
  end
30
31
 
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module Arturo
3
+ VERSION = '4.1.1'
4
+ end
data/lib/arturo.rb CHANGED
@@ -1,19 +1,41 @@
1
- module Arturo
1
+ # frozen_string_literal: true
2
2
 
3
- require 'arturo/special_handling'
4
- require 'arturo/feature_availability'
5
- require 'arturo/feature_management'
6
- require 'arturo/feature_caching'
7
- require 'arturo/engine' if defined?(Rails) && Rails::VERSION::MAJOR >= 3
3
+ require_relative 'arturo/null_logger'
4
+ require_relative 'arturo/special_handling'
5
+ require_relative 'arturo/feature_methods'
6
+ require_relative 'arturo/feature_availability'
7
+ require_relative 'arturo/feature_management'
8
+ require_relative 'arturo/feature_caching'
9
+ require_relative 'arturo/controller_filters'
8
10
 
11
+ module Arturo
9
12
  class << self
10
13
  # Quick check for whether a feature is enabled for a recipient.
11
14
  # @param [String, Symbol] feature_name
12
15
  # @param [#id] recipient
13
16
  # @return [true,false] whether the feature exists and is enabled for the recipient
14
17
  def feature_enabled_for?(feature_name, recipient)
18
+ return false if recipient.nil?
19
+
15
20
  f = self::Feature.to_feature(feature_name)
16
21
  f && f.enabled_for?(recipient)
17
22
  end
23
+
24
+ def logger=(logger)
25
+ @logger = logger
26
+ end
27
+
28
+ def logger
29
+ @logger || NullLogger.new
30
+ end
31
+ end
32
+ end
33
+
34
+ ActiveSupport.on_load(:action_controller) do
35
+ include Arturo::FeatureAvailability
36
+ include Arturo::ControllerFilters
37
+ if respond_to?(:helper)
38
+ helper Arturo::FeatureAvailability
39
+ helper Arturo::FeatureManagement
18
40
  end
19
41
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'rails/generators'
2
3
 
3
4
  module Arturo
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ require 'rails/generators'
3
+
4
+ module Arturo
5
+ class FeatureModelGenerator < Rails::Generators::Base
6
+ def self.source_root
7
+ File.join(File.dirname(__FILE__), 'templates')
8
+ end
9
+
10
+ def copy_feature_model_file
11
+ copy_file "feature.rb", "app/models/arturo/feature.rb"
12
+ end
13
+ end
14
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'rails/generators'
2
3
 
3
4
  module Arturo
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rails/generators'
2
4
  require 'rails/generators/migration'
5
+ require 'rails/generators/active_record'
3
6
 
4
7
  module Arturo
5
8
  class MigrationGenerator < Rails::Generators::Base
@@ -9,19 +12,16 @@ module Arturo
9
12
  File.join(File.dirname(__FILE__), 'templates')
10
13
  end
11
14
 
12
- # Implement the required interface for Rails::Generators::Migration.
13
- # taken from
14
- # http://github.com/rails/rails/blob/master/activerecord/lib/generators/active_record.rb
15
- def self.next_migration_number(dirname) #:nodoc:
16
- if ActiveRecord::Base.timestamped_migrations
17
- Time.now.utc.strftime("%Y%m%d%H%M%S")
18
- else
19
- "%.3d" % (current_migration_number(dirname) + 1)
20
- end
15
+ def self.next_migration_number(dirname)
16
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
21
17
  end
22
18
 
23
19
  def create_migration_file
24
- migration_template 'migration.rb', 'db/migrate/create_features.rb'
20
+ migration_template 'migration.erb', 'db/migrate/create_features.rb', { migration_version: migration_version }
21
+ end
22
+
23
+ def migration_version
24
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
25
25
  end
26
26
  end
27
27
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'rails/generators'
2
3
 
3
4
  module Arturo
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+
5
+ module Arturo
6
+ class Feature < ::ActiveRecord::Base
7
+ include Arturo::FeatureMethods
8
+ end
9
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'arturo'
2
3
  require 'arturo/feature'
3
4
 
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateFeatures < ActiveRecord::Migration<%= migration_version %>
4
+ def self.up
5
+ create_table :features do |t|
6
+ t.string :symbol, null: false
7
+ t.integer :deployment_percentage, null: false
8
+ # Any additional fields here
9
+
10
+ t.timestamps
11
+ end
12
+ end
13
+
14
+ def self.down
15
+ drop_table :features
16
+ end
17
+ end