toggly 0.6.0 → 1.0.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: 2f6a78a0c74623574333be32320578cdd12ee7bf1415ed199c2584716b5e98aa
4
- data.tar.gz: c38ea07fda43a2ce57af893804471bd45ac819d51019e47e082aeccf0411e157
3
+ metadata.gz: af67c2cd1ab3f935ea72b5f3959e0958f8e7225eb8d176a40fbdb9baf3b3f57d
4
+ data.tar.gz: 8911b6582910f651d545a1fd625314f7a5aab4c8972188bc31844156ee37882e
5
5
  SHA512:
6
- metadata.gz: 9e6ce90750098dd4d67642925c3239f1a39a43d5f894d6de60949614d5e6c1de662912073001b9e1c1db42cc6baf9fd39e99efbeeb629d71dd92c54420461539
7
- data.tar.gz: 3dce06734ce96188493c9fef22d030f17e538f8ae8711a128c5918a7c9c9ecdedd3786e1d6881553b1c962185940523df22a576ec059e8649c670d70053becd2
6
+ metadata.gz: 78ff528d676027bb0379688179b888fae05c567f0a1f57b78982890e07fc3c68cf68c763f0ddd63c8611e21c2aea4bcc0e25d31dde2f33e6ba11adbb52653cf4
7
+ data.tar.gz: dbe5aad2ad173a579bd7385a24b00a18980be769ea89a46e875ec6ab31d5e5af4246a9ac74789530cc7bf673dd237addee389261e2c5fc515d3989fe520b5653
data/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.0.0] - 2026-09-23
9
+
10
+ ### Changed (Breaking)
11
+
12
+ - **Catalog-local, MF-parity feature variants**, replacing the
13
+ `enable_variants` / `evaluated-variants-signed` dual-rail from 0.6.0.
14
+ `Client#get_variant` / `#get_variant_value` now assign variants **locally**
15
+ from the same `variants` / `allocation` payload on the `definitions` /
16
+ `definitions-signed` catalog that drives `enabled?` — there is no separate
17
+ network call. Assignment matches `Microsoft.FeatureManagement` 4.7.0's
18
+ `IVariantFeatureManager` bit-for-bit (user → group → percentile → default,
19
+ `StatusOverride`, percentile SHA-256 hashing) and is verified against the
20
+ shared `variant-allocator-corpus/cases.json` gold corpus (100% pass).
21
+ - `get_variant(feature_key, context: nil)` / `get_variant_value(feature_key,
22
+ context: nil)` now take the same `context:` (`userId` + `groups`) used by
23
+ `enabled?`, instead of a client-wide `variant_identity`.
24
+ - `VariantResult` gains `enabled` (effective enabled after the assigned
25
+ variant's `StatusOverride` — `enabled?` itself stays filter-based only)
26
+ and `reason` (`"User"` | `"Group"` | `"Percentile"` |
27
+ `"DefaultWhenEnabled"` | `"DefaultWhenDisabled"`).
28
+ - New `FeatureDefinition#variants` / `#allocation` (`FeatureVariant`,
29
+ `FeatureVariantAllocation`), parsed from the `definitions` wire.
30
+ - **Removed**: `Config#enable_variants` / `#variant_identity` /
31
+ `#variant_groups` / `#variant_claims` / `#variants_endpoint`,
32
+ `Client#variant_defs` / `#set_variant_identity`, `EvaluatedVariantDef`,
33
+ and the `evaluated-variants-signed` fetch/cache rail (including the
34
+ `SnapshotProviders` `save_variants` / `load_variants` hooks — variants are
35
+ now part of the ordinary definitions snapshot via `FeatureDefinition`).
36
+
37
+ **Migration**: drop `enable_variants` / `variant_identity` /
38
+ `set_variant_identity` from your config. Pass targeting via
39
+ `get_variant(key, context: Toggly::Context.new(identity: ..., groups: ...))`.
40
+
8
41
  ## [0.6.0] - 2026-09-22
9
42
 
10
43
  ### Added
data/README.md CHANGED
@@ -38,6 +38,28 @@ if client.enabled?(:my_feature)
38
38
  end
39
39
  ```
40
40
 
41
+ ## Feature variants (catalog-local, MF-parity)
42
+
43
+ `get_variant` / `get_variant_value` assign a variant purely from the
44
+ feature's `variants` / `allocation` catalog data (no network round-trip),
45
+ matching `Microsoft.FeatureManagement`'s `IVariantFeatureManager`
46
+ bit-for-bit — verified against the shared
47
+ `variant-allocator-corpus/cases.json` gold corpus.
48
+
49
+ ```ruby
50
+ variant = client.get_variant('checkout-flow', context: Toggly::Context.new(identity: 'user-1'))
51
+ variant&.name # assigned variant name, or nil
52
+ variant&.configuration_value # untyped configuration payload
53
+ variant&.enabled # effective enabled after StatusOverride
54
+ variant&.reason # "User" | "Group" | "Percentile" | "DefaultWhenEnabled" | "DefaultWhenDisabled"
55
+
56
+ client.get_variant_value('checkout-flow', context: context) # shortcut for configuration_value
57
+ ```
58
+
59
+ `enabled?` stays filter-based only. The `enable_variants` /
60
+ `evaluated-variants-signed` dual-rail (and `set_variant_identity`) was
61
+ removed in 1.0 — see [CHANGELOG.md](CHANGELOG.md).
62
+
41
63
  ## Usage & metrics telemetry
42
64
 
43
65
  When `app_key` is set, usage tracking and metrics default **on** (disable with
@@ -2,31 +2,22 @@
2
2
 
3
3
  module Toggly
4
4
  class Client
5
- # Durable snapshot load/save helpers for Client. Dual-rail: `definitions`
6
- # is always persisted/restored (sole source of truth for `enabled?`).
7
- # `variant_defs` is an additive rail, persisted/restored independently
8
- # only when `config.enable_variants` is true — it never replaces the
9
- # definitions snapshot.
5
+ # Durable snapshot load/save helpers for Client. Definitions (including
6
+ # `variants` / `allocation`) are the only persisted rail — there is no
7
+ # separate evaluated-variants snapshot (removed with the dual-rail).
10
8
  module SnapshotSupport
11
9
  private
12
10
 
13
- # @return [Boolean] true when any durable snapshot rail was applied
14
- # into memory (definitions and/or evaluated variants)
11
+ # @return [Boolean] true when the durable snapshot was applied into memory
15
12
  def load_snapshot
16
13
  return false unless @config.snapshot_provider
17
14
 
18
- definitions_loaded = load_definitions_snapshot
19
- variants_loaded = @config.enable_variants && load_variants_snapshot
20
-
21
- definitions_loaded || variants_loaded
15
+ load_definitions_snapshot
22
16
  rescue StandardError => e
23
17
  log_warn("Failed to load snapshot: #{e.message}")
24
18
  false
25
19
  end
26
20
 
27
- # Persist the definitions rail. Always available regardless of
28
- # `config.enable_variants` — definitions remain authoritative for
29
- # `enabled?`.
30
21
  def save_definitions_snapshot
31
22
  return unless @config.snapshot_provider
32
23
 
@@ -36,17 +27,6 @@ module Toggly
36
27
  log_warn("Failed to save snapshot: #{e.message}")
37
28
  end
38
29
 
39
- # Persist the evaluated-variants rail. Additive only; called only when
40
- # `config.enable_variants` is true.
41
- def save_variants_snapshot
42
- return unless @config.snapshot_provider
43
-
44
- @config.snapshot_provider.save_variants(@variant_defs)
45
- log_debug("Saved variants snapshot with #{@variant_defs.size} features")
46
- rescue StandardError => e
47
- log_warn("Failed to save variants snapshot: #{e.message}")
48
- end
49
-
50
30
  def load_definitions_snapshot
51
31
  data = @config.snapshot_provider.load
52
32
  return false unless data
@@ -59,20 +39,6 @@ module Toggly
59
39
  log_debug("Loaded #{@definitions.size} features from snapshot")
60
40
  true
61
41
  end
62
-
63
- def load_variants_snapshot
64
- return false unless @config.snapshot_provider.respond_to?(:load_variants)
65
-
66
- data = @config.snapshot_provider.load_variants
67
- return false unless data
68
-
69
- @mutex.synchronize do
70
- @variant_defs = data[:variants] || {}
71
- end
72
-
73
- log_debug("Loaded #{@variant_defs.size} evaluated variants from snapshot")
74
- true
75
- end
76
42
  end
77
43
  end
78
44
  end
data/lib/toggly/client.rb CHANGED
@@ -33,10 +33,6 @@ module Toggly
33
33
  # @return [Hash<String, FeatureDefinition>] Current definitions
34
34
  attr_reader :definitions
35
35
 
36
- # @return [Hash<String, EvaluatedVariantDef>] Current evaluated variants
37
- # (populated only when `config.enable_variants` is true)
38
- attr_reader :variant_defs
39
-
40
36
  # @return [Boolean] Whether the client is ready
41
37
  attr_reader :ready
42
38
 
@@ -48,7 +44,6 @@ module Toggly
48
44
  @config.validate!
49
45
 
50
46
  @definitions = {}
51
- @variant_defs = {}
52
47
  # True once a revision (including empty) or durable snapshot was applied.
53
48
  @definitions_loaded = false
54
49
  @mutex = Mutex.new
@@ -85,10 +80,9 @@ module Toggly
85
80
 
86
81
  definition = @mutex.synchronize { @definitions[key] }
87
82
 
88
- # Dual-rail: definitions/definitions-signed are the sole source of
89
- # truth for enabled? — this holds even when config.enable_variants is
90
- # true. Evaluated variants (@variant_defs) are an additive rail read
91
- # only by get_variant / get_variant_value and never override this.
83
+ # `enabled?` stays filter-based only (definitions/definitions-signed).
84
+ # It never reflects a variant's StatusOverride — see `get_variant`
85
+ # (`VariantResult#enabled`) for MF-identical effective-enabled semantics.
92
86
  result = if definition.nil?
93
87
  if !default.nil?
94
88
  default
@@ -117,52 +111,52 @@ module Toggly
117
111
  !enabled?(feature_key, context: context, default: default.nil? ? nil : !default)
118
112
  end
119
113
 
120
- # Get the assigned variant for a feature. Requires `config.enable_variants`;
121
- # returns nil when variants are disabled, unknown, or unassigned.
114
+ # Get the assigned variant for a feature, computed locally from the
115
+ # feature's catalog `variants` / `allocation` (MF-parity, bit-for-bit
116
+ # with `Microsoft.FeatureManagement`'s `IVariantFeatureManager`). Returns
117
+ # nil when the feature is unknown, has no variants configured, or no
118
+ # variant resolves for this context (see {VariantAllocator}).
119
+ #
120
+ # There is no network round-trip here — this superseded the
121
+ # `enable_variants` / `evaluated-variants-signed` dual-rail removed in
122
+ # 1.0 (see CHANGELOG).
122
123
  #
123
124
  # NOTE: this is the actual A/B assignment. It is unrelated to the
124
125
  # `variant:` telemetry label on `record_usage` / `record_view`, which is
125
- # a free-form usage tag (defaults to "enabled"/"disabled") and does not
126
- # reflect `evaluated-variants-signed` results.
126
+ # a free-form usage tag (defaults to "enabled"/"disabled").
127
127
  #
128
128
  # @param feature_key [String, Symbol] The feature key
129
+ # @param context [Context, nil] Optional targeting context (userId + groups)
129
130
  # @return [VariantResult, nil]
130
- def get_variant(feature_key)
131
- return nil unless @config.enable_variants
132
-
131
+ def get_variant(feature_key, context: nil)
133
132
  key = feature_key.to_s
134
- entry = @mutex.synchronize { @variant_defs[key] }
135
- return nil if entry.nil? || entry.variant.nil? || entry.variant.to_s.empty?
133
+ definition = @mutex.synchronize { @definitions[key] }
134
+ return nil if definition.nil?
135
+
136
+ enabled = @engine.evaluate(definition, context)
137
+ assignment = VariantAllocator.assign(
138
+ definition,
139
+ enabled: enabled,
140
+ identity: context&.identity,
141
+ groups: context&.groups || []
142
+ )
143
+ return nil if assignment.variant_name.nil?
136
144
 
137
- VariantResult.new(name: entry.variant, configuration_value: entry.configuration_value)
145
+ VariantResult.new(
146
+ name: assignment.variant_name,
147
+ configuration_value: assignment.configuration_value,
148
+ enabled: assignment.enabled,
149
+ reason: assignment.reason
150
+ )
138
151
  end
139
152
 
140
153
  # Get the configuration value for the assigned variant, if any.
141
154
  #
142
155
  # @param feature_key [String, Symbol] The feature key
156
+ # @param context [Context, nil] Optional targeting context (userId + groups)
143
157
  # @return [Object, nil]
144
- def get_variant_value(feature_key)
145
- get_variant(feature_key)&.configuration_value
146
- end
147
-
148
- # Update the `userId` sent to `evaluated-variants-signed` and, when
149
- # `config.enable_variants` is true, clear cached variants and refresh.
150
- # No-op (besides updating provider state) when variants are disabled.
151
- # Named as an action (not `variant_identity=`) because it also triggers
152
- # a network refresh — it is not a passive attribute writer.
153
- #
154
- # @param identity [String, nil]
155
- # @return [Boolean] true if the identity changed
156
- # rubocop:disable-next Naming/AccessorMethodName
157
- def set_variant_identity(identity)
158
- changed = @provider.set_variant_identity(identity)
159
-
160
- if changed && @config.enable_variants
161
- @mutex.synchronize { @variant_defs = {} }
162
- refresh(force: true) unless @config.offline_mode?
163
- end
164
-
165
- changed
158
+ def get_variant_value(feature_key, context: nil)
159
+ get_variant(feature_key, context: context)&.configuration_value
166
160
  end
167
161
 
168
162
  # Get detailed evaluation result
@@ -217,15 +211,7 @@ module Toggly
217
211
  end
218
212
 
219
213
  begin
220
- # Dual-rail: definitions/definitions-signed are always refreshed —
221
- # the sole source of truth for enabled?. When config.enable_variants
222
- # is true, evaluated-variants-signed is ALSO fetched on its own rail,
223
- # additive only for get_variant / get_variant_value. A failure on
224
- # either rail must never affect the other.
225
- definitions_updated = refresh_definitions_rail(force: force)
226
- variants_updated = @config.enable_variants ? refresh_variants_rail(force: force) : false
227
-
228
- definitions_updated || variants_updated
214
+ refresh_definitions_rail(force: force)
229
215
  ensure
230
216
  drain_pending = false
231
217
  @mutex.synchronize do
@@ -334,9 +320,9 @@ module Toggly
334
320
 
335
321
  private
336
322
 
337
- # Definitions rail: `definitions` / `definitions-signed` → local rule eval.
338
- # Always runs (dual-rail): the sole source of truth for `enabled?`,
339
- # regardless of `config.enable_variants`.
323
+ # `definitions` / `definitions-signed` → local rule eval. The sole
324
+ # source of truth for `enabled?`; also carries `variants` / `allocation`
325
+ # for the catalog-local `get_variant` assignment.
340
326
  def refresh_definitions_rail(force:)
341
327
  refresh_definitions(force: force)
342
328
  rescue StandardError => e
@@ -365,36 +351,6 @@ module Toggly
365
351
  end
366
352
  end
367
353
 
368
- # Variants rail: `evaluated-variants-signed` → server-evaluated assignment.
369
- # Additive only (used by get_variant / get_variant_value); runs only when
370
- # `config.enable_variants` is true and never influences `enabled?`. A
371
- # failure here must never affect the definitions rail above, and is not
372
- # counted in definition cache-hit/miss telemetry (that metric is scoped
373
- # to the definitions rail).
374
- def refresh_variants_rail(force:)
375
- refresh_variants(force: force)
376
- rescue StandardError => e
377
- log_error("Failed to refresh evaluated variants: #{e.message}")
378
- false
379
- end
380
-
381
- def refresh_variants(force:)
382
- result = @provider.fetch_variants(force: force)
383
-
384
- if result.variants
385
- @mutex.synchronize do
386
- @variant_defs = result.variants
387
- @ready = true
388
- end
389
-
390
- save_variants_snapshot
391
- log_info("Evaluated variants refreshed (#{result.variants.size} features)")
392
- true
393
- else
394
- false
395
- end
396
- end
397
-
398
354
  def initialize_definitions
399
355
  # Startup served from durable snapshot before first network — cache hit.
400
356
  # Distinct from the subsequent refresh() network outcome (no double-count
@@ -419,7 +375,7 @@ module Toggly
419
375
  log_error("Failed to initialize definitions: #{e.message}")
420
376
 
421
377
  # Use snapshot or defaults as fallback
422
- @ready = true if @definitions.any? || @variant_defs.any? || @config.defaults.any?
378
+ @ready = true if @definitions.any? || @config.defaults.any?
423
379
  end
424
380
 
425
381
  def start_background_refresh
data/lib/toggly/config.rb CHANGED
@@ -55,25 +55,6 @@ module Toggly
55
55
  # @return [Array<String>] Allowed key IDs for signed definitions
56
56
  attr_accessor :allowed_key_ids
57
57
 
58
- # @return [Boolean] When true, additionally fetch server-evaluated variants
59
- # from `evaluated-variants-signed` on their own rail (dual-rail).
60
- # `definitions` / `definitions-signed` remain the sole source of truth
61
- # for `enabled?` regardless of this setting — evaluated variants are
62
- # additive and only feed `Client#get_variant` / `#get_variant_value`.
63
- attr_accessor :enable_variants
64
-
65
- # @return [String, nil] `userId` sent to `evaluated-variants-signed` for targeting.
66
- # Separate from the per-call `Context#identity` used for local rule evaluation.
67
- attr_accessor :variant_identity
68
-
69
- # @return [Array<String>] Application-wide group memberships sent to
70
- # `evaluated-variants-signed` (not request-local booleans).
71
- attr_accessor :variant_groups
72
-
73
- # @return [Hash<String, String>] Application-wide string claims sent to
74
- # `evaluated-variants-signed` (at most 20 on the wire).
75
- attr_accessor :variant_claims
76
-
77
58
  # @return [Logger, nil] Logger instance
78
59
  attr_accessor :logger
79
60
 
@@ -123,10 +104,6 @@ module Toggly
123
104
  @snapshot_provider = options[:snapshot_provider]
124
105
  @use_signed_definitions = options[:use_signed_definitions] || false
125
106
  @allowed_key_ids = options[:allowed_key_ids] || []
126
- @enable_variants = options[:enable_variants] || false
127
- @variant_identity = options[:variant_identity]
128
- @variant_groups = Array(options[:variant_groups])
129
- @variant_claims = options[:variant_claims] || {}
130
107
  @logger = options[:logger]
131
108
 
132
109
  @usage_tracking_explicit = options.key?(:enable_usage_tracking)
@@ -150,16 +127,6 @@ module Toggly
150
127
  "#{normalize_url(base)}#{endpoint}/#{@app_key}/#{@environment}"
151
128
  end
152
129
 
153
- # Get the evaluated-variants-signed endpoint URL. Always signed; query
154
- # params (userId/groups/claims) are added by the provider since they can
155
- # change at runtime (see `Client#set_variant_identity`).
156
- #
157
- # @return [String]
158
- def variants_endpoint
159
- base = @definitions_url || @base_url
160
- "#{normalize_url(base)}evaluated-variants-signed/#{@app_key}/#{@environment}"
161
- end
162
-
163
130
  # Validate the configuration
164
131
  #
165
132
  # @raise [ConfigError] if configuration is invalid
@@ -219,7 +186,6 @@ module Toggly
219
186
  enable_undefined_in_dev: @enable_undefined_in_dev,
220
187
  disable_background_refresh: @disable_background_refresh,
221
188
  enable_live_updates: @enable_live_updates,
222
- enable_variants: @enable_variants,
223
189
  app_version: @app_version,
224
190
  instance_name: @instance_name,
225
191
  use_signed_definitions: @use_signed_definitions,
@@ -17,12 +17,6 @@ module Toggly
17
17
  # Result of one HTTP definitions fetch with cache telemetry outcome.
18
18
  FetchResult = Struct.new(:definitions, :cache_outcome, keyword_init: true)
19
19
 
20
- # Result of one HTTP evaluated-variants fetch with cache telemetry outcome.
21
- VariantFetchResult = Struct.new(:variants, :cache_outcome, keyword_init: true)
22
-
23
- # Maximum number of string claims sent on the evaluated-variants-signed wire.
24
- MAX_VARIANT_CLAIMS = 20
25
-
26
20
  # Fallback HTTP refresh interval when WebSocket is connected (20 minutes)
27
21
  FALLBACK_REFRESH_INTERVAL = 20 * 60
28
22
 
@@ -43,13 +37,6 @@ module Toggly
43
37
  @last_modified = nil
44
38
  @last_ts = 0
45
39
 
46
- # Evaluated-variants rail state — separate ETag/Last-Modified/timestamp
47
- # namespace from definitions so the two rails never cross-invalidate.
48
- @variant_etag = nil
49
- @variant_last_modified = nil
50
- @variant_last_ts = 0
51
- @variant_identity = config.variant_identity
52
-
53
40
  # WebSocket state
54
41
  @ws = nil
55
42
  @ws_connected = false
@@ -90,56 +77,6 @@ module Toggly
90
77
  @last_ts = 0
91
78
  end
92
79
 
93
- # Fetch server-evaluated variants from `evaluated-variants-signed`.
94
- # Independent ETag/timestamp cache from {#fetch} (separate rail).
95
- #
96
- # @param force [Boolean] Force fetch even if cached
97
- # @return [VariantFetchResult] variants (Hash or nil) plus :hit / :miss outcome
98
- # @raise [NetworkError] On network failures
99
- # @raise [DefinitionsError] On API errors
100
- def fetch_variants(force: false)
101
- return VariantFetchResult.new(variants: nil, cache_outcome: :hit) if @config.offline_mode?
102
-
103
- uri = URI.parse(build_variant_request_url)
104
- http = build_http(uri)
105
- request = build_variant_request(uri, force)
106
-
107
- response = http.request(request)
108
- handle_variant_response(response)
109
- rescue Net::OpenTimeout, Net::ReadTimeout => e
110
- raise NetworkError, "Request timeout: #{e.message}"
111
- rescue SocketError, Errno::ECONNREFUSED => e
112
- raise NetworkError, "Connection failed: #{e.message}"
113
- rescue NetworkError, DefinitionsError
114
- raise
115
- rescue StandardError => e
116
- raise NetworkError, "Request failed: #{e.message}"
117
- end
118
-
119
- # Update the `userId` sent to `evaluated-variants-signed`. Distinct from
120
- # any per-call `Context#identity` used for local rule evaluation. Named
121
- # as an action (not `variant_identity=`) because it also invalidates the
122
- # variants cache — it is not a passive attribute writer.
123
- #
124
- # @param identity [String, nil]
125
- # @return [Boolean] true if the identity changed (cache invalidated)
126
- # rubocop:disable-next Naming/AccessorMethodName
127
- def set_variant_identity(identity)
128
- normalized = identity&.to_s
129
- return false if normalized == @variant_identity
130
-
131
- @variant_identity = normalized
132
- reset_variant_cache
133
- true
134
- end
135
-
136
- # Reset cached variant headers/timestamp (force full fetch next time)
137
- def reset_variant_cache
138
- @variant_etag = nil
139
- @variant_last_modified = nil
140
- @variant_last_ts = 0
141
- end
142
-
143
80
  # Check whether the periodic refresh should be skipped because the
144
81
  # WebSocket connection is active and the fallback interval has not elapsed.
145
82
  #
@@ -299,110 +236,6 @@ module Toggly
299
236
  @last_modified = last_modified if last_modified && !last_modified.empty?
300
237
  end
301
238
 
302
- def build_variant_request_url
303
- pairs = variant_query_pairs
304
- base = @config.variants_endpoint
305
- pairs.empty? ? base : "#{base}?#{URI.encode_www_form(pairs)}"
306
- end
307
-
308
- def variant_query_pairs
309
- pairs = []
310
- pairs << ["userId", @variant_identity] if @variant_identity && !@variant_identity.empty?
311
-
312
- Array(@config.variant_groups).each do |group|
313
- next unless group.is_a?(String)
314
-
315
- trimmed = group.strip
316
- pairs << ["g", trimmed] unless trimmed.empty?
317
- end
318
-
319
- normalized_claims = {}
320
- (@config.variant_claims || {}).each do |key, value|
321
- next unless key.is_a?(String) && value.is_a?(String)
322
- next if key.empty? || value.empty?
323
-
324
- normalized_claims[key] = value
325
- end
326
- normalized_claims.keys.sort.first(MAX_VARIANT_CLAIMS).each do |key|
327
- pairs << ["claim.#{key}", normalized_claims[key]]
328
- end
329
-
330
- pairs
331
- end
332
-
333
- def build_variant_request(uri, force)
334
- request = Net::HTTP::Get.new(uri)
335
- request["Accept"] = "application/json"
336
- request["User-Agent"] = "toggly-ruby/#{Toggly::VERSION}"
337
- request["X-App-Version"] = @config.app_version if @config.app_version
338
- request["X-Instance-Name"] = @config.instance_name if @config.instance_name
339
-
340
- unless force
341
- request["If-None-Match"] = @variant_etag if @variant_etag
342
- request["If-Modified-Since"] = @variant_last_modified if @variant_last_modified
343
- end
344
-
345
- request
346
- end
347
-
348
- def handle_variant_response(response)
349
- status = response.code.to_i
350
- response_etag = response["ETag"]
351
- response_lm = response["Last-Modified"]
352
- kind = DefinitionCache.classify_http(
353
- status,
354
- @variant_etag,
355
- response_etag,
356
- existing_last_modified: @variant_last_modified,
357
- response_last_modified: response_lm
358
- )
359
-
360
- case kind
361
- when :not_modified
362
- log_debug("Evaluated variants not modified")
363
- VariantFetchResult.new(variants: nil, cache_outcome: :hit)
364
- when :same_revision
365
- log_debug("Evaluated variants revision matches existing (ETag or Last-Modified)")
366
- store_variant_revision_headers(response_etag, response_lm)
367
- VariantFetchResult.new(variants: nil, cache_outcome: :hit)
368
- when :new_content
369
- handle_new_variant_content(response, response_etag, response_lm)
370
- when :error_status
371
- handle_error_status(status, response, resource: "evaluated variants")
372
- end
373
- end
374
-
375
- def handle_new_variant_content(response, response_etag, response_lm)
376
- data = JSON.parse(response.body)
377
- signed_ts = extract_signed_timestamp(data)
378
- if DefinitionCache.cached_signed_timestamp?(@variant_last_ts, signed_ts)
379
- log_debug("Evaluated variants signed timestamp is not newer than cached revision")
380
- store_variant_revision_headers(response_etag, response_lm)
381
- return VariantFetchResult.new(variants: nil, cache_outcome: :hit)
382
- end
383
-
384
- variants = parse_variant_defs(data)
385
- store_variant_revision_headers(response_etag, response_lm)
386
- @variant_last_ts = signed_ts if signed_ts&.positive?
387
- VariantFetchResult.new(variants: variants, cache_outcome: :miss)
388
- rescue JSON::ParserError => e
389
- raise DefinitionsError, "Failed to parse evaluated variants: #{e.message}"
390
- end
391
-
392
- def store_variant_revision_headers(etag, last_modified)
393
- @variant_etag = etag if etag && !etag.empty?
394
- @variant_last_modified = last_modified if last_modified && !last_modified.empty?
395
- end
396
-
397
- def parse_variant_defs(data)
398
- raw = data.is_a?(Hash) ? data["defs"] : nil
399
- return {} unless raw.is_a?(Hash)
400
-
401
- raw.each_with_object({}) do |(key, value), hash|
402
- hash[key] = EvaluatedVariantDef.from_hash(value) if value.is_a?(Hash)
403
- end
404
- end
405
-
406
239
  def extract_signed_timestamp(data)
407
240
  return nil unless data.is_a?(Hash)
408
241
 
@@ -35,6 +35,12 @@ module Toggly
35
35
  # @return [String, nil] Feature description
36
36
  attr_reader :description
37
37
 
38
+ # @return [Array<FeatureVariant>] Named variants for MF-parity assignment
39
+ attr_reader :variants
40
+
41
+ # @return [FeatureVariantAllocation, nil] Allocation rules for variant assignment
42
+ attr_reader :allocation
43
+
38
44
  # Feature types
39
45
  TYPES = %w[Release Experiment Ops Permission].freeze
40
46
 
@@ -50,7 +56,9 @@ module Toggly
50
56
  updated_at: nil,
51
57
  requirement_type: "Any",
52
58
  context_kind: nil,
53
- context_requirement_type: nil
59
+ context_requirement_type: nil,
60
+ variants: [],
61
+ allocation: nil
54
62
  )
55
63
  @feature_key = feature_key.to_s
56
64
  @feature_type = validate_type(feature_type)
@@ -63,6 +71,8 @@ module Toggly
63
71
  @requirement_type = requirement_type || "Any"
64
72
  @context_kind = context_kind
65
73
  @context_requirement_type = context_requirement_type
74
+ @variants = Array(variants)
75
+ @allocation = allocation
66
76
  end
67
77
 
68
78
  # Create from a hash (e.g., from JSON)
@@ -83,6 +93,9 @@ module Toggly
83
93
  !rules.empty?
84
94
  end
85
95
 
96
+ variants = Array(hash[:variants]).map { |v| FeatureVariant.from_hash(v) }
97
+ allocation = FeatureVariantAllocation.from_hash(hash[:allocation])
98
+
86
99
  new(
87
100
  feature_key: hash[:featureKey] || hash[:feature_key],
88
101
  feature_type: hash[:featureType] || hash[:feature_type] || "Release",
@@ -94,7 +107,9 @@ module Toggly
94
107
  updated_at: hash[:updatedAt] || hash[:updated_at],
95
108
  requirement_type: hash[:requirementType] || hash[:requirement_type] || "Any",
96
109
  context_kind: hash[:contextKind] || hash[:context_kind],
97
- context_requirement_type: hash[:contextRequirementType] || hash[:context_requirement_type]
110
+ context_requirement_type: hash[:contextRequirementType] || hash[:context_requirement_type],
111
+ variants: variants,
112
+ allocation: allocation
98
113
  )
99
114
  end
100
115
 
@@ -110,7 +125,9 @@ module Toggly
110
125
  metadata: @metadata,
111
126
  description: @description,
112
127
  created_at: @created_at&.iso8601,
113
- updated_at: @updated_at&.iso8601
128
+ updated_at: @updated_at&.iso8601,
129
+ variants: @variants.map(&:to_h),
130
+ allocation: @allocation&.to_h
114
131
  }
115
132
  end
116
133