flipper 1.3.6 → 1.4.1

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -6
  3. data/.github/workflows/examples.yml +5 -4
  4. data/.github/workflows/release.yml +54 -0
  5. data/.superset/config.json +4 -0
  6. data/CLAUDE.md +22 -3
  7. data/README.md +4 -3
  8. data/examples/cloud/poll_interval/README.md +111 -0
  9. data/examples/cloud/poll_interval/client.rb +108 -0
  10. data/examples/cloud/poll_interval/server.rb +98 -0
  11. data/examples/expressions.rb +35 -11
  12. data/lib/flipper/adapter.rb +17 -1
  13. data/lib/flipper/adapters/actor_limit.rb +27 -1
  14. data/lib/flipper/adapters/cache_base.rb +21 -3
  15. data/lib/flipper/adapters/dual_write.rb +6 -2
  16. data/lib/flipper/adapters/failover.rb +9 -3
  17. data/lib/flipper/adapters/failsafe.rb +2 -2
  18. data/lib/flipper/adapters/http/client.rb +15 -4
  19. data/lib/flipper/adapters/http.rb +37 -2
  20. data/lib/flipper/adapters/instrumented.rb +2 -2
  21. data/lib/flipper/adapters/memoizable.rb +3 -3
  22. data/lib/flipper/adapters/memory.rb +1 -1
  23. data/lib/flipper/adapters/pstore.rb +1 -1
  24. data/lib/flipper/adapters/strict.rb +30 -0
  25. data/lib/flipper/adapters/sync/feature_synchronizer.rb +5 -1
  26. data/lib/flipper/adapters/sync/synchronizer.rb +13 -5
  27. data/lib/flipper/adapters/sync.rb +7 -3
  28. data/lib/flipper/cli.rb +51 -0
  29. data/lib/flipper/cloud/configuration.rb +9 -4
  30. data/lib/flipper/cloud/dsl.rb +2 -2
  31. data/lib/flipper/cloud/middleware.rb +1 -1
  32. data/lib/flipper/cloud/migrate.rb +71 -0
  33. data/lib/flipper/cloud/telemetry.rb +1 -1
  34. data/lib/flipper/cloud.rb +1 -0
  35. data/lib/flipper/dsl.rb +1 -1
  36. data/lib/flipper/expressions/feature_enabled.rb +34 -0
  37. data/lib/flipper/expressions/time.rb +8 -1
  38. data/lib/flipper/gates/expression.rb +2 -2
  39. data/lib/flipper/poller.rb +52 -9
  40. data/lib/flipper/version.rb +1 -1
  41. data/lib/flipper.rb +17 -1
  42. data/spec/flipper/adapter_spec.rb +20 -0
  43. data/spec/flipper/adapters/actor_limit_spec.rb +55 -0
  44. data/spec/flipper/adapters/dual_write_spec.rb +13 -0
  45. data/spec/flipper/adapters/failover_spec.rb +12 -0
  46. data/spec/flipper/adapters/http_spec.rb +240 -0
  47. data/spec/flipper/adapters/strict_spec.rb +62 -4
  48. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +12 -0
  49. data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
  50. data/spec/flipper/adapters/sync_spec.rb +13 -0
  51. data/spec/flipper/cli_spec.rb +51 -0
  52. data/spec/flipper/cloud/configuration_spec.rb +6 -0
  53. data/spec/flipper/cloud/dsl_spec.rb +10 -2
  54. data/spec/flipper/cloud/middleware_spec.rb +34 -16
  55. data/spec/flipper/cloud/migrate_spec.rb +160 -0
  56. data/spec/flipper/cloud/telemetry_spec.rb +1 -1
  57. data/spec/flipper/engine_spec.rb +2 -2
  58. data/spec/flipper/expressions/time_spec.rb +16 -0
  59. data/spec/flipper/gates/expression_spec.rb +82 -0
  60. data/spec/flipper/middleware/memoizer_spec.rb +37 -6
  61. data/spec/flipper/poller_spec.rb +347 -4
  62. data/spec/flipper_integration_spec.rb +133 -0
  63. data/spec/flipper_spec.rb +6 -1
  64. data/spec/spec_helper.rb +7 -0
  65. metadata +17 -112
  66. data/lib/flipper/expressions/duration.rb +0 -28
  67. data/spec/flipper/expressions/duration_spec.rb +0 -43
@@ -17,6 +17,9 @@ module Flipper
17
17
  # Public: The cache key where the set of known features is cached.
18
18
  attr_reader :features_cache_key
19
19
 
20
+ # Public: The cache key where the set of all features with gates is cached.
21
+ attr_reader :get_all_cache_key
22
+
20
23
  # Public: Alias expires_in to ttl for compatibility.
21
24
  alias_method :expires_in, :ttl
22
25
 
@@ -29,16 +32,24 @@ module Flipper
29
32
  @namespace = "flipper/#{@cache_version}"
30
33
  @namespace = @namespace.prepend(prefix) if prefix
31
34
  @features_cache_key = "#{@namespace}/features"
35
+ @get_all_cache_key = "#{@namespace}/get_all"
36
+ end
37
+
38
+ # Public: Expire the cache for the set of all features with gates.
39
+ def expire_get_all_cache
40
+ cache_delete @get_all_cache_key
32
41
  end
33
42
 
34
43
  # Public: Expire the cache for the set of known feature names.
35
44
  def expire_features_cache
36
45
  cache_delete @features_cache_key
46
+ expire_get_all_cache
37
47
  end
38
48
 
39
49
  # Public: Expire the cache for a given feature.
40
50
  def expire_feature_cache(key)
41
51
  cache_delete feature_cache_key(key)
52
+ expire_get_all_cache
42
53
  end
43
54
 
44
55
  # Public
@@ -79,9 +90,12 @@ module Flipper
79
90
  end
80
91
 
81
92
  # Public
82
- def get_all
83
- features = read_feature_keys.map { |key| Flipper::Feature.new(key, self) }
84
- read_many_features(features)
93
+ def get_all(**kwargs)
94
+ cache_fetch(@get_all_cache_key) {
95
+ result = read_all_features(**kwargs)
96
+ cache_write @features_cache_key, result.keys.to_set
97
+ result
98
+ }
85
99
  end
86
100
 
87
101
  # Public
@@ -107,6 +121,10 @@ module Flipper
107
121
 
108
122
  private
109
123
 
124
+ def read_all_features(**kwargs)
125
+ @adapter.get_all(**kwargs)
126
+ end
127
+
110
128
  # Private: Returns the Set of known feature keys.
111
129
  def read_feature_keys
112
130
  cache_fetch(@features_cache_key) { @adapter.features }
@@ -15,6 +15,10 @@ module Flipper
15
15
  @remote = remote
16
16
  end
17
17
 
18
+ def adapter_stack
19
+ "#{name}(local: #{@local.adapter_stack}, remote: #{@remote.adapter_stack})"
20
+ end
21
+
18
22
  def features
19
23
  @local.features
20
24
  end
@@ -27,8 +31,8 @@ module Flipper
27
31
  @local.get_multi(features)
28
32
  end
29
33
 
30
- def get_all
31
- @local.get_all
34
+ def get_all(**kwargs)
35
+ @local.get_all(**kwargs)
32
36
  end
33
37
 
34
38
  def add(feature)
@@ -13,6 +13,8 @@ module Flipper
13
13
  # primary is updated
14
14
  # :errors - Array of exception types for which to failover
15
15
 
16
+ attr_reader :primary, :secondary
17
+
16
18
  def initialize(primary, secondary, options = {})
17
19
  @primary = primary
18
20
  @secondary = secondary
@@ -21,6 +23,10 @@ module Flipper
21
23
  @errors = options.fetch(:errors, [ StandardError ])
22
24
  end
23
25
 
26
+ def adapter_stack
27
+ "#{name}(primary: #{@primary.adapter_stack}, secondary: #{@secondary.adapter_stack})"
28
+ end
29
+
24
30
  def features
25
31
  @primary.features
26
32
  rescue *@errors
@@ -39,10 +45,10 @@ module Flipper
39
45
  @secondary.get_multi(features)
40
46
  end
41
47
 
42
- def get_all
43
- @primary.get_all
48
+ def get_all(**kwargs)
49
+ @primary.get_all(**kwargs)
44
50
  rescue *@errors
45
- @secondary.get_all
51
+ @secondary.get_all(**kwargs)
46
52
  end
47
53
 
48
54
  def add(feature)
@@ -50,8 +50,8 @@ module Flipper
50
50
  {}
51
51
  end
52
52
 
53
- def get_all
54
- @adapter.get_all
53
+ def get_all(**kwargs)
54
+ @adapter.get_all(**kwargs)
55
55
  rescue *@errors
56
56
  {}
57
57
  end
@@ -45,12 +45,19 @@ module Flipper
45
45
  end
46
46
 
47
47
  def add_header(key, value)
48
- key = key.to_s.downcase.gsub('_'.freeze, '-'.freeze).freeze
49
- @headers[key] = value
48
+ @headers[normalize_header_key(key)] = value
50
49
  end
51
50
 
52
- def get(path)
53
- perform Net::HTTP::Get, path, @headers
51
+ def get(path, options = {})
52
+ headers = @headers.dup
53
+
54
+ if options[:headers]
55
+ options[:headers].each do |key, value|
56
+ headers[normalize_header_key(key)] = value
57
+ end
58
+ end
59
+
60
+ perform Net::HTTP::Get, path, headers
54
61
  end
55
62
 
56
63
  def post(path, body = nil)
@@ -125,6 +132,10 @@ module Flipper
125
132
  def client_frameworks
126
133
  CLIENT_FRAMEWORKS.transform_values { |detect| detect.call rescue nil }.compact
127
134
  end
135
+
136
+ def normalize_header_key(key)
137
+ key.to_s.downcase.gsub('_'.freeze, '-'.freeze)
138
+ end
128
139
  end
129
140
  end
130
141
  end
@@ -22,6 +22,10 @@ module Flipper
22
22
  write_timeout: options[:write_timeout],
23
23
  max_retries: options[:max_retries],
24
24
  debug_output: options[:debug_output])
25
+ @last_get_all_etag = nil
26
+ @last_get_all_result = nil
27
+ @last_get_all_response = nil
28
+ @get_all_mutex = Mutex.new
25
29
  end
26
30
 
27
31
  def get(feature)
@@ -55,8 +59,29 @@ module Flipper
55
59
  result
56
60
  end
57
61
 
58
- def get_all
59
- response = @client.get("/features?exclude_gate_names=true")
62
+ def get_all(cache_bust: false)
63
+ options = {}
64
+ path = "/features?exclude_gate_names=true"
65
+ path += "&_cb=#{Time.now.to_i}" if cache_bust
66
+ etag = @get_all_mutex.synchronize { @last_get_all_etag }
67
+
68
+ if etag && !cache_bust
69
+ options[:headers] = { if_none_match: etag }
70
+ end
71
+
72
+ response = @client.get(path, options)
73
+ @get_all_mutex.synchronize { @last_get_all_response = response }
74
+
75
+ if response.is_a?(Net::HTTPNotModified)
76
+ cached_result = @get_all_mutex.synchronize { @last_get_all_result }
77
+
78
+ if cached_result
79
+ return cached_result
80
+ else
81
+ raise Error, response
82
+ end
83
+ end
84
+
60
85
  raise Error, response unless response.is_a?(Net::HTTPOK)
61
86
 
62
87
  parsed_response = response.body.empty? ? {} : Typecast.from_json(response.body)
@@ -71,9 +96,19 @@ module Flipper
71
96
  feature = Feature.new(key, self)
72
97
  result[feature.key] = result_for_feature(feature, gates_by_key[feature.key])
73
98
  end
99
+
100
+ @get_all_mutex.synchronize do
101
+ @last_get_all_etag = response['etag'] if response['etag']
102
+ @last_get_all_result = result
103
+ end
104
+
74
105
  result
75
106
  end
76
107
 
108
+ def last_get_all_response
109
+ @get_all_mutex.synchronize { @last_get_all_response }
110
+ end
111
+
77
112
  def features
78
113
  response = @client.get('/features?exclude_gate_names=true')
79
114
  raise Error, response unless response.is_a?(Net::HTTPOK)
@@ -101,14 +101,14 @@ module Flipper
101
101
  end
102
102
  end
103
103
 
104
- def get_all
104
+ def get_all(**kwargs)
105
105
  default_payload = {
106
106
  operation: :get_all,
107
107
  adapter_name: @adapter.name,
108
108
  }
109
109
 
110
110
  @instrumenter.instrument(InstrumentationName, default_payload) do |payload|
111
- payload[:result] = @adapter.get_all
111
+ payload[:result] = @adapter.get_all(**kwargs)
112
112
  end
113
113
  end
114
114
 
@@ -81,7 +81,7 @@ module Flipper
81
81
  end
82
82
  end
83
83
 
84
- def get_all
84
+ def get_all(**kwargs)
85
85
  if memoizing?
86
86
  response = nil
87
87
  if cache[@get_all_key]
@@ -90,7 +90,7 @@ module Flipper
90
90
  response[key] = cache[key_for(key)]
91
91
  end
92
92
  else
93
- response = @adapter.get_all
93
+ response = @adapter.get_all(**kwargs)
94
94
  response.each do |key, value|
95
95
  cache[key_for(key)] = value
96
96
  end
@@ -103,7 +103,7 @@ module Flipper
103
103
  response.default_proc = ->(memo, key) { memo[key] = default_config }
104
104
  response
105
105
  else
106
- @adapter.get_all
106
+ @adapter.get_all(**kwargs)
107
107
  end
108
108
  end
109
109
 
@@ -54,7 +54,7 @@ module Flipper
54
54
  end
55
55
  end
56
56
 
57
- def get_all
57
+ def get_all(**kwargs)
58
58
  synchronize { Typecast.features_hash(@source) }
59
59
  end
60
60
 
@@ -66,7 +66,7 @@ module Flipper
66
66
  end
67
67
  end
68
68
 
69
- def get_all
69
+ def get_all(**kwargs)
70
70
  @store.transaction do
71
71
  features = read_feature_keys.map { |key| Flipper::Feature.new(key, self) }
72
72
  read_many_features(features)
@@ -10,11 +10,40 @@ module Flipper
10
10
  end
11
11
  end
12
12
 
13
+ class << self
14
+ # Returns whether sync mode is enabled for the current thread.
15
+ # When sync mode is enabled, strict checks are not enforced,
16
+ # allowing sync operations to add features and bring local state
17
+ # in line with remote state.
18
+ def sync_mode
19
+ Thread.current[:flipper_strict_sync_mode]
20
+ end
21
+
22
+ def sync_mode=(value)
23
+ Thread.current[:flipper_strict_sync_mode] = value
24
+ end
25
+
26
+ # Executes a block with sync mode enabled. Strict checks will
27
+ # not be enforced within the block.
28
+ def with_sync_mode
29
+ old_value = sync_mode
30
+ self.sync_mode = true
31
+ yield
32
+ ensure
33
+ self.sync_mode = old_value
34
+ end
35
+ end
36
+
13
37
  def initialize(adapter, handler = nil, &block)
14
38
  super(adapter)
15
39
  @handler = block || handler
16
40
  end
17
41
 
42
+ def add(feature)
43
+ assert_feature_exists(feature) unless self.class.sync_mode
44
+ super
45
+ end
46
+
18
47
  def get(feature)
19
48
  assert_feature_exists(feature)
20
49
  super
@@ -28,6 +57,7 @@ module Flipper
28
57
  private
29
58
 
30
59
  def assert_feature_exists(feature)
60
+ return if self.class.sync_mode
31
61
  return if @adapter.features.include?(feature.key)
32
62
 
33
63
  case handler
@@ -55,7 +55,11 @@ module Flipper
55
55
  def sync_expression
56
56
  return if local_expression == remote_expression
57
57
 
58
- @feature.enable_expression remote_expression
58
+ if remote_expression.nil?
59
+ @feature.disable_expression
60
+ else
61
+ @feature.enable_expression remote_expression
62
+ end
59
63
  end
60
64
 
61
65
  def sync_actors
@@ -1,5 +1,7 @@
1
1
  require "flipper/feature"
2
2
  require "flipper/gate_values"
3
+ require "flipper/adapters/actor_limit"
4
+ require "flipper/adapters/strict"
3
5
  require "flipper/adapters/sync/feature_synchronizer"
4
6
 
5
7
  module Flipper
@@ -16,27 +18,33 @@ module Flipper
16
18
  # options - The Hash of options.
17
19
  # :instrumenter - The instrumenter used to instrument.
18
20
  # :raise - Should errors be raised (default: true).
21
+ # :cache_bust - Should cache busting be used for remote get_all (default: false).
19
22
  def initialize(local, remote, options = {})
20
23
  @local = local
21
24
  @remote = remote
22
25
  @instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
23
26
  @raise = options.fetch(:raise, true)
27
+ @cache_bust = options.fetch(:cache_bust, false)
24
28
  end
25
29
 
26
30
  # Public: Forces a sync.
27
31
  def call
28
- @instrumenter.instrument("synchronizer_call.flipper") { sync }
32
+ @instrumenter.instrument("synchronizer_call.flipper") do
33
+ Flipper::Adapters::Strict.with_sync_mode do
34
+ Flipper::Adapters::ActorLimit.with_sync_mode { sync }
35
+ end
36
+ end
29
37
  end
30
38
 
31
39
  private
32
40
 
33
41
  def sync
34
42
  local_get_all = @local.get_all
35
- remote_get_all = @remote.get_all
43
+ remote_get_all = @remote.get_all(cache_bust: @cache_bust)
36
44
 
37
45
  # Sync all the gate values.
38
46
  remote_get_all.each do |feature_key, remote_gates_hash|
39
- feature = Feature.new(feature_key, @local)
47
+ feature = Feature.new(feature_key, @local, instrumenter: @instrumenter)
40
48
  # Check if feature_key is in hash before accessing to prevent unintended hash modification
41
49
  local_gates_hash = local_get_all.key?(feature_key) ? local_get_all[feature_key] : @local.default_config
42
50
  local_gate_values = GateValues.new(local_gates_hash)
@@ -46,11 +54,11 @@ module Flipper
46
54
 
47
55
  # Add features that are missing in local and present in remote.
48
56
  features_to_add = remote_get_all.keys - local_get_all.keys
49
- features_to_add.each { |key| Feature.new(key, @local).add }
57
+ features_to_add.each { |key| Feature.new(key, @local, instrumenter: @instrumenter).add }
50
58
 
51
59
  # Remove features that are present in local and missing in remote.
52
60
  features_to_remove = local_get_all.keys - remote_get_all.keys
53
- features_to_remove.each { |key| Feature.new(key, @local).remove }
61
+ features_to_remove.each { |key| Feature.new(key, @local, instrumenter: @instrumenter).remove }
54
62
 
55
63
  nil
56
64
  rescue => exception
@@ -9,7 +9,7 @@ module Flipper
9
9
  include ::Flipper::Adapter
10
10
 
11
11
  # Public: The synchronizer that will keep the local and remote in sync.
12
- attr_reader :synchronizer
12
+ attr_reader :synchronizer, :local, :remote
13
13
 
14
14
  # Public: Build a new sync instance.
15
15
  #
@@ -33,6 +33,10 @@ module Flipper
33
33
  synchronize
34
34
  end
35
35
 
36
+ def adapter_stack
37
+ "#{name}(local: #{@local.adapter_stack}, remote: #{@remote.adapter_stack})"
38
+ end
39
+
36
40
  def features
37
41
  synchronize
38
42
  @local.features
@@ -48,9 +52,9 @@ module Flipper
48
52
  @local.get_multi(features)
49
53
  end
50
54
 
51
- def get_all
55
+ def get_all(**kwargs)
52
56
  synchronize
53
- @local.get_all
57
+ @local.get_all(**kwargs)
54
58
  end
55
59
 
56
60
  def add(feature)
data/lib/flipper/cli.rb CHANGED
@@ -84,6 +84,57 @@ module Flipper
84
84
  end
85
85
  end
86
86
 
87
+ command 'export' do |c|
88
+ c.description = "Export features as JSON"
89
+ c.action do
90
+ export = Flipper.export(format: :json, version: 1)
91
+ ui.info export.contents
92
+ end
93
+ end
94
+
95
+ command 'cloud' do |c|
96
+ c.description = "Flipper Cloud commands"
97
+ c.action do |subcommand = nil, *args|
98
+ require 'flipper/cloud/migrate'
99
+
100
+ case subcommand
101
+ when 'migrate'
102
+ result = Flipper::Cloud.migrate(Flipper)
103
+ if result.url
104
+ ui.info "Migrating to Flipper Cloud..."
105
+ ui.info result.url
106
+ system("open", result.url)
107
+ else
108
+ message = "Migration failed (HTTP #{result.code})"
109
+ message << ": #{result.message}" if result.message
110
+ ui.error message
111
+ exit 1
112
+ end
113
+ when 'push'
114
+ token = args.first
115
+ unless token
116
+ ui.error "Usage: flipper cloud push <token>"
117
+ exit 1
118
+ end
119
+ result = Flipper::Cloud.push(token, Flipper)
120
+ if result.code == 204
121
+ ui.info "Successfully pushed features to Flipper Cloud"
122
+ else
123
+ message = "Push failed (HTTP #{result.code})"
124
+ message << ": #{result.message}" if result.message
125
+ ui.error message
126
+ exit 1
127
+ end
128
+ else
129
+ ui.info "Usage: flipper cloud <command>"
130
+ ui.info ""
131
+ ui.info "Commands:"
132
+ ui.info " migrate Migrate features to a new Flipper Cloud account"
133
+ ui.info " push Push features to an existing Flipper Cloud project"
134
+ end
135
+ end
136
+ end
137
+
87
138
  command 'help' do |c|
88
139
  c.load_environment = false
89
140
  c.action do |command = nil|
@@ -110,9 +110,10 @@ module Flipper
110
110
  end
111
111
 
112
112
  # Public: Force a sync.
113
- def sync
113
+ def sync(cache_bust: false)
114
114
  Flipper::Adapters::Sync::Synchronizer.new(local_adapter, http_adapter, {
115
115
  instrumenter: instrumenter,
116
+ cache_bust: cache_bust,
116
117
  }).call
117
118
  end
118
119
 
@@ -141,8 +142,7 @@ module Flipper
141
142
  private
142
143
 
143
144
  def app_adapter
144
- read_adapter = sync_method == :webhook ? local_adapter : poll_adapter
145
- Flipper::Adapters::DualWrite.new(read_adapter, http_adapter)
145
+ Flipper::Adapters::DualWrite.new(poll_adapter, http_adapter)
146
146
  end
147
147
 
148
148
  def poller
@@ -201,8 +201,13 @@ module Flipper
201
201
  end
202
202
 
203
203
  def setup_sync(options)
204
- set_option :sync_interval, options, default: 10, typecast: :float, minimum: 10
205
204
  set_option :sync_secret, options
205
+
206
+ # 1 hour for webhook, 10 seconds for poll. If using webhooks we don't
207
+ # need to sync as often but we should still sync occasionally to avoid
208
+ # any chance of stale data.
209
+ default_interval = sync_method == :webhook ? 3600 : 10
210
+ set_option :sync_interval, options, default: default_interval, typecast: :float, minimum: 10
206
211
  end
207
212
 
208
213
  def setup_adapter(options)
@@ -10,8 +10,8 @@ module Flipper
10
10
  super Flipper.new(@cloud_configuration.adapter, instrumenter: @cloud_configuration.instrumenter)
11
11
  end
12
12
 
13
- def sync
14
- @cloud_configuration.sync
13
+ def sync(**kwargs)
14
+ @cloud_configuration.sync(**kwargs)
15
15
  end
16
16
 
17
17
  def sync_secret
@@ -35,7 +35,7 @@ module Flipper
35
35
  message_verifier = MessageVerifier.new(secret: flipper.sync_secret)
36
36
  if message_verifier.verify(payload, signature)
37
37
  begin
38
- flipper.sync
38
+ flipper.sync(cache_bust: true)
39
39
  body = JSON.generate({
40
40
  groups: Flipper.group_names.map { |name| {name: name}}
41
41
  })
@@ -0,0 +1,71 @@
1
+ require "flipper/adapters/http/client"
2
+ require "flipper/typecast"
3
+
4
+ module Flipper
5
+ module Cloud
6
+ MigrateResult = Struct.new(:code, :url, :message, keyword_init: true)
7
+
8
+ DEFAULT_CLOUD_URL = "https://www.flippercloud.io".freeze
9
+
10
+ # Public: Migrate features to Flipper Cloud.
11
+ #
12
+ # flipper - The Flipper instance to export features from (default: Flipper).
13
+ # app_name - Optional String name of the application.
14
+ #
15
+ # Returns a MigrateResult with code, url, and message.
16
+ def self.migrate(flipper = Flipper, app_name: nil)
17
+ export = flipper.export(format: :json, version: 1)
18
+ payload = {
19
+ export: Typecast.from_json(export.contents),
20
+ metadata: {app_name: app_name},
21
+ }
22
+
23
+ client = build_client("/api")
24
+ response = client.post("/migrate", Typecast.to_gzip(Typecast.to_json(payload)))
25
+ body = Typecast.from_json(response.body) rescue nil
26
+
27
+ MigrateResult.new(
28
+ code: response.code.to_i,
29
+ url: body&.dig("url"),
30
+ message: body&.dig("error"),
31
+ )
32
+ end
33
+
34
+ # Public: Push features to an existing Flipper Cloud project.
35
+ #
36
+ # token - The String token for the Cloud environment.
37
+ # flipper - The Flipper instance to export features from (default: Flipper).
38
+ #
39
+ # Returns a MigrateResult with code and message.
40
+ def self.push(token, flipper = Flipper)
41
+ export = flipper.export(format: :json, version: 1)
42
+
43
+ client = build_client("/adapter", headers: {
44
+ "flipper-cloud-token" => token,
45
+ })
46
+ response = client.post("/import", Typecast.to_gzip(export.contents))
47
+ body = Typecast.from_json(response.body) rescue nil
48
+
49
+ MigrateResult.new(
50
+ code: response.code.to_i,
51
+ url: nil,
52
+ message: body&.dig("error"),
53
+ )
54
+ end
55
+
56
+ # Private: Build an HTTP client for Cloud API requests.
57
+ def self.build_client(path, headers: {})
58
+ base_url = ENV.fetch("FLIPPER_CLOUD_URL", DEFAULT_CLOUD_URL)
59
+
60
+ Flipper::Adapters::Http::Client.new(
61
+ url: "#{base_url}#{path}",
62
+ headers: {"content-encoding" => "gzip"}.merge(headers),
63
+ open_timeout: 5,
64
+ read_timeout: 30,
65
+ write_timeout: 30,
66
+ max_retries: 2,
67
+ )
68
+ end
69
+ private_class_method :build_client
70
+ end
71
+ end
@@ -168,7 +168,7 @@ module Flipper
168
168
  end
169
169
 
170
170
  if interval = response["telemetry-interval"]
171
- self.interval = interval.to_f
171
+ self.interval = interval
172
172
  end
173
173
  end
174
174
  rescue => error
data/lib/flipper/cloud.rb CHANGED
@@ -4,6 +4,7 @@ require "flipper/middleware/memoizer"
4
4
  require "flipper/cloud/configuration"
5
5
  require "flipper/cloud/dsl"
6
6
  require "flipper/cloud/middleware"
7
+ require "flipper/cloud/migrate"
7
8
 
8
9
  module Flipper
9
10
  module Cloud
data/lib/flipper/dsl.rb CHANGED
@@ -10,7 +10,7 @@ module Flipper
10
10
  # Private: What is being used to instrument all the things.
11
11
  attr_reader :instrumenter
12
12
 
13
- def_delegators :@adapter, :memoize=, :memoizing?, :import, :export
13
+ def_delegators :@adapter, :memoize=, :memoizing?, :import, :export, :adapter_stack
14
14
 
15
15
  # Public: Returns a new instance of the DSL.
16
16
  #