magick-feature-flags 0.7.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +363 -0
- data/lib/generators/magick/install/install_generator.rb +19 -0
- data/lib/generators/magick/install/templates/README +25 -0
- data/lib/generators/magick/install/templates/magick.rb +32 -0
- data/lib/magick/adapters/base.rb +27 -0
- data/lib/magick/adapters/memory.rb +113 -0
- data/lib/magick/adapters/redis.rb +97 -0
- data/lib/magick/adapters/registry.rb +133 -0
- data/lib/magick/audit_log.rb +65 -0
- data/lib/magick/circuit_breaker.rb +65 -0
- data/lib/magick/config.rb +179 -0
- data/lib/magick/dsl.rb +80 -0
- data/lib/magick/errors.rb +9 -0
- data/lib/magick/export_import.rb +82 -0
- data/lib/magick/feature.rb +665 -0
- data/lib/magick/feature_dependency.rb +28 -0
- data/lib/magick/feature_variant.rb +17 -0
- data/lib/magick/performance_metrics.rb +76 -0
- data/lib/magick/rails/event_subscriber.rb +55 -0
- data/lib/magick/rails/events.rb +236 -0
- data/lib/magick/rails/railtie.rb +94 -0
- data/lib/magick/rails.rb +7 -0
- data/lib/magick/targeting/base.rb +11 -0
- data/lib/magick/targeting/complex.rb +27 -0
- data/lib/magick/targeting/custom_attribute.rb +35 -0
- data/lib/magick/targeting/date_range.rb +17 -0
- data/lib/magick/targeting/group.rb +15 -0
- data/lib/magick/targeting/ip_address.rb +22 -0
- data/lib/magick/targeting/percentage.rb +24 -0
- data/lib/magick/targeting/request_percentage.rb +15 -0
- data/lib/magick/targeting/role.rb +15 -0
- data/lib/magick/targeting/user.rb +15 -0
- data/lib/magick/testing_helpers.rb +45 -0
- data/lib/magick/version.rb +5 -0
- data/lib/magick/versioning.rb +98 -0
- data/lib/magick.rb +143 -0
- metadata +123 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require_relative '../magick/feature_variant'
|
|
5
|
+
|
|
6
|
+
module Magick
|
|
7
|
+
class Feature
|
|
8
|
+
VALID_TYPES = %i[boolean string number].freeze
|
|
9
|
+
VALID_STATUSES = %i[active inactive deprecated].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :name, :type, :status, :default_value, :description, :adapter_registry
|
|
12
|
+
|
|
13
|
+
def initialize(name, adapter_registry, **options)
|
|
14
|
+
@name = name.to_s
|
|
15
|
+
@adapter_registry = adapter_registry
|
|
16
|
+
@type = (options[:type] || :boolean).to_sym
|
|
17
|
+
@status = (options[:status] || :active).to_sym
|
|
18
|
+
@default_value = options.fetch(:default_value, default_for_type)
|
|
19
|
+
@description = options[:description]
|
|
20
|
+
@targeting = {}
|
|
21
|
+
@dependencies = options[:dependencies] ? Array(options[:dependencies]) : []
|
|
22
|
+
|
|
23
|
+
validate_type!
|
|
24
|
+
validate_default_value!
|
|
25
|
+
load_from_adapter
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def enabled?(context = {})
|
|
29
|
+
# Track usage if metrics available
|
|
30
|
+
start_time = Time.now if Magick.performance_metrics
|
|
31
|
+
|
|
32
|
+
result = check_enabled(context)
|
|
33
|
+
|
|
34
|
+
if Magick.performance_metrics
|
|
35
|
+
duration = (Time.now - start_time) * 1000 # milliseconds
|
|
36
|
+
Magick.performance_metrics.record(name, 'enabled?', duration, success: true)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Rails 8+ events for enabled/disabled
|
|
40
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
41
|
+
if result
|
|
42
|
+
Magick::Rails::Events.feature_enabled(name, context: context)
|
|
43
|
+
else
|
|
44
|
+
Magick::Rails::Events.feature_disabled(name, context: context)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Warn if deprecated
|
|
49
|
+
if status == :deprecated && result && !context[:allow_deprecated]
|
|
50
|
+
warn "DEPRECATED: Feature '#{name}' is deprecated and will be removed." if Magick.warn_on_deprecated
|
|
51
|
+
|
|
52
|
+
# Rails 8+ event for deprecation warning
|
|
53
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
54
|
+
Magick::Rails::Events.deprecated_warning(name)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
result
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
if Magick.performance_metrics
|
|
61
|
+
duration = (Time.now - start_time) * 1000
|
|
62
|
+
Magick.performance_metrics.record(name, 'enabled?', duration, success: false)
|
|
63
|
+
end
|
|
64
|
+
raise e
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def check_enabled(context = {})
|
|
68
|
+
return false if status == :inactive
|
|
69
|
+
return false if status == :deprecated && !context[:allow_deprecated]
|
|
70
|
+
|
|
71
|
+
# Check feature dependencies
|
|
72
|
+
unless dependencies.empty?
|
|
73
|
+
return false unless dependencies.all? { |dep_name| Magick.enabled?(dep_name, context) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Check date/time range targeting
|
|
77
|
+
if targeting[:date_range] && !date_range_active?(targeting[:date_range])
|
|
78
|
+
return false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check IP address targeting
|
|
82
|
+
if targeting[:ip_address] && context[:ip_address]
|
|
83
|
+
return false unless ip_address_matches?(context[:ip_address])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Check custom attributes
|
|
87
|
+
if targeting[:custom_attributes]
|
|
88
|
+
return false unless custom_attributes_match?(context, targeting[:custom_attributes])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Check complex conditions
|
|
92
|
+
if targeting[:complex_conditions]
|
|
93
|
+
return false unless complex_conditions_match?(context, targeting[:complex_conditions])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
value = get_value(context)
|
|
97
|
+
case type
|
|
98
|
+
when :boolean
|
|
99
|
+
value == true
|
|
100
|
+
when :string
|
|
101
|
+
!value.nil? && value != ''
|
|
102
|
+
when :number
|
|
103
|
+
value.to_f > 0
|
|
104
|
+
else
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def disabled?(context = {})
|
|
110
|
+
!enabled?(context)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def value(context = {})
|
|
114
|
+
get_value(context)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def get_value(context = {})
|
|
118
|
+
# Check targeting rules first
|
|
119
|
+
targeting_result = check_targeting(context)
|
|
120
|
+
return targeting_result unless targeting_result.nil?
|
|
121
|
+
|
|
122
|
+
# Fall back to stored value or default
|
|
123
|
+
stored_value || default_value
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def enable_for_user(user_id)
|
|
127
|
+
enable_targeting(:user, user_id)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def disable_for_user(user_id)
|
|
131
|
+
disable_targeting(:user, user_id)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def enable_for_group(group_name)
|
|
135
|
+
enable_targeting(:group, group_name)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def disable_for_group(group_name)
|
|
139
|
+
disable_targeting(:group, group_name)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def enable_for_role(role_name)
|
|
143
|
+
enable_targeting(:role, role_name)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def disable_for_role(role_name)
|
|
147
|
+
disable_targeting(:role, role_name)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def enable_percentage_of_users(percentage)
|
|
151
|
+
@targeting[:percentage_users] = percentage.to_f
|
|
152
|
+
save_targeting
|
|
153
|
+
|
|
154
|
+
# Update registered feature instance if it exists
|
|
155
|
+
if Magick.features.key?(name)
|
|
156
|
+
Magick.features[name].instance_variable_set(:@targeting, @targeting.dup)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Rails 8+ event
|
|
160
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
161
|
+
Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_users, targeting_value: percentage)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def disable_percentage_of_users
|
|
166
|
+
disable_targeting(:percentage_users)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def enable_percentage_of_requests(percentage)
|
|
170
|
+
@targeting[:percentage_requests] = percentage.to_f
|
|
171
|
+
save_targeting
|
|
172
|
+
|
|
173
|
+
# Update registered feature instance if it exists
|
|
174
|
+
if Magick.features.key?(name)
|
|
175
|
+
Magick.features[name].instance_variable_set(:@targeting, @targeting.dup)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Rails 8+ event
|
|
179
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
180
|
+
Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_requests, targeting_value: percentage)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def disable_percentage_of_requests
|
|
185
|
+
disable_targeting(:percentage_requests)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def enable_for_date_range(start_date, end_date)
|
|
189
|
+
enable_targeting(:date_range, { start: start_date, end: end_date })
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def disable_date_range
|
|
193
|
+
disable_targeting(:date_range)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def enable_for_ip_addresses(ip_addresses)
|
|
197
|
+
enable_targeting(:ip_address, Array(ip_addresses))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def disable_ip_addresses
|
|
201
|
+
disable_targeting(:ip_address)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def enable_for_custom_attribute(attribute_name, values, operator: :equals)
|
|
205
|
+
custom_attrs = targeting[:custom_attributes] || {}
|
|
206
|
+
custom_attrs[attribute_name.to_sym] = { values: Array(values), operator: operator }
|
|
207
|
+
enable_targeting(:custom_attributes, custom_attrs)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def disable_custom_attribute(attribute_name)
|
|
211
|
+
custom_attrs = targeting[:custom_attributes] || {}
|
|
212
|
+
custom_attrs.delete(attribute_name.to_sym)
|
|
213
|
+
if custom_attrs.empty?
|
|
214
|
+
disable_targeting(:custom_attributes)
|
|
215
|
+
else
|
|
216
|
+
enable_targeting(:custom_attributes, custom_attrs)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def set_variants(variants)
|
|
221
|
+
variants_array = Array(variants).map do |v|
|
|
222
|
+
v.is_a?(FeatureVariant) ? v : FeatureVariant.new(v[:name], v[:value], weight: v[:weight] || 0)
|
|
223
|
+
end
|
|
224
|
+
enable_targeting(:variants, variants_array.map(&:to_h))
|
|
225
|
+
|
|
226
|
+
# Rails 8+ event
|
|
227
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
228
|
+
Magick::Rails::Events.variant_set(name, variants: variants_array)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def add_dependency(dependency_name)
|
|
233
|
+
@dependencies ||= []
|
|
234
|
+
@dependencies << dependency_name.to_s unless @dependencies.include?(dependency_name.to_s)
|
|
235
|
+
|
|
236
|
+
# Rails 8+ event
|
|
237
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
238
|
+
Magick::Rails::Events.dependency_added(name, dependency_name)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def remove_dependency(dependency_name)
|
|
243
|
+
@dependencies&.delete(dependency_name.to_s)
|
|
244
|
+
|
|
245
|
+
# Rails 8+ event
|
|
246
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
247
|
+
Magick::Rails::Events.dependency_removed(name, dependency_name)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def dependencies
|
|
252
|
+
@dependencies || []
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def get_variant(context = {})
|
|
256
|
+
return nil unless targeting[:variants]
|
|
257
|
+
|
|
258
|
+
variants = targeting[:variants]
|
|
259
|
+
selected_variant = if variants.length == 1
|
|
260
|
+
variants.first[:name]
|
|
261
|
+
else
|
|
262
|
+
# Weighted random selection
|
|
263
|
+
total_weight = variants.sum { |v| v[:weight] || 0 }
|
|
264
|
+
if total_weight == 0
|
|
265
|
+
variants.first[:name]
|
|
266
|
+
else
|
|
267
|
+
random = rand(total_weight)
|
|
268
|
+
current = 0
|
|
269
|
+
selected = nil
|
|
270
|
+
variants.each do |variant|
|
|
271
|
+
current += (variant[:weight] || 0)
|
|
272
|
+
if random < current
|
|
273
|
+
selected = variant[:name]
|
|
274
|
+
break
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
selected || variants.first[:name]
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Rails 8+ event
|
|
282
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
283
|
+
Magick::Rails::Events.variant_selected(name, variant_name: selected_variant, context: context)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
selected_variant
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def set_value(value, user_id: nil)
|
|
290
|
+
old_value = @stored_value
|
|
291
|
+
validate_value!(value)
|
|
292
|
+
adapter_registry.set(name, 'value', value)
|
|
293
|
+
adapter_registry.set(name, 'type', type)
|
|
294
|
+
adapter_registry.set(name, 'status', status)
|
|
295
|
+
adapter_registry.set(name, 'default_value', default_value)
|
|
296
|
+
adapter_registry.set(name, 'description', description) if description
|
|
297
|
+
@stored_value = value
|
|
298
|
+
|
|
299
|
+
# Update registered feature instance if it exists
|
|
300
|
+
if Magick.features.key?(name)
|
|
301
|
+
Magick.features[name].instance_variable_set(:@stored_value, value)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
changes = { value: { from: old_value, to: value } }
|
|
305
|
+
|
|
306
|
+
# Audit log
|
|
307
|
+
if Magick.audit_log
|
|
308
|
+
Magick.audit_log.log(
|
|
309
|
+
name,
|
|
310
|
+
'set_value',
|
|
311
|
+
user_id: user_id,
|
|
312
|
+
changes: changes
|
|
313
|
+
)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Rails 8+ events
|
|
317
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
318
|
+
Magick::Rails::Events.feature_changed(name, changes: changes, user_id: user_id)
|
|
319
|
+
Magick::Rails::Events.audit_logged(name, action: 'set_value', user_id: user_id, changes: changes)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def enable(user_id: nil)
|
|
324
|
+
# Clear all targeting to enable globally
|
|
325
|
+
@targeting = {}
|
|
326
|
+
save_targeting
|
|
327
|
+
|
|
328
|
+
case type
|
|
329
|
+
when :boolean
|
|
330
|
+
set_value(true, user_id: user_id)
|
|
331
|
+
when :string
|
|
332
|
+
raise InvalidFeatureValueError, "Cannot enable string feature. Use set_value instead."
|
|
333
|
+
when :number
|
|
334
|
+
raise InvalidFeatureValueError, "Cannot enable number feature. Use set_value instead."
|
|
335
|
+
else
|
|
336
|
+
raise InvalidFeatureValueError, "Cannot enable feature of type #{type}"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Rails 8+ event
|
|
340
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
341
|
+
Magick::Rails::Events.feature_enabled_globally(name, user_id: user_id)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def disable(user_id: nil)
|
|
346
|
+
# Clear all targeting to disable globally
|
|
347
|
+
@targeting = {}
|
|
348
|
+
save_targeting
|
|
349
|
+
|
|
350
|
+
case type
|
|
351
|
+
when :boolean
|
|
352
|
+
set_value(false, user_id: user_id)
|
|
353
|
+
when :string
|
|
354
|
+
set_value('', user_id: user_id)
|
|
355
|
+
when :number
|
|
356
|
+
set_value(0, user_id: user_id)
|
|
357
|
+
else
|
|
358
|
+
raise InvalidFeatureValueError, "Cannot disable feature of type #{type}"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Rails 8+ event
|
|
362
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
363
|
+
Magick::Rails::Events.feature_disabled_globally(name, user_id: user_id)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def set_status(new_status)
|
|
368
|
+
raise InvalidFeatureValueError, "Invalid status: #{new_status}" unless VALID_STATUSES.include?(new_status.to_sym)
|
|
369
|
+
|
|
370
|
+
@status = new_status.to_sym
|
|
371
|
+
adapter_registry.set(name, 'status', status)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def delete
|
|
375
|
+
adapter_registry.delete(name)
|
|
376
|
+
@stored_value = nil
|
|
377
|
+
@targeting = {}
|
|
378
|
+
# Also remove from Magick.features if registered
|
|
379
|
+
Magick.features.delete(name.to_s)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def to_h
|
|
383
|
+
{
|
|
384
|
+
name: name,
|
|
385
|
+
type: type,
|
|
386
|
+
status: status,
|
|
387
|
+
value: stored_value,
|
|
388
|
+
default_value: default_value,
|
|
389
|
+
description: description,
|
|
390
|
+
targeting: targeting
|
|
391
|
+
}
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
private
|
|
395
|
+
|
|
396
|
+
attr_reader :targeting
|
|
397
|
+
|
|
398
|
+
def stored_value
|
|
399
|
+
# Always go through adapter to check for cross-process updates via version checking
|
|
400
|
+
# The adapter registry will check Redis version and invalidate memory cache if stale
|
|
401
|
+
load_value_from_adapter
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Reload feature state from adapter (useful when feature is changed externally)
|
|
405
|
+
def reload
|
|
406
|
+
load_from_adapter
|
|
407
|
+
# Update registered feature instance if it exists
|
|
408
|
+
if Magick.features.key?(name)
|
|
409
|
+
registered = Magick.features[name]
|
|
410
|
+
registered.instance_variable_set(:@stored_value, @stored_value)
|
|
411
|
+
registered.instance_variable_set(:@status, @status)
|
|
412
|
+
registered.instance_variable_set(:@targeting, @targeting.dup)
|
|
413
|
+
end
|
|
414
|
+
self
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def load_from_adapter
|
|
418
|
+
# Clear cached value to force reload from adapter (which checks version)
|
|
419
|
+
@stored_value = nil
|
|
420
|
+
@stored_value = load_value_from_adapter
|
|
421
|
+
status_value = adapter_registry.get(name, 'status')
|
|
422
|
+
@status = status_value ? status_value.to_sym : status
|
|
423
|
+
targeting_value = adapter_registry.get(name, 'targeting')
|
|
424
|
+
if targeting_value.is_a?(Hash)
|
|
425
|
+
# Normalize keys to symbols and handle nested structures
|
|
426
|
+
@targeting = targeting_value.transform_keys(&:to_sym)
|
|
427
|
+
# Handle percentage_users and percentage_requests which might be stored as numbers
|
|
428
|
+
@targeting[:percentage_users] = @targeting[:percentage_users].to_f if @targeting[:percentage_users]
|
|
429
|
+
@targeting[:percentage_requests] = @targeting[:percentage_requests].to_f if @targeting[:percentage_requests]
|
|
430
|
+
else
|
|
431
|
+
@targeting = {}
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def load_value_from_adapter
|
|
436
|
+
value = adapter_registry.get(name, 'value')
|
|
437
|
+
return nil if value.nil?
|
|
438
|
+
|
|
439
|
+
case type
|
|
440
|
+
when :boolean
|
|
441
|
+
value == true || value == 'true' || value == 1
|
|
442
|
+
when :string
|
|
443
|
+
value.to_s
|
|
444
|
+
when :number
|
|
445
|
+
value.to_f
|
|
446
|
+
else
|
|
447
|
+
value
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def check_targeting(context)
|
|
452
|
+
return nil if targeting.empty?
|
|
453
|
+
|
|
454
|
+
# Normalize targeting keys (handle both string and symbol keys)
|
|
455
|
+
target = targeting.transform_keys(&:to_sym)
|
|
456
|
+
|
|
457
|
+
# Check user targeting
|
|
458
|
+
if context[:user_id] && target[:user]
|
|
459
|
+
user_list = target[:user].is_a?(Array) ? target[:user] : [target[:user]]
|
|
460
|
+
return true if user_list.include?(context[:user_id].to_s)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Check group targeting
|
|
464
|
+
if context[:group] && target[:group]
|
|
465
|
+
group_list = target[:group].is_a?(Array) ? target[:group] : [target[:group]]
|
|
466
|
+
return true if group_list.include?(context[:group].to_s)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Check role targeting
|
|
470
|
+
if context[:role] && target[:role]
|
|
471
|
+
role_list = target[:role].is_a?(Array) ? target[:role] : [target[:role]]
|
|
472
|
+
return true if role_list.include?(context[:role].to_s)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Check percentage of users (consistent based on user_id)
|
|
476
|
+
if context[:user_id] && target[:percentage_users]
|
|
477
|
+
percentage = target[:percentage_users].to_f
|
|
478
|
+
return true if user_in_percentage?(context[:user_id], percentage)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Check percentage of requests (random)
|
|
482
|
+
if target[:percentage_requests]
|
|
483
|
+
percentage = target[:percentage_requests].to_f
|
|
484
|
+
return true if rand(100) < percentage
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
nil
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def date_range_active?(date_range_config)
|
|
491
|
+
return true unless date_range_config
|
|
492
|
+
|
|
493
|
+
start_date = date_range_config[:start] || date_range_config['start']
|
|
494
|
+
end_date = date_range_config[:end] || date_range_config['end']
|
|
495
|
+
return true unless start_date && end_date
|
|
496
|
+
|
|
497
|
+
start_time = start_date.is_a?(String) ? Time.parse(start_date) : start_date
|
|
498
|
+
end_time = end_date.is_a?(String) ? Time.parse(end_date) : end_date
|
|
499
|
+
now = Time.now
|
|
500
|
+
now >= start_time && now <= end_time
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def ip_address_matches?(ip_address)
|
|
504
|
+
return false unless targeting[:ip_address]
|
|
505
|
+
|
|
506
|
+
require 'ipaddr'
|
|
507
|
+
ip_list = Array(targeting[:ip_address])
|
|
508
|
+
client_ip = IPAddr.new(ip_address)
|
|
509
|
+
ip_list.any? do |ip_str|
|
|
510
|
+
IPAddr.new(ip_str).include?(client_ip)
|
|
511
|
+
end
|
|
512
|
+
rescue IPAddr::InvalidAddressError
|
|
513
|
+
false
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def custom_attributes_match?(context, custom_attrs_config)
|
|
517
|
+
return true unless custom_attrs_config
|
|
518
|
+
|
|
519
|
+
custom_attrs_config.all? do |attr_name, config|
|
|
520
|
+
context_value = context[attr_name] || context[attr_name.to_s]
|
|
521
|
+
next false if context_value.nil?
|
|
522
|
+
|
|
523
|
+
values = Array(config[:values] || config['values'])
|
|
524
|
+
operator = (config[:operator] || config['operator'] || :equals).to_sym
|
|
525
|
+
|
|
526
|
+
case operator
|
|
527
|
+
when :equals, :eq
|
|
528
|
+
values.include?(context_value.to_s)
|
|
529
|
+
when :not_equals, :ne
|
|
530
|
+
!values.include?(context_value.to_s)
|
|
531
|
+
when :in
|
|
532
|
+
values.include?(context_value.to_s)
|
|
533
|
+
when :not_in
|
|
534
|
+
!values.include?(context_value.to_s)
|
|
535
|
+
when :greater_than, :gt
|
|
536
|
+
context_value.to_f > values.first.to_f
|
|
537
|
+
when :less_than, :lt
|
|
538
|
+
context_value.to_f < values.first.to_f
|
|
539
|
+
else
|
|
540
|
+
false
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def complex_conditions_match?(context, complex_config)
|
|
546
|
+
return true unless complex_config
|
|
547
|
+
|
|
548
|
+
conditions = complex_config[:conditions] || complex_config['conditions'] || []
|
|
549
|
+
operator = (complex_config[:operator] || complex_config['operator'] || :and).to_sym
|
|
550
|
+
|
|
551
|
+
results = conditions.map do |condition|
|
|
552
|
+
# Each condition is a hash with type and params
|
|
553
|
+
condition_type = (condition[:type] || condition['type']).to_sym
|
|
554
|
+
condition_params = condition[:params] || condition['params'] || {}
|
|
555
|
+
|
|
556
|
+
case condition_type
|
|
557
|
+
when :user
|
|
558
|
+
user_list = Array(condition_params[:user_ids] || condition_params['user_ids'])
|
|
559
|
+
user_list.include?(context[:user_id]&.to_s)
|
|
560
|
+
when :group
|
|
561
|
+
group_list = Array(condition_params[:groups] || condition_params['groups'])
|
|
562
|
+
group_list.include?(context[:group]&.to_s)
|
|
563
|
+
when :role
|
|
564
|
+
role_list = Array(condition_params[:roles] || condition_params['roles'])
|
|
565
|
+
role_list.include?(context[:role]&.to_s)
|
|
566
|
+
when :custom_attribute
|
|
567
|
+
attr_name = condition_params[:attribute] || condition_params['attribute']
|
|
568
|
+
attr_values = Array(condition_params[:values] || condition_params['values'])
|
|
569
|
+
attr_values.include?(context[attr_name]&.to_s)
|
|
570
|
+
else
|
|
571
|
+
false
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
case operator
|
|
576
|
+
when :and, :all
|
|
577
|
+
results.all?
|
|
578
|
+
when :or, :any
|
|
579
|
+
results.any?
|
|
580
|
+
else
|
|
581
|
+
false
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def user_in_percentage?(user_id, percentage)
|
|
586
|
+
hash = Digest::MD5.hexdigest("#{name}:#{user_id}")
|
|
587
|
+
hash_value = hash[0..7].to_i(16)
|
|
588
|
+
(hash_value % 100) < percentage
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def enable_targeting(type, value)
|
|
592
|
+
@targeting[type] ||= []
|
|
593
|
+
@targeting[type] << value.to_s unless @targeting[type].include?(value.to_s)
|
|
594
|
+
save_targeting
|
|
595
|
+
|
|
596
|
+
# Rails 8+ event
|
|
597
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
598
|
+
Magick::Rails::Events.targeting_added(name, targeting_type: type, targeting_value: value)
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def disable_targeting(type, value = nil)
|
|
603
|
+
if value.nil?
|
|
604
|
+
@targeting.delete(type)
|
|
605
|
+
else
|
|
606
|
+
@targeting[type]&.delete(value.to_s)
|
|
607
|
+
end
|
|
608
|
+
save_targeting
|
|
609
|
+
|
|
610
|
+
# Rails 8+ event
|
|
611
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
612
|
+
Magick::Rails::Events.targeting_removed(name, targeting_type: type, targeting_value: value)
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def save_targeting
|
|
617
|
+
adapter_registry.set(name, 'targeting', targeting)
|
|
618
|
+
# Update the feature in Magick.features if it's registered
|
|
619
|
+
if Magick.features.key?(name)
|
|
620
|
+
Magick.features[name].instance_variable_set(:@targeting, targeting.dup)
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def default_for_type
|
|
625
|
+
case type
|
|
626
|
+
when :boolean
|
|
627
|
+
false
|
|
628
|
+
when :string
|
|
629
|
+
''
|
|
630
|
+
when :number
|
|
631
|
+
0
|
|
632
|
+
else
|
|
633
|
+
false
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def validate_type!
|
|
638
|
+
return if VALID_TYPES.include?(type)
|
|
639
|
+
|
|
640
|
+
raise InvalidFeatureTypeError, "Invalid feature type: #{type}. Valid types are: #{VALID_TYPES.join(', ')}"
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def validate_default_value!
|
|
644
|
+
case type
|
|
645
|
+
when :boolean
|
|
646
|
+
raise InvalidFeatureValueError, "Default value must be boolean for type :boolean" unless [true, false].include?(default_value)
|
|
647
|
+
when :string
|
|
648
|
+
raise InvalidFeatureValueError, "Default value must be a string for type :string" unless default_value.is_a?(String)
|
|
649
|
+
when :number
|
|
650
|
+
raise InvalidFeatureValueError, "Default value must be numeric for type :number" unless default_value.is_a?(Numeric)
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def validate_value!(value)
|
|
655
|
+
case type
|
|
656
|
+
when :boolean
|
|
657
|
+
raise InvalidFeatureValueError, "Value must be boolean for type :boolean" unless [true, false].include?(value)
|
|
658
|
+
when :string
|
|
659
|
+
raise InvalidFeatureValueError, "Value must be a string for type :string" unless value.is_a?(String)
|
|
660
|
+
when :number
|
|
661
|
+
raise InvalidFeatureValueError, "Value must be numeric for type :number" unless value.is_a?(Numeric)
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
class FeatureDependency
|
|
5
|
+
def self.check(feature_name, context = {})
|
|
6
|
+
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
7
|
+
dependencies = feature.instance_variable_get(:@dependencies) || []
|
|
8
|
+
|
|
9
|
+
dependencies.all? do |dep_name|
|
|
10
|
+
Magick.enabled?(dep_name, context)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.add_dependency(feature_name, dependency_name)
|
|
15
|
+
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
16
|
+
dependencies = feature.instance_variable_get(:@dependencies) || []
|
|
17
|
+
dependencies << dependency_name.to_s unless dependencies.include?(dependency_name.to_s)
|
|
18
|
+
feature.instance_variable_set(:@dependencies, dependencies)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.remove_dependency(feature_name, dependency_name)
|
|
22
|
+
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
23
|
+
dependencies = feature.instance_variable_get(:@dependencies) || []
|
|
24
|
+
dependencies.delete(dependency_name.to_s)
|
|
25
|
+
feature.instance_variable_set(:@dependencies, dependencies)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
class FeatureVariant
|
|
5
|
+
attr_reader :name, :value, :weight
|
|
6
|
+
|
|
7
|
+
def initialize(name, value, weight: 0)
|
|
8
|
+
@name = name.to_s
|
|
9
|
+
@value = value
|
|
10
|
+
@weight = weight.to_f
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_h
|
|
14
|
+
{ name: name, value: value, weight: weight }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|