magick-feature-flags 0.7.2 → 0.8.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 +4 -4
- data/README.md +49 -0
- data/lib/generators/magick/install/templates/magick.rb +2 -0
- data/lib/magick/adapters/memory.rb +0 -2
- data/lib/magick/adapters/registry.rb +37 -39
- data/lib/magick/config.rb +8 -19
- data/lib/magick/dsl.rb +9 -0
- data/lib/magick/export_import.rb +4 -2
- data/lib/magick/feature.rb +143 -64
- data/lib/magick/rails/event_subscriber.rb +2 -2
- data/lib/magick/rails/railtie.rb +8 -6
- data/lib/magick/rails.rb +1 -3
- data/lib/magick/testing_helpers.rb +3 -3
- data/lib/magick/version.rb +1 -1
- data/lib/magick/versioning.rb +9 -11
- data/lib/magick.rb +12 -3
- data/lib/{magick-feature-flags.rb → magick_feature_flags.rb} +1 -2
- metadata +7 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a5124df88b7d429811d4b0f131d01aeeef2dedabc25b598da96fa5f01eb07163
|
|
4
|
+
data.tar.gz: 72c12e81d29990d6990f0f749effb978c7f9a4d7a316d3455e8ceb508269889d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 880f57facbd900c562aa10d21708afe0100087a6160ac814cc9d3453233d62cb7b41de1bfe18a831f27ecbd14cfb2b59b238a92f91c4a8fa56d0ae30a62ba125
|
|
7
|
+
data.tar.gz: cd4addd623241b24d2bc3d08cab687233de382d3c1dc260461484e6b750c637f19dc51368c552694c021f503a30c85e1425016f1af4a7056b8f3cf19f816a299
|
data/README.md
CHANGED
|
@@ -166,6 +166,55 @@ feature.enable_for_ip_addresses('192.168.1.0/24', '10.0.0.1')
|
|
|
166
166
|
feature.enable_for_custom_attribute(:subscription_tier, ['premium', 'enterprise'])
|
|
167
167
|
```
|
|
168
168
|
|
|
169
|
+
### Checking Feature Enablement with Objects
|
|
170
|
+
|
|
171
|
+
You can check if a feature is enabled for an object (like a User model) and its fields:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
# Using enabled_for? with an object
|
|
175
|
+
user = User.find(123)
|
|
176
|
+
if Magick.enabled_for?(:premium_features, user)
|
|
177
|
+
# Feature is enabled for this user
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Or using the feature directly
|
|
181
|
+
feature = Magick[:premium_features]
|
|
182
|
+
if feature.enabled_for?(user)
|
|
183
|
+
# Feature is enabled for this user
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# With additional context
|
|
187
|
+
if Magick.enabled_for?(:premium_features, user, ip_address: request.remote_ip)
|
|
188
|
+
# Feature is enabled for this user and IP
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Works with ActiveRecord objects, hashes, or simple IDs
|
|
192
|
+
Magick.enabled_for?(:feature, user) # ActiveRecord object
|
|
193
|
+
Magick.enabled_for?(:feature, { id: 123, role: 'admin' }) # Hash
|
|
194
|
+
Magick.enabled_for?(:feature, 123) # Simple ID
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The `enabled_for?` method automatically extracts:
|
|
198
|
+
- `user_id` from `id` or `user_id` attribute
|
|
199
|
+
- `group` from `group` attribute
|
|
200
|
+
- `role` from `role` attribute
|
|
201
|
+
- `ip_address` from `ip_address` attribute
|
|
202
|
+
- All other attributes for custom attribute matching
|
|
203
|
+
|
|
204
|
+
### Return Values
|
|
205
|
+
|
|
206
|
+
All enable/disable methods now return `true` to indicate success:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
# All these methods return true on success
|
|
210
|
+
result = feature.enable # => true
|
|
211
|
+
result = feature.disable # => true
|
|
212
|
+
result = feature.enable_for_user(123) # => true
|
|
213
|
+
result = feature.enable_for_group('beta') # => true
|
|
214
|
+
result = feature.enable_percentage_of_users(25) # => true
|
|
215
|
+
result = feature.set_value(true) # => true
|
|
216
|
+
```
|
|
217
|
+
|
|
169
218
|
### DSL Configuration
|
|
170
219
|
|
|
171
220
|
Create `config/features.rb`:
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Magick
|
|
4
4
|
module Adapters
|
|
5
5
|
class Registry
|
|
6
|
-
CACHE_INVALIDATION_CHANNEL = 'magick:cache:invalidate'
|
|
6
|
+
CACHE_INVALIDATION_CHANNEL = 'magick:cache:invalidate'
|
|
7
7
|
|
|
8
8
|
def initialize(memory_adapter, redis_adapter = nil, circuit_breaker: nil, async: false)
|
|
9
9
|
@memory_adapter = memory_adapter
|
|
@@ -43,36 +43,36 @@ module Magick
|
|
|
43
43
|
memory_adapter.set(feature_name, key, value)
|
|
44
44
|
|
|
45
45
|
# Update Redis if available
|
|
46
|
-
|
|
47
|
-
update_redis = proc do
|
|
48
|
-
circuit_breaker.call do
|
|
49
|
-
redis_adapter.set(feature_name, key, value)
|
|
50
|
-
# Publish cache invalidation message to notify other processes
|
|
51
|
-
publish_cache_invalidation(feature_name)
|
|
52
|
-
end
|
|
53
|
-
rescue AdapterError => e
|
|
54
|
-
# Log error but don't fail - memory is updated
|
|
55
|
-
warn "Failed to update Redis: #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
56
|
-
end
|
|
46
|
+
return unless redis_adapter
|
|
57
47
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
48
|
+
update_redis = proc do
|
|
49
|
+
circuit_breaker.call do
|
|
50
|
+
redis_adapter.set(feature_name, key, value)
|
|
51
|
+
# Publish cache invalidation message to notify other processes
|
|
52
|
+
publish_cache_invalidation(feature_name)
|
|
62
53
|
end
|
|
54
|
+
rescue AdapterError => e
|
|
55
|
+
# Log error but don't fail - memory is updated
|
|
56
|
+
warn "Failed to update Redis: #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if @async && defined?(Thread)
|
|
60
|
+
Thread.new { update_redis.call }
|
|
61
|
+
else
|
|
62
|
+
update_redis.call
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
def delete(feature_name)
|
|
67
67
|
memory_adapter.delete(feature_name)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
return unless redis_adapter
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
redis_adapter.delete(feature_name)
|
|
72
|
+
# Publish cache invalidation message
|
|
73
|
+
publish_cache_invalidation(feature_name)
|
|
74
|
+
rescue AdapterError
|
|
75
|
+
# Continue even if Redis fails
|
|
76
76
|
end
|
|
77
77
|
end
|
|
78
78
|
|
|
@@ -108,23 +108,21 @@ module Magick
|
|
|
108
108
|
return unless redis_adapter && defined?(Thread)
|
|
109
109
|
|
|
110
110
|
@subscriber_thread = Thread.new do
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
memory_adapter.delete(feature_name)
|
|
120
|
-
end
|
|
111
|
+
redis_client = redis_adapter.instance_variable_get(:@redis)
|
|
112
|
+
return unless redis_client
|
|
113
|
+
|
|
114
|
+
@subscriber = redis_client.dup
|
|
115
|
+
@subscriber.subscribe(CACHE_INVALIDATION_CHANNEL) do |on|
|
|
116
|
+
on.message do |_channel, feature_name|
|
|
117
|
+
# Invalidate memory cache for this feature
|
|
118
|
+
memory_adapter.delete(feature_name)
|
|
121
119
|
end
|
|
122
|
-
rescue StandardError => e
|
|
123
|
-
# If subscription fails, log and retry after a delay
|
|
124
|
-
warn "Cache invalidation subscriber error: #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
125
|
-
sleep 5
|
|
126
|
-
retry
|
|
127
120
|
end
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
# If subscription fails, log and retry after a delay
|
|
123
|
+
warn "Cache invalidation subscriber error: #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
124
|
+
sleep 5
|
|
125
|
+
retry
|
|
128
126
|
end
|
|
129
127
|
@subscriber_thread.abort_on_exception = false
|
|
130
128
|
end
|
data/lib/magick/config.rb
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Magick
|
|
4
4
|
class Config
|
|
5
|
-
attr_accessor :adapter_registry, :performance_metrics, :audit_log, :versioning
|
|
6
|
-
|
|
7
|
-
attr_accessor :circuit_breaker_timeout, :redis_url, :redis_namespace, :environment
|
|
5
|
+
attr_accessor :adapter_registry, :performance_metrics, :audit_log, :versioning, :warn_on_deprecated,
|
|
6
|
+
:async_updates, :memory_ttl, :circuit_breaker_threshold, :circuit_breaker_timeout, :redis_url, :redis_namespace, :environment
|
|
8
7
|
|
|
9
8
|
def initialize
|
|
10
9
|
@warn_on_deprecated = false
|
|
@@ -45,28 +44,18 @@ module Magick
|
|
|
45
44
|
configure_redis_adapter(url: url, namespace: namespace, **options)
|
|
46
45
|
end
|
|
47
46
|
|
|
48
|
-
def performance_metrics(enabled: true, **
|
|
49
|
-
if enabled
|
|
50
|
-
@performance_metrics = PerformanceMetrics.new
|
|
51
|
-
else
|
|
52
|
-
@performance_metrics = nil
|
|
53
|
-
end
|
|
47
|
+
def performance_metrics(enabled: true, **_options)
|
|
48
|
+
@performance_metrics = (PerformanceMetrics.new if enabled)
|
|
54
49
|
end
|
|
55
50
|
|
|
56
51
|
def audit_log(enabled: true, adapter: nil)
|
|
57
|
-
if enabled
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
@audit_log = nil
|
|
61
|
-
end
|
|
52
|
+
@audit_log = if enabled
|
|
53
|
+
adapter ? AuditLog.new(adapter) : AuditLog.new
|
|
54
|
+
end
|
|
62
55
|
end
|
|
63
56
|
|
|
64
57
|
def versioning(enabled: true)
|
|
65
|
-
if enabled
|
|
66
|
-
@versioning = Versioning.new(adapter_registry || default_adapter_registry)
|
|
67
|
-
else
|
|
68
|
-
@versioning = nil
|
|
69
|
-
end
|
|
58
|
+
@versioning = (Versioning.new(adapter_registry || default_adapter_registry) if enabled)
|
|
70
59
|
end
|
|
71
60
|
|
|
72
61
|
def circuit_breaker(threshold: nil, timeout: nil)
|
data/lib/magick/dsl.rb
CHANGED
|
@@ -70,6 +70,15 @@ module Magick
|
|
|
70
70
|
def disable_feature(feature_name, user_id: nil)
|
|
71
71
|
Magick[feature_name].disable(user_id: user_id)
|
|
72
72
|
end
|
|
73
|
+
|
|
74
|
+
# Check enablement for an object
|
|
75
|
+
def enabled_for?(feature_name, object, **additional_context)
|
|
76
|
+
Magick.enabled_for?(feature_name, object, **additional_context)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def disabled_for?(feature_name, object, **additional_context)
|
|
80
|
+
Magick.disabled_for?(feature_name, object, **additional_context)
|
|
81
|
+
end
|
|
73
82
|
end
|
|
74
83
|
end
|
|
75
84
|
|
data/lib/magick/export_import.rb
CHANGED
|
@@ -5,7 +5,7 @@ require 'json'
|
|
|
5
5
|
module Magick
|
|
6
6
|
class ExportImport
|
|
7
7
|
def self.export(features_hash)
|
|
8
|
-
result = features_hash.map do |
|
|
8
|
+
result = features_hash.map do |_name, feature|
|
|
9
9
|
feature.to_h
|
|
10
10
|
end
|
|
11
11
|
|
|
@@ -45,7 +45,9 @@ module Magick
|
|
|
45
45
|
description: feature_data['description'] || feature_data[:description]
|
|
46
46
|
)
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
if feature_data['value'] || feature_data[:value]
|
|
49
|
+
feature.set_value(feature_data['value'] || feature_data[:value])
|
|
50
|
+
end
|
|
49
51
|
|
|
50
52
|
# Import targeting
|
|
51
53
|
if feature_data['targeting'] || feature_data[:targeting]
|
data/lib/magick/feature.rb
CHANGED
|
@@ -69,28 +69,20 @@ module Magick
|
|
|
69
69
|
return false if status == :deprecated && !context[:allow_deprecated]
|
|
70
70
|
|
|
71
71
|
# Check feature dependencies
|
|
72
|
-
|
|
73
|
-
return false unless dependencies.all? { |dep_name| Magick.enabled?(dep_name, context) }
|
|
74
|
-
end
|
|
72
|
+
return false if !dependencies.empty? && !dependencies.all? { |dep_name| Magick.enabled?(dep_name, context) }
|
|
75
73
|
|
|
76
74
|
# Check date/time range targeting
|
|
77
|
-
if targeting[:date_range] && !date_range_active?(targeting[:date_range])
|
|
78
|
-
return false
|
|
79
|
-
end
|
|
75
|
+
return false if targeting[:date_range] && !date_range_active?(targeting[:date_range])
|
|
80
76
|
|
|
81
77
|
# Check IP address targeting
|
|
82
|
-
if targeting[:ip_address] && context[:ip_address]
|
|
83
|
-
return false unless ip_address_matches?(context[:ip_address])
|
|
84
|
-
end
|
|
78
|
+
return false if targeting[:ip_address] && context[:ip_address] && !ip_address_matches?(context[:ip_address])
|
|
85
79
|
|
|
86
80
|
# Check custom attributes
|
|
87
|
-
if targeting[:custom_attributes]
|
|
88
|
-
return false unless custom_attributes_match?(context, targeting[:custom_attributes])
|
|
89
|
-
end
|
|
81
|
+
return false if targeting[:custom_attributes] && !custom_attributes_match?(context, targeting[:custom_attributes])
|
|
90
82
|
|
|
91
83
|
# Check complex conditions
|
|
92
|
-
if targeting[:complex_conditions]
|
|
93
|
-
return false
|
|
84
|
+
if targeting[:complex_conditions] && !complex_conditions_match?(context, targeting[:complex_conditions])
|
|
85
|
+
return false
|
|
94
86
|
end
|
|
95
87
|
|
|
96
88
|
value = get_value(context)
|
|
@@ -100,7 +92,7 @@ module Magick
|
|
|
100
92
|
when :string
|
|
101
93
|
!value.nil? && value != ''
|
|
102
94
|
when :number
|
|
103
|
-
value.to_f
|
|
95
|
+
value.to_f.positive?
|
|
104
96
|
else
|
|
105
97
|
false
|
|
106
98
|
end
|
|
@@ -110,6 +102,18 @@ module Magick
|
|
|
110
102
|
!enabled?(context)
|
|
111
103
|
end
|
|
112
104
|
|
|
105
|
+
def enabled_for?(object, **additional_context)
|
|
106
|
+
# Extract context from object
|
|
107
|
+
context = extract_context_from_object(object)
|
|
108
|
+
# Merge with any additional context provided
|
|
109
|
+
context.merge!(additional_context)
|
|
110
|
+
enabled?(context)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def disabled_for?(object, **additional_context)
|
|
114
|
+
!enabled_for?(object, **additional_context)
|
|
115
|
+
end
|
|
116
|
+
|
|
113
117
|
def value(context = {})
|
|
114
118
|
get_value(context)
|
|
115
119
|
end
|
|
@@ -125,26 +129,32 @@ module Magick
|
|
|
125
129
|
|
|
126
130
|
def enable_for_user(user_id)
|
|
127
131
|
enable_targeting(:user, user_id)
|
|
132
|
+
true
|
|
128
133
|
end
|
|
129
134
|
|
|
130
135
|
def disable_for_user(user_id)
|
|
131
136
|
disable_targeting(:user, user_id)
|
|
137
|
+
true
|
|
132
138
|
end
|
|
133
139
|
|
|
134
140
|
def enable_for_group(group_name)
|
|
135
141
|
enable_targeting(:group, group_name)
|
|
142
|
+
true
|
|
136
143
|
end
|
|
137
144
|
|
|
138
145
|
def disable_for_group(group_name)
|
|
139
146
|
disable_targeting(:group, group_name)
|
|
147
|
+
true
|
|
140
148
|
end
|
|
141
149
|
|
|
142
150
|
def enable_for_role(role_name)
|
|
143
151
|
enable_targeting(:role, role_name)
|
|
152
|
+
true
|
|
144
153
|
end
|
|
145
154
|
|
|
146
155
|
def disable_for_role(role_name)
|
|
147
156
|
disable_targeting(:role, role_name)
|
|
157
|
+
true
|
|
148
158
|
end
|
|
149
159
|
|
|
150
160
|
def enable_percentage_of_users(percentage)
|
|
@@ -152,18 +162,19 @@ module Magick
|
|
|
152
162
|
save_targeting
|
|
153
163
|
|
|
154
164
|
# 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
|
|
165
|
+
Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
|
|
158
166
|
|
|
159
167
|
# Rails 8+ event
|
|
160
168
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
161
169
|
Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_users, targeting_value: percentage)
|
|
162
170
|
end
|
|
171
|
+
|
|
172
|
+
true
|
|
163
173
|
end
|
|
164
174
|
|
|
165
175
|
def disable_percentage_of_users
|
|
166
176
|
disable_targeting(:percentage_users)
|
|
177
|
+
true
|
|
167
178
|
end
|
|
168
179
|
|
|
169
180
|
def enable_percentage_of_requests(percentage)
|
|
@@ -171,40 +182,46 @@ module Magick
|
|
|
171
182
|
save_targeting
|
|
172
183
|
|
|
173
184
|
# 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
|
|
185
|
+
Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
|
|
177
186
|
|
|
178
187
|
# Rails 8+ event
|
|
179
188
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
180
189
|
Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_requests, targeting_value: percentage)
|
|
181
190
|
end
|
|
191
|
+
|
|
192
|
+
true
|
|
182
193
|
end
|
|
183
194
|
|
|
184
195
|
def disable_percentage_of_requests
|
|
185
196
|
disable_targeting(:percentage_requests)
|
|
197
|
+
true
|
|
186
198
|
end
|
|
187
199
|
|
|
188
200
|
def enable_for_date_range(start_date, end_date)
|
|
189
201
|
enable_targeting(:date_range, { start: start_date, end: end_date })
|
|
202
|
+
true
|
|
190
203
|
end
|
|
191
204
|
|
|
192
205
|
def disable_date_range
|
|
193
206
|
disable_targeting(:date_range)
|
|
207
|
+
true
|
|
194
208
|
end
|
|
195
209
|
|
|
196
210
|
def enable_for_ip_addresses(ip_addresses)
|
|
197
211
|
enable_targeting(:ip_address, Array(ip_addresses))
|
|
212
|
+
true
|
|
198
213
|
end
|
|
199
214
|
|
|
200
215
|
def disable_ip_addresses
|
|
201
216
|
disable_targeting(:ip_address)
|
|
217
|
+
true
|
|
202
218
|
end
|
|
203
219
|
|
|
204
220
|
def enable_for_custom_attribute(attribute_name, values, operator: :equals)
|
|
205
221
|
custom_attrs = targeting[:custom_attributes] || {}
|
|
206
222
|
custom_attrs[attribute_name.to_sym] = { values: Array(values), operator: operator }
|
|
207
223
|
enable_targeting(:custom_attributes, custom_attrs)
|
|
224
|
+
true
|
|
208
225
|
end
|
|
209
226
|
|
|
210
227
|
def disable_custom_attribute(attribute_name)
|
|
@@ -215,6 +232,7 @@ module Magick
|
|
|
215
232
|
else
|
|
216
233
|
enable_targeting(:custom_attributes, custom_attrs)
|
|
217
234
|
end
|
|
235
|
+
true
|
|
218
236
|
end
|
|
219
237
|
|
|
220
238
|
def set_variants(variants)
|
|
@@ -227,6 +245,8 @@ module Magick
|
|
|
227
245
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
228
246
|
Magick::Rails::Events.variant_set(name, variants: variants_array)
|
|
229
247
|
end
|
|
248
|
+
|
|
249
|
+
true
|
|
230
250
|
end
|
|
231
251
|
|
|
232
252
|
def add_dependency(dependency_name)
|
|
@@ -237,6 +257,8 @@ module Magick
|
|
|
237
257
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
238
258
|
Magick::Rails::Events.dependency_added(name, dependency_name)
|
|
239
259
|
end
|
|
260
|
+
|
|
261
|
+
true
|
|
240
262
|
end
|
|
241
263
|
|
|
242
264
|
def remove_dependency(dependency_name)
|
|
@@ -246,6 +268,8 @@ module Magick
|
|
|
246
268
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
247
269
|
Magick::Rails::Events.dependency_removed(name, dependency_name)
|
|
248
270
|
end
|
|
271
|
+
|
|
272
|
+
true
|
|
249
273
|
end
|
|
250
274
|
|
|
251
275
|
def dependencies
|
|
@@ -257,26 +281,26 @@ module Magick
|
|
|
257
281
|
|
|
258
282
|
variants = targeting[:variants]
|
|
259
283
|
selected_variant = if variants.length == 1
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
284
|
+
variants.first[:name]
|
|
285
|
+
else
|
|
286
|
+
# Weighted random selection
|
|
287
|
+
total_weight = variants.sum { |v| v[:weight] || 0 }
|
|
288
|
+
if total_weight.zero?
|
|
289
|
+
variants.first[:name]
|
|
290
|
+
else
|
|
291
|
+
random = rand(total_weight)
|
|
292
|
+
current = 0
|
|
293
|
+
selected = nil
|
|
294
|
+
variants.each do |variant|
|
|
295
|
+
current += variant[:weight] || 0
|
|
296
|
+
if random < current
|
|
297
|
+
selected = variant[:name]
|
|
298
|
+
break
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
selected || variants.first[:name]
|
|
302
|
+
end
|
|
303
|
+
end
|
|
280
304
|
|
|
281
305
|
# Rails 8+ event
|
|
282
306
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
@@ -297,27 +321,25 @@ module Magick
|
|
|
297
321
|
@stored_value = value
|
|
298
322
|
|
|
299
323
|
# 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
|
|
324
|
+
Magick.features[name].instance_variable_set(:@stored_value, value) if Magick.features.key?(name)
|
|
303
325
|
|
|
304
326
|
changes = { value: { from: old_value, to: value } }
|
|
305
327
|
|
|
306
328
|
# Audit log
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
)
|
|
314
|
-
end
|
|
329
|
+
Magick.audit_log&.log(
|
|
330
|
+
name,
|
|
331
|
+
'set_value',
|
|
332
|
+
user_id: user_id,
|
|
333
|
+
changes: changes
|
|
334
|
+
)
|
|
315
335
|
|
|
316
336
|
# Rails 8+ events
|
|
317
337
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
318
338
|
Magick::Rails::Events.feature_changed(name, changes: changes, user_id: user_id)
|
|
319
339
|
Magick::Rails::Events.audit_logged(name, action: 'set_value', user_id: user_id, changes: changes)
|
|
320
340
|
end
|
|
341
|
+
|
|
342
|
+
true
|
|
321
343
|
end
|
|
322
344
|
|
|
323
345
|
def enable(user_id: nil)
|
|
@@ -329,9 +351,9 @@ module Magick
|
|
|
329
351
|
when :boolean
|
|
330
352
|
set_value(true, user_id: user_id)
|
|
331
353
|
when :string
|
|
332
|
-
raise InvalidFeatureValueError,
|
|
354
|
+
raise InvalidFeatureValueError, 'Cannot enable string feature. Use set_value instead.'
|
|
333
355
|
when :number
|
|
334
|
-
raise InvalidFeatureValueError,
|
|
356
|
+
raise InvalidFeatureValueError, 'Cannot enable number feature. Use set_value instead.'
|
|
335
357
|
else
|
|
336
358
|
raise InvalidFeatureValueError, "Cannot enable feature of type #{type}"
|
|
337
359
|
end
|
|
@@ -340,6 +362,8 @@ module Magick
|
|
|
340
362
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
341
363
|
Magick::Rails::Events.feature_enabled_globally(name, user_id: user_id)
|
|
342
364
|
end
|
|
365
|
+
|
|
366
|
+
true
|
|
343
367
|
end
|
|
344
368
|
|
|
345
369
|
def disable(user_id: nil)
|
|
@@ -362,6 +386,8 @@ module Magick
|
|
|
362
386
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
363
387
|
Magick::Rails::Events.feature_disabled_globally(name, user_id: user_id)
|
|
364
388
|
end
|
|
389
|
+
|
|
390
|
+
true
|
|
365
391
|
end
|
|
366
392
|
|
|
367
393
|
def set_status(new_status)
|
|
@@ -369,6 +395,7 @@ module Magick
|
|
|
369
395
|
|
|
370
396
|
@status = new_status.to_sym
|
|
371
397
|
adapter_registry.set(name, 'status', status)
|
|
398
|
+
true
|
|
372
399
|
end
|
|
373
400
|
|
|
374
401
|
def delete
|
|
@@ -377,6 +404,7 @@ module Magick
|
|
|
377
404
|
@targeting = {}
|
|
378
405
|
# Also remove from Magick.features if registered
|
|
379
406
|
Magick.features.delete(name.to_s)
|
|
407
|
+
true
|
|
380
408
|
end
|
|
381
409
|
|
|
382
410
|
def to_h
|
|
@@ -438,7 +466,7 @@ module Magick
|
|
|
438
466
|
|
|
439
467
|
case type
|
|
440
468
|
when :boolean
|
|
441
|
-
|
|
469
|
+
[true, 'true', 1].include?(value)
|
|
442
470
|
when :string
|
|
443
471
|
value.to_s
|
|
444
472
|
when :number
|
|
@@ -588,6 +616,45 @@ module Magick
|
|
|
588
616
|
(hash_value % 100) < percentage
|
|
589
617
|
end
|
|
590
618
|
|
|
619
|
+
def extract_context_from_object(object)
|
|
620
|
+
context = {}
|
|
621
|
+
|
|
622
|
+
# Handle hash/struct-like objects
|
|
623
|
+
if object.is_a?(Hash)
|
|
624
|
+
context[:user_id] = object[:user_id] || object['user_id'] || object[:id] || object['id']
|
|
625
|
+
context[:group] = object[:group] || object['group']
|
|
626
|
+
context[:role] = object[:role] || object['role']
|
|
627
|
+
context[:ip_address] = object[:ip_address] || object['ip_address']
|
|
628
|
+
# Include all other attributes for custom attribute matching
|
|
629
|
+
object.each do |key, value|
|
|
630
|
+
next if %i[user_id id group role ip_address].include?(key.to_sym)
|
|
631
|
+
next if %w[user_id id group role ip_address].include?(key.to_s)
|
|
632
|
+
|
|
633
|
+
context[key.to_sym] = value
|
|
634
|
+
end
|
|
635
|
+
# Handle ActiveRecord-like objects (respond to methods)
|
|
636
|
+
elsif object.respond_to?(:id) || object.respond_to?(:user_id)
|
|
637
|
+
context[:user_id] = object.respond_to?(:user_id) ? object.user_id : object.id
|
|
638
|
+
context[:group] = object.group if object.respond_to?(:group)
|
|
639
|
+
context[:role] = object.role if object.respond_to?(:role)
|
|
640
|
+
context[:ip_address] = object.ip_address if object.respond_to?(:ip_address)
|
|
641
|
+
|
|
642
|
+
# For ActiveRecord objects, include all attributes
|
|
643
|
+
if object.respond_to?(:attributes)
|
|
644
|
+
object.attributes.each do |key, value|
|
|
645
|
+
next if %w[id user_id group role ip_address].include?(key.to_s)
|
|
646
|
+
|
|
647
|
+
context[key.to_sym] = value
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
# Handle simple values (like user_id directly)
|
|
651
|
+
elsif object.respond_to?(:to_i) && object.to_i.to_s == object.to_s
|
|
652
|
+
context[:user_id] = object.to_i
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
context
|
|
656
|
+
end
|
|
657
|
+
|
|
591
658
|
def enable_targeting(type, value)
|
|
592
659
|
@targeting[type] ||= []
|
|
593
660
|
@targeting[type] << value.to_s unless @targeting[type].include?(value.to_s)
|
|
@@ -597,6 +664,8 @@ module Magick
|
|
|
597
664
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
598
665
|
Magick::Rails::Events.targeting_added(name, targeting_type: type, targeting_value: value)
|
|
599
666
|
end
|
|
667
|
+
|
|
668
|
+
true
|
|
600
669
|
end
|
|
601
670
|
|
|
602
671
|
def disable_targeting(type, value = nil)
|
|
@@ -611,14 +680,16 @@ module Magick
|
|
|
611
680
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
612
681
|
Magick::Rails::Events.targeting_removed(name, targeting_type: type, targeting_value: value)
|
|
613
682
|
end
|
|
683
|
+
|
|
684
|
+
true
|
|
614
685
|
end
|
|
615
686
|
|
|
616
687
|
def save_targeting
|
|
617
688
|
adapter_registry.set(name, 'targeting', targeting)
|
|
618
689
|
# Update the feature in Magick.features if it's registered
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
690
|
+
return unless Magick.features.key?(name)
|
|
691
|
+
|
|
692
|
+
Magick.features[name].instance_variable_set(:@targeting, targeting.dup)
|
|
622
693
|
end
|
|
623
694
|
|
|
624
695
|
def default_for_type
|
|
@@ -643,22 +714,30 @@ module Magick
|
|
|
643
714
|
def validate_default_value!
|
|
644
715
|
case type
|
|
645
716
|
when :boolean
|
|
646
|
-
|
|
717
|
+
unless [true, false].include?(default_value)
|
|
718
|
+
raise InvalidFeatureValueError, 'Default value must be boolean for type :boolean'
|
|
719
|
+
end
|
|
647
720
|
when :string
|
|
648
|
-
|
|
721
|
+
unless default_value.is_a?(String)
|
|
722
|
+
raise InvalidFeatureValueError,
|
|
723
|
+
'Default value must be a string for type :string'
|
|
724
|
+
end
|
|
649
725
|
when :number
|
|
650
|
-
|
|
726
|
+
unless default_value.is_a?(Numeric)
|
|
727
|
+
raise InvalidFeatureValueError,
|
|
728
|
+
'Default value must be numeric for type :number'
|
|
729
|
+
end
|
|
651
730
|
end
|
|
652
731
|
end
|
|
653
732
|
|
|
654
733
|
def validate_value!(value)
|
|
655
734
|
case type
|
|
656
735
|
when :boolean
|
|
657
|
-
raise InvalidFeatureValueError,
|
|
736
|
+
raise InvalidFeatureValueError, 'Value must be boolean for type :boolean' unless [true, false].include?(value)
|
|
658
737
|
when :string
|
|
659
|
-
raise InvalidFeatureValueError,
|
|
738
|
+
raise InvalidFeatureValueError, 'Value must be a string for type :string' unless value.is_a?(String)
|
|
660
739
|
when :number
|
|
661
|
-
raise InvalidFeatureValueError,
|
|
740
|
+
raise InvalidFeatureValueError, 'Value must be numeric for type :number' unless value.is_a?(Numeric)
|
|
662
741
|
end
|
|
663
742
|
end
|
|
664
743
|
end
|
|
@@ -20,7 +20,7 @@ module Magick
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
# Subscribe to a specific event
|
|
23
|
-
def subscribe_to(event_name
|
|
23
|
+
def subscribe_to(event_name)
|
|
24
24
|
return unless Events.rails81?
|
|
25
25
|
|
|
26
26
|
full_event_name = Events::EVENTS[event_name] || event_name.to_s
|
|
@@ -44,7 +44,7 @@ module Magick
|
|
|
44
44
|
# Default log subscriber for Magick events
|
|
45
45
|
class LogSubscriber
|
|
46
46
|
def emit(event)
|
|
47
|
-
payload = event[:payload].map { |key, value| "#{key}=#{value}" }.join(
|
|
47
|
+
payload = event[:payload].map { |key, value| "#{key}=#{value}" }.join(' ')
|
|
48
48
|
source_location = event[:source_location]
|
|
49
49
|
log = "[#{event[:name]}] #{payload}"
|
|
50
50
|
log += " at #{source_location[:filepath]}:#{source_location[:lineno]}" if source_location
|
data/lib/magick/rails/railtie.rb
CHANGED
|
@@ -43,20 +43,22 @@ if defined?(Rails)
|
|
|
43
43
|
else
|
|
44
44
|
# Fallback to config/initializers/features.rb (already loaded by Rails, but check anyway)
|
|
45
45
|
initializer_file = Rails.root.join('config', 'initializers', 'features.rb')
|
|
46
|
-
if File.exist?(initializer_file)
|
|
46
|
+
if File.exist?(initializer_file) && !defined?(Magick::Rails::FeaturesLoaded)
|
|
47
47
|
# Only load if not already loaded (Rails may have already loaded it)
|
|
48
|
-
load initializer_file
|
|
48
|
+
load initializer_file
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
|
-
|
|
51
|
+
begin
|
|
52
|
+
Magick::Rails.const_set(:FeaturesLoaded, true)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
52
56
|
end
|
|
53
57
|
end
|
|
54
58
|
|
|
55
59
|
# Preload features in request store
|
|
56
60
|
config.to_prepare do
|
|
57
|
-
if defined?(RequestStore)
|
|
58
|
-
RequestStore.store[:magick_features] ||= {}
|
|
59
|
-
end
|
|
61
|
+
RequestStore.store[:magick_features] ||= {} if defined?(RequestStore)
|
|
60
62
|
end
|
|
61
63
|
end
|
|
62
64
|
end
|
data/lib/magick/rails.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Magick
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
module ClassMethods
|
|
10
|
-
def with_feature_enabled(feature_name
|
|
10
|
+
def with_feature_enabled(feature_name)
|
|
11
11
|
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
12
12
|
original_value = feature.value
|
|
13
13
|
feature.set_value(true)
|
|
@@ -16,7 +16,7 @@ module Magick
|
|
|
16
16
|
feature.set_value(original_value)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def with_feature_disabled(feature_name
|
|
19
|
+
def with_feature_disabled(feature_name)
|
|
20
20
|
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
21
21
|
original_value = feature.value
|
|
22
22
|
feature.set_value(false)
|
|
@@ -25,7 +25,7 @@ module Magick
|
|
|
25
25
|
feature.set_value(original_value)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def with_feature_value(feature_name, value
|
|
28
|
+
def with_feature_value(feature_name, value)
|
|
29
29
|
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
30
30
|
original_value = feature.value
|
|
31
31
|
feature.set_value(value)
|
data/lib/magick/version.rb
CHANGED
data/lib/magick/versioning.rb
CHANGED
|
@@ -61,17 +61,15 @@ module Magick
|
|
|
61
61
|
feature.set_status(feature_data[:status]) if feature_data[:status]
|
|
62
62
|
|
|
63
63
|
# Restore targeting
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
feature.enable_for_role(value)
|
|
74
|
-
end
|
|
64
|
+
feature_data[:targeting]&.each do |type, values|
|
|
65
|
+
Array(values).each do |value|
|
|
66
|
+
case type.to_sym
|
|
67
|
+
when :user
|
|
68
|
+
feature.enable_for_user(value)
|
|
69
|
+
when :group
|
|
70
|
+
feature.enable_for_group(value)
|
|
71
|
+
when :role
|
|
72
|
+
feature.enable_for_role(value)
|
|
75
73
|
end
|
|
76
74
|
end
|
|
77
75
|
end
|
data/lib/magick.rb
CHANGED
|
@@ -39,7 +39,7 @@ module Magick
|
|
|
39
39
|
# Support both old style and new DSL style
|
|
40
40
|
return unless block_given?
|
|
41
41
|
|
|
42
|
-
if block.arity
|
|
42
|
+
if block.arity.zero?
|
|
43
43
|
# New DSL style
|
|
44
44
|
ConfigDSL.configure(&block)
|
|
45
45
|
else
|
|
@@ -68,6 +68,15 @@ module Magick
|
|
|
68
68
|
feature.enabled?(context)
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
def enabled_for?(feature_name, object, **additional_context)
|
|
72
|
+
feature = features[feature_name.to_s] || self[feature_name]
|
|
73
|
+
feature.enabled_for?(object, **additional_context)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def disabled_for?(feature_name, object, **additional_context)
|
|
77
|
+
!enabled_for?(feature_name, object, **additional_context)
|
|
78
|
+
end
|
|
79
|
+
|
|
71
80
|
# Reload a feature from the adapter (useful when feature is changed externally)
|
|
72
81
|
def reload_feature(feature_name)
|
|
73
82
|
feature = features[feature_name.to_s] || self[feature_name]
|
|
@@ -82,7 +91,7 @@ module Magick
|
|
|
82
91
|
features.key?(feature_name.to_s) || (adapter_registry || default_adapter_registry).exists?(feature_name)
|
|
83
92
|
end
|
|
84
93
|
|
|
85
|
-
def bulk_enable(feature_names,
|
|
94
|
+
def bulk_enable(feature_names, _context = {})
|
|
86
95
|
feature_names.map do |name|
|
|
87
96
|
feature = features[name.to_s] || self[name]
|
|
88
97
|
feature.set_value(true) if feature.type == :boolean
|
|
@@ -90,7 +99,7 @@ module Magick
|
|
|
90
99
|
end
|
|
91
100
|
end
|
|
92
101
|
|
|
93
|
-
def bulk_disable(feature_names,
|
|
102
|
+
def bulk_disable(feature_names, _context = {})
|
|
94
103
|
feature_names.map do |name|
|
|
95
104
|
feature = features[name.to_s] || self[name]
|
|
96
105
|
feature.set_value(false) if feature.type == :boolean
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Auto-require file for Rails
|
|
4
|
-
# When gem 'magick-feature-flags' is in Gemfile, Rails will try to require
|
|
4
|
+
# When gem 'magick-feature-flags' is in Gemfile, Rails will try to require 'magick_feature_flags'
|
|
5
5
|
require 'magick'
|
|
6
|
-
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: magick-feature-flags
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew Lobanov
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-11-
|
|
11
|
+
date: 2025-11-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rspec
|
|
@@ -65,7 +65,6 @@ files:
|
|
|
65
65
|
- lib/generators/magick/install/install_generator.rb
|
|
66
66
|
- lib/generators/magick/install/templates/README
|
|
67
67
|
- lib/generators/magick/install/templates/magick.rb
|
|
68
|
-
- lib/magick-feature-flags.rb
|
|
69
68
|
- lib/magick.rb
|
|
70
69
|
- lib/magick/adapters/base.rb
|
|
71
70
|
- lib/magick/adapters/memory.rb
|
|
@@ -98,11 +97,12 @@ files:
|
|
|
98
97
|
- lib/magick/testing_helpers.rb
|
|
99
98
|
- lib/magick/version.rb
|
|
100
99
|
- lib/magick/versioning.rb
|
|
100
|
+
- lib/magick_feature_flags.rb
|
|
101
101
|
homepage: https://github.com/andrew-woblavobla/magick
|
|
102
102
|
licenses:
|
|
103
103
|
- MIT
|
|
104
104
|
metadata: {}
|
|
105
|
-
post_install_message:
|
|
105
|
+
post_install_message:
|
|
106
106
|
rdoc_options: []
|
|
107
107
|
require_paths:
|
|
108
108
|
- lib
|
|
@@ -117,8 +117,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
117
117
|
- !ruby/object:Gem::Version
|
|
118
118
|
version: '0'
|
|
119
119
|
requirements: []
|
|
120
|
-
rubygems_version: 3.
|
|
121
|
-
signing_key:
|
|
120
|
+
rubygems_version: 3.5.19
|
|
121
|
+
signing_key:
|
|
122
122
|
specification_version: 4
|
|
123
123
|
summary: A performant and memory-efficient feature toggle gem
|
|
124
124
|
test_files: []
|