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.
- checksums.yaml +15 -0
- data/.gitignore +5 -0
- data/.travis.yml +51 -0
- data/Gemfile +20 -0
- data/LICENSE +22 -0
- data/README.md +261 -0
- data/Rakefile +16 -0
- data/app/assets/stylesheets/flipflop.scss +109 -0
- data/app/controllers/concerns/flipflop/environment_filters.rb +5 -0
- data/app/controllers/flipflop/features_controller.rb +59 -0
- data/app/controllers/flipflop/strategies_controller.rb +30 -0
- data/app/models/flipflop/feature.rb +3 -0
- data/app/views/flipflop/features/index.html.erb +60 -0
- data/app/views/layouts/flipflop.html.erb +1 -0
- data/config/routes.rb +5 -0
- data/flipflop.gemspec +23 -0
- data/lib/flipflop/configurable.rb +27 -0
- data/lib/flipflop/engine.rb +58 -0
- data/lib/flipflop/facade.rb +23 -0
- data/lib/flipflop/feature_cache.rb +64 -0
- data/lib/flipflop/feature_definition.rb +15 -0
- data/lib/flipflop/feature_set.rb +99 -0
- data/lib/flipflop/strategies/abstract_strategy.rb +103 -0
- data/lib/flipflop/strategies/active_record_strategy.rb +43 -0
- data/lib/flipflop/strategies/cookie_strategy.rb +44 -0
- data/lib/flipflop/strategies/default_strategy.rb +15 -0
- data/lib/flipflop/strategies/lambda_strategy.rb +25 -0
- data/lib/flipflop/strategies/query_string_strategy.rb +17 -0
- data/lib/flipflop/strategies/session_strategy.rb +29 -0
- data/lib/flipflop/strategies/test_strategy.rb +40 -0
- data/lib/flipflop/version.rb +3 -0
- data/lib/flipflop.rb +26 -0
- data/lib/generators/flipflop/features/USAGE +8 -0
- data/lib/generators/flipflop/features/features_generator.rb +7 -0
- data/lib/generators/flipflop/features/templates/features.rb +21 -0
- data/lib/generators/flipflop/install/install_generator.rb +21 -0
- data/lib/generators/flipflop/migration/USAGE +5 -0
- data/lib/generators/flipflop/migration/migration_generator.rb +23 -0
- data/lib/generators/flipflop/migration/templates/create_features.rb +10 -0
- data/lib/generators/flipflop/routes/USAGE +7 -0
- data/lib/generators/flipflop/routes/routes_generator.rb +5 -0
- data/test/integration/app_test.rb +32 -0
- data/test/integration/dashboard_test.rb +162 -0
- data/test/test_helper.rb +96 -0
- data/test/unit/configurable_test.rb +104 -0
- data/test/unit/feature_cache_test.rb +142 -0
- data/test/unit/feature_definition_test.rb +42 -0
- data/test/unit/feature_set_test.rb +136 -0
- data/test/unit/flipflop_test.rb +99 -0
- data/test/unit/strategies/abstract_strategy_request_test.rb +42 -0
- data/test/unit/strategies/abstract_strategy_test.rb +124 -0
- data/test/unit/strategies/active_record_strategy_test.rb +157 -0
- data/test/unit/strategies/cookie_strategy_test.rb +126 -0
- data/test/unit/strategies/default_strategy_test.rb +44 -0
- data/test/unit/strategies/lambda_strategy_test.rb +137 -0
- data/test/unit/strategies/query_string_strategy_test.rb +70 -0
- data/test/unit/strategies/session_strategy_test.rb +101 -0
- data/test/unit/strategies/test_strategy_test.rb +76 -0
- 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
|
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
|