flipper 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (140) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.github/workflows/ci.yml +7 -3
  4. data/.github/workflows/examples.yml +27 -5
  5. data/Changelog.md +42 -0
  6. data/Gemfile +4 -4
  7. data/README.md +13 -11
  8. data/benchmark/typecast_ips.rb +8 -0
  9. data/docs/images/flipper_cloud.png +0 -0
  10. data/examples/cloud/backoff_policy.rb +13 -0
  11. data/examples/cloud/cloud_setup.rb +16 -0
  12. data/examples/cloud/forked.rb +7 -2
  13. data/examples/cloud/threaded.rb +15 -18
  14. data/examples/expressions.rb +213 -0
  15. data/examples/strict.rb +18 -0
  16. data/flipper.gemspec +1 -2
  17. data/lib/flipper/actor.rb +6 -3
  18. data/lib/flipper/adapter.rb +10 -0
  19. data/lib/flipper/adapter_builder.rb +44 -0
  20. data/lib/flipper/adapters/dual_write.rb +1 -3
  21. data/lib/flipper/adapters/failover.rb +0 -4
  22. data/lib/flipper/adapters/failsafe.rb +0 -4
  23. data/lib/flipper/adapters/http/client.rb +26 -7
  24. data/lib/flipper/adapters/http/error.rb +1 -1
  25. data/lib/flipper/adapters/http.rb +18 -13
  26. data/lib/flipper/adapters/instrumented.rb +0 -4
  27. data/lib/flipper/adapters/memoizable.rb +14 -19
  28. data/lib/flipper/adapters/memory.rb +4 -6
  29. data/lib/flipper/adapters/operation_logger.rb +0 -4
  30. data/lib/flipper/adapters/poll.rb +1 -3
  31. data/lib/flipper/adapters/pstore.rb +17 -11
  32. data/lib/flipper/adapters/read_only.rb +4 -4
  33. data/lib/flipper/adapters/strict.rb +47 -0
  34. data/lib/flipper/adapters/sync/feature_synchronizer.rb +10 -1
  35. data/lib/flipper/adapters/sync.rb +0 -4
  36. data/lib/flipper/cloud/configuration.rb +121 -52
  37. data/lib/flipper/cloud/telemetry/backoff_policy.rb +93 -0
  38. data/lib/flipper/cloud/telemetry/instrumenter.rb +26 -0
  39. data/lib/flipper/cloud/telemetry/metric.rb +39 -0
  40. data/lib/flipper/cloud/telemetry/metric_storage.rb +30 -0
  41. data/lib/flipper/cloud/telemetry/submitter.rb +98 -0
  42. data/lib/flipper/cloud/telemetry.rb +183 -0
  43. data/lib/flipper/configuration.rb +25 -4
  44. data/lib/flipper/dsl.rb +51 -0
  45. data/lib/flipper/engine.rb +28 -3
  46. data/lib/flipper/exporters/json/export.rb +1 -1
  47. data/lib/flipper/exporters/json/v1.rb +1 -1
  48. data/lib/flipper/expression/builder.rb +73 -0
  49. data/lib/flipper/expression/constant.rb +25 -0
  50. data/lib/flipper/expression.rb +71 -0
  51. data/lib/flipper/expressions/all.rb +11 -0
  52. data/lib/flipper/expressions/any.rb +9 -0
  53. data/lib/flipper/expressions/boolean.rb +9 -0
  54. data/lib/flipper/expressions/comparable.rb +13 -0
  55. data/lib/flipper/expressions/duration.rb +28 -0
  56. data/lib/flipper/expressions/equal.rb +9 -0
  57. data/lib/flipper/expressions/greater_than.rb +9 -0
  58. data/lib/flipper/expressions/greater_than_or_equal_to.rb +9 -0
  59. data/lib/flipper/expressions/less_than.rb +9 -0
  60. data/lib/flipper/expressions/less_than_or_equal_to.rb +9 -0
  61. data/lib/flipper/expressions/not_equal.rb +9 -0
  62. data/lib/flipper/expressions/now.rb +9 -0
  63. data/lib/flipper/expressions/number.rb +9 -0
  64. data/lib/flipper/expressions/percentage.rb +9 -0
  65. data/lib/flipper/expressions/percentage_of_actors.rb +12 -0
  66. data/lib/flipper/expressions/property.rb +9 -0
  67. data/lib/flipper/expressions/random.rb +9 -0
  68. data/lib/flipper/expressions/string.rb +9 -0
  69. data/lib/flipper/expressions/time.rb +9 -0
  70. data/lib/flipper/feature.rb +55 -0
  71. data/lib/flipper/gate.rb +1 -0
  72. data/lib/flipper/gate_values.rb +5 -2
  73. data/lib/flipper/gates/expression.rb +75 -0
  74. data/lib/flipper/instrumentation/statsd_subscriber.rb +2 -4
  75. data/lib/flipper/middleware/memoizer.rb +29 -13
  76. data/lib/flipper/poller.rb +1 -1
  77. data/lib/flipper/serializers/gzip.rb +24 -0
  78. data/lib/flipper/serializers/json.rb +19 -0
  79. data/lib/flipper/spec/shared_adapter_specs.rb +29 -11
  80. data/lib/flipper/test/shared_adapter_test.rb +24 -5
  81. data/lib/flipper/typecast.rb +34 -6
  82. data/lib/flipper/types/percentage.rb +1 -1
  83. data/lib/flipper/version.rb +1 -1
  84. data/lib/flipper.rb +38 -1
  85. data/spec/flipper/adapter_builder_spec.rb +73 -0
  86. data/spec/flipper/adapter_spec.rb +1 -0
  87. data/spec/flipper/adapters/http_spec.rb +39 -5
  88. data/spec/flipper/adapters/memoizable_spec.rb +15 -15
  89. data/spec/flipper/adapters/read_only_spec.rb +26 -11
  90. data/spec/flipper/adapters/strict_spec.rb +62 -0
  91. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +27 -0
  92. data/spec/flipper/cloud/configuration_spec.rb +6 -23
  93. data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +108 -0
  94. data/spec/flipper/cloud/telemetry/metric_spec.rb +87 -0
  95. data/spec/flipper/cloud/telemetry/metric_storage_spec.rb +58 -0
  96. data/spec/flipper/cloud/telemetry/submitter_spec.rb +145 -0
  97. data/spec/flipper/cloud/telemetry_spec.rb +156 -0
  98. data/spec/flipper/cloud_spec.rb +12 -12
  99. data/spec/flipper/configuration_spec.rb +17 -0
  100. data/spec/flipper/dsl_spec.rb +39 -0
  101. data/spec/flipper/engine_spec.rb +108 -7
  102. data/spec/flipper/exporters/json/v1_spec.rb +3 -3
  103. data/spec/flipper/expression/builder_spec.rb +248 -0
  104. data/spec/flipper/expression_spec.rb +188 -0
  105. data/spec/flipper/expressions/all_spec.rb +15 -0
  106. data/spec/flipper/expressions/any_spec.rb +15 -0
  107. data/spec/flipper/expressions/boolean_spec.rb +15 -0
  108. data/spec/flipper/expressions/duration_spec.rb +43 -0
  109. data/spec/flipper/expressions/equal_spec.rb +24 -0
  110. data/spec/flipper/expressions/greater_than_or_equal_to_spec.rb +28 -0
  111. data/spec/flipper/expressions/greater_than_spec.rb +28 -0
  112. data/spec/flipper/expressions/less_than_or_equal_to_spec.rb +28 -0
  113. data/spec/flipper/expressions/less_than_spec.rb +32 -0
  114. data/spec/flipper/expressions/not_equal_spec.rb +15 -0
  115. data/spec/flipper/expressions/now_spec.rb +11 -0
  116. data/spec/flipper/expressions/number_spec.rb +21 -0
  117. data/spec/flipper/expressions/percentage_of_actors_spec.rb +20 -0
  118. data/spec/flipper/expressions/percentage_spec.rb +15 -0
  119. data/spec/flipper/expressions/property_spec.rb +13 -0
  120. data/spec/flipper/expressions/random_spec.rb +9 -0
  121. data/spec/flipper/expressions/string_spec.rb +11 -0
  122. data/spec/flipper/expressions/time_spec.rb +13 -0
  123. data/spec/flipper/feature_spec.rb +360 -1
  124. data/spec/flipper/gate_values_spec.rb +2 -2
  125. data/spec/flipper/gates/expression_spec.rb +108 -0
  126. data/spec/flipper/identifier_spec.rb +4 -5
  127. data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +15 -1
  128. data/spec/flipper/middleware/memoizer_spec.rb +67 -0
  129. data/spec/flipper/serializers/gzip_spec.rb +13 -0
  130. data/spec/flipper/serializers/json_spec.rb +13 -0
  131. data/spec/flipper/typecast_spec.rb +43 -7
  132. data/spec/flipper/types/actor_spec.rb +18 -1
  133. data/spec/flipper_integration_spec.rb +102 -4
  134. data/spec/flipper_spec.rb +89 -1
  135. data/spec/spec_helper.rb +5 -0
  136. data/spec/support/actor_names.yml +1 -0
  137. data/spec/support/fake_backoff_policy.rb +15 -0
  138. data/spec/support/spec_helpers.rb +11 -3
  139. metadata +104 -18
  140. data/lib/flipper/cloud/instrumenter.rb +0 -48
@@ -0,0 +1,183 @@
1
+ require "concurrent/map"
2
+ require "concurrent/timer_task"
3
+ require "concurrent/executor/fixed_thread_pool"
4
+ require "flipper/typecast"
5
+ require "flipper/cloud/telemetry/metric"
6
+ require "flipper/cloud/telemetry/metric_storage"
7
+ require "flipper/cloud/telemetry/submitter"
8
+
9
+ module Flipper
10
+ module Cloud
11
+ class Telemetry
12
+ # Internal: Map of instances of telemetry.
13
+ def self.instances
14
+ @instances ||= Concurrent::Map.new
15
+ end
16
+ private_class_method :instances
17
+
18
+ def self.reset
19
+ instances.each { |_, instance| instance.stop }.clear
20
+ end
21
+
22
+ # Internal: Fetch an instance of telemetry once per process per url +
23
+ # token (aka cloud endpoint). Should only ever be one instance unless you
24
+ # are doing some funky stuff.
25
+ def self.instance_for(cloud_configuration)
26
+ instances.compute_if_absent(cloud_configuration.url + cloud_configuration.token) do
27
+ new(cloud_configuration)
28
+ end
29
+ end
30
+
31
+ # Public: The cloud configuration to use for this telemetry instance.
32
+ attr_reader :cloud_configuration
33
+
34
+ # Internal: Where the metrics are stored between cloud submissions.
35
+ attr_reader :metric_storage
36
+
37
+ # Internal: The pool of background threads that submits metrics to cloud.
38
+ attr_reader :pool
39
+
40
+ # Internal: The timer that triggers draining the metrics to the pool.
41
+ attr_reader :timer
42
+
43
+ # Internal: The interval in seconds for how often telemetry should be sent to cloud.
44
+ attr_reader :interval
45
+
46
+ # Internal: The timeout in seconds for how long to wait for the pool to shutdown.
47
+ attr_reader :shutdown_timeout
48
+
49
+ # Internal: The proc that is called to submit metrics to cloud.
50
+ attr_accessor :submitter
51
+
52
+ def initialize(cloud_configuration)
53
+ @pid = $$
54
+ @cloud_configuration = cloud_configuration
55
+ self.interval = ENV.fetch("FLIPPER_TELEMETRY_INTERVAL", 60).to_f
56
+ self.shutdown_timeout = ENV.fetch("FLIPPER_TELEMETRY_SHUTDOWN_TIMEOUT", 5).to_f
57
+ self.submitter = ->(drained) { Submitter.new(@cloud_configuration).call(drained) }
58
+ start
59
+ at_exit { stop }
60
+ end
61
+
62
+ # Public: Records telemetry events based on active support notifications.
63
+ def record(name, payload)
64
+ return unless name == Flipper::Feature::InstrumentationName
65
+ return unless payload[:operation] == :enabled?
66
+ detect_forking
67
+
68
+ metric = Metric.new(payload[:feature_name].to_s.freeze, payload[:result])
69
+ @metric_storage.increment metric
70
+ end
71
+
72
+ # Public: Start all the tasks and setup new metric storage.
73
+ def start
74
+ info "action=start"
75
+
76
+ @metric_storage = MetricStorage.new
77
+
78
+ @pool = Concurrent::FixedThreadPool.new(2, {
79
+ max_queue: 5,
80
+ fallback_policy: :discard,
81
+ name: "flipper-telemetry-post-to-cloud-pool".freeze,
82
+ })
83
+
84
+ @timer = Concurrent::TimerTask.execute({
85
+ execution_interval: interval,
86
+ name: "flipper-telemetry-post-to-pool-timer".freeze,
87
+ }) { post_to_pool }
88
+ end
89
+
90
+ # Public: Shuts down all the tasks and tries to flush any remaining info to Cloud.
91
+ def stop
92
+ info "action=stop"
93
+
94
+ if @timer
95
+ debug "action=timer_shutdown_start"
96
+ @timer.shutdown
97
+ # no need to wait long for timer, all it does is drain in memory metric
98
+ # storage and post to the pool of background workers
99
+ timer_termination_result = @timer.wait_for_termination(1)
100
+ @timer.kill unless timer_termination_result
101
+ debug "action=timer_shutdown_end result=#{timer_termination_result}"
102
+ end
103
+
104
+ if @pool
105
+ post_to_pool # one last drain
106
+ debug "action=pool_shutdown_start"
107
+ @pool.shutdown
108
+ pool_termination_result = @pool.wait_for_termination(@shutdown_timeout)
109
+ @pool.kill unless pool_termination_result
110
+ debug "action=pool_shutdown_end result=#{pool_termination_result}"
111
+ end
112
+ end
113
+
114
+ # Public: Restart all the tasks and reset the storage.
115
+ def restart
116
+ stop
117
+ start
118
+ end
119
+
120
+ # Internal: Sets the interval in seconds for how often telemetry should be sent to cloud.
121
+ def interval=(value)
122
+ new_interval = [Typecast.to_float(value), 10].max
123
+ @timer&.execution_interval = new_interval
124
+ @interval = new_interval
125
+ end
126
+
127
+ # Internal: Sets the timeout in seconds for how long to wait for the pool to shutdown.
128
+ def shutdown_timeout=(value)
129
+ new_shutdown_timeout = [Typecast.to_float(value), 0.1].max
130
+ @shutdown_timeout = new_shutdown_timeout
131
+ end
132
+
133
+ private
134
+
135
+ def detect_forking
136
+ if @pid != $$
137
+ info "action=fork_detected pid_was#{@pid} pid_is=#{$$}"
138
+ restart
139
+ @pid = $$
140
+ end
141
+ end
142
+
143
+ # Drains the metric storage and enqueues the metrics to be posted to cloud.
144
+ def post_to_pool
145
+ drained = @metric_storage.drain
146
+ return if drained.empty?
147
+ debug "action=post_to_pool metrics=#{drained.size}"
148
+ @pool.post { post_to_cloud(drained) }
149
+ rescue => error
150
+ error "action=post_to_pool error=#{error.inspect}"
151
+ end
152
+
153
+ # Posts the drained metrics to cloud.
154
+ def post_to_cloud(drained)
155
+ debug "action=post_to_cloud metrics=#{drained.size}"
156
+ response, error = submitter.call(drained)
157
+ debug "action=post_to_cloud response=#{response.inspect} body=#{response&.body.inspect} error=#{error.inspect}"
158
+
159
+ # Some of the errors are response code errors which have a response and
160
+ # thus may have a telemetry-interval header for us to respect.
161
+ response ||= error.response if error && error.respond_to?(:response)
162
+
163
+ if response && interval = response["telemetry-interval"]
164
+ self.interval = interval.to_f
165
+ end
166
+ rescue => error
167
+ error "action=post_to_cloud error=#{error.inspect}"
168
+ end
169
+
170
+ def error(message)
171
+ @cloud_configuration.log message, level: :error
172
+ end
173
+
174
+ def info(message)
175
+ @cloud_configuration.log message, level: :info
176
+ end
177
+
178
+ def debug(message)
179
+ @cloud_configuration.log message
180
+ end
181
+ end
182
+ end
183
+ end
@@ -1,8 +1,8 @@
1
1
  module Flipper
2
2
  class Configuration
3
3
  def initialize(options = {})
4
- @default = -> { Flipper.new(adapter) }
5
- @adapter = -> { Flipper::Adapters::Memory.new }
4
+ @builder = AdapterBuilder.new { store Flipper::Adapters::Memory }
5
+ @default = -> { Flipper.new(@builder.to_adapter) }
6
6
  end
7
7
 
8
8
  # The default adapter to use.
@@ -24,9 +24,20 @@ module Flipper
24
24
  #
25
25
  def adapter(&block)
26
26
  if block_given?
27
- @adapter = block
27
+ @builder.store(block)
28
28
  else
29
- @adapter.call
29
+ @builder.to_adapter
30
+ end
31
+ end
32
+
33
+ # An adapter to use to augment the primary storage adapter. See `AdapterBuilder#use`
34
+ if RUBY_VERSION >= '3.0'
35
+ def use(klass, *args, **kwargs, &block)
36
+ @builder.use(klass, *args, **kwargs, &block)
37
+ end
38
+ else
39
+ def use(klass, *args, &block)
40
+ @builder.use(klass, *args, &block)
30
41
  end
31
42
  end
32
43
 
@@ -54,5 +65,15 @@ module Flipper
54
65
  @default.call
55
66
  end
56
67
  end
68
+
69
+ def statsd
70
+ require 'flipper/instrumentation/statsd_subscriber'
71
+ Flipper::Instrumentation::StatsdSubscriber.client
72
+ end
73
+
74
+ def statsd=(client)
75
+ require "flipper/instrumentation/statsd"
76
+ Flipper::Instrumentation::StatsdSubscriber.client = client
77
+ end
57
78
  end
58
79
  end
data/lib/flipper/dsl.rb CHANGED
@@ -46,6 +46,25 @@ module Flipper
46
46
  feature(name).enable(*args)
47
47
  end
48
48
 
49
+ # Public: Enable a feature for an expression.
50
+ #
51
+ # name - The String or Symbol name of the feature.
52
+ # expression - a Flipper::Expression instance or a Hash.
53
+ #
54
+ # Returns result of Feature#enable.
55
+ def enable_expression(name, expression)
56
+ feature(name).enable_expression(expression)
57
+ end
58
+
59
+ # Public: Add an expression to a feature.
60
+ #
61
+ # expression - an expression or Hash that can be converted to an expression.
62
+ #
63
+ # Returns result of enable.
64
+ def add_expression(name, expression)
65
+ feature(name).add_expression(expression)
66
+ end
67
+
49
68
  # Public: Enable a feature for an actor.
50
69
  #
51
70
  # name - The String or Symbol name of the feature.
@@ -100,6 +119,24 @@ module Flipper
100
119
  feature(name).disable(*args)
101
120
  end
102
121
 
122
+ # Public: Disable expression for feature.
123
+ #
124
+ # name - The String or Symbol name of the feature.
125
+ #
126
+ # Returns result of Feature#disable.
127
+ def disable_expression(name)
128
+ feature(name).disable_expression
129
+ end
130
+
131
+ # Public: Remove an expression from a feature.
132
+ #
133
+ # expression - an Expression or Hash that can be converted to an expression.
134
+ #
135
+ # Returns result of enable.
136
+ def remove_expression(name, expression)
137
+ feature(name).remove_expression(expression)
138
+ end
139
+
103
140
  # Public: Disable a feature for an actor.
104
141
  #
105
142
  # name - The String or Symbol name of the feature.
@@ -219,6 +256,15 @@ module Flipper
219
256
  Flipper.group(name)
220
257
  end
221
258
 
259
+ # Public: Gets the expression for the feature.
260
+ #
261
+ # name - The String or Symbol name of the feature.
262
+ #
263
+ # Returns an instance of Flipper::Expression.
264
+ def expression(name)
265
+ feature(name).expression
266
+ end
267
+
222
268
  # Public: Returns a Set of the known features for this adapter.
223
269
  #
224
270
  # Returns Set of Flipper::Feature instances.
@@ -226,6 +272,11 @@ module Flipper
226
272
  adapter.features.map { |name| feature(name) }.to_set
227
273
  end
228
274
 
275
+ # Public: Does this adapter support writes or not.
276
+ def read_only?
277
+ adapter.read_only?
278
+ end
279
+
229
280
  # Cloud DSL method that does nothing for open source version.
230
281
  def sync
231
282
  end
@@ -9,20 +9,31 @@ module Flipper
9
9
  preload: ENV.fetch('FLIPPER_PRELOAD', 'true').casecmp('true').zero?,
10
10
  instrumenter: ENV.fetch('FLIPPER_INSTRUMENTER', 'ActiveSupport::Notifications').constantize,
11
11
  log: ENV.fetch('FLIPPER_LOG', 'true').casecmp('true').zero?,
12
- cloud_path: "_flipper"
12
+ cloud_path: "_flipper",
13
+ strict: default_strict_value
13
14
  )
14
15
  end
15
16
 
16
- initializer "flipper.identifier" do
17
+ initializer "flipper.properties" do
18
+ require "flipper/model/active_record"
19
+
17
20
  ActiveSupport.on_load(:active_record) do
18
- ActiveRecord::Base.include Flipper::Identifier
21
+ ActiveRecord::Base.include Flipper::Model::ActiveRecord
19
22
  end
20
23
  end
21
24
 
22
25
  initializer "flipper.default", before: :load_config_initializers do |app|
26
+ # Load cloud secrets from Rails credentials
27
+ ENV["FLIPPER_CLOUD_TOKEN"] ||= app.credentials.dig(:flipper, :cloud_token)
28
+ ENV["FLIPPER_CLOUD_SYNC_SECRET"] ||= app.credentials.dig(:flipper, :cloud_sync_secret)
29
+
23
30
  require 'flipper/cloud' if cloud?
24
31
 
25
32
  Flipper.configure do |config|
33
+ if app.config.flipper.strict
34
+ config.use Flipper::Adapters::Strict, app.config.flipper.strict
35
+ end
36
+
26
37
  config.default do
27
38
  if cloud?
28
39
  Flipper::Cloud.new(
@@ -59,5 +70,19 @@ module Flipper
59
70
  def cloud?
60
71
  !!ENV["FLIPPER_CLOUD_TOKEN"]
61
72
  end
73
+
74
+ def default_strict_value
75
+ value = ENV["FLIPPER_STRICT"]
76
+ if value.in?(["warn", "raise", "noop"])
77
+ value.to_sym
78
+ elsif value
79
+ Typecast.to_boolean(value) ? :raise : false
80
+ elsif Rails.env.production?
81
+ false
82
+ else
83
+ # Warn for now. Future versions will default to :raise in development and test
84
+ :warn
85
+ end
86
+ end
62
87
  end
63
88
  end
@@ -18,7 +18,7 @@ module Flipper
18
18
  # Public: The features hash identical to calling get_all on adapter.
19
19
  def features
20
20
  @features ||= begin
21
- features = JSON.parse(contents).fetch("features")
21
+ features = Typecast.from_json(contents).fetch("features")
22
22
  Typecast.features_hash(features)
23
23
  rescue JSON::ParserError
24
24
  raise JsonError
@@ -20,7 +20,7 @@ module Flipper
20
20
  end
21
21
  end
22
22
 
23
- json = JSON.dump({
23
+ json = Typecast.to_json({
24
24
  version: VERSION,
25
25
  features: features,
26
26
  })
@@ -0,0 +1,73 @@
1
+ module Flipper
2
+ class Expression
3
+ module Builder
4
+ def build(object)
5
+ Expression.build(object)
6
+ end
7
+
8
+ def add(*expressions)
9
+ group? ? build(name => args + expressions.flatten) : any.add(*expressions)
10
+ end
11
+
12
+ def remove(*expressions)
13
+ group? ? build(name => args - expressions.flatten) : any.remove(*expressions)
14
+ end
15
+
16
+ def any
17
+ any? ? self : build({ Any: [self] })
18
+ end
19
+
20
+ def all
21
+ all? ? self : build({ All: [self] })
22
+ end
23
+
24
+ def equal(object)
25
+ build({ Equal: [self, object] })
26
+ end
27
+ alias eq equal
28
+
29
+ def not_equal(object)
30
+ build({ NotEqual: [self, object] })
31
+ end
32
+ alias neq not_equal
33
+
34
+ def greater_than(object)
35
+ build({ GreaterThan: [self, object] })
36
+ end
37
+ alias gt greater_than
38
+
39
+ def greater_than_or_equal_to(object)
40
+ build({ GreaterThanOrEqualTo: [self, object] })
41
+ end
42
+ alias gte greater_than_or_equal_to
43
+ alias greater_than_or_equal greater_than_or_equal_to
44
+
45
+ def less_than(object)
46
+ build({ LessThan: [self, object] })
47
+ end
48
+ alias lt less_than
49
+
50
+ def less_than_or_equal_to(object)
51
+ build({ LessThanOrEqualTo: [self, object] })
52
+ end
53
+ alias lte less_than_or_equal_to
54
+ alias less_than_or_equal less_than_or_equal_to
55
+
56
+ def percentage_of_actors(object)
57
+ build({ PercentageOfActors: [self, build(object)] })
58
+ end
59
+
60
+ def any?
61
+ is_a?(Expression) && function == Expressions::Any
62
+ end
63
+
64
+ def all?
65
+ is_a?(Expression) && function == Expressions::All
66
+ end
67
+
68
+ def group?
69
+ any? || all?
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,25 @@
1
+ module Flipper
2
+ class Expression
3
+ # Public: A constant value like a "string", Number (1, 3.5), Boolean (true, false).
4
+ #
5
+ # Implements the same interface as Expression
6
+ class Constant
7
+ include Expression::Builder
8
+
9
+ attr_reader :value
10
+
11
+ def initialize(value)
12
+ @value = value
13
+ end
14
+
15
+ def evaluate(_ = nil)
16
+ value
17
+ end
18
+
19
+ def eql?(other)
20
+ other.is_a?(self.class) && other.value == value
21
+ end
22
+ alias_method :==, :eql?
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,71 @@
1
+ require "flipper/expression/builder"
2
+ require "flipper/expression/constant"
3
+
4
+ module Flipper
5
+ class Expression
6
+ include Builder
7
+
8
+ def self.build(object)
9
+ return object if object.is_a?(self) || object.is_a?(Constant)
10
+
11
+ case object
12
+ when Hash
13
+ name = object.keys.first
14
+ args = object.values.first
15
+ unless name
16
+ raise ArgumentError, "#{object.inspect} cannot be converted into an expression"
17
+ end
18
+
19
+ new(name, Array(args).map { |o| build(o) })
20
+ when String, Numeric, FalseClass, TrueClass
21
+ Expression::Constant.new(object)
22
+ when Symbol
23
+ Expression::Constant.new(object.to_s)
24
+ else
25
+ raise ArgumentError, "#{object.inspect} cannot be converted into an expression"
26
+ end
27
+ end
28
+
29
+ # Use #build
30
+ private_class_method :new
31
+
32
+ attr_reader :name, :function, :args
33
+
34
+ def initialize(name, args = [])
35
+ @name = name.to_s
36
+ @function = Expressions.const_get(name)
37
+ @args = args
38
+ end
39
+
40
+ def evaluate(context = {})
41
+ if call_with_context?
42
+ function.call(*args.map {|arg| arg.evaluate(context) }, context: context)
43
+ else
44
+ function.call(*args.map {|arg| arg.evaluate(context) })
45
+ end
46
+ end
47
+
48
+ def eql?(other)
49
+ other.is_a?(self.class) && @function == other.function && @args == other.args
50
+ end
51
+ alias_method :==, :eql?
52
+
53
+ def value
54
+ {
55
+ name => args.map(&:value)
56
+ }
57
+ end
58
+
59
+ private
60
+
61
+ def call_with_context?
62
+ function.method(:call).parameters.any? do |type, name|
63
+ name == :context && [:key, :keyreq].include?(type)
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ Dir[File.join(File.dirname(__FILE__), 'expressions', '*.rb')].sort.each do |file|
70
+ require "flipper/expressions/#{File.basename(file, '.rb')}"
71
+ end
@@ -0,0 +1,11 @@
1
+ require "flipper/expression"
2
+
3
+ module Flipper
4
+ module Expressions
5
+ class All
6
+ def self.call(*args)
7
+ args.all?
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ module Flipper
2
+ module Expressions
3
+ class Any
4
+ def self.call(*args)
5
+ args.any?
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Flipper
2
+ module Expressions
3
+ class Boolean
4
+ def self.call(value)
5
+ Flipper::Typecast.to_boolean(value)
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ module Flipper
2
+ module Expressions
3
+ class Comparable
4
+ def self.operator
5
+ raise NotImplementedError
6
+ end
7
+
8
+ def self.call(left, right)
9
+ left.respond_to?(operator) && right.respond_to?(operator) && left.public_send(operator, right)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,28 @@
1
+ module Flipper
2
+ module Expressions
3
+ class Duration
4
+ SECONDS_PER = {
5
+ "second" => 1,
6
+ "minute" => 60,
7
+ "hour" => 3600,
8
+ "day" => 86400,
9
+ "week" => 604_800,
10
+ "month" => 2_629_746, # 1/12 of a gregorian year
11
+ "year" => 31_556_952 # length of a gregorian year (365.2425 days)
12
+ }.freeze
13
+
14
+ def self.call(scalar, unit = 'second')
15
+ unit = unit.to_s.downcase.chomp("s")
16
+
17
+ unless scalar.is_a?(Numeric)
18
+ raise ArgumentError.new("Duration value must be a number but was #{scalar.inspect}")
19
+ end
20
+ unless SECONDS_PER[unit]
21
+ raise ArgumentError.new("Duration unit #{unit.inspect} must be one of: #{SECONDS_PER.keys.join(', ')}")
22
+ end
23
+
24
+ scalar * SECONDS_PER[unit]
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,9 @@
1
+ module Flipper
2
+ module Expressions
3
+ class Equal < Comparable
4
+ def self.operator
5
+ :==
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Flipper
2
+ module Expressions
3
+ class GreaterThan < Comparable
4
+ def self.operator
5
+ :>
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Flipper
2
+ module Expressions
3
+ class GreaterThanOrEqualTo < Comparable
4
+ def self.operator
5
+ :>=
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Flipper
2
+ module Expressions
3
+ class LessThan < Comparable
4
+ def self.operator
5
+ :<
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Flipper
2
+ module Expressions
3
+ class LessThanOrEqualTo < Comparable
4
+ def self.operator
5
+ :<=
6
+ end
7
+ end
8
+ end
9
+ end