toggly 0.6.0 → 1.1.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.
@@ -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
 
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ # A single named variant of a feature flag, as sent on the catalog
5
+ # (`definitions` / `definitions-signed`) wire alongside `filters`.
6
+ #
7
+ # Matches the shape produced by `Microsoft.FeatureManagement`'s
8
+ # `VariantDefinition` (`Name`, `ConfigurationValue`, `StatusOverride`).
9
+ class FeatureVariant
10
+ # Valid `StatusOverride` values (MF `StatusOverride` enum as strings).
11
+ STATUS_OVERRIDES = %w[None Enabled Disabled].freeze
12
+
13
+ # @return [String] Variant name
14
+ attr_reader :name
15
+
16
+ # @return [Object, nil] Untyped configuration payload for this variant
17
+ attr_reader :configuration_value
18
+
19
+ # @return [String] "None" | "Enabled" | "Disabled"
20
+ attr_reader :status_override
21
+
22
+ def initialize(name:, configuration_value: nil, status_override: "None")
23
+ @name = name.to_s
24
+ @configuration_value = configuration_value
25
+ @status_override = STATUS_OVERRIDES.include?(status_override.to_s) ? status_override.to_s : "None"
26
+ end
27
+
28
+ # @param hash [Hash] Variant hash (camelCase wire or snake_case snapshot)
29
+ # @return [FeatureVariant]
30
+ def self.from_hash(hash)
31
+ hash = symbolize_keys(hash)
32
+ configuration_value = if hash.key?(:configurationValue)
33
+ hash[:configurationValue]
34
+ else
35
+ hash[:configuration_value]
36
+ end
37
+
38
+ new(
39
+ name: hash[:name],
40
+ configuration_value: configuration_value,
41
+ status_override: hash[:statusOverride] || hash[:status_override] || "None"
42
+ )
43
+ end
44
+
45
+ # @return [Boolean] True when this variant forces the feature to be
46
+ # reported enabled regardless of the base filter-evaluated state.
47
+ def enabled_override?
48
+ @status_override == "Enabled"
49
+ end
50
+
51
+ # @return [Boolean] True when this variant forces the feature to be
52
+ # reported disabled regardless of the base filter-evaluated state.
53
+ def disabled_override?
54
+ @status_override == "Disabled"
55
+ end
56
+
57
+ # @return [Hash]
58
+ def to_h
59
+ {
60
+ name: @name,
61
+ configuration_value: @configuration_value,
62
+ status_override: @status_override
63
+ }
64
+ end
65
+
66
+ def ==(other)
67
+ other.is_a?(FeatureVariant) &&
68
+ @name == other.name &&
69
+ @configuration_value == other.configuration_value &&
70
+ @status_override == other.status_override
71
+ end
72
+ alias eql? ==
73
+
74
+ def hash
75
+ [@name, @configuration_value, @status_override].hash
76
+ end
77
+
78
+ def self.symbolize_keys(hash)
79
+ return {} unless hash.is_a?(Hash)
80
+
81
+ hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
82
+ end
83
+ private_class_method :symbolize_keys
84
+ end
85
+
86
+ # Allocation rules for assigning variants to users/groups/percentile
87
+ # buckets, matching `Microsoft.FeatureManagement`'s `Allocation` schema.
88
+ class FeatureVariantAllocation
89
+ # @return [String, nil] Variant assigned when the feature is enabled and
90
+ # no user/group/percentile allocation matched.
91
+ attr_reader :default_when_enabled
92
+
93
+ # @return [String, nil] Variant assigned when the feature is disabled.
94
+ attr_reader :default_when_disabled
95
+
96
+ # @return [String, nil] Seed for the percentile hash; falls back to
97
+ # "allocation\n{featureName}" when nil.
98
+ attr_reader :seed
99
+
100
+ # @return [Array<Hash>] `[{ variant:, users: [...] }, ...]`
101
+ attr_reader :user
102
+
103
+ # @return [Array<Hash>] `[{ variant:, groups: [...] }, ...]`
104
+ attr_reader :group
105
+
106
+ # @return [Array<Hash>] `[{ variant:, from:, to: }, ...]`
107
+ attr_reader :percentile
108
+
109
+ def initialize(default_when_enabled: nil, default_when_disabled: nil, seed: nil, user: [], group: [], percentile: [])
110
+ @default_when_enabled = default_when_enabled
111
+ @default_when_disabled = default_when_disabled
112
+ @seed = seed
113
+ @user = Array(user)
114
+ @group = Array(group)
115
+ @percentile = Array(percentile)
116
+ end
117
+
118
+ # @param hash [Hash, nil]
119
+ # @return [FeatureVariantAllocation, nil] nil when +hash+ is nil (no allocation configured)
120
+ def self.from_hash(hash)
121
+ return nil unless hash.is_a?(Hash)
122
+
123
+ hash = symbolize_keys(hash)
124
+ new(
125
+ default_when_enabled: hash[:defaultWhenEnabled] || hash[:default_when_enabled],
126
+ default_when_disabled: hash[:defaultWhenDisabled] || hash[:default_when_disabled],
127
+ seed: hash[:seed],
128
+ user: parse_entries(hash[:user], :users),
129
+ group: parse_entries(hash[:group], :groups),
130
+ percentile: parse_percentile_entries(hash[:percentile])
131
+ )
132
+ end
133
+
134
+ # @return [Hash]
135
+ def to_h
136
+ {
137
+ default_when_enabled: @default_when_enabled,
138
+ default_when_disabled: @default_when_disabled,
139
+ seed: @seed,
140
+ user: @user,
141
+ group: @group,
142
+ percentile: @percentile
143
+ }
144
+ end
145
+
146
+ def ==(other)
147
+ other.is_a?(FeatureVariantAllocation) &&
148
+ @default_when_enabled == other.default_when_enabled &&
149
+ @default_when_disabled == other.default_when_disabled &&
150
+ @seed == other.seed &&
151
+ @user == other.user &&
152
+ @group == other.group &&
153
+ @percentile == other.percentile
154
+ end
155
+ alias eql? ==
156
+
157
+ def hash
158
+ [@default_when_enabled, @default_when_disabled, @seed, @user, @group, @percentile].hash
159
+ end
160
+
161
+ def self.parse_entries(raw, members_key)
162
+ Array(raw).filter_map do |entry|
163
+ next unless entry.is_a?(Hash)
164
+
165
+ entry = symbolize_keys(entry)
166
+ { variant: entry[:variant], members_key => Array(entry[members_key]).map(&:to_s) }
167
+ end
168
+ end
169
+ private_class_method :parse_entries
170
+
171
+ def self.parse_percentile_entries(raw)
172
+ Array(raw).filter_map do |entry|
173
+ next unless entry.is_a?(Hash)
174
+
175
+ entry = symbolize_keys(entry)
176
+ { variant: entry[:variant], from: entry[:from].to_f, to: entry[:to].to_f }
177
+ end
178
+ end
179
+ private_class_method :parse_percentile_entries
180
+
181
+ def self.symbolize_keys(hash)
182
+ return {} unless hash.is_a?(Hash)
183
+
184
+ hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
185
+ end
186
+ private_class_method :symbolize_keys
187
+ end
188
+ end
@@ -40,21 +40,6 @@ module Toggly
40
40
  false
41
41
  end
42
42
 
43
- # Save evaluated variants snapshot (dual-rail, used when
44
- # `config.enable_variants` is true). Default: no-op. Override in
45
- # subclasses to persist variants across restarts.
46
- #
47
- # @param variants [Hash<String, EvaluatedVariantDef>] Variants to save
48
- # @param metadata [Hash] Optional metadata
49
- def save_variants(_variants, _metadata = {}); end
50
-
51
- # Load evaluated variants snapshot (dual-rail).
52
- #
53
- # @return [Hash, nil] Hash with :variants and :metadata, or nil if not available
54
- def load_variants
55
- nil
56
- end
57
-
58
43
  protected
59
44
 
60
45
  # Serialize definitions to a storable format
@@ -77,26 +62,6 @@ module Toggly
77
62
  hash[definition.feature_key] = definition
78
63
  end
79
64
  end
80
-
81
- # Serialize evaluated variants to a storable format
82
- #
83
- # @param variants [Hash<String, EvaluatedVariantDef>] Variants
84
- # @return [Hash<String, Hash>]
85
- def serialize_variants(variants)
86
- variants.transform_values(&:to_h)
87
- end
88
-
89
- # Deserialize evaluated variants from stored format
90
- #
91
- # @param data [Hash<String, Hash>] Serialized variants
92
- # @return [Hash<String, EvaluatedVariantDef>]
93
- def deserialize_variants(data)
94
- return {} unless data.is_a?(Hash)
95
-
96
- data.each_with_object({}) do |(key, value), hash|
97
- hash[key.to_s] = EvaluatedVariantDef.from_hash(value)
98
- end
99
- end
100
65
  end
101
66
  end
102
67
  end
@@ -12,14 +12,10 @@ module Toggly
12
12
  # @return [String] Path to the snapshot file
13
13
  attr_reader :path
14
14
 
15
- # @return [String] Path to the evaluated-variants snapshot file (dual-rail)
16
- attr_reader :variants_path
17
-
18
15
  # @param path [String] Path to the snapshot file
19
16
  def initialize(path:)
20
17
  super()
21
18
  @path = path
22
- @variants_path = derive_variants_path(path)
23
19
  @mutex = Mutex.new
24
20
  end
25
21
 
@@ -70,7 +66,6 @@ module Toggly
70
66
  def clear
71
67
  @mutex.synchronize do
72
68
  FileUtils.rm_f(@path)
73
- FileUtils.rm_f(@variants_path)
74
69
  end
75
70
  rescue StandardError => e
76
71
  raise SnapshotError, "Failed to clear snapshot: #{e.message}"
@@ -83,48 +78,6 @@ module Toggly
83
78
  ::File.exist?(@path)
84
79
  end
85
80
 
86
- # Save evaluated variants to file using atomic write (dual-rail)
87
- #
88
- # @param variants [Hash<String, EvaluatedVariantDef>] Variants
89
- # @param metadata [Hash] Optional metadata
90
- def save_variants(variants, metadata = {})
91
- @mutex.synchronize do
92
- ensure_directory_exists
93
-
94
- data = {
95
- "variants" => serialize_variants(variants),
96
- "metadata" => metadata.merge("saved_at" => Time.now.utc.iso8601)
97
- }
98
-
99
- temp_path = "#{@variants_path}.tmp"
100
- ::File.write(temp_path, JSON.pretty_generate(data))
101
- ::File.rename(temp_path, @variants_path)
102
- end
103
- rescue StandardError => e
104
- raise SnapshotError, "Failed to save variants snapshot: #{e.message}"
105
- end
106
-
107
- # Load evaluated variants from file (dual-rail)
108
- #
109
- # @return [Hash, nil] Hash with :variants and :metadata
110
- def load_variants
111
- @mutex.synchronize do
112
- return nil unless ::File.exist?(@variants_path)
113
-
114
- content = ::File.read(@variants_path)
115
- data = JSON.parse(content)
116
-
117
- {
118
- variants: deserialize_variants(data["variants"]),
119
- metadata: symbolize_keys(data["metadata"] || {})
120
- }
121
- end
122
- rescue JSON::ParserError => e
123
- raise SnapshotError, "Failed to parse variants snapshot: #{e.message}"
124
- rescue StandardError => e
125
- raise SnapshotError, "Failed to load variants snapshot: #{e.message}"
126
- end
127
-
128
81
  private
129
82
 
130
83
  def ensure_directory_exists
@@ -132,13 +85,6 @@ module Toggly
132
85
  FileUtils.mkdir_p(dir) unless ::File.directory?(dir)
133
86
  end
134
87
 
135
- def derive_variants_path(path)
136
- dir = ::File.dirname(path)
137
- ext = ::File.extname(path)
138
- base = ::File.basename(path, ext)
139
- ::File.join(dir, "#{base}_variants#{ext}")
140
- end
141
-
142
88
  def symbolize_keys(hash)
143
89
  return {} unless hash.is_a?(Hash)
144
90
 
@@ -9,7 +9,6 @@ module Toggly
9
9
  def initialize
10
10
  super
11
11
  @data = nil
12
- @variants_data = nil
13
12
  @mutex = Mutex.new
14
13
  end
15
14
 
@@ -44,7 +43,6 @@ module Toggly
44
43
  def clear
45
44
  @mutex.synchronize do
46
45
  @data = nil
47
- @variants_data = nil
48
46
  end
49
47
  end
50
48
 
@@ -56,33 +54,6 @@ module Toggly
56
54
  !@data.nil?
57
55
  end
58
56
  end
59
-
60
- # Save evaluated variants to memory (dual-rail)
61
- #
62
- # @param variants [Hash<String, EvaluatedVariantDef>] Variants
63
- # @param metadata [Hash] Optional metadata
64
- def save_variants(variants, metadata = {})
65
- @mutex.synchronize do
66
- @variants_data = {
67
- variants: serialize_variants(variants),
68
- metadata: metadata.merge(saved_at: Time.now.utc.iso8601)
69
- }
70
- end
71
- end
72
-
73
- # Load evaluated variants from memory (dual-rail)
74
- #
75
- # @return [Hash, nil] Hash with :variants and :metadata
76
- def load_variants
77
- @mutex.synchronize do
78
- return nil unless @variants_data
79
-
80
- {
81
- variants: deserialize_variants(@variants_data[:variants]),
82
- metadata: @variants_data[:metadata]
83
- }
84
- end
85
- end
86
57
  end
87
58
  end
88
59
  end