magick-feature-flags 1.5.0 → 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: 958764166d59a55b2243c5ce0f009164c26c75ae07740b200808866f3d6ad543
4
- data.tar.gz: a96a1b187259c225a0b55ec243dbdfc9d9771621b420a4519c2fc7c4e9ee6a5c
3
+ metadata.gz: be6153e50823e724cefb980f60d0272c878ed9fd3619cfa98eaaa8917ab621d1
4
+ data.tar.gz: a6e50cabc9eb5730f819ff038ce23b9869e56140244d2b6b48a4c6efa46c1667
5
5
  SHA512:
6
- metadata.gz: 1affc66448028865d889535959d7970e8b54d1b3e3367d0bd4fb19ec8b18451c9bbc4714735e5f98f26da4a124e52b56a00c345d161660bfb944a9adb2bcbb12
7
- data.tar.gz: 3ef2b6d8341517ef3bc8099a582e550877242d50f3fb67eab645b7d13f7adbdcf999f9f7a1d39e86ed3f5d1c13a98e8a69200dc5ab3b8c666cd14e924c462d0f
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.**
@@ -42,8 +42,7 @@ module Magick
42
42
  end
43
43
 
44
44
  def partially_enabled?(feature)
45
- targeting = feature.instance_variable_get(:@targeting) || {}
46
- targeting.any? && !targeting.empty?
45
+ (feature.targeting || {}).any?
47
46
  end
48
47
 
49
48
  def index
@@ -132,7 +131,6 @@ module Magick
132
131
  end
133
132
 
134
133
  def update_targeting
135
- # Handle targeting updates from form
136
134
  targeting_params = params[:targeting] || {}
137
135
  unless hash_like?(targeting_params)
138
136
  redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Invalid targeting payload.'
@@ -143,146 +141,13 @@ module Magick
143
141
  feature_name = @feature.name.to_s
144
142
  @feature = Magick.features[feature_name] if Magick.features.key?(feature_name)
145
143
 
146
- current_targeting = @feature.instance_variable_get(:@targeting) || {}
147
-
148
- # Handle roles - always clear existing and set new ones
149
- # Rails checkboxes don't send unchecked values, so we need to check what was sent
150
- current_roles = current_targeting[:role].is_a?(Array) ? current_targeting[:role] : (current_targeting[:role] ? [current_targeting[:role]] : [])
151
- selected_roles = Array(targeting_params[:roles]).reject(&:blank?)
152
-
153
- # Disable roles that are no longer selected
154
- (current_roles - selected_roles).each do |role|
155
- @feature.disable_for_role(role) if role.present?
156
- end
157
-
158
- # Enable newly selected roles
159
- (selected_roles - current_roles).each do |role|
160
- @feature.enable_for_role(role) if role.present?
161
- end
162
-
163
- # Handle tags - always clear existing and set new ones
164
- # Rails checkboxes don't send unchecked values, so we need to check what was sent
165
- current_tags = current_targeting[:tag].is_a?(Array) ? current_targeting[:tag] : (current_targeting[:tag] ? [current_targeting[:tag]] : [])
166
- selected_tags = Array(targeting_params[:tags]).reject(&:blank?)
167
-
168
- # Disable tags that are no longer selected
169
- (current_tags - selected_tags).each do |tag|
170
- @feature.disable_for_tag(tag) if tag.present?
171
- end
172
-
173
- # Enable newly selected tags
174
- (selected_tags - current_tags).each do |tag|
175
- @feature.enable_for_tag(tag) if tag.present?
176
- end
177
-
178
- # Handle user IDs - replace existing user targeting
179
- if targeting_params[:user_ids].present?
180
- user_ids = targeting_params[:user_ids].split(',').map(&:strip).reject(&:blank?)
181
- current_user_ids = current_targeting[:user].is_a?(Array) ? current_targeting[:user] : (current_targeting[:user] ? [current_targeting[:user]] : [])
182
-
183
- # Disable users that are no longer in the list
184
- (current_user_ids - user_ids).each do |user_id|
185
- @feature.disable_for_user(user_id) if user_id.present?
186
- end
187
-
188
- # Enable new users
189
- (user_ids - current_user_ids).each do |user_id|
190
- @feature.enable_for_user(user_id) if user_id.present?
191
- end
192
- elsif targeting_params.key?(:user_ids) && targeting_params[:user_ids].blank?
193
- # Clear all user targeting if field was cleared
194
- current_user_ids = current_targeting[:user].is_a?(Array) ? current_targeting[:user] : (current_targeting[:user] ? [current_targeting[:user]] : [])
195
- current_user_ids.each do |user_id|
196
- @feature.disable_for_user(user_id) if user_id.present?
197
- end
198
- end
199
-
200
- # Handle percentage of users
201
- percentage_users_value = targeting_params[:percentage_users]
202
- if percentage_users_value.present? && percentage_users_value.to_s.strip != ''
203
- percentage = percentage_users_value.to_f
204
- if percentage > 0 && percentage <= 100
205
- result = @feature.enable_percentage_of_users(percentage)
206
- Rails.logger.debug "Magick: Enabled percentage_users #{percentage} for #{@feature.name}: #{result}" if defined?(Rails)
207
- else
208
- # Value is 0 or invalid - disable
209
- @feature.disable_percentage_of_users
210
- end
211
- else
212
- # Field is empty - disable if it was previously set
213
- @feature.disable_percentage_of_users if current_targeting[:percentage_users]
214
- end
215
-
216
- # Handle percentage of requests
217
- percentage_requests_value = targeting_params[:percentage_requests]
218
- if percentage_requests_value.present? && percentage_requests_value.to_s.strip != ''
219
- percentage = percentage_requests_value.to_f
220
- if percentage > 0 && percentage <= 100
221
- result = @feature.enable_percentage_of_requests(percentage)
222
- Rails.logger.debug "Magick: Enabled percentage_requests #{percentage} for #{@feature.name}: #{result}" if defined?(Rails)
223
- else
224
- # Value is 0 or invalid - disable
225
- @feature.disable_percentage_of_requests
226
- end
227
- else
228
- # Field is empty - disable if it was previously set
229
- @feature.disable_percentage_of_requests if current_targeting[:percentage_requests]
230
- end
231
-
232
- # Handle excluded user IDs
233
- if targeting_params[:excluded_user_ids].present?
234
- excluded_user_ids = targeting_params[:excluded_user_ids].split(',').map(&:strip).reject(&:blank?)
235
- current_excluded_users = current_targeting[:excluded_users].is_a?(Array) ? current_targeting[:excluded_users] : (current_targeting[:excluded_users] ? [current_targeting[:excluded_users]] : [])
236
-
237
- (current_excluded_users - excluded_user_ids).each do |user_id|
238
- @feature.remove_user_exclusion(user_id) if user_id.present?
239
- end
240
-
241
- (excluded_user_ids - current_excluded_users).each do |user_id|
242
- @feature.exclude_user(user_id) if user_id.present?
243
- end
244
- elsif targeting_params.key?(:excluded_user_ids) && targeting_params[:excluded_user_ids].blank?
245
- current_excluded_users = current_targeting[:excluded_users].is_a?(Array) ? current_targeting[:excluded_users] : (current_targeting[:excluded_users] ? [current_targeting[:excluded_users]] : [])
246
- current_excluded_users.each do |user_id|
247
- @feature.remove_user_exclusion(user_id) if user_id.present?
248
- end
249
- end
250
-
251
- # Handle excluded roles
252
- current_excluded_roles = current_targeting[:excluded_roles].is_a?(Array) ? current_targeting[:excluded_roles] : (current_targeting[:excluded_roles] ? [current_targeting[:excluded_roles]] : [])
253
- selected_excluded_roles = Array(targeting_params[:excluded_roles]).reject(&:blank?)
254
-
255
- (current_excluded_roles - selected_excluded_roles).each do |role|
256
- @feature.remove_role_exclusion(role) if role.present?
257
- end
258
-
259
- (selected_excluded_roles - current_excluded_roles).each do |role|
260
- @feature.exclude_role(role) if role.present?
261
- end
262
-
263
- # Handle excluded tags
264
- current_excluded_tags = current_targeting[:excluded_tags].is_a?(Array) ? current_targeting[:excluded_tags] : (current_targeting[:excluded_tags] ? [current_targeting[:excluded_tags]] : [])
265
- selected_excluded_tags = Array(targeting_params[:excluded_tags]).reject(&:blank?)
266
-
267
- (current_excluded_tags - selected_excluded_tags).each do |tag|
268
- @feature.remove_tag_exclusion(tag) if tag.present?
269
- end
270
-
271
- (selected_excluded_tags - current_excluded_tags).each do |tag|
272
- @feature.exclude_tag(tag) if tag.present?
273
- end
274
-
275
- # After all targeting updates, ensure we're using the registered feature instance
276
- # and reload it to get the latest state from adapter
277
- feature_name = @feature.name.to_s
278
- if Magick.features.key?(feature_name)
279
- @feature = Magick.features[feature_name]
280
- @feature.reload
281
- else
282
- @feature.reload
283
- 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
284
147
 
285
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}"
286
151
  rescue StandardError => e
287
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)
288
153
  redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Could not update targeting — see server logs for details.'
@@ -322,8 +187,56 @@ module Magick
322
187
  redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Could not update variants — see server logs for details.'
323
188
  end
324
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
+
325
199
  private
326
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
+
327
240
  # Resolve the acting admin via the configurable AdminUI hook and run the
328
241
  # action inside Magick.with_actor. A failing resolver only costs
329
242
  # attribution — it must never 500 the admin UI, and it is rescued
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)
@@ -9,7 +9,8 @@ module Magick
9
9
  VALID_TYPES = %i[boolean string number].freeze
10
10
  VALID_STATUSES = %i[active inactive deprecated].freeze
11
11
 
12
- attr_reader :name, :type, :status, :default_value, :description, :display_name, :group, :adapter_registry
12
+ attr_reader :name, :type, :status, :default_value, :description, :display_name, :group, :adapter_registry,
13
+ :targeting
13
14
 
14
15
  def initialize(name, adapter_registry, **options)
15
16
  @name = name.to_s
@@ -749,6 +750,64 @@ module Magick
749
750
  }
750
751
  end
751
752
 
753
+ # Wire-format serializer for control-plane APIs (e.g. the platform's
754
+ # /internal/panel/flags endpoints). The "targeting" key is ALWAYS present
755
+ # ({} = no targeting), array rules are arrays of strings, percentages are
756
+ # floats, and the internal :variants entry never appears inside targeting.
757
+ # Rails-idiomatic: `render json: feature` (or a collection) emits this.
758
+ def as_json(_options = nil)
759
+ {
760
+ 'name' => name,
761
+ 'display_name' => display_name,
762
+ 'group' => group,
763
+ 'type' => type.to_s,
764
+ 'status' => status.to_s,
765
+ 'value' => stored_value,
766
+ 'default_value' => default_value,
767
+ 'description' => description,
768
+ 'targeting' => TargetingPayload.serialize(targeting),
769
+ 'dependencies' => (@dependencies || []).map(&:to_s),
770
+ # Variants live inside @targeting under the internal :variants key
771
+ # (variants_for_export reads a never-assigned ivar and is always
772
+ # empty), so the wire payload reads the authoritative source.
773
+ 'variants' => TargetingPayload.deep_stringify(targeting[:variants] || [])
774
+ }
775
+ end
776
+
777
+ # Wholesale, declarative targeting write: the payload IS the new
778
+ # targeting state. Keys absent from it are removed; {} clears all
779
+ # targeting. Accepts wire input leniently (string/symbol keys, plural
780
+ # aliases, scalars for lists, numeric strings) but validates strictly —
781
+ # unknown keys or invalid values raise InvalidTargetingError before any
782
+ # state is touched. The internal :variants entry is not part of the wire
783
+ # payload and survives the replace untouched.
784
+ # Accepts the payload as a positional hash or inline keywords
785
+ # (replace_targeting(user: [3])) — Ruby routes a braceless hash to
786
+ # keywords, so both spellings must land in the same place. Passing
787
+ # nothing raises (via normalize): clearing requires an explicit {}.
788
+ def replace_targeting(payload = nil, user_id: nil, **inline_rules)
789
+ raise ArgumentError, 'pass targeting either as a hash or inline, not both' if payload && inline_rules.any?
790
+
791
+ normalized = TargetingPayload.normalize(payload || (inline_rules unless inline_rules.empty?))
792
+ normalized[:variants] = targeting[:variants] if targeting[:variants]
793
+
794
+ changes = {
795
+ targeting: {
796
+ from: TargetingPayload.serialize(targeting),
797
+ to: TargetingPayload.serialize(normalized)
798
+ }
799
+ }
800
+ record_change('replace_targeting', changes, user_id: user_id) do
801
+ @targeting = normalized
802
+ persist_targeting
803
+
804
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
805
+ Magick::Rails::Events.feature_changed(name, changes: changes, user_id: user_id)
806
+ end
807
+ end
808
+ true
809
+ end
810
+
752
811
  def variants_for_export
753
812
  return [] unless defined?(Magick::FeatureVariant)
754
813
 
@@ -790,8 +849,6 @@ module Magick
790
849
 
791
850
  private
792
851
 
793
- attr_reader :targeting
794
-
795
852
  # Single choke point for change tracking: wraps a public mutator's body,
796
853
  # then writes one audit entry and one version snapshot for the operation.
797
854
  # A thread-local guard makes nested mutator calls (enable -> set_value)
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+ require 'time'
5
+
6
+ module Magick
7
+ # Translates between the internal targeting hash and the wire-format
8
+ # targeting payload exchanged with control planes (panel contract):
9
+ #
10
+ # GET -> Feature#as_json emits `serialize(targeting)` — string keys,
11
+ # arrays of strings, floats; the "targeting" key is always
12
+ # present ({} = no targeting).
13
+ # PATCH -> Feature#replace_targeting runs the inbound payload through
14
+ # `normalize` — lenient about aliases/scalars/key types, strict
15
+ # about vocabulary and values (all-or-nothing).
16
+ #
17
+ # The internal-only :variants key never crosses the wire in either
18
+ # direction: `serialize` drops it, `normalize` rejects it.
19
+ module TargetingPayload
20
+ # Inclusion keys are singular, exclusion keys plural — mirrors the
21
+ # internal @targeting layout, which is also the persisted layout.
22
+ ARRAY_KEYS = %i[
23
+ user group role tag ip_address
24
+ excluded_users excluded_groups excluded_roles excluded_tags excluded_ip_addresses
25
+ ].freeze
26
+ PERCENTAGE_KEYS = %i[percentage_users percentage_requests].freeze
27
+ STRUCTURED_KEYS = %i[date_range custom_attributes complex_conditions].freeze
28
+ CANONICAL_KEYS = (ARRAY_KEYS + PERCENTAGE_KEYS + STRUCTURED_KEYS).freeze
29
+
30
+ # Plural spellings accepted on input for parity with Magick.import.
31
+ ALIASES = {
32
+ users: :user,
33
+ groups: :group,
34
+ roles: :role,
35
+ tags: :tag,
36
+ ip_addresses: :ip_address
37
+ }.freeze
38
+
39
+ IP_KEYS = %i[ip_address excluded_ip_addresses].freeze
40
+
41
+ CUSTOM_ATTRIBUTE_OPERATORS = %i[equals eq not_equals ne in not_in greater_than gt less_than lt].freeze
42
+ COMPLEX_OPERATORS = %i[and or].freeze
43
+ COMPLEX_CONDITION_TYPES = %i[user group role custom_attribute].freeze
44
+
45
+ module_function
46
+
47
+ # Wire payload -> canonical internal targeting hash. Raises
48
+ # InvalidTargetingError on the first problem, before any state is
49
+ # touched, so callers get all-or-nothing semantics for free.
50
+ def normalize(payload)
51
+ raise InvalidTargetingError, "targeting must be a Hash, got #{payload.class}" unless payload.is_a?(Hash)
52
+
53
+ normalized = {}
54
+ payload.each do |raw_key, raw_value|
55
+ key = canonical_key(raw_key)
56
+ raise InvalidTargetingError, "conflicting targeting keys resolve to '#{key}'" if normalized.key?(key)
57
+ next if raw_value.nil? # nil = remove the rule, same as omitting the key
58
+
59
+ value = normalize_value(key, raw_value)
60
+ normalized[key] = value unless value.nil?
61
+ end
62
+ normalized
63
+ end
64
+
65
+ # Internal targeting hash -> wire payload. Never raises: the read path
66
+ # must serialize whatever historical state an adapter hands back.
67
+ def serialize(targeting)
68
+ return {} unless targeting.is_a?(Hash)
69
+
70
+ targeting.each_with_object({}) do |(key, value), wire|
71
+ key = key.to_sym
72
+ next if key == :variants # internal-only, managed via set_variants
73
+
74
+ wire[key.to_s] =
75
+ if ARRAY_KEYS.include?(key)
76
+ Array(value).map(&:to_s)
77
+ elsif PERCENTAGE_KEYS.include?(key)
78
+ value.to_f
79
+ elsif key == :date_range
80
+ serialize_date_range(value)
81
+ else
82
+ deep_stringify(value)
83
+ end
84
+ end
85
+ end
86
+
87
+ def canonical_key(raw_key)
88
+ key = raw_key.to_sym
89
+ key = ALIASES.fetch(key, key)
90
+ if key == :variants
91
+ raise InvalidTargetingError, 'variants are not part of the targeting payload (use set_variants)'
92
+ end
93
+ raise InvalidTargetingError, "unknown targeting key: '#{raw_key}'" unless CANONICAL_KEYS.include?(key)
94
+
95
+ key
96
+ end
97
+
98
+ def normalize_value(key, value)
99
+ if ARRAY_KEYS.include?(key)
100
+ normalize_array(key, value)
101
+ elsif PERCENTAGE_KEYS.include?(key)
102
+ normalize_percentage(key, value)
103
+ elsif key == :date_range
104
+ normalize_date_range(value)
105
+ elsif key == :custom_attributes
106
+ normalize_custom_attributes(value)
107
+ else # :complex_conditions
108
+ normalize_complex_conditions(value)
109
+ end
110
+ end
111
+
112
+ # Scalars are accepted as one-element lists; empty lists mean "no rule"
113
+ # and collapse to key removal, keeping {} the only spelling of
114
+ # "no targeting".
115
+ def normalize_array(key, value)
116
+ values = (value.is_a?(Array) ? value : [value]).compact.map { |v| v.to_s.strip }.reject(&:empty?).uniq
117
+ return nil if values.empty?
118
+
119
+ validate_ip_list!(key, values) if IP_KEYS.include?(key)
120
+ values
121
+ end
122
+
123
+ def validate_ip_list!(key, values)
124
+ values.each do |ip|
125
+ IPAddr.new(ip)
126
+ rescue ArgumentError # includes IPAddr::Error
127
+ raise InvalidTargetingError, "invalid IP address or CIDR range in '#{key}': '#{ip}'"
128
+ end
129
+ end
130
+
131
+ def normalize_percentage(key, value)
132
+ percentage = begin
133
+ Float(value)
134
+ rescue ArgumentError, TypeError
135
+ raise InvalidTargetingError, "'#{key}' must be a number, got #{value.inspect}"
136
+ end
137
+ unless percentage.positive? && percentage <= 100
138
+ raise InvalidTargetingError, "'#{key}' must be within (0, 100], got #{percentage}"
139
+ end
140
+
141
+ percentage
142
+ end
143
+
144
+ def normalize_date_range(value)
145
+ raise InvalidTargetingError, "'date_range' must be a Hash with start and end" unless value.is_a?(Hash)
146
+
147
+ start_value = value[:start] || value['start']
148
+ end_value = value[:end] || value['end']
149
+ raise InvalidTargetingError, "'date_range' requires both start and end" unless start_value && end_value
150
+
151
+ { start: parse_date_bound(start_value), end: parse_date_bound(end_value) }
152
+ end
153
+
154
+ def parse_date_bound(value)
155
+ return value unless value.is_a?(String)
156
+
157
+ Time.parse(value)
158
+ value
159
+ rescue ArgumentError
160
+ raise InvalidTargetingError, "'date_range' bound is not a parseable time: '#{value}'"
161
+ end
162
+
163
+ def normalize_custom_attributes(value)
164
+ raise InvalidTargetingError, "'custom_attributes' must be a Hash" unless value.is_a?(Hash)
165
+
166
+ value.each_with_object({}) do |(attribute, rule), result|
167
+ raise InvalidTargetingError, "rule for custom attribute '#{attribute}' must be a Hash" unless rule.is_a?(Hash)
168
+
169
+ values = Array(rule[:values] || rule['values']).compact.map(&:to_s).reject(&:empty?)
170
+ raise InvalidTargetingError, "custom attribute '#{attribute}' requires values" if values.empty?
171
+
172
+ operator = (rule[:operator] || rule['operator'] || :equals).to_sym
173
+ unless CUSTOM_ATTRIBUTE_OPERATORS.include?(operator)
174
+ raise InvalidTargetingError, "unknown operator '#{operator}' for custom attribute '#{attribute}'"
175
+ end
176
+
177
+ result[attribute.to_sym] = { values: values, operator: operator }
178
+ end
179
+ end
180
+
181
+ def normalize_complex_conditions(value)
182
+ raise InvalidTargetingError, "'complex_conditions' must be a Hash" unless value.is_a?(Hash)
183
+
184
+ operator = (value[:operator] || value['operator'] || :and).to_sym
185
+ unless COMPLEX_OPERATORS.include?(operator)
186
+ raise InvalidTargetingError, "'complex_conditions' operator must be and/or, got '#{operator}'"
187
+ end
188
+
189
+ conditions = value[:conditions] || value['conditions']
190
+ raise InvalidTargetingError, "'complex_conditions' requires a conditions Array" unless conditions.is_a?(Array)
191
+
192
+ { operator: operator, conditions: conditions.map { |c| normalize_complex_condition(c) } }
193
+ end
194
+
195
+ def normalize_complex_condition(condition)
196
+ raise InvalidTargetingError, 'each complex condition must be a Hash' unless condition.is_a?(Hash)
197
+
198
+ type = (condition[:type] || condition['type']).to_s.to_sym
199
+ unless COMPLEX_CONDITION_TYPES.include?(type)
200
+ raise InvalidTargetingError, "unknown complex condition type: '#{type}'"
201
+ end
202
+
203
+ params = condition[:params] || condition['params'] || {}
204
+ raise InvalidTargetingError, "params for complex condition '#{type}' must be a Hash" unless params.is_a?(Hash)
205
+
206
+ { type: type, params: params.transform_keys(&:to_sym) }
207
+ end
208
+
209
+ def serialize_date_range(value)
210
+ return deep_stringify(value) unless value.is_a?(Hash)
211
+
212
+ start_value = value[:start] || value['start']
213
+ end_value = value[:end] || value['end']
214
+ { 'start' => serialize_time(start_value), 'end' => serialize_time(end_value) }
215
+ end
216
+
217
+ def serialize_time(value)
218
+ return value if value.nil? || value.is_a?(String)
219
+ return value.iso8601 if value.respond_to?(:iso8601)
220
+
221
+ value.to_s
222
+ end
223
+
224
+ def deep_stringify(value)
225
+ case value
226
+ when Hash
227
+ value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) }
228
+ when Array
229
+ value.map { |v| deep_stringify(v) }
230
+ when Symbol
231
+ value.to_s
232
+ else
233
+ value
234
+ end
235
+ end
236
+ end
237
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Magick
4
- VERSION = '1.5.0'
4
+ VERSION = '1.6.0'
5
5
  end
data/lib/magick.rb CHANGED
@@ -23,6 +23,7 @@ require_relative 'magick/targeting/ip_address'
23
23
  require_relative 'magick/targeting/custom_attribute'
24
24
  require_relative 'magick/targeting/complex'
25
25
  require_relative 'magick/errors'
26
+ require_relative 'magick/targeting_payload'
26
27
 
27
28
  require_relative 'magick/log_safe'
28
29
  require_relative 'magick/audit_log'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: magick-feature-flags
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Lobanov
@@ -145,6 +145,7 @@ files:
145
145
  - lib/magick/targeting/request_percentage.rb
146
146
  - lib/magick/targeting/role.rb
147
147
  - lib/magick/targeting/user.rb
148
+ - lib/magick/targeting_payload.rb
148
149
  - lib/magick/testing_helpers.rb
149
150
  - lib/magick/version.rb
150
151
  - lib/magick/versioning.rb