magick-feature-flags 1.1.3 → 1.2.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 +114 -6
- data/app/controllers/magick/adminui/features_controller.rb +43 -0
- data/lib/magick/dsl.rb +21 -0
- data/lib/magick/feature.rb +103 -1
- data/lib/magick/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f87e02cd09ce5fb4e837f66c5117d2ca709918cd4bcc1f1fe808ceaafe7e049
|
|
4
|
+
data.tar.gz: 5ed85473443dd9e065c2a7298030a361b1fe965376c68085b0355d57124c04e2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 29402326b70df322e48ae798571ee500a12eafc964545ed0e1a2f15e56bce6197254084b3c9eb75cadd22a61e2583c74fbbb41d0c50f478f733e561d8c41917f
|
|
7
|
+
data.tar.gz: a8d84d507bab0fd1e02eb3583830c059f3cdd5d475a137bfe952a85194fc66f2e5a8f8b548096722664ae0a92325087d265b17aa3f1d12e6d3f3f5176c212ca5
|
data/README.md
CHANGED
|
@@ -6,6 +6,7 @@ A performant and memory-efficient feature toggle gem for Ruby and Rails applicat
|
|
|
6
6
|
|
|
7
7
|
- **Multiple Feature Types**: Boolean, string, and number feature flags
|
|
8
8
|
- **Flexible Targeting**: Enable features for specific users, groups, roles, tags, or percentages
|
|
9
|
+
- **Exclusions**: Exclude specific users, groups, roles, tags, or IPs — exclusions always take priority over inclusions
|
|
9
10
|
- **Dual Backend**: Memory adapter (fast) with Redis fallback (persistent)
|
|
10
11
|
- **Rails Integration**: Seamless integration with Rails, including request store caching
|
|
11
12
|
- **DSL Support**: Define features in a Ruby DSL file (`config/features.rb`)
|
|
@@ -193,6 +194,76 @@ feature.enable_for_ip_addresses('192.168.1.0/24', '10.0.0.1')
|
|
|
193
194
|
feature.enable_for_custom_attribute(:subscription_tier, ['premium', 'enterprise'])
|
|
194
195
|
```
|
|
195
196
|
|
|
197
|
+
### Feature Exclusions
|
|
198
|
+
|
|
199
|
+
Exclusions let you block specific users, groups, roles, tags, or IP addresses from a feature — even if they match an inclusion rule. **Exclusions always take priority over inclusions.**
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
feature = Magick[:new_dashboard]
|
|
203
|
+
|
|
204
|
+
# Exclude specific users
|
|
205
|
+
feature.exclude_user(456)
|
|
206
|
+
feature.exclude_user(789)
|
|
207
|
+
|
|
208
|
+
# Exclude specific tags
|
|
209
|
+
feature.exclude_tag('legacy_tier')
|
|
210
|
+
feature.exclude_tag('banned')
|
|
211
|
+
|
|
212
|
+
# Exclude specific groups
|
|
213
|
+
feature.exclude_group('suspended_users')
|
|
214
|
+
|
|
215
|
+
# Exclude specific roles
|
|
216
|
+
feature.exclude_role('guest')
|
|
217
|
+
|
|
218
|
+
# Exclude IP addresses (supports CIDR notation)
|
|
219
|
+
feature.exclude_ip_addresses(['10.0.0.0/8', '192.168.1.100'])
|
|
220
|
+
|
|
221
|
+
# Remove exclusions
|
|
222
|
+
feature.remove_user_exclusion(456)
|
|
223
|
+
feature.remove_tag_exclusion('legacy_tier')
|
|
224
|
+
feature.remove_group_exclusion('suspended_users')
|
|
225
|
+
feature.remove_role_exclusion('guest')
|
|
226
|
+
feature.remove_ip_exclusion # Removes all IP exclusions
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
**Exclusions win over inclusions:**
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
feature = Magick[:premium_features]
|
|
233
|
+
feature.enable # Enabled globally for everyone
|
|
234
|
+
|
|
235
|
+
feature.exclude_user(123)
|
|
236
|
+
Magick.enabled?(:premium_features, user_id: 123) # => false (excluded)
|
|
237
|
+
Magick.enabled?(:premium_features, user_id: 456) # => true (not excluded)
|
|
238
|
+
|
|
239
|
+
# Even percentage targeting is overridden
|
|
240
|
+
feature.enable_percentage_of_users(100) # 100% of users
|
|
241
|
+
feature.exclude_user(123)
|
|
242
|
+
Magick.enabled?(:premium_features, user_id: 123) # => false (still excluded)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
**Exclusions in DSL (`config/features.rb`):**
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
boolean_feature :new_dashboard, default: true
|
|
249
|
+
|
|
250
|
+
# Exclude problematic users
|
|
251
|
+
exclude_user :new_dashboard, 'user_123'
|
|
252
|
+
exclude_user :new_dashboard, 'user_456'
|
|
253
|
+
|
|
254
|
+
# Exclude legacy tiers
|
|
255
|
+
exclude_tag :new_dashboard, 'legacy_tier'
|
|
256
|
+
|
|
257
|
+
# Exclude groups
|
|
258
|
+
exclude_group :new_dashboard, 'banned_users'
|
|
259
|
+
|
|
260
|
+
# Exclude roles
|
|
261
|
+
exclude_role :new_dashboard, 'suspended'
|
|
262
|
+
|
|
263
|
+
# Exclude IPs
|
|
264
|
+
exclude_ip_addresses :new_dashboard, '10.0.0.0/8'
|
|
265
|
+
```
|
|
266
|
+
|
|
196
267
|
### Checking Feature Enablement with Objects
|
|
197
268
|
|
|
198
269
|
You can check if a feature is enabled for an object (like a User model) and its fields:
|
|
@@ -258,6 +329,12 @@ result = feature.enable_for_role('admin') # => true
|
|
|
258
329
|
result = feature.enable_for_tag('premium') # => true
|
|
259
330
|
result = feature.enable_percentage_of_users(25) # => true
|
|
260
331
|
result = feature.set_value(true) # => true
|
|
332
|
+
|
|
333
|
+
# Exclusion methods also return true
|
|
334
|
+
result = feature.exclude_user(456) # => true
|
|
335
|
+
result = feature.exclude_tag('banned') # => true
|
|
336
|
+
result = feature.exclude_group('blocked') # => true
|
|
337
|
+
result = feature.exclude_role('guest') # => true
|
|
261
338
|
```
|
|
262
339
|
|
|
263
340
|
### DSL Configuration
|
|
@@ -303,6 +380,13 @@ boolean_feature :premium_feature,
|
|
|
303
380
|
|
|
304
381
|
# Add dependencies after feature definition
|
|
305
382
|
add_dependency(:another_feature, :required_feature)
|
|
383
|
+
|
|
384
|
+
# Exclusions - block specific users/groups/roles/tags
|
|
385
|
+
exclude_user :new_dashboard, 'user_123'
|
|
386
|
+
exclude_tag :new_dashboard, 'legacy_tier'
|
|
387
|
+
exclude_group :new_dashboard, 'banned_users'
|
|
388
|
+
exclude_role :new_dashboard, 'suspended'
|
|
389
|
+
exclude_ip_addresses :new_dashboard, '10.0.0.0/8'
|
|
306
390
|
```
|
|
307
391
|
|
|
308
392
|
### In Controllers
|
|
@@ -443,8 +527,8 @@ end
|
|
|
443
527
|
|
|
444
528
|
Magick uses a dual-adapter strategy:
|
|
445
529
|
|
|
446
|
-
1. **Memory Adapter**: Fast, in-memory storage with TTL support
|
|
447
|
-
2. **Redis Adapter**: Persistent storage for distributed systems (optional)
|
|
530
|
+
1. **Memory Adapter**: Fast, in-memory storage with TTL support and JSON serialization
|
|
531
|
+
2. **Redis Adapter**: Persistent storage for distributed systems (optional), uses SCAN instead of KEYS and pipelined bulk operations
|
|
448
532
|
|
|
449
533
|
The registry automatically falls back from memory to Redis if a feature isn't found in memory. When features are updated:
|
|
450
534
|
- Both adapters are updated simultaneously
|
|
@@ -589,6 +673,7 @@ Once mounted, visit `/magick` in your browser to access the Admin UI.
|
|
|
589
673
|
- **Role Targeting**: Select roles from a configured list (checkboxes)
|
|
590
674
|
- **Tag Targeting**: Select tags from a dynamically loaded list (checkboxes)
|
|
591
675
|
- **User Targeting**: Enter user IDs (comma-separated)
|
|
676
|
+
- **Exclusions**: Exclude users, roles, and tags from a feature (exclusions override inclusions)
|
|
592
677
|
- **Visual Display**: See all active targeting rules with badges
|
|
593
678
|
- **Edit Features**: Update feature values (boolean, string, number) directly from the UI
|
|
594
679
|
- **Statistics**: View performance metrics and usage statistics for each feature
|
|
@@ -641,7 +726,12 @@ The Admin UI provides a comprehensive targeting interface:
|
|
|
641
726
|
- Add or remove users dynamically
|
|
642
727
|
- Clear all user targeting by leaving the field empty
|
|
643
728
|
|
|
644
|
-
4. **
|
|
729
|
+
4. **Exclusion Targeting**:
|
|
730
|
+
- Exclude specific users (comma-separated IDs), roles, and tags
|
|
731
|
+
- Exclusions always take priority over inclusions
|
|
732
|
+
- Managed through the same targeting form
|
|
733
|
+
|
|
734
|
+
5. **Visual Feedback**:
|
|
645
735
|
- All targeting rules are displayed as badges in the feature details view
|
|
646
736
|
- Easy to see which roles/tags/users have access to each feature
|
|
647
737
|
|
|
@@ -663,7 +753,27 @@ The Admin UI provides the following routes:
|
|
|
663
753
|
|
|
664
754
|
**Security:**
|
|
665
755
|
|
|
666
|
-
The Admin UI
|
|
756
|
+
The Admin UI includes a built-in authentication hook via `require_role`. Configure it to gate access:
|
|
757
|
+
|
|
758
|
+
```ruby
|
|
759
|
+
# config/initializers/magick.rb
|
|
760
|
+
Rails.application.config.after_initialize do
|
|
761
|
+
Magick::AdminUI.configure do |config|
|
|
762
|
+
# Option 1: Lambda-based authentication (recommended)
|
|
763
|
+
config.require_role = ->(controller) {
|
|
764
|
+
# Return true to allow, false to deny (returns 403 Forbidden)
|
|
765
|
+
controller.current_user&.admin?
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
# Option 2: Check for a specific role
|
|
769
|
+
config.require_role = ->(controller) {
|
|
770
|
+
controller.current_user&.role == 'admin'
|
|
771
|
+
}
|
|
772
|
+
end
|
|
773
|
+
end
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
You can also gate access at the routing level:
|
|
667
777
|
|
|
668
778
|
```ruby
|
|
669
779
|
# config/routes.rb
|
|
@@ -680,8 +790,6 @@ Rails.application.routes.draw do
|
|
|
680
790
|
end
|
|
681
791
|
```
|
|
682
792
|
|
|
683
|
-
Or use a before_action in your ApplicationController if you mount it at the application level.
|
|
684
|
-
|
|
685
793
|
**Note:** The Admin UI is optional and only loaded when explicitly enabled in configuration. It requires Rails to be available.
|
|
686
794
|
|
|
687
795
|
### Feature Types
|
|
@@ -208,6 +208,49 @@ module Magick
|
|
|
208
208
|
@feature.disable_percentage_of_requests if current_targeting[:percentage_requests]
|
|
209
209
|
end
|
|
210
210
|
|
|
211
|
+
# Handle excluded user IDs
|
|
212
|
+
if targeting_params[:excluded_user_ids].present?
|
|
213
|
+
excluded_user_ids = targeting_params[:excluded_user_ids].split(',').map(&:strip).reject(&:blank?)
|
|
214
|
+
current_excluded_users = current_targeting[:excluded_users].is_a?(Array) ? current_targeting[:excluded_users] : (current_targeting[:excluded_users] ? [current_targeting[:excluded_users]] : [])
|
|
215
|
+
|
|
216
|
+
(current_excluded_users - excluded_user_ids).each do |user_id|
|
|
217
|
+
@feature.remove_user_exclusion(user_id) if user_id.present?
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
(excluded_user_ids - current_excluded_users).each do |user_id|
|
|
221
|
+
@feature.exclude_user(user_id) if user_id.present?
|
|
222
|
+
end
|
|
223
|
+
elsif targeting_params.key?(:excluded_user_ids) && targeting_params[:excluded_user_ids].blank?
|
|
224
|
+
current_excluded_users = current_targeting[:excluded_users].is_a?(Array) ? current_targeting[:excluded_users] : (current_targeting[:excluded_users] ? [current_targeting[:excluded_users]] : [])
|
|
225
|
+
current_excluded_users.each do |user_id|
|
|
226
|
+
@feature.remove_user_exclusion(user_id) if user_id.present?
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Handle excluded roles
|
|
231
|
+
current_excluded_roles = current_targeting[:excluded_roles].is_a?(Array) ? current_targeting[:excluded_roles] : (current_targeting[:excluded_roles] ? [current_targeting[:excluded_roles]] : [])
|
|
232
|
+
selected_excluded_roles = Array(targeting_params[:excluded_roles]).reject(&:blank?)
|
|
233
|
+
|
|
234
|
+
(current_excluded_roles - selected_excluded_roles).each do |role|
|
|
235
|
+
@feature.remove_role_exclusion(role) if role.present?
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
(selected_excluded_roles - current_excluded_roles).each do |role|
|
|
239
|
+
@feature.exclude_role(role) if role.present?
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Handle excluded tags
|
|
243
|
+
current_excluded_tags = current_targeting[:excluded_tags].is_a?(Array) ? current_targeting[:excluded_tags] : (current_targeting[:excluded_tags] ? [current_targeting[:excluded_tags]] : [])
|
|
244
|
+
selected_excluded_tags = Array(targeting_params[:excluded_tags]).reject(&:blank?)
|
|
245
|
+
|
|
246
|
+
(current_excluded_tags - selected_excluded_tags).each do |tag|
|
|
247
|
+
@feature.remove_tag_exclusion(tag) if tag.present?
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
(selected_excluded_tags - current_excluded_tags).each do |tag|
|
|
251
|
+
@feature.exclude_tag(tag) if tag.present?
|
|
252
|
+
end
|
|
253
|
+
|
|
211
254
|
# After all targeting updates, ensure we're using the registered feature instance
|
|
212
255
|
# and reload it to get the latest state from adapter
|
|
213
256
|
feature_name = @feature.name.to_s
|
data/lib/magick/dsl.rb
CHANGED
|
@@ -58,6 +58,27 @@ module Magick
|
|
|
58
58
|
Magick[feature_name].enable_for_custom_attribute(attribute_name, values, operator: operator)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
# Exclusion DSL methods
|
|
62
|
+
def exclude_user(feature_name, user_id)
|
|
63
|
+
Magick[feature_name].exclude_user(user_id)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def exclude_tag(feature_name, tag_name)
|
|
67
|
+
Magick[feature_name].exclude_tag(tag_name)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def exclude_group(feature_name, group_name)
|
|
71
|
+
Magick[feature_name].exclude_group(group_name)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def exclude_role(feature_name, role_name)
|
|
75
|
+
Magick[feature_name].exclude_role(role_name)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def exclude_ip_addresses(feature_name, *ip_addresses)
|
|
79
|
+
Magick[feature_name].exclude_ip_addresses(ip_addresses)
|
|
80
|
+
end
|
|
81
|
+
|
|
61
82
|
def set_variants(feature_name, variants)
|
|
62
83
|
Magick[feature_name].set_variants(variants)
|
|
63
84
|
end
|
data/lib/magick/feature.rb
CHANGED
|
@@ -109,6 +109,9 @@ module Magick
|
|
|
109
109
|
|
|
110
110
|
# Fast path: skip targeting checks if targeting is empty (most common case)
|
|
111
111
|
unless @_targeting_empty
|
|
112
|
+
# Check exclusions FIRST — exclusions always take priority over inclusions
|
|
113
|
+
return false if excluded?(context)
|
|
114
|
+
|
|
112
115
|
# Check date/time range targeting
|
|
113
116
|
return false if targeting[:date_range] && !date_range_active?(targeting[:date_range])
|
|
114
117
|
|
|
@@ -264,6 +267,58 @@ module Magick
|
|
|
264
267
|
true
|
|
265
268
|
end
|
|
266
269
|
|
|
270
|
+
# --- Exclusion methods ---
|
|
271
|
+
|
|
272
|
+
def exclude_user(user_id)
|
|
273
|
+
enable_targeting(:excluded_users, user_id)
|
|
274
|
+
true
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def remove_user_exclusion(user_id)
|
|
278
|
+
disable_targeting(:excluded_users, user_id)
|
|
279
|
+
true
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def exclude_tag(tag_name)
|
|
283
|
+
enable_targeting(:excluded_tags, tag_name)
|
|
284
|
+
true
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def remove_tag_exclusion(tag_name)
|
|
288
|
+
disable_targeting(:excluded_tags, tag_name)
|
|
289
|
+
true
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def exclude_group(group_name)
|
|
293
|
+
enable_targeting(:excluded_groups, group_name)
|
|
294
|
+
true
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def remove_group_exclusion(group_name)
|
|
298
|
+
disable_targeting(:excluded_groups, group_name)
|
|
299
|
+
true
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def exclude_role(role_name)
|
|
303
|
+
enable_targeting(:excluded_roles, role_name)
|
|
304
|
+
true
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def remove_role_exclusion(role_name)
|
|
308
|
+
disable_targeting(:excluded_roles, role_name)
|
|
309
|
+
true
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def exclude_ip_addresses(ip_addresses)
|
|
313
|
+
enable_targeting(:excluded_ip_addresses, Array(ip_addresses))
|
|
314
|
+
true
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def remove_ip_exclusion
|
|
318
|
+
disable_targeting(:excluded_ip_addresses)
|
|
319
|
+
true
|
|
320
|
+
end
|
|
321
|
+
|
|
267
322
|
def enable_percentage_of_users(percentage)
|
|
268
323
|
@targeting[:percentage_users] = percentage.to_f
|
|
269
324
|
save_targeting
|
|
@@ -719,6 +774,10 @@ module Magick
|
|
|
719
774
|
# Normalize targeting keys (handle both string and symbol keys)
|
|
720
775
|
target = targeting.transform_keys(&:to_sym)
|
|
721
776
|
|
|
777
|
+
# If only exclusion keys exist (no inclusion targeting), treat as globally enabled
|
|
778
|
+
inclusion_keys = target.keys.reject { |k| k.to_s.start_with?('excluded_') }
|
|
779
|
+
return true if inclusion_keys.empty?
|
|
780
|
+
|
|
722
781
|
# Check user targeting
|
|
723
782
|
if context[:user_id] && target[:user]
|
|
724
783
|
user_list = target[:user].is_a?(Array) ? target[:user] : [target[:user]]
|
|
@@ -963,9 +1022,52 @@ module Magick
|
|
|
963
1022
|
dependent_features
|
|
964
1023
|
end
|
|
965
1024
|
|
|
1025
|
+
def excluded?(context)
|
|
1026
|
+
target = targeting.transform_keys(&:to_sym)
|
|
1027
|
+
|
|
1028
|
+
# Check excluded users
|
|
1029
|
+
if context[:user_id] && target[:excluded_users]
|
|
1030
|
+
excluded_list = target[:excluded_users].is_a?(Array) ? target[:excluded_users] : [target[:excluded_users]]
|
|
1031
|
+
return true if excluded_list.include?(context[:user_id].to_s)
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
# Check excluded groups
|
|
1035
|
+
if context[:group] && target[:excluded_groups]
|
|
1036
|
+
excluded_list = target[:excluded_groups].is_a?(Array) ? target[:excluded_groups] : [target[:excluded_groups]]
|
|
1037
|
+
return true if excluded_list.include?(context[:group].to_s)
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
# Check excluded roles
|
|
1041
|
+
if context[:role] && target[:excluded_roles]
|
|
1042
|
+
excluded_list = target[:excluded_roles].is_a?(Array) ? target[:excluded_roles] : [target[:excluded_roles]]
|
|
1043
|
+
return true if excluded_list.include?(context[:role].to_s)
|
|
1044
|
+
end
|
|
1045
|
+
|
|
1046
|
+
# Check excluded tags
|
|
1047
|
+
if context[:tags] && target[:excluded_tags]
|
|
1048
|
+
context_tags = Array(context[:tags]).map(&:to_s)
|
|
1049
|
+
excluded_tags = target[:excluded_tags].is_a?(Array) ? target[:excluded_tags].map(&:to_s) : [target[:excluded_tags].to_s]
|
|
1050
|
+
return true if (context_tags & excluded_tags).any?
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
# Check excluded IP addresses
|
|
1054
|
+
if context[:ip_address] && target[:excluded_ip_addresses]
|
|
1055
|
+
begin
|
|
1056
|
+
require 'ipaddr'
|
|
1057
|
+
excluded_ips = Array(target[:excluded_ip_addresses])
|
|
1058
|
+
client_ip = IPAddr.new(context[:ip_address])
|
|
1059
|
+
return true if excluded_ips.any? { |ip_str| IPAddr.new(ip_str).include?(client_ip) }
|
|
1060
|
+
rescue IPAddr::InvalidAddressError, ArgumentError
|
|
1061
|
+
# Invalid IP, not excluded
|
|
1062
|
+
end
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
false
|
|
1066
|
+
end
|
|
1067
|
+
|
|
966
1068
|
def enable_targeting(type, value)
|
|
967
1069
|
case type
|
|
968
|
-
when :date_range, :custom_attributes, :complex_conditions, :variants
|
|
1070
|
+
when :date_range, :custom_attributes, :complex_conditions, :variants, :excluded_ip_addresses
|
|
969
1071
|
# These types store structured data directly (Hash or Array of Hashes)
|
|
970
1072
|
@targeting[type] = value
|
|
971
1073
|
when :percentage_users, :percentage_requests
|
data/lib/magick/version.rb
CHANGED