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 +4 -4
- data/README.md +34 -0
- data/app/controllers/magick/adminui/features_controller.rb +54 -141
- data/lib/magick/errors.rb +4 -0
- data/lib/magick/export_import.rb +9 -46
- data/lib/magick/feature.rb +60 -3
- data/lib/magick/targeting_payload.rb +237 -0
- data/lib/magick/version.rb +1 -1
- data/lib/magick.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be6153e50823e724cefb980f60d0272c878ed9fd3619cfa98eaaa8917ab621d1
|
|
4
|
+
data.tar.gz: a6e50cabc9eb5730f819ff038ce23b9869e56140244d2b6b48a4c6efa46c1667
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
data/lib/magick/export_import.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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)
|
data/lib/magick/feature.rb
CHANGED
|
@@ -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
|
data/lib/magick/version.rb
CHANGED
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.
|
|
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
|