simple_feature_flags 1.1.1 → 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.
@@ -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