flipflop 2.0.0

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 (59) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +5 -0
  3. data/.travis.yml +51 -0
  4. data/Gemfile +20 -0
  5. data/LICENSE +22 -0
  6. data/README.md +261 -0
  7. data/Rakefile +16 -0
  8. data/app/assets/stylesheets/flipflop.scss +109 -0
  9. data/app/controllers/concerns/flipflop/environment_filters.rb +5 -0
  10. data/app/controllers/flipflop/features_controller.rb +59 -0
  11. data/app/controllers/flipflop/strategies_controller.rb +30 -0
  12. data/app/models/flipflop/feature.rb +3 -0
  13. data/app/views/flipflop/features/index.html.erb +60 -0
  14. data/app/views/layouts/flipflop.html.erb +1 -0
  15. data/config/routes.rb +5 -0
  16. data/flipflop.gemspec +23 -0
  17. data/lib/flipflop/configurable.rb +27 -0
  18. data/lib/flipflop/engine.rb +58 -0
  19. data/lib/flipflop/facade.rb +23 -0
  20. data/lib/flipflop/feature_cache.rb +64 -0
  21. data/lib/flipflop/feature_definition.rb +15 -0
  22. data/lib/flipflop/feature_set.rb +99 -0
  23. data/lib/flipflop/strategies/abstract_strategy.rb +103 -0
  24. data/lib/flipflop/strategies/active_record_strategy.rb +43 -0
  25. data/lib/flipflop/strategies/cookie_strategy.rb +44 -0
  26. data/lib/flipflop/strategies/default_strategy.rb +15 -0
  27. data/lib/flipflop/strategies/lambda_strategy.rb +25 -0
  28. data/lib/flipflop/strategies/query_string_strategy.rb +17 -0
  29. data/lib/flipflop/strategies/session_strategy.rb +29 -0
  30. data/lib/flipflop/strategies/test_strategy.rb +40 -0
  31. data/lib/flipflop/version.rb +3 -0
  32. data/lib/flipflop.rb +26 -0
  33. data/lib/generators/flipflop/features/USAGE +8 -0
  34. data/lib/generators/flipflop/features/features_generator.rb +7 -0
  35. data/lib/generators/flipflop/features/templates/features.rb +21 -0
  36. data/lib/generators/flipflop/install/install_generator.rb +21 -0
  37. data/lib/generators/flipflop/migration/USAGE +5 -0
  38. data/lib/generators/flipflop/migration/migration_generator.rb +23 -0
  39. data/lib/generators/flipflop/migration/templates/create_features.rb +10 -0
  40. data/lib/generators/flipflop/routes/USAGE +7 -0
  41. data/lib/generators/flipflop/routes/routes_generator.rb +5 -0
  42. data/test/integration/app_test.rb +32 -0
  43. data/test/integration/dashboard_test.rb +162 -0
  44. data/test/test_helper.rb +96 -0
  45. data/test/unit/configurable_test.rb +104 -0
  46. data/test/unit/feature_cache_test.rb +142 -0
  47. data/test/unit/feature_definition_test.rb +42 -0
  48. data/test/unit/feature_set_test.rb +136 -0
  49. data/test/unit/flipflop_test.rb +99 -0
  50. data/test/unit/strategies/abstract_strategy_request_test.rb +42 -0
  51. data/test/unit/strategies/abstract_strategy_test.rb +124 -0
  52. data/test/unit/strategies/active_record_strategy_test.rb +157 -0
  53. data/test/unit/strategies/cookie_strategy_test.rb +126 -0
  54. data/test/unit/strategies/default_strategy_test.rb +44 -0
  55. data/test/unit/strategies/lambda_strategy_test.rb +137 -0
  56. data/test/unit/strategies/query_string_strategy_test.rb +70 -0
  57. data/test/unit/strategies/session_strategy_test.rb +101 -0
  58. data/test/unit/strategies/test_strategy_test.rb +76 -0
  59. metadata +134 -0
data/flipflop.gemspec ADDED
@@ -0,0 +1,23 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "flipflop/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "flipflop"
7
+ s.version = Flipflop::VERSION
8
+ s.platform = Gem::Platform::RUBY
9
+ s.authors = ["Paul Annesley", "Rolf Timmermans", "Jippe Holwerda"]
10
+ s.email = ["paul@annesley.cc", "rolftimmermans@voormedia.com", "jippeholwerda@voormedia.com"]
11
+ s.homepage = "https://github.com/voormedia/flipflop"
12
+ s.summary = %q{A feature flipflopper for Rails web applications.}
13
+ s.description = %q{Declarative API for specifying features, switchable in declaration, database and cookies.}
14
+ s.license = "MIT"
15
+
16
+ s.files = `git ls-files`.split("\n")
17
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
19
+ s.require_paths = ["lib"]
20
+
21
+ s.add_dependency("activesupport", ">= 4.0")
22
+ s.add_dependency("bootstrap", "~> 4.0.0.alpha3")
23
+ end
@@ -0,0 +1,27 @@
1
+ module Flipflop
2
+ module Configurable
3
+ def feature(feature, **options)
4
+ feature = FeatureDefinition.new(feature, **options)
5
+ FeatureSet.current.add(feature)
6
+ end
7
+
8
+ def strategy(strategy = nil, **options)
9
+ if block_given?
10
+ options[:name] = strategy.to_s
11
+ options[:lambda] = Proc.new
12
+ strategy = Strategies::LambdaStrategy
13
+ end
14
+
15
+ if strategy.kind_of?(Symbol)
16
+ name = ActiveSupport::Inflector.camelize(strategy) + "Strategy"
17
+ strategy = Strategies.const_get(name)
18
+ end
19
+
20
+ if strategy.kind_of?(Class)
21
+ strategy = strategy.new(**options)
22
+ end
23
+
24
+ FeatureSet.current.use(strategy)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,58 @@
1
+ module Flipflop
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Flipflop
4
+
5
+ config.app_middleware.insert_after ActionDispatch::Callbacks,
6
+ FeatureCache::Middleware
7
+
8
+ config.flipflop = ActiveSupport::OrderedOptions.new
9
+
10
+ initializer "flipflop.assets" do |app|
11
+ config.assets.precompile += ["flipflop.css"]
12
+ end
13
+
14
+ initializer "flipflop.features_path" do |app|
15
+ app.paths.add("config/features.rb")
16
+ end
17
+
18
+ initializer "flipflop.features_reloader" do |app|
19
+ app.reloaders.push(reloader = feature_reloader(app))
20
+ to_prepare do
21
+ reloader.execute
22
+ end
23
+ end
24
+
25
+ initializer "flipflop.dashboard", after: "flipflop.features_reloader" do |app|
26
+ if actions = config.flipflop.dashboard_access_filter
27
+ to_prepare do
28
+ Flipflop::FeaturesController.before_action(*actions)
29
+ Flipflop::StrategiesController.before_action(*actions)
30
+ end
31
+ else
32
+ unless defined?(Rails::Generators) or defined?(Rake)
33
+ warn("WARNING: You have not set `config.flipflop.dashboard_access_filter`; " +
34
+ "the Flipflop dashboard is now always public!")
35
+ end
36
+ end
37
+ end
38
+
39
+ initializer "flipflop.request_interceptor" do |app|
40
+ interceptor = Strategies::AbstractStrategy::RequestInterceptor
41
+ ActionController::Base.send(:include, interceptor)
42
+ end
43
+
44
+ private
45
+
46
+ def feature_reloader(app)
47
+ features = app.paths["config/features.rb"].existent
48
+ ActiveSupport::FileUpdateChecker.new(features) do
49
+ features.each { |path| load(path) }
50
+ end
51
+ end
52
+
53
+ def to_prepare
54
+ klass = defined?(ActiveSupport::Reloader) ? ActiveSupport::Reloader : ActionDispatch::Reloader
55
+ klass.to_prepare(&Proc.new)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,23 @@
1
+ module Flipflop
2
+ module Facade
3
+ extend Forwardable
4
+ delegate [:configure, :enabled?] => :feature_set
5
+ alias_method :on?, :enabled?
6
+
7
+ def feature_set
8
+ FeatureSet.current
9
+ end
10
+
11
+ def respond_to_missing?(method, include_private = false)
12
+ method[-1] == "?"
13
+ end
14
+
15
+ def method_missing(method, *args)
16
+ if method[-1] == "?"
17
+ FeatureSet.current.enabled?(method[0..-2].to_sym)
18
+ else
19
+ super
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,64 @@
1
+ module Flipflop
2
+ class FeatureCache
3
+ class Middleware
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ return @app.call(env) if FeatureCache.current.enabled?
10
+
11
+ FeatureCache.current.enable!
12
+ response = @app.call(env)
13
+ response[2] = Rack::BodyProxy.new(response[2]) do
14
+ FeatureCache.current.disable!
15
+ end
16
+ response
17
+ rescue Exception => err
18
+ FeatureCache.current.disable!
19
+ raise err
20
+ end
21
+ end
22
+
23
+ class << self
24
+ def current
25
+ Thread.current.thread_variable_get(:flipflop_cache) or
26
+ Thread.current.thread_variable_set(:flipflop_cache, new)
27
+ end
28
+
29
+ private :new
30
+ end
31
+
32
+ def initialize
33
+ @enabled = false
34
+ @cache = {}
35
+ end
36
+
37
+ def enabled?
38
+ @enabled
39
+ end
40
+
41
+ def clear!
42
+ @cache.clear
43
+ end
44
+
45
+ def enable!
46
+ @enabled = true
47
+ end
48
+
49
+ def disable!
50
+ @enabled = false
51
+ @cache.clear
52
+ end
53
+
54
+ def fetch(key)
55
+ if @enabled
56
+ @cache.fetch(key) do
57
+ @cache[key] = yield
58
+ end
59
+ else
60
+ yield
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,15 @@
1
+ module Flipflop
2
+ class FeatureDefinition
3
+ attr_reader :key, :default, :description
4
+
5
+ def initialize(key, **options)
6
+ @key = key
7
+ @default = !!options.delete(:default) || false
8
+ @description = options.delete(:description) || key.to_s.humanize + "."
9
+ end
10
+
11
+ def name
12
+ key.to_s
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,99 @@
1
+ module Flipflop
2
+ class FeatureError < StandardError
3
+ def initialize(feature, error)
4
+ super("Feature '#{feature}' #{error}.")
5
+ end
6
+ end
7
+
8
+ class StrategyError < StandardError
9
+ def initialize(strategy, error)
10
+ super("Strategy '#{strategy}' #{error}.")
11
+ end
12
+ end
13
+
14
+ class FeatureSet
15
+ @@lock = Monitor.new
16
+
17
+ class << self
18
+ def current
19
+ @current or @@lock.synchronize { @current ||= new }
20
+ end
21
+
22
+ private :new
23
+ end
24
+
25
+ def initialize
26
+ @features = {}
27
+ @strategies = {}
28
+ end
29
+
30
+ def configure
31
+ @@lock.synchronize do
32
+ initialize
33
+ Module.new do
34
+ extend Configurable
35
+ instance_exec(&Proc.new)
36
+ end
37
+ @features.freeze
38
+ @strategies.freeze
39
+ end
40
+ self
41
+ end
42
+
43
+ def reset!
44
+ @@lock.synchronize do
45
+ initialize
46
+ end
47
+ self
48
+ end
49
+
50
+ def test!(strategy = Strategies::TestStrategy.new)
51
+ @@lock.synchronize do
52
+ @strategies = { strategy.key => strategy.freeze }.freeze
53
+ end
54
+ strategy
55
+ end
56
+
57
+ def add(feature)
58
+ @@lock.synchronize do
59
+ @features[feature.key] = feature.freeze
60
+ end
61
+ end
62
+
63
+ def use(strategy)
64
+ @@lock.synchronize do
65
+ @strategies[strategy.key] = strategy.freeze
66
+ end
67
+ end
68
+
69
+ def enabled?(feature)
70
+ FeatureCache.current.fetch(feature) do
71
+ result = @strategies.each_value.inject(nil) do |status, strategy|
72
+ break status unless status.nil?
73
+ strategy.enabled?(feature)
74
+ end
75
+ result.nil? ? feature(feature).default : result
76
+ end
77
+ end
78
+
79
+ def feature(feature)
80
+ @features.fetch(feature) do
81
+ raise FeatureError.new(feature, "unknown")
82
+ end
83
+ end
84
+
85
+ def features
86
+ @features.values
87
+ end
88
+
89
+ def strategy(strategy)
90
+ @strategies.fetch(strategy) do
91
+ raise StrategyError.new(strategy, "unknown")
92
+ end
93
+ end
94
+
95
+ def strategies
96
+ @strategies.values
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,103 @@
1
+ module Flipflop
2
+ module Strategies
3
+ class AbstractStrategy
4
+ module RequestInterceptor
5
+ class << self
6
+ def request
7
+ Thread.current.thread_variable_get(:flipflop_request)
8
+ end
9
+
10
+ def request=(request)
11
+ Thread.current.thread_variable_set(:flipflop_request, request)
12
+ end
13
+ end
14
+
15
+ extend ActiveSupport::Concern
16
+
17
+ included do
18
+ before_action do
19
+ RequestInterceptor.request = request
20
+ end
21
+
22
+ after_action do
23
+ RequestInterceptor.request = nil
24
+ end
25
+ end
26
+ end
27
+
28
+ class << self
29
+ def default_name
30
+ return "anonymous" unless name
31
+ name.split("::").last.gsub(/Strategy$/, "").underscore
32
+ end
33
+
34
+ def default_description
35
+ end
36
+ end
37
+
38
+ attr_reader :name, :description
39
+
40
+ def initialize(**options)
41
+ @name = (options.delete(:name) || self.class.default_name).freeze
42
+ @description = (options.delete(:description) || self.class.default_description).freeze
43
+ @hidden = !!options.delete(:hidden) || false
44
+ if options.any?
45
+ raise StrategyError.new(name, "did not understand option #{options.keys.map(&:inspect) * ', '}")
46
+ end
47
+ end
48
+
49
+ def hidden?
50
+ @hidden
51
+ end
52
+
53
+ def key
54
+ # TODO: Object ID changes if the feature definitions are reloaded. Maybe
55
+ # we can use the index instead?
56
+ object_id.to_s
57
+ end
58
+
59
+ # Return true iff this strategy is able to switch features on/off.
60
+ # Return false otherwise.
61
+ def switchable?
62
+ false
63
+ end
64
+
65
+ # Return true iff the given feature symbol is explicitly enabled.
66
+ # Return false iff the given feature symbol is explicitly disabled.
67
+ # Return nil iff the given feature symbol is unknown by this strategy.
68
+ def enabled?(feature)
69
+ raise NotImplementedError
70
+ end
71
+
72
+ # Enable/disable (true/false) the given feature symbol explicitly.
73
+ def switch!(feature, enabled)
74
+ raise NotImplementedError
75
+ end
76
+
77
+ # Remove the feature symbol from this strategy. It should no longer be
78
+ # recognized afterwards: enabled?(feature) will return nil.
79
+ def clear!(feature)
80
+ raise NotImplementedError
81
+ end
82
+
83
+ # Optional. Remove all features, so that no feature is known.
84
+ def reset!
85
+ raise NotImplementedError
86
+ end
87
+
88
+ protected
89
+
90
+ # Returns the request. Raises if no request is available, for example if
91
+ # the strategy was used outside of a request context.
92
+ def request
93
+ RequestInterceptor.request or
94
+ raise StrategyError.new(name, "required request, but was used outside request context")
95
+ end
96
+
97
+ # Returns true iff a request is available.
98
+ def request?
99
+ !RequestInterceptor.request.nil?
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,43 @@
1
+ module Flipflop
2
+ module Strategies
3
+ class ActiveRecordStrategy < AbstractStrategy
4
+ class << self
5
+ def default_description
6
+ "Stores features in database. Applies to all users."
7
+ end
8
+ end
9
+
10
+ def initialize(**options)
11
+ @class = options.delete(:class) || "Flipflop::Feature"
12
+ if !@class.kind_of?(Class)
13
+ @class = ActiveSupport::Inflector.constantize(@class.to_s)
14
+ end
15
+ super(**options)
16
+ end
17
+
18
+ def switchable?
19
+ true
20
+ end
21
+
22
+ def enabled?(feature)
23
+ find(feature).first.try(:enabled?)
24
+ end
25
+
26
+ def switch!(feature, enabled)
27
+ record = find(feature).first_or_initialize
28
+ record.enabled = enabled
29
+ record.save!
30
+ end
31
+
32
+ def clear!(feature)
33
+ find(feature).first.try(:destroy)
34
+ end
35
+
36
+ protected
37
+
38
+ def find(feature)
39
+ @class.where(key: feature.to_s)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,44 @@
1
+ module Flipflop
2
+ module Strategies
3
+ class CookieStrategy < AbstractStrategy
4
+ class << self
5
+ def default_description
6
+ "Stores features in a browser cookie. Applies to current user."
7
+ end
8
+ end
9
+
10
+ def initialize(**options)
11
+ # TODO: Support :expires as a runtime-evaluated option?
12
+ @options = options.extract!(:path, :domain, :secure, :httponly).freeze
13
+ super(**options)
14
+ end
15
+
16
+ def switchable?
17
+ request?
18
+ end
19
+
20
+ def enabled?(feature)
21
+ return unless request?
22
+ return unless request.cookie_jar.has_key?(cookie_name(feature))
23
+ cookie = request.cookie_jar[cookie_name(feature)]
24
+ cookie_value = cookie.is_a?(Hash) ? cookie["value"] : cookie
25
+ cookie_value === "1"
26
+ end
27
+
28
+ def switch!(feature, enabled)
29
+ value = @options.merge(value: enabled ? "1" : "0")
30
+ request.cookie_jar[cookie_name(feature)] = value
31
+ end
32
+
33
+ def clear!(feature)
34
+ request.cookie_jar.delete(cookie_name(feature), **@options)
35
+ end
36
+
37
+ protected
38
+
39
+ def cookie_name(feature)
40
+ :"flipflop_#{feature}"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,15 @@
1
+ module Flipflop
2
+ module Strategies
3
+ class DefaultStrategy < AbstractStrategy
4
+ class << self
5
+ def default_description
6
+ "Uses feature default status."
7
+ end
8
+ end
9
+
10
+ def enabled?(feature)
11
+ FeatureSet.current.feature(feature).default
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,25 @@
1
+ module Flipflop
2
+ module Strategies
3
+ class LambdaStrategy < AbstractStrategy
4
+ class << self
5
+ def default_description
6
+ "Resolves feature settings with custom code."
7
+ end
8
+ end
9
+
10
+ def initialize(**options)
11
+ @lambda = (options.delete(:lambda) || ->(*) { }).freeze
12
+ super(**options)
13
+ if @lambda.arity.abs != 1
14
+ raise StrategyError.new(name, "has lambda with arity #{@lambda.arity}, expected 1 or -1")
15
+ end
16
+ end
17
+
18
+ def enabled?(feature)
19
+ result = instance_exec(feature, &@lambda)
20
+ return result if result.nil? or result == !!result
21
+ raise StrategyError.new(name, "returned invalid result #{result.inspect} for feature '#{feature}'")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ module Flipflop
2
+ module Strategies
3
+ class QueryStringStrategy < AbstractStrategy
4
+ class << self
5
+ def default_description
6
+ "Interprets query string parameters as features."
7
+ end
8
+ end
9
+
10
+ def enabled?(feature)
11
+ return unless request?
12
+ return unless request.params.has_key?(feature)
13
+ request.params[feature].to_s != "0"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,29 @@
1
+ module Flipflop
2
+ module Strategies
3
+ class SessionStrategy < AbstractStrategy
4
+ class << self
5
+ def default_description
6
+ "Stores features in the user session. Applies to current user."
7
+ end
8
+ end
9
+
10
+ def switchable?
11
+ request?
12
+ end
13
+
14
+ def enabled?(feature)
15
+ return unless request?
16
+ return unless request.session.has_key?(feature)
17
+ request.session[feature]
18
+ end
19
+
20
+ def switch!(feature, enabled)
21
+ request.session[feature] = enabled
22
+ end
23
+
24
+ def clear!(feature)
25
+ request.session.delete(feature)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,40 @@
1
+ module Flipflop
2
+ module Strategies
3
+ class TestStrategy < AbstractStrategy
4
+ @@lock = Mutex.new
5
+
6
+ def initialize(**options)
7
+ @features = {}
8
+ super(**options)
9
+ end
10
+
11
+ def switchable?
12
+ true
13
+ end
14
+
15
+ def enabled?(feature)
16
+ @@lock.synchronize do
17
+ @features[feature]
18
+ end
19
+ end
20
+
21
+ def switch!(feature, enabled)
22
+ @@lock.synchronize do
23
+ @features[feature] = enabled
24
+ end
25
+ end
26
+
27
+ def clear!(feature)
28
+ @@lock.synchronize do
29
+ @features.delete(feature)
30
+ end
31
+ end
32
+
33
+ def reset!
34
+ @@lock.synchronize do
35
+ @features.clear
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,3 @@
1
+ module Flipflop
2
+ VERSION = "2.0.0"
3
+ end
data/lib/flipflop.rb ADDED
@@ -0,0 +1,26 @@
1
+ require "active_support/concern"
2
+ require "active_support/core_ext/hash/slice"
3
+ require "active_support/core_ext/object/blank"
4
+ require "active_support/core_ext/object/try"
5
+ require "active_support/inflector"
6
+
7
+ require "flipflop/configurable"
8
+ require "flipflop/facade"
9
+ require "flipflop/feature_cache"
10
+ require "flipflop/feature_definition"
11
+ require "flipflop/feature_set"
12
+
13
+ require "flipflop/strategies/abstract_strategy"
14
+ require "flipflop/strategies/active_record_strategy"
15
+ require "flipflop/strategies/cookie_strategy"
16
+ require "flipflop/strategies/default_strategy"
17
+ require "flipflop/strategies/lambda_strategy"
18
+ require "flipflop/strategies/query_string_strategy"
19
+ require "flipflop/strategies/session_strategy"
20
+ require "flipflop/strategies/test_strategy"
21
+
22
+ require "flipflop/engine" if defined?(Rails)
23
+
24
+ module Flipflop
25
+ extend Facade
26
+ end
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Generates a Flipflop features configuration file for your application.
3
+
4
+ Example:
5
+ rails generate flipflop:features
6
+
7
+ This will create:
8
+ config/features.rb
@@ -0,0 +1,7 @@
1
+ class Flipflop::FeaturesGenerator < Rails::Generators::Base
2
+ source_root File.expand_path("../templates", __FILE__)
3
+
4
+ def copy_features_file
5
+ copy_file "features.rb", "config/features.rb"
6
+ end
7
+ end