magick-feature-flags 1.4.3 → 1.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e6f3b4ee81b2521e7f6975c0bd3c7a0a886ea2e23b5792b37e48cd28b08cb8d
4
- data.tar.gz: 3aeb62560e637d844af0ea4e320a08a48eb72a33ebfd0653482899f42e8e2079
3
+ metadata.gz: be6153e50823e724cefb980f60d0272c878ed9fd3619cfa98eaaa8917ab621d1
4
+ data.tar.gz: a6e50cabc9eb5730f819ff038ce23b9869e56140244d2b6b48a4c6efa46c1667
5
5
  SHA512:
6
- metadata.gz: 1135ba02b878e0d772259545231aa89f950e11199a0ee8d982440b9040f9c575a077567f3cd404727b27eb6eb962d811c6b301f92ad2227817289851392cf6c9
7
- data.tar.gz: fb95eca4f8aa3b013298c087209818c91338ef4f9fe2a843951777df4c17fbe9bf83fdc5b1b45447503d5e718c6059d8a57b888746a36f463c929d8965526b3a
6
+ metadata.gz: d392122de52073465642db7d78302e4bd7aecb9d9507b9d2189f02c3bc9332b47c9facc02a5c1f7771d8bc355e25fe7607bd0c56a655536031bac6257b9a6cd0
7
+ data.tar.gz: 5feae702d0c3f67142b70ea65b1b396ea34c352430db81749eecdcb636481cbb87aff026dad4012c89e8875f4a6395edca61ca0669859039e6d7912ebd65375e
data/README.md CHANGED
@@ -195,6 +195,40 @@ feature.enable_for_ip_addresses('192.168.1.0/24', '10.0.0.1')
195
195
  feature.enable_for_custom_attribute(:subscription_tier, ['premium', 'enterprise'])
196
196
  ```
197
197
 
198
+ ### Wire Targeting Payload (control-plane APIs)
199
+
200
+ For building flag-management endpoints on top of the gem (an internal panel,
201
+ a sync job, any JSON API), two primitives implement the wire contract — the
202
+ gem ships no routes, your app owns paths and auth:
203
+
204
+ ```ruby
205
+ # GET side — full flag payload, string keys. The "targeting" key is ALWAYS
206
+ # present ({} = no targeting); list rules are arrays of strings, percentages
207
+ # floats. Rails-idiomatic: works with render json: directly.
208
+ render json: Magick.features.values # [{... "targeting" => {"user" => ["3"], "percentage_users" => 50.0}}, ...]
209
+
210
+ # PATCH side — wholesale, declarative write: the payload IS the new targeting
211
+ # state. Keys absent from the payload are removed; {} clears everything.
212
+ feature.replace_targeting(payload['targeting'])
213
+ ```
214
+
215
+ `replace_targeting` is lenient about input spellings (string or symbol keys,
216
+ plural aliases like `users:`, scalars for lists, numeric strings) but strict
217
+ about content: unknown keys or invalid values (percentage outside `(0, 100]`,
218
+ malformed date ranges, junk IPs) raise `Magick::InvalidTargetingError`
219
+ *before anything is applied* — map it to a 422:
220
+
221
+ ```ruby
222
+ rescue Magick::InvalidTargetingError => e
223
+ render json: { error: e.message }, status: :unprocessable_entity
224
+ ```
225
+
226
+ Each call records one audit entry and one version snapshot
227
+ (`replace_targeting`). A/B variants are not part of the targeting payload —
228
+ they never appear inside the wire `targeting` object and survive a replace
229
+ untouched (manage them via `set_variants`). `enable`/`disable` still clear
230
+ all targeting wholesale, so their wire representation is `"targeting": {}`.
231
+
198
232
  ### Feature Exclusions
199
233
 
200
234
  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.**
@@ -531,12 +565,58 @@ Magick.import(File.read('features.json'))
531
565
 
532
566
  #### Versioning and Rollback
533
567
 
568
+ Every state-changing operation (value, status, group, targeting, exclusions,
569
+ variants, dependencies, delete) automatically records a version snapshot and
570
+ an audit entry — one per logical operation, under its real action name
571
+ (`enable`, `exclude_user`, `set_status`, …). Nested internals never
572
+ double-record.
573
+
534
574
  ```ruby
535
- # Save current state as version
575
+ # History accumulates automatically:
576
+ Magick[:my_feature].enable # => version 1 (action: "enable")
577
+ Magick[:my_feature].enable_for_user(42) # => version 2 (action: "enable_for_user")
578
+
579
+ # Inspect history (hot window: last 50 versions by default)
580
+ Magick.versioning.get_versions(:my_feature)
581
+
582
+ # Include the unlimited ActiveRecord archive (when AR adapter is configured)
583
+ Magick.versioning.get_versions(:my_feature, all: true)
584
+
585
+ # Rollback fully restores a snapshot: value (including false/empty), status,
586
+ # group, and the entire targeting hash — and records the rollback itself as a
587
+ # new version, so history only ever rolls forward.
588
+ Magick.versioning.rollback(:my_feature, 2)
589
+
590
+ # Manual snapshots still work (action: "manual")
536
591
  Magick.versioning.save_version(:my_feature, created_by: current_user.id)
592
+ ```
537
593
 
538
- # Rollback to previous version
539
- Magick.versioning.rollback(:my_feature, version: 2)
594
+ **Retention is tiered:** memory/Redis keep the last `max_versions` snapshots
595
+ (default 50) for fast access; the ActiveRecord adapter keeps an unlimited
596
+ archive that also survives feature deletion.
597
+
598
+ ```ruby
599
+ Magick.configure do
600
+ versioning enabled: true, max_versions: 50
601
+ end
602
+ ```
603
+
604
+ **Attribution:** wrap changes in `Magick.with_actor` to stamp audit entries
605
+ (`user_id`) and versions (`created_by`):
606
+
607
+ ```ruby
608
+ Magick.with_actor(current_user.id) do
609
+ Magick[:my_feature].enable_for_user(42)
610
+ end
611
+ ```
612
+
613
+ **Boot replay is not recorded:** the Rails railtie loads `config/features.rb`
614
+ inside `Magick.definition_mode`, so re-applying declarative definitions on
615
+ every process boot does not flood history. Non-Rails apps should wrap their
616
+ own definition file load the same way:
617
+
618
+ ```ruby
619
+ Magick.definition_mode { load 'config/features.rb' }
540
620
  ```
541
621
 
542
622
  #### Performance Metrics
@@ -595,6 +675,11 @@ end
595
675
 
596
676
  #### Audit Logging
597
677
 
678
+ Every mutation is logged under its real action name (`enable`, `disable`,
679
+ `set_value`, `enable_for_user`, `exclude_role`, `set_status`, `set_group`,
680
+ `delete`, `rollback`, …). One logical operation produces exactly one entry:
681
+ `enable` no longer surfaces as a bare `set_value`.
682
+
598
683
  ```ruby
599
684
  # View audit log entries
600
685
  entries = Magick.audit_log.entries(feature_name: :my_feature, limit: 100)
@@ -603,6 +688,15 @@ entries.each do |entry|
603
688
  end
604
689
  ```
605
690
 
691
+ In the Admin UI, configure a `current_actor` hook so every change made
692
+ through the UI is attributed:
693
+
694
+ ```ruby
695
+ Magick::AdminUI.configure do |config|
696
+ config.current_actor = ->(controller) { controller.session[:admin_id] }
697
+ end
698
+ ```
699
+
606
700
  ## Architecture
607
701
 
608
702
  ### Adapters
@@ -15,6 +15,9 @@ module Magick
15
15
  layout 'application'
16
16
  before_action :authenticate_admin!
17
17
  before_action :set_feature, only: %i[show edit update enable disable enable_for_user enable_for_role disable_for_role update_targeting update_variants]
18
+ # Attribute every change made during the request to the configured
19
+ # actor, so audit entries and version snapshots record who did it.
20
+ around_action :with_magick_actor
18
21
  # Render the TRUE current state, not this process's local cache. In a
19
22
  # multi-process / multi-container deployment the enable/disable POST and
20
23
  # the redirected GET are load-balanced to different processes, so the
@@ -39,8 +42,7 @@ module Magick
39
42
  end
40
43
 
41
44
  def partially_enabled?(feature)
42
- targeting = feature.instance_variable_get(:@targeting) || {}
43
- targeting.any? && !targeting.empty?
45
+ (feature.targeting || {}).any?
44
46
  end
45
47
 
46
48
  def index
@@ -129,7 +131,6 @@ module Magick
129
131
  end
130
132
 
131
133
  def update_targeting
132
- # Handle targeting updates from form
133
134
  targeting_params = params[:targeting] || {}
134
135
  unless hash_like?(targeting_params)
135
136
  redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Invalid targeting payload.'
@@ -140,146 +141,13 @@ module Magick
140
141
  feature_name = @feature.name.to_s
141
142
  @feature = Magick.features[feature_name] if Magick.features.key?(feature_name)
142
143
 
143
- current_targeting = @feature.instance_variable_get(:@targeting) || {}
144
-
145
- # Handle roles - always clear existing and set new ones
146
- # Rails checkboxes don't send unchecked values, so we need to check what was sent
147
- current_roles = current_targeting[:role].is_a?(Array) ? current_targeting[:role] : (current_targeting[:role] ? [current_targeting[:role]] : [])
148
- selected_roles = Array(targeting_params[:roles]).reject(&:blank?)
149
-
150
- # Disable roles that are no longer selected
151
- (current_roles - selected_roles).each do |role|
152
- @feature.disable_for_role(role) if role.present?
153
- end
154
-
155
- # Enable newly selected roles
156
- (selected_roles - current_roles).each do |role|
157
- @feature.enable_for_role(role) if role.present?
158
- end
159
-
160
- # Handle tags - always clear existing and set new ones
161
- # Rails checkboxes don't send unchecked values, so we need to check what was sent
162
- current_tags = current_targeting[:tag].is_a?(Array) ? current_targeting[:tag] : (current_targeting[:tag] ? [current_targeting[:tag]] : [])
163
- selected_tags = Array(targeting_params[:tags]).reject(&:blank?)
164
-
165
- # Disable tags that are no longer selected
166
- (current_tags - selected_tags).each do |tag|
167
- @feature.disable_for_tag(tag) if tag.present?
168
- end
169
-
170
- # Enable newly selected tags
171
- (selected_tags - current_tags).each do |tag|
172
- @feature.enable_for_tag(tag) if tag.present?
173
- end
174
-
175
- # Handle user IDs - replace existing user targeting
176
- if targeting_params[:user_ids].present?
177
- user_ids = targeting_params[:user_ids].split(',').map(&:strip).reject(&:blank?)
178
- current_user_ids = current_targeting[:user].is_a?(Array) ? current_targeting[:user] : (current_targeting[:user] ? [current_targeting[:user]] : [])
179
-
180
- # Disable users that are no longer in the list
181
- (current_user_ids - user_ids).each do |user_id|
182
- @feature.disable_for_user(user_id) if user_id.present?
183
- end
184
-
185
- # Enable new users
186
- (user_ids - current_user_ids).each do |user_id|
187
- @feature.enable_for_user(user_id) if user_id.present?
188
- end
189
- elsif targeting_params.key?(:user_ids) && targeting_params[:user_ids].blank?
190
- # Clear all user targeting if field was cleared
191
- current_user_ids = current_targeting[:user].is_a?(Array) ? current_targeting[:user] : (current_targeting[:user] ? [current_targeting[:user]] : [])
192
- current_user_ids.each do |user_id|
193
- @feature.disable_for_user(user_id) if user_id.present?
194
- end
195
- end
196
-
197
- # Handle percentage of users
198
- percentage_users_value = targeting_params[:percentage_users]
199
- if percentage_users_value.present? && percentage_users_value.to_s.strip != ''
200
- percentage = percentage_users_value.to_f
201
- if percentage > 0 && percentage <= 100
202
- result = @feature.enable_percentage_of_users(percentage)
203
- Rails.logger.debug "Magick: Enabled percentage_users #{percentage} for #{@feature.name}: #{result}" if defined?(Rails)
204
- else
205
- # Value is 0 or invalid - disable
206
- @feature.disable_percentage_of_users
207
- end
208
- else
209
- # Field is empty - disable if it was previously set
210
- @feature.disable_percentage_of_users if current_targeting[:percentage_users]
211
- end
212
-
213
- # Handle percentage of requests
214
- percentage_requests_value = targeting_params[:percentage_requests]
215
- if percentage_requests_value.present? && percentage_requests_value.to_s.strip != ''
216
- percentage = percentage_requests_value.to_f
217
- if percentage > 0 && percentage <= 100
218
- result = @feature.enable_percentage_of_requests(percentage)
219
- Rails.logger.debug "Magick: Enabled percentage_requests #{percentage} for #{@feature.name}: #{result}" if defined?(Rails)
220
- else
221
- # Value is 0 or invalid - disable
222
- @feature.disable_percentage_of_requests
223
- end
224
- else
225
- # Field is empty - disable if it was previously set
226
- @feature.disable_percentage_of_requests if current_targeting[:percentage_requests]
227
- end
228
-
229
- # Handle excluded user IDs
230
- if targeting_params[:excluded_user_ids].present?
231
- excluded_user_ids = targeting_params[:excluded_user_ids].split(',').map(&:strip).reject(&:blank?)
232
- current_excluded_users = current_targeting[:excluded_users].is_a?(Array) ? current_targeting[:excluded_users] : (current_targeting[:excluded_users] ? [current_targeting[:excluded_users]] : [])
233
-
234
- (current_excluded_users - excluded_user_ids).each do |user_id|
235
- @feature.remove_user_exclusion(user_id) if user_id.present?
236
- end
237
-
238
- (excluded_user_ids - current_excluded_users).each do |user_id|
239
- @feature.exclude_user(user_id) if user_id.present?
240
- end
241
- elsif targeting_params.key?(:excluded_user_ids) && targeting_params[:excluded_user_ids].blank?
242
- current_excluded_users = current_targeting[:excluded_users].is_a?(Array) ? current_targeting[:excluded_users] : (current_targeting[:excluded_users] ? [current_targeting[:excluded_users]] : [])
243
- current_excluded_users.each do |user_id|
244
- @feature.remove_user_exclusion(user_id) if user_id.present?
245
- end
246
- end
247
-
248
- # Handle excluded roles
249
- current_excluded_roles = current_targeting[:excluded_roles].is_a?(Array) ? current_targeting[:excluded_roles] : (current_targeting[:excluded_roles] ? [current_targeting[:excluded_roles]] : [])
250
- selected_excluded_roles = Array(targeting_params[:excluded_roles]).reject(&:blank?)
251
-
252
- (current_excluded_roles - selected_excluded_roles).each do |role|
253
- @feature.remove_role_exclusion(role) if role.present?
254
- end
255
-
256
- (selected_excluded_roles - current_excluded_roles).each do |role|
257
- @feature.exclude_role(role) if role.present?
258
- end
259
-
260
- # Handle excluded tags
261
- current_excluded_tags = current_targeting[:excluded_tags].is_a?(Array) ? current_targeting[:excluded_tags] : (current_targeting[:excluded_tags] ? [current_targeting[:excluded_tags]] : [])
262
- selected_excluded_tags = Array(targeting_params[:excluded_tags]).reject(&:blank?)
263
-
264
- (current_excluded_tags - selected_excluded_tags).each do |tag|
265
- @feature.remove_tag_exclusion(tag) if tag.present?
266
- end
267
-
268
- (selected_excluded_tags - current_excluded_tags).each do |tag|
269
- @feature.exclude_tag(tag) if tag.present?
270
- end
271
-
272
- # After all targeting updates, ensure we're using the registered feature instance
273
- # and reload it to get the latest state from adapter
274
- feature_name = @feature.name.to_s
275
- if Magick.features.key?(feature_name)
276
- @feature = Magick.features[feature_name]
277
- @feature.reload
278
- else
279
- @feature.reload
280
- end
144
+ # One declarative write: one audit entry + one version per submit.
145
+ @feature.replace_targeting(desired_targeting_from_form(targeting_params))
146
+ @feature.reload
281
147
 
282
148
  redirect_to magick_admin_ui.feature_path(@feature.name), notice: 'Targeting updated successfully'
149
+ rescue Magick::InvalidTargetingError => e
150
+ redirect_to magick_admin_ui.feature_path(@feature.name), alert: "Invalid targeting: #{e.message}"
283
151
  rescue StandardError => e
284
152
  Rails.logger.error "Magick: Error updating targeting for #{@feature.name}: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}" if defined?(Rails)
285
153
  redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Could not update targeting — see server logs for details.'
@@ -319,8 +187,72 @@ module Magick
319
187
  redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Could not update variants — see server logs for details.'
320
188
  end
321
189
 
190
+ # Targeting rules the edit form has no fields for. They are carried over
191
+ # from the current state on every submit so a form save can never
192
+ # silently destroy them. (:variants is preserved by replace_targeting
193
+ # itself.)
194
+ FORM_UNMANAGED_TARGETING_KEYS = %i[
195
+ group excluded_groups ip_address excluded_ip_addresses
196
+ date_range custom_attributes complex_conditions
197
+ ].freeze
198
+
322
199
  private
323
200
 
201
+ # Build the full desired targeting state from the edit form. Checkbox
202
+ # groups (roles/tags and their exclusions) and the percentage fields are
203
+ # authoritative on every submit — unchecked/blank means "remove the
204
+ # rule". The comma-separated user lists are only authoritative when
205
+ # their field was actually sent. Percentages stay lenient here (blank
206
+ # or out-of-range clears the rule, as the form always behaved) —
207
+ # strict validation is for API callers of replace_targeting.
208
+ def desired_targeting_from_form(targeting_params)
209
+ current = @feature.targeting || {}
210
+ desired = {}
211
+ FORM_UNMANAGED_TARGETING_KEYS.each { |key| desired[key] = current[key] if current.key?(key) }
212
+
213
+ desired[:role] = Array(targeting_params[:roles]).reject(&:blank?)
214
+ desired[:tag] = Array(targeting_params[:tags]).reject(&:blank?)
215
+ desired[:excluded_roles] = Array(targeting_params[:excluded_roles]).reject(&:blank?)
216
+ desired[:excluded_tags] = Array(targeting_params[:excluded_tags]).reject(&:blank?)
217
+
218
+ desired[:user] = csv_ids(targeting_params, :user_ids, current[:user])
219
+ desired[:excluded_users] = csv_ids(targeting_params, :excluded_user_ids, current[:excluded_users])
220
+
221
+ desired[:percentage_users] = form_percentage(targeting_params[:percentage_users])
222
+ desired[:percentage_requests] = form_percentage(targeting_params[:percentage_requests])
223
+
224
+ desired.compact
225
+ end
226
+
227
+ def csv_ids(targeting_params, field, current_value)
228
+ return Array(current_value) unless targeting_params.key?(field)
229
+
230
+ targeting_params[field].to_s.split(',').map(&:strip).reject(&:blank?)
231
+ end
232
+
233
+ def form_percentage(raw)
234
+ return nil if raw.blank?
235
+
236
+ percentage = raw.to_f
237
+ percentage.positive? && percentage <= 100 ? percentage : nil
238
+ end
239
+
240
+ # Resolve the acting admin via the configurable AdminUI hook and run the
241
+ # action inside Magick.with_actor. A failing resolver only costs
242
+ # attribution — it must never 500 the admin UI, and it is rescued
243
+ # separately so an action error is never swallowed or re-run.
244
+ def with_magick_actor(&block)
245
+ actor = begin
246
+ resolver = Magick::AdminUI.config.current_actor
247
+ resolver.respond_to?(:call) ? resolver.call(self) : nil
248
+ rescue StandardError => e
249
+ Rails.logger.warn "Magick: current_actor hook failed: #{e.class}: #{e.message}" if defined?(Rails)
250
+ nil
251
+ end
252
+
253
+ actor ? Magick.with_actor(actor, &block) : yield
254
+ end
255
+
324
256
  def authenticate_admin!
325
257
  return unless Magick::AdminUI.config.require_role
326
258
 
@@ -20,11 +20,13 @@ Magick.configure do
20
20
  # Enable performance metrics tracking
21
21
  performance_metrics enabled: true
22
22
 
23
- # Enable audit logging
23
+ # Enable audit logging (every mutation is logged under its real action name)
24
24
  audit_log enabled: true
25
25
 
26
- # Enable versioning support
27
- versioning enabled: true
26
+ # Enable versioning (every save creates a version snapshot; allows rollback)
27
+ # max_versions caps the hot window kept in memory/Redis; the ActiveRecord
28
+ # adapter keeps an unlimited archive.
29
+ versioning enabled: true, max_versions: 50
28
30
 
29
31
  # Enable deprecation warnings
30
32
  warn_on_deprecated true
@@ -23,11 +23,13 @@ Magick.configure do
23
23
  # Enable performance metrics tracking
24
24
  performance_metrics enabled: true
25
25
 
26
- # Enable audit logging (tracks who changed what, when)
26
+ # Enable audit logging (every mutation is logged under its real action name)
27
27
  audit_log enabled: true
28
28
 
29
- # Enable versioning support (allows rollback)
30
- versioning enabled: true
29
+ # Enable versioning (every save creates a version snapshot; allows rollback)
30
+ # max_versions caps the hot window kept in memory/Redis; the ActiveRecord
31
+ # adapter keeps an unlimited archive.
32
+ versioning enabled: true, max_versions: 50
31
33
 
32
34
  # Enable deprecation warnings in logs
33
35
  warn_on_deprecated enabled: true
@@ -176,7 +176,9 @@ module Magick
176
176
  features += memory_adapter.all_features if memory_adapter
177
177
  features += redis_adapter.all_features if redis_adapter
178
178
  features += active_record_adapter.all_features if active_record_adapter
179
- features.uniq
179
+ # Version history is stored under a reserved pseudo-feature namespace;
180
+ # it is bookkeeping, not a feature.
181
+ features.uniq.reject { |f| f.to_s.start_with?(Versioning::STORE_PREFIX) }
180
182
  end
181
183
 
182
184
  # Load all keys for a single feature in one call instead of N separate get() calls
@@ -357,9 +359,13 @@ module Magick
357
359
  end
358
360
  end
359
361
 
362
+ # Public so Versioning can apply tiered retention: hot window written to
363
+ # memory/Redis, unlimited archive written to ActiveRecord only.
364
+ attr_reader :memory_adapter, :redis_adapter, :active_record_adapter
365
+
360
366
  private
361
367
 
362
- attr_reader :memory_adapter, :redis_adapter, :active_record_adapter, :circuit_breaker
368
+ attr_reader :circuit_breaker
363
369
 
364
370
  # Signal the subscribe loop to return, then close the connection so any
365
371
  # retry/reconnect attempt fails fast instead of sleeping for 5s.
@@ -17,7 +17,7 @@ module Magick
17
17
  end
18
18
 
19
19
  class Configuration
20
- attr_accessor :theme, :brand_name, :require_role, :available_roles, :available_tags
20
+ attr_accessor :theme, :brand_name, :require_role, :available_roles, :available_tags, :current_actor
21
21
 
22
22
  def initialize
23
23
  @theme = :light
@@ -25,6 +25,10 @@ module Magick
25
25
  @require_role = nil
26
26
  @available_roles = [] # Can be populated via DSL: admin_ui { roles ['admin', 'user', 'manager'] }
27
27
  @available_tags = nil # Can be array or lambda: -> { Tag.all }
28
+ # Lambda receiving the controller, returning who is making the
29
+ # change; stamped onto audit entries (user_id) and versions
30
+ # (created_by): -> (controller) { controller.current_user&.id }
31
+ @current_actor = nil
28
32
  end
29
33
 
30
34
  # Get available tags, calling lambda if needed
data/lib/magick/config.rb CHANGED
@@ -135,8 +135,11 @@ module Magick
135
135
  end
136
136
  end
137
137
 
138
- def versioning(enabled: true)
139
- @versioning = (Versioning.new(adapter_registry || default_adapter_registry) if enabled)
138
+ def versioning(enabled: true, max_versions: Versioning::DEFAULT_MAX_VERSIONS)
139
+ @versioning_enabled = enabled
140
+ @versioning = if enabled
141
+ Versioning.new(adapter_registry || default_adapter_registry, max_versions: max_versions)
142
+ end
140
143
  end
141
144
 
142
145
  def circuit_breaker(threshold: nil, timeout: nil)
@@ -186,8 +189,11 @@ module Magick
186
189
  end
187
190
  end
188
191
 
189
- Magick.audit_log = audit_log if audit_log
190
- Magick.versioning = versioning if versioning
192
+ # Read the ivars directly: calling the DSL methods here would re-run
193
+ # them with their defaults and stomp explicit `enabled: false` settings.
194
+ Magick.audit_log = @audit_log if @audit_log
195
+ Magick.versioning = @versioning if @versioning
196
+ Magick.versioning_enabled = @versioning_enabled unless @versioning_enabled.nil?
191
197
  Magick.warn_on_deprecated = warn_on_deprecated
192
198
  end
193
199
 
data/lib/magick/errors.rb CHANGED
@@ -5,5 +5,9 @@ module Magick
5
5
  class FeatureNotFoundError < Error; end
6
6
  class InvalidFeatureTypeError < Error; end
7
7
  class InvalidFeatureValueError < Error; end
8
+ # Raised by Feature#replace_targeting when a wire targeting payload
9
+ # contains unknown keys or invalid values. Nothing is applied on failure,
10
+ # so API callers can map it straight to a 422.
11
+ class InvalidTargetingError < Error; end
8
12
  class AdapterError < Error; end
9
13
  end
@@ -103,55 +103,18 @@ module Magick
103
103
  feature.set_value(value) if !value.nil? && !(value.is_a?(String) && value.empty?)
104
104
  end
105
105
 
106
- # rubocop:disable Metrics/MethodLength
107
- # rubocop:disable Metrics/CyclomaticComplexity
108
106
  def self.apply_targeting(feature, targeting)
109
- targeting.each do |type, values|
110
- case type.to_sym
111
- when :user, :users
112
- Array(values).each { |v| feature.enable_for_user(v) }
113
- when :excluded_users
114
- Array(values).each { |v| feature.exclude_user(v) }
115
- when :group, :groups
116
- Array(values).each { |v| feature.enable_for_group(v) }
117
- when :excluded_groups
118
- Array(values).each { |v| feature.exclude_group(v) }
119
- when :role, :roles
120
- Array(values).each { |v| feature.enable_for_role(v) }
121
- when :excluded_roles
122
- Array(values).each { |v| feature.exclude_role(v) }
123
- when :tag, :tags
124
- Array(values).each { |v| feature.enable_for_tag(v) }
125
- when :excluded_tags
126
- Array(values).each { |v| feature.exclude_tag(v) }
127
- when :ip_address, :ip_addresses
128
- feature.enable_for_ip_addresses(Array(values))
129
- when :excluded_ip_addresses
130
- feature.exclude_ip_addresses(Array(values))
131
- when :percentage_users
132
- feature.enable_percentage_of_users(values)
133
- when :percentage_requests
134
- feature.enable_percentage_of_requests(values)
135
- when :date_range
136
- range = values.is_a?(Hash) ? values.transform_keys(&:to_sym) : values
137
- feature.enable_for_date_range(range[:start], range[:end]) if range.is_a?(Hash) && range[:start] && range[:end]
138
- when :custom_attributes
139
- apply_custom_attributes(feature, values)
140
- end
141
- end
142
- end
143
- # rubocop:enable Metrics/MethodLength
144
- # rubocop:enable Metrics/CyclomaticComplexity
107
+ return unless targeting.is_a?(Hash)
145
108
 
146
- def self.apply_custom_attributes(feature, values)
147
- return unless values.is_a?(Hash)
109
+ # Exports from gem versions where variants leaked into the targeting
110
+ # hash must stay importable; variants are applied separately from the
111
+ # top-level key.
112
+ payload = targeting.reject { |key, _| key.to_s == 'variants' }
113
+ return if payload.empty?
148
114
 
149
- values.each do |attr, rule|
150
- rule_h = rule.is_a?(Hash) ? rule.transform_keys(&:to_sym) : {}
151
- next unless rule_h[:values]
152
-
153
- feature.enable_for_custom_attribute(attr, rule_h[:values], operator: (rule_h[:operator] || :equals).to_sym)
154
- end
115
+ feature.replace_targeting(payload)
116
+ rescue Magick::InvalidTargetingError => e
117
+ raise ImportError, "Magick.import: invalid targeting for '#{feature.name}': #{e.message}"
155
118
  end
156
119
 
157
120
  def self.apply_variants(feature, variants)