simple_feature_flags 1.1.1 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,11 +1,21 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require 'yaml'
4
5
 
5
6
  module SimpleFeatureFlags
6
- class RedisStorage
7
- attr_reader :file, :redis, :mandatory_flags
7
+ # Stores feature flags in Redis.
8
+ class RedisStorage < BaseStorage
9
+ sig { override.returns(String) }
10
+ attr_reader :file
8
11
 
12
+ sig { override.returns(T::Array[String]) }
13
+ attr_reader :mandatory_flags
14
+
15
+ sig { returns(T.any(::Redis, ::Redis::Namespace)) }
16
+ attr_reader :redis
17
+
18
+ sig { params(redis: T.any(::Redis, ::Redis::Namespace), file: String).void }
9
19
  def initialize(redis, file)
10
20
  @file = file
11
21
  @redis = redis
@@ -14,34 +24,70 @@ module SimpleFeatureFlags
14
24
  import_flags_from_file
15
25
  end
16
26
 
27
+ # Checks whether the flag is active. Returns `true`, `false`, `:globally` or `:partially`
28
+ sig { override.params(feature: T.any(Symbol, String)).returns(T.any(Symbol, T::Boolean)) }
17
29
  def active(feature)
18
30
  case redis.hget(feature.to_s, 'active')
19
31
  when 'globally'
20
32
  :globally
21
33
  when 'partially'
22
34
  :partially
23
- when 'true'
35
+ when 'true', true
24
36
  true
25
- when 'false'
37
+ else
26
38
  false
27
39
  end
28
40
  end
29
41
 
42
+ # Checks whether the flag is active.
43
+ sig { override.params(feature: T.any(Symbol, String)).returns(T::Boolean) }
30
44
  def active?(feature)
31
45
  return true if active(feature)
32
46
 
33
47
  false
34
48
  end
35
49
 
50
+ # Checks whether the flag is inactive.
51
+ sig { override.params(feature: T.any(Symbol, String)).returns(T::Boolean) }
52
+ def inactive?(feature)
53
+ !active?(feature)
54
+ end
55
+
56
+ # Checks whether the flag is active globally, for every object.
57
+ sig { override.params(feature: T.any(Symbol, String)).returns(T::Boolean) }
36
58
  def active_globally?(feature)
37
59
  ACTIVE_GLOBALLY.include? redis.hget(feature.to_s, 'active')
38
60
  end
39
61
 
62
+ # Checks whether the flag is inactive globally, for every object.
63
+ sig { override.params(feature: T.any(Symbol, String)).returns(T::Boolean) }
64
+ def inactive_globally?(feature)
65
+ !active_globally?(feature)
66
+ end
67
+
68
+ # Checks whether the flag is active partially, only for certain objects.
69
+ sig { override.params(feature: T.any(Symbol, String)).returns(T::Boolean) }
40
70
  def active_partially?(feature)
41
71
  ACTIVE_PARTIALLY.include? redis.hget(feature.to_s, 'active')
42
72
  end
43
73
 
44
- def active_for?(feature, object, object_id_method = CONFIG.default_id_method)
74
+ # Checks whether the flag is inactive partially, only for certain objects.
75
+ sig { override.params(feature: T.any(Symbol, String)).returns(T::Boolean) }
76
+ def inactive_partially?(feature)
77
+ !active_partially?(feature)
78
+ end
79
+
80
+ # Checks whether the flag is active for the given object.
81
+ sig do
82
+ override
83
+ .params(
84
+ feature: T.any(Symbol, String),
85
+ object: Object,
86
+ object_id_method: Symbol,
87
+ )
88
+ .returns(T::Boolean)
89
+ end
90
+ def active_for?(feature, object, object_id_method: CONFIG.default_id_method)
45
91
  return false unless active?(feature)
46
92
  return true if active_globally?(feature)
47
93
 
@@ -53,40 +99,152 @@ module SimpleFeatureFlags
53
99
  active_ids.include? object.public_send(object_id_method)
54
100
  end
55
101
 
102
+ # Checks whether the flag is inactive for the given object.
103
+ sig do
104
+ override
105
+ .params(
106
+ feature: T.any(Symbol, String),
107
+ object: Object,
108
+ object_id_method: Symbol,
109
+ )
110
+ .returns(T::Boolean)
111
+ end
112
+ def inactive_for?(feature, object, object_id_method: CONFIG.default_id_method)
113
+ !active_for?(feature, object, object_id_method: object_id_method)
114
+ end
115
+
116
+ # Checks whether the flag exists.
117
+ sig { override.params(feature: T.any(Symbol, String)).returns(T::Boolean) }
56
118
  def exists?(feature)
57
119
  return false if [nil, ''].include? redis.hget(feature.to_s, 'name')
58
120
 
59
121
  true
60
122
  end
61
123
 
124
+ # Returns the description of the flag if it exists.
125
+ sig { override.params(feature: T.any(Symbol, String)).returns(T.nilable(String)) }
62
126
  def description(feature)
63
127
  redis.hget(feature.to_s, 'description')
64
128
  end
65
129
 
130
+ # Calls the given block if the flag is active.
131
+ sig do
132
+ override
133
+ .params(
134
+ feature: T.any(Symbol, String),
135
+ block: T.proc.void,
136
+ ).void
137
+ end
66
138
  def when_active(feature, &block)
67
139
  return unless active?(feature)
68
140
 
69
141
  block.call
70
142
  end
71
143
 
144
+ # Calls the given block if the flag is inactive.
145
+ sig do
146
+ override
147
+ .params(
148
+ feature: T.any(Symbol, String),
149
+ block: T.proc.void,
150
+ ).void
151
+ end
152
+ def when_inactive(feature, &block)
153
+ return unless inactive?(feature)
154
+
155
+ block.call
156
+ end
157
+
158
+ # Calls the given block if the flag is active globally.
159
+ sig do
160
+ override
161
+ .params(
162
+ feature: T.any(Symbol, String),
163
+ block: T.proc.void,
164
+ ).void
165
+ end
72
166
  def when_active_globally(feature, &block)
73
167
  return unless active_globally?(feature)
74
168
 
75
169
  block.call
76
170
  end
77
171
 
172
+ # Calls the given block if the flag is inactive globally.
173
+ sig do
174
+ override
175
+ .params(
176
+ feature: T.any(Symbol, String),
177
+ block: T.proc.void,
178
+ ).void
179
+ end
180
+ def when_inactive_globally(feature, &block)
181
+ return unless inactive_globally?(feature)
182
+
183
+ block.call
184
+ end
185
+
186
+ # Calls the given block if the flag is active partially.
187
+ sig do
188
+ override
189
+ .params(
190
+ feature: T.any(Symbol, String),
191
+ block: T.proc.void,
192
+ ).void
193
+ end
78
194
  def when_active_partially(feature, &block)
79
195
  return unless active_partially?(feature)
80
196
 
81
197
  block.call
82
198
  end
83
199
 
84
- def when_active_for(feature, object, object_id_method = CONFIG.default_id_method, &block)
85
- return unless active_for?(feature, object, object_id_method)
200
+ # Calls the given block if the flag is inactive partially.
201
+ sig do
202
+ override
203
+ .params(
204
+ feature: T.any(Symbol, String),
205
+ block: T.proc.void,
206
+ ).void
207
+ end
208
+ def when_inactive_partially(feature, &block)
209
+ return unless inactive_partially?(feature)
210
+
211
+ block.call
212
+ end
213
+
214
+ # Calls the given block if the flag is active for the given object.
215
+ sig do
216
+ override
217
+ .params(
218
+ feature: T.any(Symbol, String),
219
+ object: Object,
220
+ object_id_method: Symbol,
221
+ block: T.proc.void,
222
+ ).void
223
+ end
224
+ def when_active_for(feature, object, object_id_method: CONFIG.default_id_method, &block)
225
+ return unless active_for?(feature, object, object_id_method: object_id_method)
226
+
227
+ block.call
228
+ end
229
+
230
+ # Calls the given block if the flag is inactive for the given object.
231
+ sig do
232
+ override
233
+ .params(
234
+ feature: T.any(Symbol, String),
235
+ object: Object,
236
+ object_id_method: Symbol,
237
+ block: T.proc.void,
238
+ ).void
239
+ end
240
+ def when_inactive_for(feature, object, object_id_method: CONFIG.default_id_method, &block)
241
+ return unless inactive_for?(feature, object, object_id_method: object_id_method)
86
242
 
87
243
  block.call
88
244
  end
89
245
 
246
+ # Activates the given flag. Returns `false` if it does not exist.
247
+ sig { override.params(feature: T.any(Symbol, String)).returns(T::Boolean) }
90
248
  def activate(feature)
91
249
  return false unless exists?(feature)
92
250
 
@@ -97,6 +255,8 @@ module SimpleFeatureFlags
97
255
 
98
256
  alias activate_globally activate
99
257
 
258
+ # Activates the given flag partially. Returns `false` if it does not exist.
259
+ sig { override.params(feature: T.any(Symbol, String)).returns(T::Boolean) }
100
260
  def activate_partially(feature)
101
261
  return false unless exists?(feature)
102
262
 
@@ -105,17 +265,25 @@ module SimpleFeatureFlags
105
265
  true
106
266
  end
107
267
 
108
- def activate_for(feature, objects, object_id_method = CONFIG.default_id_method)
268
+ # Activates the given flag for the given objects. Returns `false` if it does not exist.
269
+ sig do
270
+ override
271
+ .params(
272
+ feature: T.any(Symbol, String),
273
+ objects: Object,
274
+ object_id_method: Symbol,
275
+ ).void
276
+ end
277
+ def activate_for(feature, *objects, object_id_method: CONFIG.default_id_method)
109
278
  return false unless exists?(feature)
110
279
 
111
- objects = [objects] unless objects.is_a? ::Array
112
- to_activate_hash = objects_to_hash(objects, object_id_method)
280
+ to_activate_hash = objects_to_hash(objects, object_id_method: object_id_method)
113
281
  active_objects_hash = active_objects(feature)
114
282
 
115
283
  to_activate_hash.each do |klass, ids|
116
284
  (active_objects_hash[klass] = ids) && next unless active_objects_hash[klass]
117
285
 
118
- active_objects_hash[klass].concat(ids).uniq!.sort!
286
+ active_objects_hash[klass]&.concat(ids)&.uniq!&.sort! # rubocop:disable Style/SafeNavigationChainLength
119
287
  end
120
288
 
121
289
  redis.hset(feature.to_s, 'active_for_objects', active_objects_hash.to_json)
@@ -123,12 +291,26 @@ module SimpleFeatureFlags
123
291
  true
124
292
  end
125
293
 
126
- def activate_for!(feature, objects, object_id_method = CONFIG.default_id_method)
127
- return false unless activate_for(feature, objects, object_id_method)
294
+ # Activates the given flag for the given objects and sets the flag as partially active.
295
+ # Returns `false` if it does not exist.
296
+ sig do
297
+ override
298
+ .params(
299
+ feature: T.any(Symbol, String),
300
+ objects: Object,
301
+ object_id_method: Symbol,
302
+ ).void
303
+ end
304
+ def activate_for!(feature, *objects, object_id_method: CONFIG.default_id_method)
305
+ return false unless T.unsafe(self).activate_for(feature, *objects, object_id_method: object_id_method)
128
306
 
129
307
  activate_partially(feature)
130
308
  end
131
309
 
310
+ # Deactivates the given flag for all objects.
311
+ # Resets the list of objects that this flag has been turned on for.
312
+ # Returns `false` if it does not exist.
313
+ sig { override.params(feature: T.any(Symbol, String)).returns(T::Boolean) }
132
314
  def deactivate!(feature)
133
315
  return false unless exists?(feature)
134
316
 
@@ -138,6 +320,10 @@ module SimpleFeatureFlags
138
320
  true
139
321
  end
140
322
 
323
+ # Deactivates the given flag globally.
324
+ # Does not reset the list of objects that this flag has been turned on for.
325
+ # Returns `false` if it does not exist.
326
+ sig { override.params(feature: T.any(Symbol, String)).returns(T::Boolean) }
141
327
  def deactivate(feature)
142
328
  return false unless exists?(feature)
143
329
 
@@ -146,18 +332,39 @@ module SimpleFeatureFlags
146
332
  true
147
333
  end
148
334
 
335
+ # Returns a hash of Objects that the given flag is turned on for.
336
+ # The keys are class/model names, values are arrays of IDs of instances/records.
337
+ #
338
+ # looks like this:
339
+ #
340
+ # { "Page" => [25, 89], "Book" => [152] }
341
+ #
342
+ sig do
343
+ override
344
+ .params(feature: T.any(Symbol, String))
345
+ .returns(T::Hash[String, T::Array[Object]])
346
+ end
149
347
  def active_objects(feature)
150
348
  ::JSON.parse(redis.hget(feature.to_s, 'active_for_objects').to_s)
151
349
  rescue ::JSON::ParserError
152
350
  {}
153
351
  end
154
352
 
155
- def deactivate_for(feature, objects, object_id_method = CONFIG.default_id_method)
353
+ # Deactivates the given flag for the given objects. Returns `false` if it does not exist.
354
+ sig do
355
+ override
356
+ .params(
357
+ feature: T.any(Symbol, String),
358
+ objects: Object,
359
+ object_id_method: Symbol,
360
+ ).void
361
+ end
362
+ def deactivate_for(feature, *objects, object_id_method: CONFIG.default_id_method)
156
363
  return false unless exists?(feature)
157
364
 
158
365
  active_objects_hash = active_objects(feature)
159
366
 
160
- objects_to_deactivate_hash = objects_to_hash(objects, object_id_method)
367
+ objects_to_deactivate_hash = objects_to_hash(objects, object_id_method: object_id_method)
161
368
 
162
369
  objects_to_deactivate_hash.each do |klass, ids_to_remove|
163
370
  active_ids = active_objects_hash[klass]
@@ -171,18 +378,38 @@ module SimpleFeatureFlags
171
378
  true
172
379
  end
173
380
 
381
+ # Returns the data of the flag in a hash.
382
+ sig do
383
+ override
384
+ .params(
385
+ feature: T.any(Symbol, String),
386
+ ).returns(T.nilable(T::Hash[String, T.anything]))
387
+ end
174
388
  def get(feature)
175
389
  return unless exists?(feature)
176
390
 
177
391
  hash = redis.hgetall(feature.to_s)
178
392
  hash['mandatory'] = mandatory_flags.include?(feature.to_s)
179
- hash['active_for_objects'] = ::JSON.parse(hash['active_for_objects']) rescue {}
393
+ hash['active_for_objects'] = begin
394
+ ::JSON.parse(hash['active_for_objects'])
395
+ rescue StandardError
396
+ {}
397
+ end
180
398
 
181
399
  hash
182
400
  end
183
401
 
402
+ # Adds the given feature flag.
403
+ sig do
404
+ override
405
+ .params(
406
+ feature: T.any(Symbol, String),
407
+ description: String,
408
+ active: T.any(String, Symbol, T::Boolean, NilClass),
409
+ ).returns(T.nilable(T::Hash[String, T.anything]))
410
+ end
184
411
  def add(feature, description, active = 'false')
185
- return false if exists?(feature)
412
+ return if exists?(feature)
186
413
 
187
414
  active = if ACTIVE_GLOBALLY.include?(active)
188
415
  'globally'
@@ -193,17 +420,25 @@ module SimpleFeatureFlags
193
420
  end
194
421
 
195
422
  hash = {
196
- 'name' => feature.to_s,
197
- 'active' => active,
198
- 'description' => description
423
+ 'name' => feature.to_s,
424
+ 'active' => active,
425
+ 'description' => description,
199
426
  }
200
427
 
201
428
  redis.hset(feature.to_s, hash)
202
429
  hash
203
430
  end
204
431
 
432
+ # Removes the given feature flag.
433
+ # Returns its data or nil if it does not exist.
434
+ sig do
435
+ override
436
+ .params(
437
+ feature: T.any(Symbol, String),
438
+ ).returns(T.nilable(T::Hash[String, T.anything]))
439
+ end
205
440
  def remove(feature)
206
- return false unless exists?(feature)
441
+ return unless exists?(feature)
207
442
 
208
443
  removed = get(feature)
209
444
  redis.del(feature.to_s)
@@ -211,10 +446,14 @@ module SimpleFeatureFlags
211
446
  removed
212
447
  end
213
448
 
449
+ # Returns the data of all feature flags.
450
+ sig do
451
+ override.returns(T::Array[T::Hash[String, T.anything]])
452
+ end
214
453
  def all
215
454
  keys = []
216
455
  hashes = []
217
- redis.scan_each(match: "*") do |key|
456
+ redis.scan_each(match: '*') do |key|
218
457
  next if keys.include?(key)
219
458
 
220
459
  keys << key
@@ -224,30 +463,12 @@ module SimpleFeatureFlags
224
463
  hashes
225
464
  end
226
465
 
466
+ sig { returns(T.nilable(Redis::Namespace)) }
227
467
  def namespaced_redis
228
- redis
229
- end
230
-
231
- private
468
+ r = redis
469
+ return unless r.is_a?(Redis::Namespace)
232
470
 
233
- def objects_to_hash(objects, object_id_method = CONFIG.default_id_method)
234
- objects = [objects] unless objects.is_a? ::Array
235
-
236
- objects.group_by { |ob| ob.class.to_s }.transform_values { |arr| arr.map(&object_id_method) }
237
- end
238
-
239
- def import_flags_from_file
240
- changes = ::YAML.load_file(file)
241
- changes = { mandatory: [], remove: [] } unless changes.is_a? ::Hash
242
-
243
- changes[:mandatory].each do |el|
244
- mandatory_flags << el['name']
245
- add(el['name'], el['description'], el['active'])
246
- end
247
-
248
- changes[:remove].each do |el|
249
- remove(el)
250
- end
471
+ r
251
472
  end
252
473
  end
253
474
  end
@@ -1,9 +1,15 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module SimpleFeatureFlags
5
+ # Used in tests
4
6
  class TestRamStorage < RamStorage
7
+ sig { override.params(feature: T.any(Symbol, String)).returns(T::Boolean) }
5
8
  def active?(feature)
6
- raise(FlagNotDefinedError, "Feature Flag `#{feature}` is not defined as mandatory in #{file}") unless mandatory_flags.include?(feature.to_s)
9
+ unless mandatory_flags.include?(feature.to_s)
10
+ raise(FlagNotDefinedError,
11
+ "Feature Flag `#{feature}` is not defined as mandatory in #{file}",)
12
+ end
7
13
 
8
14
  super
9
15
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleFeatureFlags
4
- VERSION = "1.1.1"
4
+ VERSION = '1.3.0'
5
5
  end
@@ -1,17 +1,24 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require 'json'
5
+ require 'set'
6
+ require 'sorbet-runtime'
4
7
 
5
- Dir[File.expand_path('simple_feature_flags/*.rb', __dir__)].sort.each { |file| require file }
8
+ Dir[File.expand_path('simple_feature_flags/*.rb', __dir__)].each { |file| require file }
6
9
 
10
+ # Tha main namespace of the `simple_feature_flags` gem.
7
11
  module SimpleFeatureFlags
12
+ extend T::Sig
13
+
8
14
  NOT_PRESENT = ::Object.new.freeze
9
- UI_GEM = 'simple_feature_flags-ui'
10
- UI_CLASS_NAME = '::SimpleFeatureFlags::Ui'
11
- WEB_UI_CLASS_NAME = '::SimpleFeatureFlags::Ui::Web'
15
+ UI_GEM = T.let('simple_feature_flags-ui', String)
16
+ UI_CLASS_NAME = T.let('::SimpleFeatureFlags::Ui', String)
17
+ WEB_UI_CLASS_NAME = T.let('::SimpleFeatureFlags::Ui::Web', String)
12
18
 
13
- ACTIVE_GLOBALLY = ['globally', :globally, 'true', true].freeze
14
- ACTIVE_PARTIALLY = ['partially', :partially].freeze
19
+ ACTIVE_GLOBALLY = T.let(::Set['globally', :globally, 'true', true].freeze,
20
+ T::Set[T.any(String, Symbol, T::Boolean, NilClass)],)
21
+ ACTIVE_PARTIALLY = T.let(::Set['partially', :partially].freeze, T::Set[T.any(String, Symbol, T::Boolean, NilClass)])
15
22
 
16
23
  class NoSuchCommandError < StandardError; end
17
24
 
@@ -19,9 +26,15 @@ module SimpleFeatureFlags
19
26
 
20
27
  class FlagNotDefinedError < StandardError; end
21
28
 
22
- CONFIG = Configuration.new
29
+ CONFIG = T.let(Configuration.new, Configuration)
30
+
31
+ class << self
32
+ extend T::Sig
23
33
 
24
- def self.configure(&block)
25
- block.call(CONFIG)
34
+ sig { params(block: T.proc.params(arg0: Configuration).void).returns(Configuration) }
35
+ def configure(&block)
36
+ block.call(CONFIG)
37
+ CONFIG
38
+ end
26
39
  end
27
40
  end
@@ -3,36 +3,31 @@
3
3
  require_relative 'lib/simple_feature_flags/version'
4
4
 
5
5
  ::Gem::Specification.new do |spec|
6
- spec.name = "simple_feature_flags"
6
+ spec.name = 'simple_feature_flags'
7
7
  spec.version = ::SimpleFeatureFlags::VERSION
8
- spec.authors = ["Espago", "Mateusz Drewniak"]
9
- spec.email = ["m.drewniak@espago.com"]
8
+ spec.authors = ['Espago', 'Mateusz Drewniak']
9
+ spec.email = ['m.drewniak@espago.com']
10
10
 
11
- spec.summary = "Simple feature flag functionality for your Ruby/Rails/Sinatra app!"
12
- spec.description = "A simple Ruby gem which lets you dynamically enable/disable parts of your code using Redis or your server's RAM!"
13
- spec.homepage = "https://github.com/espago/simple_feature_flags"
14
- spec.license = "MIT"
15
- spec.required_ruby_version = ::Gem::Requirement.new(">= 2.5.0")
11
+ spec.summary = 'Simple feature flag functionality for your Ruby/Rails/Sinatra app!'
12
+ spec.description = <<~DESC
13
+ A simple Ruby gem which lets you dynamically enable/disable parts of your code using Redis or your server's RAM!
14
+ DESC
15
+ spec.homepage = 'https://github.com/espago/simple_feature_flags'
16
+ spec.license = 'MIT'
17
+ spec.required_ruby_version = '>= 3.1.0'
16
18
 
17
- spec.metadata["homepage_uri"] = spec.homepage
18
- spec.metadata["source_code_uri"] = "https://github.com/espago/simple_feature_flags"
19
+ spec.metadata['homepage_uri'] = spec.homepage
20
+ spec.metadata['source_code_uri'] = 'https://github.com/espago/simple_feature_flags'
19
21
 
20
22
  # Specify which files should be added to the gem when it is released.
21
23
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
24
  spec.files = ::Dir.chdir(::File.expand_path(__dir__)) do
23
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
25
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|sorbet)/}) }
24
26
  end
25
- spec.bindir = "exe"
27
+ spec.bindir = 'exe'
26
28
  spec.executables = ['simple_feature_flags']
27
- spec.require_paths = ["lib"]
29
+ spec.require_paths = ['lib']
28
30
 
29
- spec.add_development_dependency 'bundler'
30
- spec.add_development_dependency 'bundler-audit'
31
- spec.add_development_dependency 'byebug'
32
- spec.add_development_dependency 'minitest', '~> 5.0'
33
- spec.add_development_dependency 'rake', '~> 12.0'
34
- spec.add_development_dependency 'redis'
35
- spec.add_development_dependency 'redis-namespace'
36
- spec.add_development_dependency 'rubocop'
37
- spec.add_development_dependency 'solargraph'
31
+ spec.add_dependency 'sorbet-runtime', '> 0.5'
32
+ spec.metadata['rubygems_mfa_required'] = 'true'
38
33
  end