flipper 1.1.2 → 1.3.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -2
  3. data/.github/workflows/examples.yml +8 -2
  4. data/Changelog.md +1 -647
  5. data/Gemfile +3 -2
  6. data/README.md +3 -1
  7. data/Rakefile +2 -2
  8. data/docs/images/banner.jpg +0 -0
  9. data/exe/flipper +5 -0
  10. data/flipper.gemspec +5 -1
  11. data/lib/flipper/adapters/actor_limit.rb +28 -0
  12. data/lib/flipper/adapters/cache_base.rb +143 -0
  13. data/lib/flipper/adapters/http/client.rb +25 -16
  14. data/lib/flipper/adapters/operation_logger.rb +18 -88
  15. data/lib/flipper/adapters/read_only.rb +6 -39
  16. data/lib/flipper/adapters/strict.rb +16 -18
  17. data/lib/flipper/adapters/wrapper.rb +54 -0
  18. data/lib/flipper/cli.rb +263 -0
  19. data/lib/flipper/cloud/configuration.rb +9 -4
  20. data/lib/flipper/cloud/middleware.rb +5 -5
  21. data/lib/flipper/cloud/telemetry/instrumenter.rb +4 -8
  22. data/lib/flipper/cloud/telemetry/submitter.rb +2 -2
  23. data/lib/flipper/cloud/telemetry.rb +10 -2
  24. data/lib/flipper/cloud.rb +1 -1
  25. data/lib/flipper/engine.rb +32 -17
  26. data/lib/flipper/instrumentation/log_subscriber.rb +12 -3
  27. data/lib/flipper/metadata.rb +3 -1
  28. data/lib/flipper/poller.rb +6 -5
  29. data/lib/flipper/serializers/gzip.rb +3 -5
  30. data/lib/flipper/serializers/json.rb +3 -5
  31. data/lib/flipper/spec/shared_adapter_specs.rb +17 -16
  32. data/lib/flipper/test/shared_adapter_test.rb +17 -17
  33. data/lib/flipper/test_help.rb +43 -0
  34. data/lib/flipper/typecast.rb +3 -3
  35. data/lib/flipper/version.rb +11 -1
  36. data/lib/flipper.rb +3 -1
  37. data/lib/generators/flipper/setup_generator.rb +63 -0
  38. data/package-lock.json +41 -0
  39. data/package.json +10 -0
  40. data/spec/fixtures/environment.rb +1 -0
  41. data/spec/flipper/adapter_builder_spec.rb +1 -2
  42. data/spec/flipper/adapters/actor_limit_spec.rb +20 -0
  43. data/spec/flipper/adapters/http/client_spec.rb +61 -0
  44. data/spec/flipper/adapters/http_spec.rb +102 -76
  45. data/spec/flipper/adapters/strict_spec.rb +11 -9
  46. data/spec/flipper/cli_spec.rb +164 -0
  47. data/spec/flipper/cloud/configuration_spec.rb +35 -36
  48. data/spec/flipper/cloud/dsl_spec.rb +5 -5
  49. data/spec/flipper/cloud/middleware_spec.rb +8 -8
  50. data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +8 -9
  51. data/spec/flipper/cloud/telemetry/submitter_spec.rb +24 -24
  52. data/spec/flipper/cloud/telemetry_spec.rb +53 -1
  53. data/spec/flipper/cloud_spec.rb +10 -9
  54. data/spec/flipper/engine_spec.rb +140 -58
  55. data/spec/flipper/instrumentation/log_subscriber_spec.rb +9 -2
  56. data/spec/flipper/middleware/memoizer_spec.rb +7 -4
  57. data/spec/flipper_spec.rb +1 -1
  58. data/spec/spec_helper.rb +1 -0
  59. data/spec/support/fail_on_output.rb +8 -0
  60. data/spec/support/spec_helpers.rb +12 -5
  61. data/test/adapters/actor_limit_test.rb +20 -0
  62. data/test_rails/generators/flipper/setup_generator_test.rb +64 -0
  63. data/test_rails/system/test_help_test.rb +51 -0
  64. metadata +31 -9
  65. data/spec/support/climate_control.rb +0 -7
@@ -0,0 +1,263 @@
1
+ require 'optparse'
2
+
3
+ module Flipper
4
+ class CLI < OptionParser
5
+ def self.run(argv = ARGV)
6
+ new.run(argv)
7
+ end
8
+
9
+ # Path to the local Rails application's environment configuration.
10
+ DEFAULT_REQUIRE = "./config/environment"
11
+
12
+ attr_accessor :shell
13
+
14
+ def initialize(stdout: $stdout, stderr: $stderr, shell: Bundler::Thor::Base.shell.new)
15
+ super
16
+
17
+ # Program is always flipper, no matter how it's invoked
18
+ @program_name = 'flipper'
19
+ @require = ENV.fetch("FLIPPER_REQUIRE", DEFAULT_REQUIRE)
20
+ @commands = {}
21
+
22
+ # Extend whatever shell to support output redirection
23
+ @shell = shell.extend(ShellOutput)
24
+ shell.redirect(stdout: stdout, stderr: stderr)
25
+
26
+ %w[enable disable].each do |action|
27
+ command action do |c|
28
+ c.banner = "Usage: #{c.program_name} [options] <feature>"
29
+ c.description = "#{action.to_s.capitalize} a feature"
30
+
31
+ values = []
32
+
33
+ c.on('-a id', '--actor=id', "#{action} for an actor") do |id|
34
+ values << Actor.new(id)
35
+ end
36
+ c.on('-g name', '--group=name', "#{action} for a group") do |name|
37
+ values << Types::Group.new(name)
38
+ end
39
+ c.on('-p NUM', '--percentage-of-actors=NUM', Numeric, "#{action} for a percentage of actors") do |num|
40
+ values << Types::PercentageOfActors.new(num)
41
+ end
42
+ c.on('-t NUM', '--percentage-of-time=NUM', Numeric, "#{action} for a percentage of time") do |num|
43
+ values << Types::PercentageOfTime.new(num)
44
+ end
45
+ c.on('-x expressions', '--expression=NUM', "#{action} for the given expression") do |expression|
46
+ begin
47
+ values << Flipper::Expression.build(JSON.parse(expression))
48
+ rescue JSON::ParserError => e
49
+ ui.error "JSON parse error #{e.message}"
50
+ ui.trace(e)
51
+ exit 1
52
+ rescue ArgumentError => e
53
+ ui.error "Invalid expression: #{e.message}"
54
+ ui.trace(e)
55
+ exit 1
56
+ end
57
+ end
58
+
59
+ c.action do |feature|
60
+ f = Flipper.feature(feature)
61
+
62
+ if values.empty?
63
+ f.send(action)
64
+ else
65
+ values.each { |value| f.send(action, value) }
66
+ end
67
+
68
+ ui.info feature_details(f)
69
+ end
70
+ end
71
+ end
72
+
73
+ command 'list' do |c|
74
+ c.description = "List defined features"
75
+ c.action do
76
+ ui.info feature_summary(Flipper.features)
77
+ end
78
+ end
79
+
80
+ command 'show' do |c|
81
+ c.description = "Show a defined feature"
82
+ c.action do |feature|
83
+ ui.info feature_details(Flipper.feature(feature))
84
+ end
85
+ end
86
+
87
+ command 'help' do |c|
88
+ c.load_environment = false
89
+ c.action do |command = nil|
90
+ ui.info command ? @commands[command].help : help
91
+ end
92
+ end
93
+
94
+ on_tail('-r path', "The path to load your application. Default: #{@require}") do |path|
95
+ @require = path
96
+ end
97
+
98
+ # Options available on all commands
99
+ on_tail('-h', '--help', 'Print help message') do
100
+ ui.info help
101
+ exit
102
+ end
103
+
104
+ # Set help documentation
105
+ self.banner = "Usage: #{program_name} [options] <command>"
106
+ separator ""
107
+ separator "Commands:"
108
+
109
+ pad = @commands.keys.map(&:length).max + 2
110
+ @commands.each do |name, command|
111
+ separator " #{name.to_s.ljust(pad, " ")} #{command.description}" if command.description
112
+ end
113
+
114
+ separator ""
115
+ separator "Options:"
116
+ end
117
+
118
+ def run(argv)
119
+ command, *args = order(argv)
120
+
121
+ if @commands[command]
122
+ load_environment! if @commands[command].load_environment
123
+ @commands[command].run(args)
124
+ else
125
+ ui.info help
126
+
127
+ if command
128
+ ui.error "Unknown command: #{command}"
129
+ exit 1
130
+ end
131
+ end
132
+ rescue OptionParser::InvalidOption => e
133
+ ui.error e.message
134
+ exit 1
135
+ end
136
+
137
+ # Helper method to define a new command
138
+ def command(name, &block)
139
+ @commands[name] = Command.new(program_name: "#{program_name} #{name}")
140
+ block.call(@commands[name])
141
+ end
142
+
143
+ def load_environment!
144
+ ENV["FLIPPER_CLOUD_LOGGING_ENABLED"] ||= "false"
145
+ require File.expand_path(@require)
146
+ # Ensure all of flipper gets loaded if it hasn't already.
147
+ require 'flipper'
148
+ rescue LoadError => e
149
+ ui.error e.message
150
+ exit 1
151
+ end
152
+
153
+ def feature_summary(features)
154
+ features = Array(features)
155
+ padding = features.map { |f| f.key.to_s.length }.max
156
+
157
+ features.map do |feature|
158
+ summary = case feature.state
159
+ when :on
160
+ colorize("⏺ enabled", [:GREEN])
161
+ when :off
162
+ "⦸ disabled"
163
+ else
164
+ "#{colorize("◯ enabled", [:YELLOW])} for " + feature.enabled_gates.map do |gate|
165
+ case gate.name
166
+ when :actor
167
+ pluralize feature.actors_value.size, 'actor', 'actors'
168
+ when :group
169
+ pluralize feature.groups_value.size, 'group', 'groups'
170
+ when :percentage_of_actors
171
+ "#{feature.percentage_of_actors_value}% of actors"
172
+ when :percentage_of_time
173
+ "#{feature.percentage_of_time_value}% of time"
174
+ when :expression
175
+ "an expression"
176
+ end
177
+ end.join(', ')
178
+ end
179
+
180
+ colorize("%-#{padding}s" % feature.key, [:BOLD, :WHITE]) + " is #{summary}"
181
+ end.join("\n")
182
+ end
183
+
184
+ def feature_details(feature)
185
+ summary = case feature.state
186
+ when :on
187
+ colorize("⏺ enabled", [:GREEN])
188
+ when :off
189
+ "⦸ disabled"
190
+ else
191
+ lines = feature.enabled_gates.map do |gate|
192
+ case gate.name
193
+ when :actor
194
+ [ pluralize(feature.actors_value.size, 'actor', 'actors') ] +
195
+ feature.actors_value.map { |actor| "- #{actor}" }
196
+ when :group
197
+ [ pluralize(feature.groups_value.size, 'group', 'groups') ] +
198
+ feature.groups_value.map { |group| " - #{group}" }
199
+ when :percentage_of_actors
200
+ "#{feature.percentage_of_actors_value}% of actors"
201
+ when :percentage_of_time
202
+ "#{feature.percentage_of_time_value}% of time"
203
+ when :expression
204
+ json = indent(JSON.pretty_generate(feature.expression_value), 2)
205
+ "the expression: \n#{colorize(json, [:MAGENTA])}"
206
+ end
207
+ end
208
+
209
+ "#{colorize("◯ conditionally enabled", [:YELLOW])} for:\n" +
210
+ indent(lines.flatten.join("\n"), 2)
211
+ end
212
+
213
+ "#{colorize(feature.key, [:BOLD, :WHITE])} is #{summary}"
214
+ end
215
+
216
+ def pluralize(count, singular, plural)
217
+ "#{count} #{count == 1 ? singular : plural}"
218
+ end
219
+
220
+ def colorize(text, colors)
221
+ ui.add_color(text, *colors)
222
+ end
223
+
224
+ def ui
225
+ @ui ||= Bundler::UI::Shell.new.tap do |ui|
226
+ ui.shell = shell
227
+ end
228
+ end
229
+
230
+ def indent(text, spaces)
231
+ text.gsub(/^/, " " * spaces)
232
+ end
233
+
234
+ # Redirect the shell's output to the given stdout and stderr streams
235
+ module ShellOutput
236
+ attr_reader :stdout, :stderr
237
+
238
+ def redirect(stdout: $stdout, stderr: $stderr)
239
+ @stdout, @stderr = stdout, stderr
240
+ end
241
+ end
242
+
243
+ class Command < OptionParser
244
+ attr_accessor :description, :load_environment
245
+
246
+ def initialize(program_name: nil)
247
+ super()
248
+ @program_name = program_name
249
+ @load_environment = true
250
+ @action = lambda { }
251
+ end
252
+
253
+ def run(argv)
254
+ # Parse argv and call action with arguments
255
+ @action.call(*permute(argv))
256
+ end
257
+
258
+ def action(&block)
259
+ @action = block
260
+ end
261
+ end
262
+ end
263
+ end
@@ -163,7 +163,8 @@ module Flipper
163
163
  max_retries: 0, # we'll handle retries ourselves
164
164
  debug_output: @debug_output,
165
165
  headers: {
166
- "Flipper-Cloud-Token" => @token,
166
+ "flipper-cloud-token" => @token,
167
+ "accept-encoding" => "gzip",
167
168
  },
168
169
  })
169
170
  end
@@ -173,7 +174,7 @@ module Flipper
173
174
  end
174
175
 
175
176
  def setup_log(options)
176
- set_option :logging_enabled, options, default: true, typecast: :boolean
177
+ set_option :logging_enabled, options, default: false, typecast: :boolean
177
178
  set_option :logger, options, from_env: false, default: -> {
178
179
  if logging_enabled
179
180
  Logger.new(STDOUT)
@@ -186,6 +187,11 @@ module Flipper
186
187
  def setup_http(options)
187
188
  set_option :url, options, default: DEFAULT_URL
188
189
  set_option :debug_output, options, from_env: false
190
+
191
+ if @debug_output.nil? && Flipper::Typecast.to_boolean(ENV["FLIPPER_CLOUD_DEBUG_OUTPUT_STDOUT"])
192
+ @debug_output = STDOUT
193
+ end
194
+
189
195
  set_option :read_timeout, options, default: 5, typecast: :float, minimum: 0.1
190
196
  set_option :open_timeout, options, default: 2, typecast: :float, minimum: 0.1
191
197
  set_option :write_timeout, options, default: 5, typecast: :float, minimum: 0.1
@@ -208,8 +214,7 @@ module Flipper
208
214
  Telemetry.instance_for(self)
209
215
  }
210
216
 
211
- # This is alpha. Don't use this unless you are me. And you are not me.
212
- set_option :telemetry_enabled, options, default: false, typecast: :boolean
217
+ set_option :telemetry_enabled, options, default: true, typecast: :boolean
213
218
  instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
214
219
  @instrumenter = if telemetry_enabled
215
220
  Telemetry::Instrumenter.new(self, instrumenter)
@@ -24,7 +24,7 @@ module Flipper
24
24
  if request.post? && (request.path_info.match(ROOT_PATH) || request.path_info.match(WEBHOOK_PATH))
25
25
  status = 200
26
26
  headers = {
27
- "content-type" => "application/json",
27
+ Rack::CONTENT_TYPE => "application/json",
28
28
  }
29
29
  body = "{}"
30
30
  payload = request.body.read
@@ -41,12 +41,12 @@ module Flipper
41
41
  })
42
42
  rescue Flipper::Adapters::Http::Error => error
43
43
  status = error.response.code.to_i == 402 ? 402 : 500
44
- headers["Flipper-Cloud-Response-Error-Class"] = error.class.name
45
- headers["Flipper-Cloud-Response-Error-Message"] = error.message
44
+ headers["flipper-cloud-response-error-class"] = error.class.name
45
+ headers["flipper-cloud-response-error-message"] = error.message
46
46
  rescue => error
47
47
  status = 500
48
- headers["Flipper-Cloud-Response-Error-Class"] = error.class.name
49
- headers["Flipper-Cloud-Response-Error-Message"] = error.message
48
+ headers["flipper-cloud-response-error-class"] = error.class.name
49
+ headers["flipper-cloud-response-error-message"] = error.message
50
50
  end
51
51
  end
52
52
  rescue MessageVerifier::InvalidSignature
@@ -3,9 +3,11 @@ require "delegate"
3
3
  module Flipper
4
4
  module Cloud
5
5
  class Telemetry
6
- class Instrumenter < SimpleDelegator
6
+ class Instrumenter
7
+ attr_reader :instrumenter
8
+
7
9
  def initialize(cloud_configuration, instrumenter)
8
- super instrumenter
10
+ @instrumenter = instrumenter
9
11
  @cloud_configuration = cloud_configuration
10
12
  end
11
13
 
@@ -14,12 +16,6 @@ module Flipper
14
16
  @cloud_configuration.telemetry.record(name, payload)
15
17
  return_value
16
18
  end
17
-
18
- private
19
-
20
- def instrumenter
21
- __getobj__
22
- end
23
19
  end
24
20
  end
25
21
  end
@@ -78,8 +78,8 @@ module Flipper
78
78
 
79
79
  def submit(body)
80
80
  client = @cloud_configuration.http_client
81
- client.add_header :schema_version, SCHEMA_VERSION
82
- client.add_header :content_encoding, GZIP_ENCODING
81
+ client.add_header "schema-version", SCHEMA_VERSION
82
+ client.add_header "content-encoding", GZIP_ENCODING
83
83
 
84
84
  response = client.post PATH, body
85
85
  code = response.code.to_i
@@ -160,8 +160,16 @@ module Flipper
160
160
  # thus may have a telemetry-interval header for us to respect.
161
161
  response ||= error.response if error && error.respond_to?(:response)
162
162
 
163
- if response && interval = response["telemetry-interval"]
164
- self.interval = interval.to_f
163
+ if response
164
+ if Flipper::Typecast.to_boolean(response["telemetry-shutdown"])
165
+ debug "action=telemetry_shutdown message=The server has requested that telemetry be shut down."
166
+ stop
167
+ return
168
+ end
169
+
170
+ if interval = response["telemetry-interval"]
171
+ self.interval = interval.to_f
172
+ end
165
173
  end
166
174
  rescue => error
167
175
  error "action=post_to_cloud error=#{error.inspect}"
data/lib/flipper/cloud.rb CHANGED
@@ -24,7 +24,7 @@ module Flipper
24
24
  env_key = options.fetch(:env_key, 'flipper')
25
25
  memoizer_options = options.fetch(:memoizer_options, {})
26
26
 
27
- app = ->(_) { [404, { 'content-type'.freeze => 'application/json'.freeze }, ['{}'.freeze]] }
27
+ app = ->(_) { [404, { Rack::CONTENT_TYPE => 'application/json'.freeze }, ['{}'.freeze]] }
28
28
  builder = Rack::Builder.new
29
29
  yield builder if block_given?
30
30
  builder.use Flipper::Middleware::SetupEnv, flipper, env_key: env_key
@@ -1,5 +1,19 @@
1
1
  module Flipper
2
2
  class Engine < Rails::Engine
3
+ def self.default_strict_value
4
+ value = ENV["FLIPPER_STRICT"]
5
+ if value.in?(["warn", "raise", "noop"])
6
+ value.to_sym
7
+ elsif value
8
+ Typecast.to_boolean(value) ? :raise : false
9
+ elsif Rails.env.production?
10
+ false
11
+ else
12
+ # Warn in development for now. Future versions may default to :raise in development and test
13
+ Rails.env.development? && :warn
14
+ end
15
+ end
16
+
3
17
  paths["config/routes.rb"] = ["lib/flipper/cloud/routes.rb"]
4
18
 
5
19
  config.before_configuration do
@@ -10,7 +24,9 @@ module Flipper
10
24
  instrumenter: ENV.fetch('FLIPPER_INSTRUMENTER', 'ActiveSupport::Notifications').constantize,
11
25
  log: ENV.fetch('FLIPPER_LOG', 'true').casecmp('true').zero?,
12
26
  cloud_path: "_flipper",
13
- strict: default_strict_value
27
+ strict: default_strict_value,
28
+ actor_limit: ENV["FLIPPER_ACTOR_LIMIT"]&.to_i || 100,
29
+ test_help: Flipper::Typecast.to_boolean(ENV["FLIPPER_TEST_HELP"] || Rails.env.test?),
14
30
  )
15
31
  end
16
32
 
@@ -29,10 +45,6 @@ module Flipper
29
45
  require 'flipper/cloud' if cloud?
30
46
 
31
47
  Flipper.configure do |config|
32
- if app.config.flipper.strict
33
- config.use Flipper::Adapters::Strict, app.config.flipper.strict
34
- end
35
-
36
48
  config.default do
37
49
  if cloud?
38
50
  Flipper::Cloud.new(
@@ -54,6 +66,15 @@ module Flipper
54
66
  end
55
67
  end
56
68
 
69
+ initializer "flipper.adapters", after: :load_config_initializers do |app|
70
+ flipper = app.config.flipper
71
+
72
+ Flipper.configure do |config|
73
+ config.use Flipper::Adapters::Strict, flipper.strict if flipper.strict
74
+ config.use Flipper::Adapters::ActorLimit, flipper.actor_limit if flipper.actor_limit
75
+ end
76
+ end
77
+
57
78
  initializer "flipper.memoizer", after: :load_config_initializers do |app|
58
79
  flipper = app.config.flipper
59
80
 
@@ -66,22 +87,16 @@ module Flipper
66
87
  end
67
88
  end
68
89
 
90
+ initializer "flipper.test" do |app|
91
+ require "flipper/test_help" if app.config.flipper.test_help
92
+ end
93
+
69
94
  def cloud?
70
95
  !!ENV["FLIPPER_CLOUD_TOKEN"]
71
96
  end
72
97
 
73
- def self.default_strict_value
74
- value = ENV["FLIPPER_STRICT"]
75
- if value.in?(["warn", "raise", "noop"])
76
- value.to_sym
77
- elsif value
78
- Typecast.to_boolean(value) ? :raise : false
79
- elsif Rails.env.production?
80
- false
81
- else
82
- # Warn for now. Future versions will default to :raise in development and test
83
- :warn
84
- end
98
+ def self.deprecated_rails_version?
99
+ Gem::Version.new(Rails.version) < Gem::Version.new(Flipper::NEXT_REQUIRED_RAILS_VERSION)
85
100
  end
86
101
  end
87
102
  end
@@ -1,4 +1,5 @@
1
1
  require 'securerandom'
2
+ require 'active_support/gem_version'
2
3
  require 'active_support/notifications'
3
4
  require 'active_support/log_subscriber'
4
5
 
@@ -71,11 +72,19 @@ module Flipper
71
72
  self.class.logger
72
73
  end
73
74
 
75
+ def self.attach
76
+ attach_to InstrumentationNamespace
77
+ end
78
+
79
+ def self.detach
80
+ # Rails 5.2 doesn't support this, that's fine
81
+ detach_from InstrumentationNamespace if respond_to?(:detach_from)
82
+ end
83
+
74
84
  private
75
85
 
76
86
  # Rails 7.1 changed the signature of this function.
77
- # Checking if > 7.0.99 rather than >= 7.1 so that 7.1 pre-release versions are included.
78
- COLOR_OPTIONS = if Rails.gem_version > Gem::Version.new('7.0.99')
87
+ COLOR_OPTIONS = if Gem::Requirement.new(">=7.1").satisfied_by?(ActiveSupport.gem_version)
79
88
  { bold: true }.freeze
80
89
  else
81
90
  true
@@ -88,5 +97,5 @@ module Flipper
88
97
  end
89
98
  end
90
99
 
91
- Instrumentation::LogSubscriber.attach_to InstrumentationNamespace
100
+ Instrumentation::LogSubscriber.attach
92
101
  end
@@ -1,9 +1,11 @@
1
+ require_relative './version'
2
+
1
3
  module Flipper
2
4
  METADATA = {
3
5
  "documentation_uri" => "https://www.flippercloud.io/docs",
4
6
  "homepage_uri" => "https://www.flippercloud.io",
5
7
  "source_code_uri" => "https://github.com/flippercloud/flipper",
6
8
  "bug_tracker_uri" => "https://github.com/flippercloud/flipper/issues",
7
- "changelog_uri" => "https://github.com/flippercloud/flipper/blob/main/Changelog.md",
9
+ "changelog_uri" => "https://github.com/flippercloud/flipper/releases/tag/v#{Flipper::VERSION}",
8
10
  }.freeze
9
11
  end
@@ -20,6 +20,8 @@ module Flipper
20
20
  instances.each {|_, instance| instance.stop }.clear
21
21
  end
22
22
 
23
+ MINIMUM_POLL_INTERVAL = 10
24
+
23
25
  def initialize(options = {})
24
26
  @thread = nil
25
27
  @pid = Process.pid
@@ -30,9 +32,9 @@ module Flipper
30
32
  @last_synced_at = Concurrent::AtomicFixnum.new(0)
31
33
  @adapter = Adapters::Memory.new(nil, threadsafe: true)
32
34
 
33
- if @interval < 1
34
- warn "Flipper::Cloud poll interval must be greater than or equal to 1 but was #{@interval}. Setting @interval to 1."
35
- @interval = 1
35
+ if @interval < MINIMUM_POLL_INTERVAL
36
+ warn "Flipper::Cloud poll interval must be greater than or equal to #{MINIMUM_POLL_INTERVAL} but was #{@interval}. Setting @interval to #{MINIMUM_POLL_INTERVAL}."
37
+ @interval = MINIMUM_POLL_INTERVAL
36
38
  end
37
39
 
38
40
  @start_automatically = options.fetch(:start_automatically, true)
@@ -64,8 +66,7 @@ module Flipper
64
66
  # you can instrument these using poller.flipper
65
67
  end
66
68
 
67
- sleep_interval = interval - (Concurrent.monotonic_time - start)
68
- sleep sleep_interval if sleep_interval.positive?
69
+ sleep interval
69
70
  end
70
71
  end
71
72
 
@@ -3,10 +3,8 @@ require "stringio"
3
3
 
4
4
  module Flipper
5
5
  module Serializers
6
- module Gzip
7
- module_function
8
-
9
- def serialize(source)
6
+ class Gzip
7
+ def self.serialize(source)
10
8
  return if source.nil?
11
9
  output = StringIO.new
12
10
  gz = Zlib::GzipWriter.new(output)
@@ -15,7 +13,7 @@ module Flipper
15
13
  output.string
16
14
  end
17
15
 
18
- def deserialize(source)
16
+ def self.deserialize(source)
19
17
  return if source.nil?
20
18
  Zlib::GzipReader.wrap(StringIO.new(source), &:read)
21
19
  end
@@ -2,15 +2,13 @@ require "json"
2
2
 
3
3
  module Flipper
4
4
  module Serializers
5
- module Json
6
- module_function
7
-
8
- def serialize(source)
5
+ class Json
6
+ def self.serialize(source)
9
7
  return if source.nil?
10
8
  JSON.generate(source)
11
9
  end
12
10
 
13
- def deserialize(source)
11
+ def self.deserialize(source)
14
12
  return if source.nil?
15
13
  JSON.parse(source)
16
14
  end
@@ -108,19 +108,19 @@ RSpec.shared_examples_for 'a flipper adapter' do
108
108
  actor22 = Flipper::Actor.new('22')
109
109
  actor_asdf = Flipper::Actor.new('asdf')
110
110
 
111
- expect(subject.enable(feature, actor_gate, Flipper::Types::Actor.new(actor22))).to eq(true)
112
- expect(subject.enable(feature, actor_gate, Flipper::Types::Actor.new(actor_asdf))).to eq(true)
111
+ expect(feature.enable(actor22)).to be(true)
112
+ expect(feature.enable(actor_asdf)).to be(true)
113
113
 
114
- result = subject.get(feature)
115
- expect(result[:actors]).to eq(Set['22', 'asdf'])
114
+ expect(feature).to be_enabled(actor22)
115
+ expect(feature).to be_enabled(actor_asdf)
116
116
 
117
- expect(subject.disable(feature, actor_gate, Flipper::Types::Actor.new(actor22))).to eq(true)
118
- result = subject.get(feature)
119
- expect(result[:actors]).to eq(Set['asdf'])
117
+ expect(feature.disable(actor22)).to be(true)
118
+ expect(feature).not_to be_enabled(actor22)
119
+ expect(feature).to be_enabled(actor_asdf)
120
120
 
121
- expect(subject.disable(feature, actor_gate, Flipper::Types::Actor.new(actor_asdf))).to eq(true)
122
- result = subject.get(feature)
123
- expect(result[:actors]).to eq(Set.new)
121
+ expect(feature.disable(actor_asdf)).to eq(true)
122
+ expect(feature).not_to be_enabled(actor22)
123
+ expect(feature).not_to be_enabled(actor_asdf)
124
124
  end
125
125
 
126
126
  it 'can enable, disable and get value for percentage of actors gate' do
@@ -182,9 +182,10 @@ RSpec.shared_examples_for 'a flipper adapter' do
182
182
  end
183
183
 
184
184
  it 'converts the actor value to a string' do
185
- expect(subject.enable(feature, actor_gate, Flipper::Types::Actor.new(Flipper::Actor.new(22)))).to eq(true)
186
- result = subject.get(feature)
187
- expect(result[:actors]).to eq(Set['22'])
185
+ actor = Flipper::Actor.new(22)
186
+ expect(feature).not_to be_enabled(actor)
187
+ feature.enable_actor actor
188
+ expect(feature).to be_enabled(actor)
188
189
  end
189
190
 
190
191
  it 'converts group value to a string' do
@@ -295,9 +296,9 @@ RSpec.shared_examples_for 'a flipper adapter' do
295
296
 
296
297
  it 'can double enable an actor without error' do
297
298
  actor = Flipper::Actor.new('Flipper::Actor;22')
298
- expect(subject.enable(feature, actor_gate, Flipper::Types::Actor.new(actor))).to eq(true)
299
- expect(subject.enable(feature, actor_gate, Flipper::Types::Actor.new(actor))).to eq(true)
300
- expect(subject.get(feature).fetch(:actors)).to eq(Set['Flipper::Actor;22'])
299
+ expect(feature.enable(actor)).to be(true)
300
+ expect(feature.enable(actor)).to be(true)
301
+ expect(feature).to be_enabled(actor)
301
302
  end
302
303
 
303
304
  it 'can double enable a group without error' do