flipflop 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
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