flipper 0.28.3 → 1.4.2

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 (204) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.github/workflows/ci.yml +56 -10
  4. data/.github/workflows/examples.yml +54 -11
  5. data/.github/workflows/release.yml +54 -0
  6. data/.superset/config.json +4 -0
  7. data/CLAUDE.md +93 -0
  8. data/Changelog.md +1 -559
  9. data/Gemfile +17 -8
  10. data/README.md +34 -29
  11. data/Rakefile +2 -2
  12. data/benchmark/typecast_ips.rb +8 -0
  13. data/docs/images/banner.jpg +0 -0
  14. data/docs/images/flipper_cloud.png +0 -0
  15. data/examples/cloud/app.ru +12 -0
  16. data/examples/cloud/backoff_policy.rb +13 -0
  17. data/examples/cloud/basic.rb +22 -0
  18. data/examples/cloud/cloud_setup.rb +20 -0
  19. data/examples/cloud/forked.rb +36 -0
  20. data/examples/cloud/import.rb +17 -0
  21. data/examples/cloud/poll_interval/README.md +111 -0
  22. data/examples/cloud/poll_interval/client.rb +108 -0
  23. data/examples/cloud/poll_interval/server.rb +98 -0
  24. data/examples/cloud/threaded.rb +33 -0
  25. data/examples/dsl.rb +0 -14
  26. data/examples/expressions.rb +237 -0
  27. data/examples/strict.rb +18 -0
  28. data/exe/flipper +5 -0
  29. data/flipper-cloud.gemspec +19 -0
  30. data/flipper.gemspec +8 -4
  31. data/lib/flipper/actor.rb +6 -3
  32. data/lib/flipper/adapter.rb +27 -1
  33. data/lib/flipper/adapter_builder.rb +44 -0
  34. data/lib/flipper/adapters/actor_limit.rb +54 -0
  35. data/lib/flipper/adapters/cache_base.rb +161 -0
  36. data/lib/flipper/adapters/dual_write.rb +7 -5
  37. data/lib/flipper/adapters/failover.rb +9 -7
  38. data/lib/flipper/adapters/failsafe.rb +2 -6
  39. data/lib/flipper/adapters/http/client.rb +53 -14
  40. data/lib/flipper/adapters/http/error.rb +2 -2
  41. data/lib/flipper/adapters/http.rb +56 -16
  42. data/lib/flipper/adapters/instrumented.rb +2 -6
  43. data/lib/flipper/adapters/memoizable.rb +17 -22
  44. data/lib/flipper/adapters/memory.rb +5 -7
  45. data/lib/flipper/adapters/operation_logger.rb +18 -92
  46. data/lib/flipper/adapters/poll.rb +16 -3
  47. data/lib/flipper/adapters/pstore.rb +18 -12
  48. data/lib/flipper/adapters/read_only.rb +8 -41
  49. data/lib/flipper/adapters/strict.rb +75 -0
  50. data/lib/flipper/adapters/sync/feature_synchronizer.rb +14 -1
  51. data/lib/flipper/adapters/sync/synchronizer.rb +13 -5
  52. data/lib/flipper/adapters/sync.rb +7 -7
  53. data/lib/flipper/adapters/wrapper.rb +54 -0
  54. data/lib/flipper/cli.rb +314 -0
  55. data/lib/flipper/cloud/configuration.rb +267 -0
  56. data/lib/flipper/cloud/dsl.rb +27 -0
  57. data/lib/flipper/cloud/message_verifier.rb +95 -0
  58. data/lib/flipper/cloud/middleware.rb +63 -0
  59. data/lib/flipper/cloud/migrate.rb +71 -0
  60. data/lib/flipper/cloud/routes.rb +14 -0
  61. data/lib/flipper/cloud/telemetry/backoff_policy.rb +96 -0
  62. data/lib/flipper/cloud/telemetry/instrumenter.rb +22 -0
  63. data/lib/flipper/cloud/telemetry/metric.rb +39 -0
  64. data/lib/flipper/cloud/telemetry/metric_storage.rb +30 -0
  65. data/lib/flipper/cloud/telemetry/submitter.rb +100 -0
  66. data/lib/flipper/cloud/telemetry.rb +191 -0
  67. data/lib/flipper/cloud.rb +54 -0
  68. data/lib/flipper/configuration.rb +25 -4
  69. data/lib/flipper/dsl.rb +48 -43
  70. data/lib/flipper/engine.rb +102 -0
  71. data/lib/flipper/export.rb +0 -2
  72. data/lib/flipper/exporters/json/export.rb +1 -1
  73. data/lib/flipper/exporters/json/v1.rb +1 -1
  74. data/lib/flipper/expression/builder.rb +73 -0
  75. data/lib/flipper/expression/constant.rb +25 -0
  76. data/lib/flipper/expression.rb +71 -0
  77. data/lib/flipper/expressions/all.rb +9 -0
  78. data/lib/flipper/expressions/any.rb +9 -0
  79. data/lib/flipper/expressions/boolean.rb +9 -0
  80. data/lib/flipper/expressions/comparable.rb +13 -0
  81. data/lib/flipper/expressions/equal.rb +9 -0
  82. data/lib/flipper/expressions/feature_enabled.rb +34 -0
  83. data/lib/flipper/expressions/greater_than.rb +9 -0
  84. data/lib/flipper/expressions/greater_than_or_equal_to.rb +9 -0
  85. data/lib/flipper/expressions/less_than.rb +9 -0
  86. data/lib/flipper/expressions/less_than_or_equal_to.rb +9 -0
  87. data/lib/flipper/expressions/not_equal.rb +9 -0
  88. data/lib/flipper/expressions/now.rb +9 -0
  89. data/lib/flipper/expressions/number.rb +9 -0
  90. data/lib/flipper/expressions/percentage.rb +9 -0
  91. data/lib/flipper/expressions/percentage_of_actors.rb +12 -0
  92. data/lib/flipper/expressions/property.rb +9 -0
  93. data/lib/flipper/expressions/random.rb +9 -0
  94. data/lib/flipper/expressions/string.rb +9 -0
  95. data/lib/flipper/expressions/time.rb +16 -0
  96. data/lib/flipper/feature.rb +63 -1
  97. data/lib/flipper/gate.rb +2 -1
  98. data/lib/flipper/gate_values.rb +5 -2
  99. data/lib/flipper/gates/expression.rb +75 -0
  100. data/lib/flipper/instrumentation/log_subscriber.rb +13 -5
  101. data/lib/flipper/instrumentation/statsd.rb +4 -2
  102. data/lib/flipper/instrumentation/statsd_subscriber.rb +2 -4
  103. data/lib/flipper/instrumentation/subscriber.rb +0 -4
  104. data/lib/flipper/metadata.rb +8 -1
  105. data/lib/flipper/middleware/memoizer.rb +30 -14
  106. data/lib/flipper/model/active_record.rb +23 -0
  107. data/lib/flipper/poller.rb +54 -12
  108. data/lib/flipper/serializers/gzip.rb +22 -0
  109. data/lib/flipper/serializers/json.rb +17 -0
  110. data/lib/flipper/spec/shared_adapter_specs.rb +82 -63
  111. data/lib/flipper/test/shared_adapter_test.rb +77 -58
  112. data/lib/flipper/test_help.rb +43 -0
  113. data/lib/flipper/typecast.rb +37 -9
  114. data/lib/flipper/types/percentage.rb +1 -1
  115. data/lib/flipper/version.rb +11 -1
  116. data/lib/flipper.rb +61 -8
  117. data/lib/generators/flipper/setup_generator.rb +68 -0
  118. data/lib/generators/flipper/templates/initializer.rb +45 -0
  119. data/lib/generators/flipper/templates/update/migrations/01_create_flipper_tables.rb.erb +22 -0
  120. data/lib/generators/flipper/templates/update/migrations/02_change_flipper_gates_value_to_text.rb.erb +18 -0
  121. data/lib/generators/flipper/update_generator.rb +35 -0
  122. data/package-lock.json +41 -0
  123. data/package.json +10 -0
  124. data/spec/fixtures/environment.rb +1 -0
  125. data/spec/flipper/adapter_builder_spec.rb +72 -0
  126. data/spec/flipper/adapter_spec.rb +21 -0
  127. data/spec/flipper/adapters/actor_limit_spec.rb +75 -0
  128. data/spec/flipper/adapters/dual_write_spec.rb +15 -2
  129. data/spec/flipper/adapters/failover_spec.rb +12 -0
  130. data/spec/flipper/adapters/http/client_spec.rb +61 -0
  131. data/spec/flipper/adapters/http_spec.rb +375 -74
  132. data/spec/flipper/adapters/instrumented_spec.rb +1 -1
  133. data/spec/flipper/adapters/memoizable_spec.rb +21 -21
  134. data/spec/flipper/adapters/operation_logger_spec.rb +2 -2
  135. data/spec/flipper/adapters/poll_spec.rb +41 -0
  136. data/spec/flipper/adapters/read_only_spec.rb +32 -17
  137. data/spec/flipper/adapters/strict_spec.rb +122 -0
  138. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +39 -0
  139. data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
  140. data/spec/flipper/adapters/sync_spec.rb +13 -0
  141. data/spec/flipper/cli_spec.rb +217 -0
  142. data/spec/flipper/cloud/configuration_spec.rb +251 -0
  143. data/spec/flipper/cloud/dsl_spec.rb +82 -0
  144. data/spec/flipper/cloud/message_verifier_spec.rb +104 -0
  145. data/spec/flipper/cloud/middleware_spec.rb +289 -0
  146. data/spec/flipper/cloud/migrate_spec.rb +160 -0
  147. data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +107 -0
  148. data/spec/flipper/cloud/telemetry/metric_spec.rb +87 -0
  149. data/spec/flipper/cloud/telemetry/metric_storage_spec.rb +58 -0
  150. data/spec/flipper/cloud/telemetry/submitter_spec.rb +145 -0
  151. data/spec/flipper/cloud/telemetry_spec.rb +208 -0
  152. data/spec/flipper/cloud_spec.rb +186 -0
  153. data/spec/flipper/configuration_spec.rb +17 -0
  154. data/spec/flipper/dsl_spec.rb +34 -73
  155. data/spec/flipper/engine_spec.rb +374 -0
  156. data/spec/flipper/exporters/json/v1_spec.rb +3 -3
  157. data/spec/flipper/expression/builder_spec.rb +248 -0
  158. data/spec/flipper/expression_spec.rb +188 -0
  159. data/spec/flipper/expressions/all_spec.rb +15 -0
  160. data/spec/flipper/expressions/any_spec.rb +15 -0
  161. data/spec/flipper/expressions/boolean_spec.rb +15 -0
  162. data/spec/flipper/expressions/equal_spec.rb +24 -0
  163. data/spec/flipper/expressions/greater_than_or_equal_to_spec.rb +28 -0
  164. data/spec/flipper/expressions/greater_than_spec.rb +28 -0
  165. data/spec/flipper/expressions/less_than_or_equal_to_spec.rb +28 -0
  166. data/spec/flipper/expressions/less_than_spec.rb +32 -0
  167. data/spec/flipper/expressions/not_equal_spec.rb +15 -0
  168. data/spec/flipper/expressions/now_spec.rb +11 -0
  169. data/spec/flipper/expressions/number_spec.rb +21 -0
  170. data/spec/flipper/expressions/percentage_of_actors_spec.rb +20 -0
  171. data/spec/flipper/expressions/percentage_spec.rb +15 -0
  172. data/spec/flipper/expressions/property_spec.rb +13 -0
  173. data/spec/flipper/expressions/random_spec.rb +9 -0
  174. data/spec/flipper/expressions/string_spec.rb +11 -0
  175. data/spec/flipper/expressions/time_spec.rb +29 -0
  176. data/spec/flipper/feature_spec.rb +380 -10
  177. data/spec/flipper/gate_values_spec.rb +2 -2
  178. data/spec/flipper/gates/expression_spec.rb +190 -0
  179. data/spec/flipper/identifier_spec.rb +4 -5
  180. data/spec/flipper/instrumentation/log_subscriber_spec.rb +10 -2
  181. data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +16 -2
  182. data/spec/flipper/middleware/memoizer_spec.rb +107 -7
  183. data/spec/flipper/model/active_record_spec.rb +72 -0
  184. data/spec/flipper/poller_spec.rb +347 -4
  185. data/spec/flipper/serializers/gzip_spec.rb +13 -0
  186. data/spec/flipper/serializers/json_spec.rb +13 -0
  187. data/spec/flipper/typecast_spec.rb +43 -7
  188. data/spec/flipper/types/actor_spec.rb +18 -1
  189. data/spec/flipper_integration_spec.rb +247 -16
  190. data/spec/flipper_spec.rb +92 -29
  191. data/spec/spec_helper.rb +24 -17
  192. data/spec/support/actor_names.yml +1 -0
  193. data/spec/support/fail_on_output.rb +8 -0
  194. data/spec/support/fake_backoff_policy.rb +15 -0
  195. data/spec/support/spec_helpers.rb +34 -8
  196. data/test/adapters/actor_limit_test.rb +20 -0
  197. data/test_rails/generators/flipper/setup_generator_test.rb +69 -0
  198. data/test_rails/generators/flipper/update_generator_test.rb +96 -0
  199. data/test_rails/helper.rb +22 -2
  200. data/test_rails/system/test_help_test.rb +52 -0
  201. metadata +139 -75
  202. data/.tool-versions +0 -1
  203. data/lib/flipper/railtie.rb +0 -47
  204. data/spec/flipper/railtie_spec.rb +0 -109
@@ -0,0 +1,289 @@
1
+ require 'securerandom'
2
+ require 'flipper/cloud'
3
+ require 'flipper/cloud/middleware'
4
+ require 'flipper/adapters/operation_logger'
5
+
6
+ RSpec.describe Flipper::Cloud::Middleware do
7
+ let(:flipper) {
8
+ Flipper::Cloud.new(token: "regular") do |config|
9
+ config.local_adapter = Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new)
10
+ config.sync_secret = "regular_tasty"
11
+ end
12
+ }
13
+
14
+ let(:env_flipper) {
15
+ Flipper::Cloud.new(token: "env") do |config|
16
+ config.local_adapter = Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new)
17
+ config.sync_secret = "env_tasty"
18
+ end
19
+ }
20
+
21
+ let(:app) { Flipper::Cloud.app(flipper) }
22
+ let(:response_body) { JSON.generate({features: {}}) }
23
+ let(:request_body) {
24
+ JSON.generate({
25
+ "environment_id" => 1,
26
+ "webhook_id" => 1,
27
+ "delivery_id" => SecureRandom.uuid,
28
+ "action" => "sync",
29
+ })
30
+ }
31
+ let(:timestamp) { Time.now }
32
+ let(:signature) {
33
+ Flipper::Cloud::MessageVerifier.new(secret: flipper.sync_secret).generate(request_body, timestamp)
34
+ }
35
+ let(:signature_header_value) {
36
+ Flipper::Cloud::MessageVerifier.new(secret: "").header(signature, timestamp)
37
+ }
38
+
39
+ context 'when initializing middleware with flipper instance' do
40
+ let(:app) { Flipper::Cloud.app(flipper) }
41
+
42
+ it 'uses instance to sync' do
43
+ Flipper.register(:admins) { |*args| false }
44
+ Flipper.register(:staff) { |*args| false }
45
+ Flipper.register(:basic) { |*args| false }
46
+ Flipper.register(:plus) { |*args| false }
47
+ Flipper.register(:premium) { |*args| false }
48
+
49
+ stub = stub_request_for_token('regular')
50
+ env = {
51
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
52
+ }
53
+ post '/', request_body, env
54
+
55
+ expect(last_response.status).to eq(200)
56
+ expect(JSON.parse(last_response.body)).to eq({
57
+ "groups" => [
58
+ {"name" => "admins"},
59
+ {"name" => "staff"},
60
+ {"name" => "basic"},
61
+ {"name" => "plus"},
62
+ {"name" => "premium"},
63
+ ],
64
+ })
65
+ expect(stub).to have_been_requested
66
+ end
67
+ end
68
+
69
+ context 'when signature is invalid' do
70
+ let(:app) { Flipper::Cloud.app(flipper) }
71
+ let(:signature) {
72
+ Flipper::Cloud::MessageVerifier.new(secret: "nope").generate(request_body, timestamp)
73
+ }
74
+
75
+ it 'uses instance to sync' do
76
+ stub = stub_request_for_token('regular')
77
+ env = {
78
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
79
+ }
80
+ post '/', request_body, env
81
+
82
+ expect(last_response.status).to eq(400)
83
+ expect(stub).not_to have_been_requested
84
+ end
85
+ end
86
+
87
+ context "when flipper cloud responds with 402" do
88
+ let(:app) { Flipper::Cloud.app(flipper) }
89
+
90
+ it "results in 402" do
91
+ Flipper.register(:admins) { |*args| false }
92
+ Flipper.register(:staff) { |*args| false }
93
+ Flipper.register(:basic) { |*args| false }
94
+ Flipper.register(:plus) { |*args| false }
95
+ Flipper.register(:premium) { |*args| false }
96
+
97
+ stub = stub_request_for_token('regular', status: 402)
98
+ env = {
99
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
100
+ }
101
+ post '/', request_body, env
102
+
103
+ expect(last_response.status).to eq(402)
104
+ expect(last_response.headers["flipper-cloud-response-error-class"]).to eq("Flipper::Adapters::Http::Error")
105
+ expect(last_response.headers["flipper-cloud-response-error-message"]).to include("Failed with status: 402")
106
+ expect(stub).to have_been_requested
107
+ end
108
+ end
109
+
110
+ context "when flipper cloud responds with non-402 and non-2xx code" do
111
+ let(:app) { Flipper::Cloud.app(flipper) }
112
+
113
+ it "results in 500" do
114
+ Flipper.register(:admins) { |*args| false }
115
+ Flipper.register(:staff) { |*args| false }
116
+ Flipper.register(:basic) { |*args| false }
117
+ Flipper.register(:plus) { |*args| false }
118
+ Flipper.register(:premium) { |*args| false }
119
+
120
+ stub = stub_request_for_token('regular', status: 503)
121
+ env = {
122
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
123
+ }
124
+ post '/', request_body, env
125
+
126
+ expect(last_response.status).to eq(500)
127
+ expect(last_response.headers["flipper-cloud-response-error-class"]).to eq("Flipper::Adapters::Http::Error")
128
+ expect(last_response.headers["flipper-cloud-response-error-message"]).to include("Failed with status: 503")
129
+ expect(stub).to have_been_requested
130
+ end
131
+ end
132
+
133
+ context "when flipper cloud responds with timeout" do
134
+ let(:app) { Flipper::Cloud.app(flipper) }
135
+
136
+ it "results in 500" do
137
+ Flipper.register(:admins) { |*args| false }
138
+ Flipper.register(:staff) { |*args| false }
139
+ Flipper.register(:basic) { |*args| false }
140
+ Flipper.register(:plus) { |*args| false }
141
+ Flipper.register(:premium) { |*args| false }
142
+
143
+ stub = stub_request_for_token('regular', status: :timeout)
144
+ env = {
145
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
146
+ }
147
+ post '/', request_body, env
148
+
149
+ expect(last_response.status).to eq(500)
150
+ expect(last_response.headers["flipper-cloud-response-error-class"]).to eq("Net::OpenTimeout")
151
+ expect(last_response.headers["flipper-cloud-response-error-message"]).to eq("execution expired")
152
+ expect(stub).to have_been_requested
153
+ end
154
+ end
155
+
156
+ context 'when initialized with flipper instance and flipper instance in env' do
157
+ let(:app) { Flipper::Cloud.app(flipper) }
158
+ let(:signature) {
159
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
160
+ }
161
+
162
+ it 'uses env instance to sync' do
163
+ stub = stub_request_for_token('env')
164
+ env = {
165
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
166
+ 'flipper' => env_flipper,
167
+ }
168
+ post '/', request_body, env
169
+
170
+ expect(last_response.status).to eq(200)
171
+ expect(stub).to have_been_requested
172
+ end
173
+ end
174
+
175
+ context 'when initialized without flipper instance but flipper instance in env' do
176
+ let(:app) { Flipper::Cloud.app }
177
+ let(:signature) {
178
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
179
+ }
180
+
181
+ it 'uses env instance to sync' do
182
+ stub = stub_request_for_token('env')
183
+ env = {
184
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
185
+ 'flipper' => env_flipper,
186
+ }
187
+ post '/', request_body, env
188
+
189
+ expect(last_response.status).to eq(200)
190
+ expect(stub).to have_been_requested
191
+ end
192
+ end
193
+
194
+ context 'when initialized with env_key' do
195
+ let(:app) { Flipper::Cloud.app(flipper, env_key: 'flipper_cloud') }
196
+ let(:signature) {
197
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
198
+ }
199
+
200
+ it 'uses provided env key instead of default' do
201
+ stub = stub_request_for_token('env')
202
+ env = {
203
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
204
+ 'flipper' => flipper,
205
+ 'flipper_cloud' => env_flipper,
206
+ }
207
+ post '/', request_body, env
208
+
209
+ expect(last_response.status).to eq(200)
210
+ expect(stub).to have_been_requested
211
+ end
212
+ end
213
+
214
+ context 'when initializing lazily with a block' do
215
+ let(:app) { Flipper::Cloud.app(-> { flipper }) }
216
+
217
+ it 'works' do
218
+ stub = stub_request_for_token('regular')
219
+ env = {
220
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
221
+ }
222
+ post '/', request_body, env
223
+
224
+ expect(last_response.status).to eq(200)
225
+ expect(stub).to have_been_requested
226
+ end
227
+ end
228
+
229
+ context 'when using older /webhooks path' do
230
+ let(:app) { Flipper::Cloud.app(flipper) }
231
+
232
+ it 'uses instance to sync' do
233
+ Flipper.register(:admins) { |*args| false }
234
+ Flipper.register(:staff) { |*args| false }
235
+ Flipper.register(:basic) { |*args| false }
236
+ Flipper.register(:plus) { |*args| false }
237
+ Flipper.register(:premium) { |*args| false }
238
+
239
+ stub = stub_request_for_token('regular')
240
+ env = {
241
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
242
+ }
243
+ post '/webhooks', request_body, env
244
+
245
+ expect(last_response.status).to eq(200)
246
+ expect(JSON.parse(last_response.body)).to eq({
247
+ "groups" => [
248
+ {"name" => "admins"},
249
+ {"name" => "staff"},
250
+ {"name" => "basic"},
251
+ {"name" => "plus"},
252
+ {"name" => "premium"},
253
+ ],
254
+ })
255
+ expect(stub).to have_been_requested
256
+ end
257
+ end
258
+
259
+ describe 'Request method unsupported' do
260
+ it 'skips middleware' do
261
+ get '/'
262
+ expect(last_response.status).to eq(404)
263
+ expect(last_response.content_type).to eq("application/json")
264
+ expect(last_response.body).to eq("{}")
265
+ end
266
+ end
267
+
268
+ describe 'Inspecting the built Rack app' do
269
+ it 'returns a String' do
270
+ expect(Flipper::Cloud.app(flipper).inspect).to eq("Flipper::Cloud")
271
+ end
272
+ end
273
+
274
+ private
275
+
276
+ def stub_request_for_token(token, status: 200)
277
+ stub = stub_request(:get, %r{\Ahttps://www\.flippercloud\.io/adapter/features\?(?=.*\bexclude_gate_names=true\b)(?=.*\b_cb=\d+\b)}).
278
+ with({
279
+ headers: {
280
+ 'flipper-cloud-token' => token,
281
+ },
282
+ })
283
+ if status == :timeout
284
+ stub.to_timeout
285
+ else
286
+ stub.to_return(status: status, body: response_body)
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,160 @@
1
+ require "flipper/cloud/migrate"
2
+ require "flipper/typecast"
3
+ require "webmock/rspec"
4
+
5
+ RSpec.describe Flipper::Cloud, ".migrate" do
6
+ let(:flipper) { Flipper.new(Flipper::Adapters::Memory.new) }
7
+
8
+ before do
9
+ flipper.enable :search
10
+ flipper.disable :analytics
11
+ flipper.enable_percentage_of_actors :checkout, 50
12
+ end
13
+
14
+ around do |example|
15
+ original = ENV["FLIPPER_CLOUD_URL"]
16
+ ENV["FLIPPER_CLOUD_URL"] = "http://localhost:5555"
17
+ example.run
18
+ ensure
19
+ ENV["FLIPPER_CLOUD_URL"] = original
20
+ end
21
+
22
+ def decompress_request_body
23
+ raw = WebMock::RequestRegistry.instance.requested_signatures.hash.keys.last.body
24
+ JSON.parse(Flipper::Typecast.from_gzip(raw))
25
+ end
26
+
27
+ describe ".migrate" do
28
+ it "returns a MigrateResult with code and url on success" do
29
+ stub_request(:post, "http://localhost:5555/api/migrate")
30
+ .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc123"}', headers: {"Content-Type" => "application/json"})
31
+
32
+ result = Flipper::Cloud.migrate(flipper)
33
+
34
+ expect(result).to be_a(Flipper::Cloud::MigrateResult)
35
+ expect(result.code).to eq(200)
36
+ expect(result.url).to eq("http://localhost:5555/cloud/setup/abc123")
37
+ end
38
+
39
+ it "sends export data and metadata in the request body" do
40
+ stub = stub_request(:post, "http://localhost:5555/api/migrate")
41
+ .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc123"}')
42
+
43
+ Flipper::Cloud.migrate(flipper, app_name: "MyApp")
44
+
45
+ expect(stub).to have_been_requested
46
+ body = decompress_request_body
47
+ expect(body["metadata"]["app_name"]).to eq("MyApp")
48
+ expect(body["export"]["version"]).to eq(1)
49
+ expect(body["export"]["features"]).to have_key("search")
50
+ end
51
+
52
+ it "sends gzip-compressed request body" do
53
+ stub = stub_request(:post, "http://localhost:5555/api/migrate")
54
+ .with(headers: {"content-encoding" => "gzip"})
55
+ .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}')
56
+
57
+ Flipper::Cloud.migrate(flipper)
58
+
59
+ expect(stub).to have_been_requested
60
+ body = decompress_request_body
61
+ expect(body["export"]["features"]).to have_key("search")
62
+ end
63
+
64
+ it "handles error responses" do
65
+ stub_request(:post, "http://localhost:5555/api/migrate")
66
+ .to_return(status: 500, body: '{"error":"Internal Server Error"}')
67
+
68
+ result = Flipper::Cloud.migrate(flipper)
69
+
70
+ expect(result.code).to eq(500)
71
+ expect(result.url).to be_nil
72
+ end
73
+
74
+ it "includes error message from response body" do
75
+ stub_request(:post, "http://localhost:5555/api/migrate")
76
+ .to_return(status: 422, body: '{"error":"Invalid export format"}')
77
+
78
+ result = Flipper::Cloud.migrate(flipper)
79
+
80
+ expect(result.code).to eq(422)
81
+ expect(result.message).to eq("Invalid export format")
82
+ end
83
+
84
+ it "uses FLIPPER_CLOUD_URL environment variable" do
85
+ stub = stub_request(:post, "http://localhost:5555/api/migrate")
86
+ .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}')
87
+
88
+ Flipper::Cloud.migrate(flipper)
89
+
90
+ expect(stub).to have_been_requested
91
+ end
92
+
93
+ it "sends content-type and accept headers" do
94
+ stub = stub_request(:post, "http://localhost:5555/api/migrate")
95
+ .with(headers: {
96
+ "content-type" => "application/json",
97
+ "accept" => "application/json",
98
+ })
99
+ .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}')
100
+
101
+ Flipper::Cloud.migrate(flipper)
102
+
103
+ expect(stub).to have_been_requested
104
+ end
105
+ end
106
+
107
+ describe ".push" do
108
+ it "returns a MigrateResult with code on success" do
109
+ stub_request(:post, "http://localhost:5555/adapter/import")
110
+ .to_return(status: 204, body: "")
111
+
112
+ result = Flipper::Cloud.push("test-token", flipper)
113
+
114
+ expect(result).to be_a(Flipper::Cloud::MigrateResult)
115
+ expect(result.code).to eq(204)
116
+ end
117
+
118
+ it "sends the token as a header" do
119
+ stub = stub_request(:post, "http://localhost:5555/adapter/import")
120
+ .with(headers: {"flipper-cloud-token" => "test-token"})
121
+ .to_return(status: 204, body: "")
122
+
123
+ Flipper::Cloud.push("test-token", flipper)
124
+
125
+ expect(stub).to have_been_requested
126
+ end
127
+
128
+ it "sends gzip-compressed export contents as the body" do
129
+ stub = stub_request(:post, "http://localhost:5555/adapter/import")
130
+ .with(headers: {"content-encoding" => "gzip"})
131
+ .to_return(status: 204, body: "")
132
+
133
+ Flipper::Cloud.push("test-token", flipper)
134
+
135
+ expect(stub).to have_been_requested
136
+ body = decompress_request_body
137
+ expect(body["version"]).to eq(1)
138
+ expect(body["features"]).to have_key("search")
139
+ end
140
+
141
+ it "handles error responses" do
142
+ stub_request(:post, "http://localhost:5555/adapter/import")
143
+ .to_return(status: 401, body: '{"error":"Unauthorized"}')
144
+
145
+ result = Flipper::Cloud.push("bad-token", flipper)
146
+
147
+ expect(result.code).to eq(401)
148
+ end
149
+
150
+ it "includes error message from response body" do
151
+ stub_request(:post, "http://localhost:5555/adapter/import")
152
+ .to_return(status: 401, body: '{"error":"Invalid token"}')
153
+
154
+ result = Flipper::Cloud.push("bad-token", flipper)
155
+
156
+ expect(result.code).to eq(401)
157
+ expect(result.message).to eq("Invalid token")
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,107 @@
1
+ require 'flipper/cloud/telemetry/backoff_policy'
2
+
3
+ RSpec.describe Flipper::Cloud::Telemetry::BackoffPolicy do
4
+ context "#initialize" do
5
+ it "with no options" do
6
+ policy = described_class.new
7
+ expect(policy.min_timeout_ms).to eq(30_000)
8
+ expect(policy.max_timeout_ms).to eq(120_000)
9
+ expect(policy.multiplier).to eq(1.5)
10
+ expect(policy.randomization_factor).to eq(0.5)
11
+ end
12
+
13
+ it "with options" do
14
+ policy = described_class.new({
15
+ min_timeout_ms: 1234,
16
+ max_timeout_ms: 5678,
17
+ multiplier: 24,
18
+ randomization_factor: 0.4,
19
+ })
20
+ expect(policy.min_timeout_ms).to eq(1234)
21
+ expect(policy.max_timeout_ms).to eq(5678)
22
+ expect(policy.multiplier).to eq(24)
23
+ expect(policy.randomization_factor).to eq(0.4)
24
+ end
25
+
26
+ it "with min higher than max" do
27
+ expect {
28
+ described_class.new({
29
+ min_timeout_ms: 2,
30
+ max_timeout_ms: 1,
31
+ })
32
+ }.to raise_error(ArgumentError, ":min_timeout_ms (2) must be <= :max_timeout_ms (1)")
33
+ end
34
+
35
+ it "with invalid min_timeout_ms" do
36
+ expect {
37
+ described_class.new({
38
+ min_timeout_ms: -1,
39
+ })
40
+ }.to raise_error(ArgumentError, ":min_timeout_ms must be >= 0 but was -1")
41
+ end
42
+
43
+ it "with invalid max_timeout_ms" do
44
+ expect {
45
+ described_class.new({
46
+ max_timeout_ms: -1,
47
+ })
48
+ }.to raise_error(ArgumentError, ":max_timeout_ms must be >= 0 but was -1")
49
+ end
50
+
51
+ it "from env" do
52
+ ENV.update(
53
+ "FLIPPER_BACKOFF_MIN_TIMEOUT_MS" => "1000",
54
+ "FLIPPER_BACKOFF_MAX_TIMEOUT_MS" => "2000",
55
+ "FLIPPER_BACKOFF_MULTIPLIER" => "1.9",
56
+ "FLIPPER_BACKOFF_RANDOMIZATION_FACTOR" => "0.1",
57
+ )
58
+
59
+ policy = described_class.new
60
+ expect(policy.min_timeout_ms).to eq(1000)
61
+ expect(policy.max_timeout_ms).to eq(2000)
62
+ expect(policy.multiplier).to eq(1.9)
63
+ expect(policy.randomization_factor).to eq(0.1)
64
+ end
65
+ end
66
+
67
+ context "#next_interval" do
68
+ it "works" do
69
+ policy = described_class.new({
70
+ min_timeout_ms: 1_000,
71
+ max_timeout_ms: 10_000,
72
+ multiplier: 2,
73
+ randomization_factor: 0.5,
74
+ })
75
+
76
+ expect(policy.next_interval).to be_within(500).of(1000)
77
+ expect(policy.next_interval).to be_within(1000).of(2000)
78
+ expect(policy.next_interval).to be_within(2000).of(4000)
79
+ expect(policy.next_interval).to be_within(4000).of(8000)
80
+ end
81
+
82
+ it "caps maximum duration at max_timeout_secs" do
83
+ policy = described_class.new({
84
+ min_timeout_ms: 1_000,
85
+ max_timeout_ms: 10_000,
86
+ multiplier: 2,
87
+ randomization_factor: 0.5,
88
+ })
89
+ 10.times { policy.next_interval }
90
+ expect(policy.next_interval).to be_within(10_000*0.1).of(10_000)
91
+ end
92
+ end
93
+
94
+ it "can reset" do
95
+ policy = described_class.new({
96
+ min_timeout_ms: 1_000,
97
+ max_timeout_ms: 10_000,
98
+ multiplier: 2,
99
+ randomization_factor: 0.5,
100
+ })
101
+ 10.times { policy.next_interval }
102
+
103
+ expect(policy.attempts).to eq(10)
104
+ policy.reset
105
+ expect(policy.attempts).to eq(0)
106
+ end
107
+ end
@@ -0,0 +1,87 @@
1
+ require 'flipper/cloud/telemetry/metric'
2
+
3
+ RSpec.describe Flipper::Cloud::Telemetry::Metric do
4
+ it 'has key, result and time' do
5
+ metric = described_class.new(:search, true, 1696793160)
6
+ expect(metric.key).to eq(:search)
7
+ expect(metric.result).to eq(true)
8
+ expect(metric.time).to eq(1696793160)
9
+ end
10
+
11
+ it "clamps time to minute" do
12
+ metric = described_class.new(:search, true, 1696793204)
13
+ expect(metric.time).to eq(1696793160)
14
+ end
15
+
16
+ describe "#eql?" do
17
+ it "returns true when key, time and result are the same" do
18
+ metric = described_class.new(:search, true, 1696793204)
19
+ other = described_class.new(:search, true, 1696793204)
20
+ expect(metric.eql?(other)).to be(true)
21
+ end
22
+
23
+ it "returns false for other class" do
24
+ metric = described_class.new(:search, true, 1696793204)
25
+ other = Object.new
26
+ expect(metric.eql?(other)).to be(false)
27
+ end
28
+
29
+ it "returns false for sub class" do
30
+ metric = described_class.new(:search, true, 1696793204)
31
+ other = Class.new(described_class).new(:search, true, 1696793204)
32
+ expect(metric.eql?(other)).to be(false)
33
+ end
34
+
35
+ it "returns false if key is different" do
36
+ metric = described_class.new(:search, true, 1696793204)
37
+ other = described_class.new(:other, true, 1696793204)
38
+ expect(metric.eql?(other)).to be(false)
39
+ end
40
+
41
+ it "returns false if time is different" do
42
+ metric = described_class.new(:search, true, 1696793204)
43
+ other = described_class.new(:search, true, 1696793204 - 60 - 60)
44
+ expect(metric.eql?(other)).to be(false)
45
+ end
46
+
47
+ it "returns true with different times if times are in same minute" do
48
+ metric = described_class.new(:search, true, 1696793204)
49
+ other = described_class.new(:search, true, 1696793206)
50
+ expect(metric.eql?(other)).to be(true)
51
+ end
52
+
53
+ it "returns false if result is different" do
54
+ metric = described_class.new(:search, true, 1696793204)
55
+ other = described_class.new(:search, false, 1696793204)
56
+ expect(metric.eql?(other)).to be(false)
57
+ end
58
+ end
59
+
60
+ describe "#hash" do
61
+ it "returns hash based on class, key, time and result" do
62
+ metric = described_class.new(:search, true, 1696793204)
63
+ expect(metric.hash).to eq([described_class, metric.key, metric.time, metric.result].hash)
64
+ end
65
+ end
66
+
67
+ describe "#as_json" do
68
+ it "returns key time and result" do
69
+ metric = described_class.new(:search, true, 1696793160)
70
+ expect(metric.as_json).to eq({
71
+ "key" => "search",
72
+ "result" => true,
73
+ "time" => 1696793160,
74
+ })
75
+ end
76
+
77
+ it "can include other hashes" do
78
+ metric = described_class.new(:search, true, 1696793160)
79
+ expect(metric.as_json(with: {"value" => 2})).to eq({
80
+ "key" => "search",
81
+ "result" => true,
82
+ "time" => 1696793160,
83
+ "value" => 2,
84
+ })
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,58 @@
1
+ require 'flipper/cloud/telemetry/metric_storage'
2
+ require 'flipper/cloud/telemetry/metric'
3
+
4
+ RSpec.describe Flipper::Cloud::Telemetry::MetricStorage do
5
+ describe "#increment" do
6
+ it "increments the counter for the metric" do
7
+ metric_storage = described_class.new
8
+ storage = metric_storage.instance_variable_get(:@storage)
9
+ metric = Flipper::Cloud::Telemetry::Metric.new(:search, true, 1696793160)
10
+ other = Flipper::Cloud::Telemetry::Metric.new(:search, false, 1696793160)
11
+
12
+ metric_storage.increment(metric)
13
+ expect(storage[metric].value).to be(1)
14
+
15
+ 5.times { metric_storage.increment(metric) }
16
+ expect(storage[metric].value).to be(6)
17
+
18
+ metric_storage.increment(other)
19
+ expect(storage[other].value).to be(1)
20
+ end
21
+ end
22
+
23
+ describe "#drain" do
24
+ it "returns clears metrics and return hash" do
25
+ metric_storage = described_class.new
26
+ storage = metric_storage.instance_variable_get(:@storage)
27
+ storage[Flipper::Cloud::Telemetry::Metric.new(:search, true, 1696793160)] = Concurrent::AtomicFixnum.new(10)
28
+ storage[Flipper::Cloud::Telemetry::Metric.new(:search, false, 1696793161)] = Concurrent::AtomicFixnum.new(15)
29
+ storage[Flipper::Cloud::Telemetry::Metric.new(:plausible, true, 1696793162)] = Concurrent::AtomicFixnum.new(25)
30
+ storage[Flipper::Cloud::Telemetry::Metric.new(:administrator, true, 1696793164)] = Concurrent::AtomicFixnum.new(1)
31
+ storage[Flipper::Cloud::Telemetry::Metric.new(:administrator, false, 1696793164)] = Concurrent::AtomicFixnum.new(24)
32
+
33
+ drained = metric_storage.drain
34
+ expect(drained).to be_frozen
35
+ expect(drained).to eq({
36
+ Flipper::Cloud::Telemetry::Metric.new(:search, true, 1696793160) => 10,
37
+ Flipper::Cloud::Telemetry::Metric.new(:search, false, 1696793161) => 15,
38
+ Flipper::Cloud::Telemetry::Metric.new(:plausible, true, 1696793162) => 25,
39
+ Flipper::Cloud::Telemetry::Metric.new(:administrator, true, 1696793164) => 1,
40
+ Flipper::Cloud::Telemetry::Metric.new(:administrator, false, 1696793164) => 24,
41
+ })
42
+ expect(storage.keys).to eq([])
43
+ end
44
+ end
45
+
46
+ describe "#empty?" do
47
+ it "returns true if empty" do
48
+ metric_storage = described_class.new
49
+ expect(metric_storage).to be_empty
50
+ end
51
+
52
+ it "returns false if not empty" do
53
+ metric_storage = described_class.new
54
+ metric_storage.increment Flipper::Cloud::Telemetry::Metric.new(:search, true, 1696793160)
55
+ expect(metric_storage).not_to be_empty
56
+ end
57
+ end
58
+ end