toggly 0.2.0 → 0.3.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: 750edb3f8314278562443c8e2d1c55342f0cf412660c67be6518b73adb7e391c
4
- data.tar.gz: 37e1959bfe363ca29d389c511663f170bac4d8501f3ae5343dcde76a43ced889
3
+ metadata.gz: 7b199467e0cb56245072bf1b0bda7b00ddf7931be0c87e691dac159287f1304e
4
+ data.tar.gz: 184d195d8caf6321358d2bda8894f8134d1bd2d5ddc3eca6dfd92c7c7c19595b
5
5
  SHA512:
6
- metadata.gz: 0ffa45e64fd2517b201de7701c74f0e797bdfbeddc76affc469fe37113e352b99a6024db42c36e9ce13d163f3530e6cc23a17bf770eeedd9f2c22dc3216c30b5
7
- data.tar.gz: 4cf489e30d206a5bf9bd92153b865e57ebaf04190909c16559d10e90edfd1a017ef27bed38c6356186102167cf0617111f0f34a950443fb3ce32b50c69d3cb9a
6
+ metadata.gz: ddfa9d5bcbf04311a427752c3dba07edb52b841e61f8630d6b9beca6d34d34d1c53b3107f7ca1c9a07f73a8d796278ef2296cd0b723d1ed83be2504277e4449b
7
+ data.tar.gz: 76bf82b4e3e23cdfea1beb0edd735a2be3ae8ce38982c7bfc75dcc00093160666b7372815ea462480590edf7c72dd0ec0b71a2bc11661b0510b8d85c39425c88
data/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ 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
+ ## [0.3.0] - 2026-09-04
9
+
10
+ ### Added
11
+
12
+ - EvalContext fields `claims` and `request` (`userAgent`, `acceptLanguage`,
13
+ `country`) plus `Toggly::RequestContext` and `Toggly::HttpRequestMapper`
14
+ (headers → request; country order `cf-ipcountry` → `x-vercel-ip-country` →
15
+ `cloudfront-viewer-country`).
16
+ - Segment filters: `BrowserFamily`, `BrowserLanguage`, `Country` /
17
+ `CountryFamily`, `DeviceType`, `OS` / `OperatingSystem` with indexed params
18
+ and Percentage fail-closed gating.
19
+ - `UserClaims` filter (`Claim` + `Value`).
20
+ - `Microsoft.Percentage`, `Microsoft.TimeWindow`, and `Microsoft.Targeting`
21
+ aliases; golden fixtures under `docs/filter-parity/fixtures/`.
22
+
23
+ ### Changed
24
+
25
+ - Sticky percentage hashing now uses Definitions / toggly-eval SHA-256
26
+ (`featureKey + "\n" + userId`, little-endian uint32 / `0xFFFFFFFF * 100`)
27
+ instead of FNV-1a. Existing sticky cohorts shift when upgrading from 0.2.x.
28
+ - Unknown filter names fail closed.
29
+ - Percentage missing or `≤0` fails closed (aligned with filter-parity contract).
30
+
31
+ ## [0.2.1] - 2026-09-03
32
+
33
+ ### Changed
34
+
35
+ - Raise required Ruby version to 3.2+ (matches Gemfile.lock Bundler and CI).
36
+
8
37
  ## [0.2.0] - 2026-08-21
9
38
 
10
39
  ### Added
@@ -1,23 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toggly
4
- # Evaluation context containing user identity, groups, and traits.
4
+ # Evaluation context containing user identity, groups, claims, request, and traits.
5
5
  #
6
6
  # @example
7
7
  # context = Toggly::Context.new(
8
8
  # identity: "user-123",
9
9
  # groups: ["beta-testers", "premium"],
10
- # traits: { country: "US", plan: "enterprise" }
10
+ # claims: { "role" => "admin" },
11
+ # request: Toggly::RequestContext.new(country: "US"),
12
+ # traits: { plan: "enterprise" }
11
13
  # )
12
14
  class Context
13
15
  # @return [String, nil] User identity for percentage rollouts and targeting
14
- # identity, groups, traits, and optional entity for ContextProperty filters
15
- attr_reader :identity, :groups, :traits, :entity
16
+ # @return [Array<String>] Audience groups
17
+ # @return [Hash{String => Object}] Legacy/custom traits
18
+ # @return [Hash{String => String}] Principal claims for UserClaims filters
19
+ # @return [RequestContext, nil] HTTP request fields for segment filters
20
+ # @return [EntityContext, nil] Optional entity for ContextProperty filters
21
+ attr_reader :identity, :groups, :traits, :claims, :request, :entity
16
22
 
17
- def initialize(identity: nil, groups: [], traits: {}, entity: nil)
23
+ def initialize(identity: nil, groups: [], traits: {}, claims: {}, request: nil, entity: nil)
18
24
  @identity = identity&.to_s
19
25
  @groups = Array(groups).map(&:to_s)
20
26
  @traits = normalize_traits(traits)
27
+ @claims = normalize_claims(claims)
28
+ @request = coerce_request(request)
21
29
  @entity = entity
22
30
  end
23
31
 
@@ -36,6 +44,33 @@ module Toggly
36
44
  new
37
45
  end
38
46
 
47
+ # Create from a hash (EvalContext / fixture style).
48
+ #
49
+ # @param data [Hash]
50
+ # @return [Context]
51
+ def self.from_hash(data)
52
+ return anonymous unless data.is_a?(Hash)
53
+
54
+ entity_data = data["entity"] || data[:entity]
55
+ entity = nil
56
+ if entity_data.is_a?(Hash)
57
+ entity = EntityContext.new(
58
+ kind: entity_data["kind"] || entity_data[:kind],
59
+ key: entity_data["key"] || entity_data[:key],
60
+ attributes: entity_data["attributes"] || entity_data[:attributes] || {}
61
+ )
62
+ end
63
+
64
+ new(
65
+ identity: data["identity"] || data[:identity],
66
+ groups: data["groups"] || data[:groups] || [],
67
+ traits: data["traits"] || data[:traits] || {},
68
+ claims: data["claims"] || data[:claims] || {},
69
+ request: RequestContext.from_hash(data["request"] || data[:request]),
70
+ entity: entity
71
+ )
72
+ end
73
+
39
74
  # Check if context has an identity
40
75
  #
41
76
  # @return [Boolean]
@@ -77,6 +112,8 @@ module Toggly
77
112
  identity: @identity,
78
113
  groups: @groups,
79
114
  traits: @traits.merge(normalize_traits(new_traits)),
115
+ claims: @claims,
116
+ request: @request,
80
117
  entity: @entity
81
118
  )
82
119
  end
@@ -90,18 +127,79 @@ module Toggly
90
127
  identity: @identity,
91
128
  groups: @groups + new_groups.flatten.map(&:to_s),
92
129
  traits: @traits,
130
+ claims: @claims,
131
+ request: @request,
93
132
  entity: @entity
94
133
  )
95
134
  end
96
135
 
136
+ # Create a new context with the specified claims map.
137
+ #
138
+ # @param new_claims [Hash]
139
+ # @return [Context]
140
+ def with_claims(new_claims)
141
+ Context.new(
142
+ identity: @identity,
143
+ groups: @groups,
144
+ traits: @traits,
145
+ claims: new_claims,
146
+ request: @request,
147
+ entity: @entity
148
+ )
149
+ end
150
+
151
+ # Create a new context with the specified request fields.
152
+ #
153
+ # @param new_request [RequestContext, Hash, nil]
154
+ # @return [Context]
155
+ def with_request(new_request)
156
+ Context.new(
157
+ identity: @identity,
158
+ groups: @groups,
159
+ traits: @traits,
160
+ claims: @claims,
161
+ request: new_request,
162
+ entity: @entity
163
+ )
164
+ end
165
+
166
+ # Create a new context with the specified entity.
167
+ #
168
+ # @param new_entity [EntityContext, nil]
169
+ # @return [Context]
170
+ def with_entity(new_entity)
171
+ Context.new(
172
+ identity: @identity,
173
+ groups: @groups,
174
+ traits: @traits,
175
+ claims: @claims,
176
+ request: @request,
177
+ entity: new_entity
178
+ )
179
+ end
180
+
97
181
  # Convert to hash for serialization
98
182
  #
99
183
  # @return [Hash]
100
184
  def to_h
185
+ entity_hash =
186
+ if @entity.nil?
187
+ nil
188
+ else
189
+ {
190
+ kind: @entity.kind,
191
+ key: @entity.key,
192
+ attributes: @entity.attributes
193
+ }
194
+ end
195
+
101
196
  {
102
197
  identity: @identity,
103
198
  groups: @groups,
104
- traits: @traits
199
+ traits: @traits,
200
+ claims: @claims,
201
+ request: @request&.to_h,
202
+ entity: entity_hash
105
203
  }
106
204
  end
107
205
 
@@ -114,7 +212,10 @@ module Toggly
114
212
 
115
213
  @identity == other.identity &&
116
214
  @groups.sort == other.groups.sort &&
117
- @traits == other.traits
215
+ @traits == other.traits &&
216
+ @claims == other.claims &&
217
+ @request == other.request &&
218
+ @entity == other.entity
118
219
  end
119
220
  alias eql? ==
120
221
 
@@ -122,14 +223,14 @@ module Toggly
122
223
  #
123
224
  # @return [Integer]
124
225
  def hash
125
- [@identity, @groups.sort, @traits].hash
226
+ [@identity, @groups.sort, @traits, @claims, @request, @entity].hash
126
227
  end
127
228
 
128
229
  # Generate cache key
129
230
  #
130
231
  # @return [String]
131
232
  def cache_key
132
- "#{@identity}:#{@groups.sort.join(",")}:#{traits_cache_key}"
233
+ "#{@identity}:#{@groups.sort.join(",")}:#{traits_cache_key}:#{claims_cache_key}:#{request_cache_key}"
133
234
  end
134
235
 
135
236
  private
@@ -140,8 +241,33 @@ module Toggly
140
241
  traits.transform_keys(&:to_s)
141
242
  end
142
243
 
244
+ def normalize_claims(claims)
245
+ return {} unless claims.is_a?(Hash)
246
+
247
+ claims.each_with_object({}) do |(key, value), memo|
248
+ memo[key.to_s] = value.to_s
249
+ end
250
+ end
251
+
252
+ def coerce_request(request)
253
+ return nil if request.nil?
254
+ return request if request.is_a?(RequestContext)
255
+
256
+ RequestContext.from_hash(request)
257
+ end
258
+
143
259
  def traits_cache_key
144
260
  @traits.sort.map { |k, v| "#{k}=#{v}" }.join(",")
145
261
  end
262
+
263
+ def claims_cache_key
264
+ @claims.sort.map { |k, v| "#{k}=#{v}" }.join(",")
265
+ end
266
+
267
+ def request_cache_key
268
+ return "" unless @request
269
+
270
+ "#{@request.user_agent}|#{@request.accept_language}|#{@request.country}"
271
+ end
146
272
  end
147
273
  end
@@ -97,7 +97,7 @@ module Toggly
97
97
  def evaluate_filter_group(rules, requirement_type, context, feature_key, result)
98
98
  req = (requirement_type || "Any").to_s
99
99
  evaluations = rules.map do |rule|
100
- rule_type = rule["type"] || rule[:type] || rule["name"] || rule[:name] || "always_on"
100
+ rule_type = rule["type"] || rule[:type] || rule["name"] || rule[:name] || "AlwaysOn"
101
101
  evaluator = @registry.get(rule_type)
102
102
  next false unless evaluator
103
103
 
@@ -115,12 +115,13 @@ module Toggly
115
115
 
116
116
  def sequential_rules(rules, definition, context, result)
117
117
  rules.each_with_index do |rule, index|
118
- rule_type = rule["type"] || rule[:type] || rule["name"] || rule[:name] || "always_on"
118
+ rule_type = rule["type"] || rule[:type] || rule["name"] || rule[:name] || "AlwaysOn"
119
119
  evaluator = @registry.get(rule_type)
120
120
 
121
121
  unless evaluator
122
122
  log_warn("Unknown evaluator type: #{rule_type} for feature #{definition.feature_key}")
123
- next
123
+ result&.reason = "unknown_filter"
124
+ return false
124
125
  end
125
126
 
126
127
  begin
@@ -7,7 +7,11 @@ module Toggly
7
7
  # Used for features that should be disabled for everyone.
8
8
  class AlwaysOff < Base
9
9
  def self.type
10
- "always_off"
10
+ "AlwaysOff"
11
+ end
12
+
13
+ def self.aliases
14
+ %w[always_off]
11
15
  end
12
16
 
13
17
  # @param rule [Hash] The rule configuration (ignored)
@@ -7,7 +7,11 @@ module Toggly
7
7
  # Used for features that should be enabled for everyone.
8
8
  class AlwaysOn < Base
9
9
  def self.type
10
- "always_on"
10
+ "AlwaysOn"
11
+ end
12
+
13
+ def self.aliases
14
+ %w[always_on]
11
15
  end
12
16
 
13
17
  # @param rule [Hash] The rule configuration (ignored)
@@ -12,6 +12,13 @@ module Toggly
12
12
  raise NotImplementedError, "Subclass must implement .type"
13
13
  end
14
14
 
15
+ # Optional alternate registration names (e.g. Microsoft.* aliases).
16
+ #
17
+ # @return [Array<String>]
18
+ def self.aliases
19
+ []
20
+ end
21
+
15
22
  # Evaluate a rule against a context
16
23
  #
17
24
  # @param rule [Hash] The rule configuration
@@ -27,19 +34,30 @@ module Toggly
27
34
  # @param rule_type [String] The rule type
28
35
  # @return [Boolean]
29
36
  def handles?(rule_type)
30
- rule_type.to_s.downcase == self.class.type.to_s.downcase
37
+ Registry.normalize_key(rule_type) == Registry.normalize_key(self.class.type)
31
38
  end
32
39
 
33
40
  protected
34
41
 
35
- # Get a value from rule with fallback
42
+ # Get a value from rule with fallback (supports nested +parameters+).
36
43
  #
37
44
  # @param rule [Hash] The rule
38
45
  # @param key [String, Symbol] The key to lookup
39
46
  # @param default [Object] Default value
40
47
  # @return [Object]
41
48
  def rule_value(rule, key, default = nil)
42
- rule[key.to_s] || rule[key.to_sym] || default
49
+ return default unless rule.is_a?(Hash)
50
+
51
+ params = rule["parameters"] || rule[:parameters]
52
+ candidates = [rule]
53
+ candidates << params if params.is_a?(Hash)
54
+
55
+ candidates.each do |source|
56
+ value = source[key.to_s] || source[key.to_sym] ||
57
+ source.find { |k, _| k.to_s.casecmp?(key.to_s) }&.last
58
+ return value unless value.nil?
59
+ end
60
+ default
43
61
  end
44
62
 
45
63
  # Log evaluation result (if logger available)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Evaluators
5
+ # Evaluator for BrowserFamily segment filters.
6
+ class BrowserFamily < Base
7
+ def self.type
8
+ "BrowserFamily"
9
+ end
10
+
11
+ def evaluate(rule, context, feature_key: nil)
12
+ percentage = SegmentHelpers.as_float(rule, "Percentage")
13
+ identity = context&.identity
14
+ return false unless StickyHash.segment_percentage_passes?(percentage, feature_key.to_s, identity)
15
+
16
+ values = SegmentHelpers.collect_indexed_values(rule, "BrowserFamily")
17
+ return false if values.empty?
18
+
19
+ ua = context&.request&.user_agent
20
+ parsed = UserAgentParser.parse(ua)
21
+ return false if parsed.nil? || parsed.browser_family == "Other"
22
+
23
+ values.any? { |value| SegmentHelpers.contains_ignore_case?(parsed.browser_family, value) }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Evaluators
5
+ # Evaluator for BrowserLanguage segment filters.
6
+ class BrowserLanguage < Base
7
+ def self.type
8
+ "BrowserLanguage"
9
+ end
10
+
11
+ def evaluate(rule, context, feature_key: nil)
12
+ percentage = SegmentHelpers.as_float(rule, "Percentage")
13
+ identity = context&.identity
14
+ return false unless StickyHash.segment_percentage_passes?(percentage, feature_key.to_s, identity)
15
+
16
+ values = SegmentHelpers.collect_indexed_values(rule, "BrowserLanguage")
17
+ return false if values.empty?
18
+
19
+ accept = context&.request&.accept_language
20
+ return false if accept.nil? || accept.empty?
21
+
22
+ values.any? { |value| SegmentHelpers.contains_ignore_case?(accept, value) }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -5,7 +5,11 @@ module Toggly
5
5
  # Evaluator for ContextProperty entity filters. Fail closed.
6
6
  class ContextProperty < Base
7
7
  def self.type
8
- "contextproperty"
8
+ "ContextProperty"
9
+ end
10
+
11
+ def self.aliases
12
+ %w[contextproperty]
9
13
  end
10
14
 
11
15
  def evaluate(rule, context, feature_key: nil)
@@ -20,6 +20,10 @@ module Toggly
20
20
  "contextual"
21
21
  end
22
22
 
23
+ def self.aliases
24
+ %w[ContextualTargeting]
25
+ end
26
+
23
27
  # Evaluate contextual targeting rule
24
28
  #
25
29
  # @param rule [Hash] Rule with "conditions" array
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Evaluators
5
+ # Evaluator for Country / CountryFamily segment filters.
6
+ class Country < Base
7
+ def self.type
8
+ "Country"
9
+ end
10
+
11
+ def self.aliases
12
+ %w[CountryFamily]
13
+ end
14
+
15
+ def evaluate(rule, context, feature_key: nil)
16
+ percentage = SegmentHelpers.as_float(rule, "Percentage")
17
+ identity = context&.identity
18
+ return false unless StickyHash.segment_percentage_passes?(percentage, feature_key.to_s, identity)
19
+
20
+ values = SegmentHelpers.collect_indexed_values(rule, "Country")
21
+ return false if values.empty?
22
+
23
+ country = context&.request&.country
24
+ return false if country.nil? || country.empty?
25
+
26
+ values.any? { |value| SegmentHelpers.equals_ignore_case?(value, country) }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Evaluators
5
+ # Evaluator for DeviceType segment filters.
6
+ class DeviceType < Base
7
+ def self.type
8
+ "DeviceType"
9
+ end
10
+
11
+ def evaluate(rule, context, feature_key: nil)
12
+ percentage = SegmentHelpers.as_float(rule, "Percentage")
13
+ identity = context&.identity
14
+ return false unless StickyHash.segment_percentage_passes?(percentage, feature_key.to_s, identity)
15
+
16
+ values = SegmentHelpers.collect_indexed_values(rule, "DeviceType")
17
+ return false if values.empty?
18
+
19
+ ua = context&.request&.user_agent
20
+ parsed = UserAgentParser.parse(ua)
21
+ return false if parsed.nil? || parsed.device_family == "Other"
22
+
23
+ values.any? { |value| SegmentHelpers.contains_ignore_case?(parsed.device_family, value) }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Evaluators
5
+ # Evaluator for OS / OperatingSystem segment filters.
6
+ class OperatingSystem < Base
7
+ def self.type
8
+ "OperatingSystem"
9
+ end
10
+
11
+ def self.aliases
12
+ %w[OS]
13
+ end
14
+
15
+ def evaluate(rule, context, feature_key: nil)
16
+ percentage = SegmentHelpers.as_float(rule, "Percentage")
17
+ identity = context&.identity
18
+ return false unless StickyHash.segment_percentage_passes?(percentage, feature_key.to_s, identity)
19
+
20
+ values = SegmentHelpers.collect_indexed_values(rule, "OperatingSystem")
21
+ return false if values.empty?
22
+
23
+ ua = context&.request&.user_agent
24
+ parsed = UserAgentParser.parse(ua)
25
+ return false if parsed.nil? || parsed.os_family == "Other"
26
+
27
+ values.any? { |value| SegmentHelpers.contains_ignore_case?(parsed.os_family, value) }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -4,68 +4,30 @@ module Toggly
4
4
  module Evaluators
5
5
  # Evaluator for percentage-based rollouts.
6
6
  #
7
- # Uses FNV-1a hash for consistent bucketing based on
8
- # feature key and user identity.
7
+ # Uses Definitions-aligned sticky SHA-256 hashing
8
+ # (+feature_key+ + "\n" + +identity+) for consistent buckets.
9
9
  class Percentage < Base
10
- # FNV-1a hash constants (32-bit)
11
- FNV_PRIME = 0x01000193
12
- FNV_OFFSET_BASIS = 0x811c9dc5
13
-
14
10
  def self.type
15
- "percentage"
11
+ "Percentage"
12
+ end
13
+
14
+ def self.aliases
15
+ %w[Microsoft.Percentage percentage]
16
16
  end
17
17
 
18
18
  # Evaluate percentage rollout
19
19
  #
20
- # @param rule [Hash] Rule with "percentage" or "value" key (0-100)
20
+ # @param rule [Hash] Rule with Percentage / Value params (0-100)
21
21
  # @param context [Context] Evaluation context with identity
22
22
  # @param feature_key [String] The feature key
23
23
  # @return [Boolean] True if user falls within percentage
24
24
  def evaluate(rule, context, feature_key: nil)
25
- percentage = rule_value(rule, "percentage") ||
26
- rule_value(rule, "value") ||
27
- 0
28
-
29
- percentage = percentage.to_f
30
-
31
- # 0% always off, 100% always on
32
- return false if percentage <= 0
25
+ percentage = SegmentHelpers.as_float(rule, "Value", "Percentage", "percentage", "value")
26
+ return false if percentage.nil? || percentage <= 0
33
27
  return true if percentage >= 100
34
-
35
- # Need identity for percentage rollouts
36
28
  return false unless context&.identity?
37
29
 
38
- # Calculate bucket using FNV-1a hash
39
- bucket_key = "#{feature_key}:#{context.identity}"
40
- bucket = calculate_bucket(bucket_key)
41
-
42
- bucket < percentage
43
- end
44
-
45
- private
46
-
47
- # Calculate bucket (0-100) using FNV-1a hash
48
- #
49
- # @param key [String] The key to hash
50
- # @return [Float] Bucket value 0-100
51
- def calculate_bucket(key)
52
- hash = fnv1a_hash(key)
53
- (hash % 10_000) / 100.0
54
- end
55
-
56
- # FNV-1a hash implementation
57
- #
58
- # @param data [String] Data to hash
59
- # @return [Integer] 32-bit hash value
60
- def fnv1a_hash(data)
61
- hash = FNV_OFFSET_BASIS
62
-
63
- data.each_byte do |byte|
64
- hash ^= byte
65
- hash = (hash * FNV_PRIME) & 0xFFFFFFFF
66
- end
67
-
68
- hash
30
+ StickyHash.compute_percentile(context.identity, feature_key.to_s) < percentage
69
31
  end
70
32
  end
71
33
  end