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