toggly 0.2.1 → 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: 661719eeb6fb2dec747b0f3bfd1dfa08349702573d583f72919abb6c5b6ae8d0
4
- data.tar.gz: f1fdf90d3fa48390637f5ea000c21a6002f6764623ed562b822569f3e0e10862
3
+ metadata.gz: 7b199467e0cb56245072bf1b0bda7b00ddf7931be0c87e691dac159287f1304e
4
+ data.tar.gz: 184d195d8caf6321358d2bda8894f8134d1bd2d5ddc3eca6dfd92c7c7c19595b
5
5
  SHA512:
6
- metadata.gz: 4e6045eb73a91c81ccb3d9e7f3b4096958453f825025b18be81707beb6d96a6ca8f636ab1ee2e6b2852ccca5c7461d93009906de1967bf3a04a44e08425ce70d
7
- data.tar.gz: c19d57ca41c649c359c29a0c4d6b5c8377d8cdcb03c4fdd1ff8020559d5d9788929aa9ee88e8281226e5e3175c1d8bb5647184c8d3d3c307a21ad6306c7f7546
6
+ metadata.gz: ddfa9d5bcbf04311a427752c3dba07edb52b841e61f8630d6b9beca6d34d34d1c53b3107f7ca1c9a07f73a8d796278ef2296cd0b723d1ed83be2504277e4449b
7
+ data.tar.gz: 76bf82b4e3e23cdfea1beb0edd735a2be3ae8ce38982c7bfc75dcc00093160666b7372815ea462480590edf7c72dd0ec0b71a2bc11661b0510b8d85c39425c88
data/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ 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
+
8
31
  ## [0.2.1] - 2026-09-03
9
32
 
10
33
  ### Changed
@@ -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
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Evaluators
5
+ # Shared helpers for Definitions-style segment / claims filters.
6
+ module SegmentHelpers
7
+ module_function
8
+
9
+ # @param rule [Hash]
10
+ # @return [Hash]
11
+ def parameters(rule)
12
+ return {} unless rule.is_a?(Hash)
13
+
14
+ params = rule["parameters"] || rule[:parameters]
15
+ params.is_a?(Hash) ? params : rule
16
+ end
17
+
18
+ # @param rule [Hash]
19
+ # @param keys [Array<String>]
20
+ # @return [Float, nil]
21
+ def as_float(rule, *keys)
22
+ params = parameters(rule)
23
+ keys.each do |key|
24
+ value = lookup(params, key)
25
+ next if value.nil?
26
+ next if [true, false].include?(value)
27
+
28
+ begin
29
+ return Float(value)
30
+ rescue ArgumentError, TypeError
31
+ return nil
32
+ end
33
+ end
34
+ nil
35
+ end
36
+
37
+ # @param rule [Hash]
38
+ # @param key [String]
39
+ # @return [String, nil]
40
+ def as_string(rule, key)
41
+ value = lookup(parameters(rule), key)
42
+ return nil if value.nil?
43
+
44
+ text = value.to_s
45
+ text.empty? ? nil : text
46
+ end
47
+
48
+ # Collect indexed RavenDB / legacy colon-prefixed parameter values.
49
+ #
50
+ # @param rule [Hash]
51
+ # @param prefixes [Array<String>]
52
+ # @return [Array<String>]
53
+ def collect_indexed_values(rule, *prefixes)
54
+ params = parameters(rule)
55
+ out = []
56
+ params.each do |key, value|
57
+ next if value.nil?
58
+
59
+ key_s = key.to_s
60
+ prefixes.each do |prefix|
61
+ next unless key_s.start_with?("#{prefix}:")
62
+
63
+ text = value.to_s
64
+ out << text unless text.empty?
65
+ break
66
+ end
67
+ end
68
+ out
69
+ end
70
+
71
+ # @param haystack [String, nil]
72
+ # @param needle [String, nil]
73
+ # @return [Boolean]
74
+ def contains_ignore_case?(haystack, needle)
75
+ return false if haystack.nil? || needle.nil?
76
+
77
+ haystack.downcase.include?(needle.downcase)
78
+ end
79
+
80
+ # @param left [String, nil]
81
+ # @param right [String, nil]
82
+ # @return [Boolean]
83
+ def equals_ignore_case?(left, right)
84
+ return false if left.nil? || right.nil?
85
+
86
+ left.casecmp?(right)
87
+ end
88
+
89
+ def lookup(params, key)
90
+ return nil unless params.is_a?(Hash)
91
+
92
+ params[key] || params[key.to_s] || params[key.to_sym] ||
93
+ params.find { |k, _| k.to_s.casecmp?(key.to_s) }&.last
94
+ end
95
+ module_function :lookup
96
+ end
97
+ end
98
+ end
@@ -7,45 +7,92 @@ module Toggly
7
7
  # Supports targeting by:
8
8
  # - Specific user identities
9
9
  # - Group membership
10
+ # - Definitions Audience.* indexed params and default rollout
10
11
  class Targeting < Base
11
12
  def self.type
12
- "targeting"
13
+ "Targeting"
14
+ end
15
+
16
+ def self.aliases
17
+ %w[Microsoft.Targeting targeting]
13
18
  end
14
19
 
15
20
  # Evaluate targeting rule
16
21
  #
17
- # @param rule [Hash] Rule with "users" and/or "groups" arrays
22
+ # @param rule [Hash] Rule with users/groups or Audience.* params
18
23
  # @param context [Context] Evaluation context
19
24
  # @param feature_key [String] The feature key
20
25
  # @return [Boolean, nil] True if matched, false if excluded, nil to continue
21
26
  def evaluate(rule, context, feature_key: nil)
22
27
  return nil unless context
23
28
 
24
- # Check user targeting
25
- users = Array(rule_value(rule, "users"))
29
+ users = collect_users(rule)
26
30
  excluded_users = Array(rule_value(rule, "excludedUsers") || rule_value(rule, "excluded_users"))
27
31
 
28
32
  if context.identity?
29
- # Check exclusion first
30
33
  return false if excluded_users.any? { |u| u.to_s == context.identity }
31
-
32
- # Check inclusion
33
- return true if users.any? { |u| u.to_s == context.identity }
34
+ return true if users.include?(context.identity)
34
35
  end
35
36
 
36
- # Check group targeting
37
- groups = Array(rule_value(rule, "groups"))
37
+ groups = collect_groups(rule)
38
38
  excluded_groups = Array(rule_value(rule, "excludedGroups") || rule_value(rule, "excluded_groups"))
39
39
 
40
- # Check group exclusion
41
40
  return false if excluded_groups.any? { |g| context.in_group?(g) }
42
-
43
- # Check group inclusion
44
41
  return true if groups.any? { |g| context.in_group?(g) }
45
42
 
46
- # No match, continue evaluation
43
+ default_percentage = SegmentHelpers.as_float(
44
+ rule,
45
+ "Audience.DefaultRolloutPercentage",
46
+ "DefaultRolloutPercentage",
47
+ "defaultRolloutPercentage",
48
+ "default_percentage",
49
+ "Percentage"
50
+ )
51
+ return StickyHash.compute_percentile(context.identity, feature_key.to_s) < default_percentage if default_percentage&.positive? && context.identity?
52
+
53
+ # No match — continue evaluation for legacy sequential rules
47
54
  nil
48
55
  end
56
+
57
+ private
58
+
59
+ def collect_users(rule)
60
+ users = []
61
+ append_list_values(users, rule_value(rule, "users") || rule_value(rule, "Users"))
62
+ SegmentHelpers.parameters(rule).each do |key, value|
63
+ next unless key.to_s.start_with?("Audience.Users:") && value
64
+
65
+ users << value.to_s
66
+ end
67
+ users.uniq
68
+ end
69
+
70
+ def collect_groups(rule)
71
+ groups = []
72
+ append_list_values(groups, rule_value(rule, "groups") || rule_value(rule, "Groups"))
73
+ SegmentHelpers.parameters(rule).each do |key, value|
74
+ next unless key.to_s.start_with?("Audience.Groups:") && value
75
+
76
+ groups << value.to_s
77
+ end
78
+ groups.uniq
79
+ end
80
+
81
+ def append_list_values(memo, raw)
82
+ case raw
83
+ when Array
84
+ raw.each { |item| memo << item.to_s }
85
+ when String
86
+ raw.split(",").each do |item|
87
+ trimmed = item.strip
88
+ memo << trimmed unless trimmed.empty?
89
+ end
90
+ when nil
91
+ nil
92
+ else
93
+ memo << raw.to_s
94
+ end
95
+ end
49
96
  end
50
97
  end
51
98
  end
@@ -7,12 +7,16 @@ module Toggly
7
7
  # Enables features within a specific time range.
8
8
  class TimeWindow < Base
9
9
  def self.type
10
- "time_window"
10
+ "TimeWindow"
11
+ end
12
+
13
+ def self.aliases
14
+ %w[Microsoft.TimeWindow time_window]
11
15
  end
12
16
 
13
17
  # Evaluate time window rule
14
18
  #
15
- # @param rule [Hash] Rule with "startTime"/"start_time" and/or "endTime"/"end_time"
19
+ # @param rule [Hash] Rule with Start/End or startTime/endTime
16
20
  # @param context [Context] Evaluation context (ignored)
17
21
  # @param feature_key [String] The feature key
18
22
  # @return [Boolean] True if current time is within window
@@ -20,19 +24,20 @@ module Toggly
20
24
  now = Time.now.utc
21
25
 
22
26
  start_time = parse_time(
23
- rule_value(rule, "startTime") || rule_value(rule, "start_time")
27
+ rule_value(rule, "Start") ||
28
+ rule_value(rule, "start") ||
29
+ rule_value(rule, "startTime") ||
30
+ rule_value(rule, "start_time")
24
31
  )
25
32
  end_time = parse_time(
26
- rule_value(rule, "endTime") || rule_value(rule, "end_time")
33
+ rule_value(rule, "End") ||
34
+ rule_value(rule, "end") ||
35
+ rule_value(rule, "endTime") ||
36
+ rule_value(rule, "end_time")
27
37
  )
28
38
 
29
- # No time constraints means always valid
30
39
  return true if start_time.nil? && end_time.nil?
31
-
32
- # Check start time
33
40
  return false if start_time && now < start_time
34
-
35
- # Check end time
36
41
  return false if end_time && now > end_time
37
42
 
38
43
  true
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Evaluators
5
+ # Evaluator for UserClaims filters (Claim + Value params).
6
+ class UserClaims < Base
7
+ def self.type
8
+ "UserClaims"
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
+ claim_type = SegmentHelpers.as_string(rule, "Claim")
17
+ claim_value = SegmentHelpers.as_string(rule, "Value")
18
+ return false if claim_type.nil? || claim_value.nil? || context.nil?
19
+
20
+ claims = context.claims
21
+ return false if claims.nil? || !claims.key?(claim_type)
22
+
23
+ claim_value == claims[claim_type]
24
+ end
25
+ end
26
+ end
27
+ end
@@ -38,7 +38,7 @@ module Toggly
38
38
  # Feature types
39
39
  TYPES = %w[Release Experiment Ops Permission].freeze
40
40
 
41
- # rubocop:disable Metrics/ParameterLists
41
+ # rubocop:disable-next Metrics/ParameterLists
42
42
  def initialize(
43
43
  feature_key:,
44
44
  feature_type: "Release",
@@ -64,7 +64,6 @@ module Toggly
64
64
  @context_kind = context_kind
65
65
  @context_requirement_type = context_requirement_type
66
66
  end
67
- # rubocop:enable Metrics/ParameterLists
68
67
 
69
68
  # Create from a hash (e.g., from JSON)
70
69
  #
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ # Maps common HTTP headers into RequestContext fields.
5
+ #
6
+ # Does not invent identity, groups, or claims — merge those separately.
7
+ class HttpRequestMapper
8
+ COUNTRY_HEADERS = %w[
9
+ cf-ipcountry
10
+ x-vercel-ip-country
11
+ cloudfront-viewer-country
12
+ ].freeze
13
+
14
+ class << self
15
+ # Build RequestContext from a header bag (case-insensitive keys).
16
+ #
17
+ # @param headers [Hash, nil]
18
+ # @return [RequestContext]
19
+ def from_http_headers(headers)
20
+ return RequestContext.new if headers.nil? || headers.empty?
21
+
22
+ RequestContext.new(
23
+ user_agent: header(headers, "user-agent"),
24
+ accept_language: header(headers, "accept-language"),
25
+ country: first_present(headers, *COUNTRY_HEADERS)
26
+ )
27
+ end
28
+
29
+ # Merge HTTP-mapped request fields over an existing evaluation context.
30
+ #
31
+ # @param headers [Hash, nil]
32
+ # @param base [Context, nil]
33
+ # @return [Context]
34
+ def merge_into(headers, base)
35
+ request = from_http_headers(headers)
36
+ return Context.new(request: request) if base.nil?
37
+
38
+ base.with_request(request)
39
+ end
40
+
41
+ private
42
+
43
+ def first_present(headers, *names)
44
+ names.each do |name|
45
+ value = header(headers, name)
46
+ return value if value
47
+ end
48
+ nil
49
+ end
50
+
51
+ def header(headers, name)
52
+ lower = name.downcase
53
+ headers.each do |key, value|
54
+ next unless key && key.to_s.downcase == lower
55
+ next if value.nil?
56
+
57
+ text = value.to_s
58
+ return text unless text.empty?
59
+ end
60
+ nil
61
+ end
62
+ end
63
+ end
64
+ end
@@ -12,13 +12,26 @@ module Toggly
12
12
  register_defaults
13
13
  end
14
14
 
15
- # Register an evaluator
15
+ # Normalize filter / evaluator names for lookup.
16
+ # Strips non-alphanumeric characters so AlwaysOn and always_on match.
17
+ #
18
+ # @param type [String, Symbol]
19
+ # @return [String]
20
+ def self.normalize_key(type)
21
+ type.to_s.downcase.gsub(/[^a-z0-9]/, "")
22
+ end
23
+
24
+ # Register an evaluator (and optional extra aliases).
16
25
  #
17
26
  # @param evaluator [Evaluators::Base] The evaluator instance
27
+ # @param extra_aliases [Array<String>] Additional names
18
28
  # @return [self]
19
- def register(evaluator)
29
+ def register(evaluator, *extra_aliases)
30
+ names = [evaluator.class.type, *Array(evaluator.class.aliases), *extra_aliases]
20
31
  @mutex.synchronize do
21
- @evaluators[evaluator.class.type.to_s.downcase] = evaluator
32
+ names.compact.each do |name|
33
+ @evaluators[self.class.normalize_key(name)] = evaluator
34
+ end
22
35
  end
23
36
  self
24
37
  end
@@ -29,7 +42,7 @@ module Toggly
29
42
  # @return [Evaluators::Base, nil]
30
43
  def get(type)
31
44
  @mutex.synchronize do
32
- @evaluators[type.to_s.downcase]
45
+ @evaluators[self.class.normalize_key(type)]
33
46
  end
34
47
  end
35
48
 
@@ -39,7 +52,7 @@ module Toggly
39
52
  # @return [Boolean]
40
53
  def registered?(type)
41
54
  @mutex.synchronize do
42
- @evaluators.key?(type.to_s.downcase)
55
+ @evaluators.key?(self.class.normalize_key(type))
43
56
  end
44
57
  end
45
58
 
@@ -58,7 +71,7 @@ module Toggly
58
71
  # @return [Evaluators::Base, nil] The removed evaluator
59
72
  def unregister(type)
60
73
  @mutex.synchronize do
61
- @evaluators.delete(type.to_s.downcase)
74
+ @evaluators.delete(self.class.normalize_key(type))
62
75
  end
63
76
  end
64
77
 
@@ -82,6 +95,12 @@ module Toggly
82
95
  register(Evaluators::TimeWindow.new)
83
96
  register(Evaluators::ContextualTargeting.new)
84
97
  register(Evaluators::ContextProperty.new)
98
+ register(Evaluators::BrowserFamily.new)
99
+ register(Evaluators::BrowserLanguage.new)
100
+ register(Evaluators::Country.new)
101
+ register(Evaluators::DeviceType.new)
102
+ register(Evaluators::OperatingSystem.new)
103
+ register(Evaluators::UserClaims.new)
85
104
  end
86
105
  end
87
106
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ # HTTP request fields used by segment identity filters.
5
+ class RequestContext
6
+ # @return [String, nil]
7
+ attr_reader :user_agent, :accept_language, :country
8
+
9
+ def initialize(user_agent: nil, accept_language: nil, country: nil)
10
+ @user_agent = blank_to_nil(user_agent)
11
+ @accept_language = blank_to_nil(accept_language)
12
+ @country = blank_to_nil(country)
13
+ end
14
+
15
+ # @return [Hash]
16
+ def to_h
17
+ {
18
+ userAgent: @user_agent,
19
+ acceptLanguage: @accept_language,
20
+ country: @country
21
+ }
22
+ end
23
+
24
+ # Create from a hash accepting camelCase or snake_case keys.
25
+ #
26
+ # @param data [Hash, nil]
27
+ # @return [RequestContext, nil]
28
+ def self.from_hash(data)
29
+ return nil unless data.is_a?(Hash)
30
+
31
+ new(
32
+ user_agent: first_value(data, "userAgent", "user_agent"),
33
+ accept_language: first_value(data, "acceptLanguage", "accept_language"),
34
+ country: first_value(data, "country")
35
+ )
36
+ end
37
+
38
+ # @param other [Object]
39
+ # @return [Boolean]
40
+ def ==(other)
41
+ return false unless other.is_a?(RequestContext)
42
+
43
+ @user_agent == other.user_agent &&
44
+ @accept_language == other.accept_language &&
45
+ @country == other.country
46
+ end
47
+ alias eql? ==
48
+
49
+ # @return [Integer]
50
+ def hash
51
+ [@user_agent, @accept_language, @country].hash
52
+ end
53
+
54
+ def self.first_value(data, *keys)
55
+ keys.each do |key|
56
+ next unless data.key?(key) || data.key?(key.to_sym)
57
+
58
+ value = data[key] || data[key.to_sym]
59
+ next if value.nil?
60
+
61
+ text = value.to_s
62
+ return text unless text.empty?
63
+ end
64
+ nil
65
+ end
66
+ private_class_method :first_value
67
+
68
+ private
69
+
70
+ def blank_to_nil(value)
71
+ return nil if value.nil?
72
+
73
+ text = value.to_s
74
+ text.empty? ? nil : text
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "securerandom"
5
+
6
+ module Toggly
7
+ # Sticky percentage bucketing aligned with Definitions / toggly-eval.
8
+ module StickyHash
9
+ module_function
10
+
11
+ # Sticky bucket in [0, 100) matching Definitions / toggly-eval SHA-256.
12
+ #
13
+ # Hash input is +feature_key+ + "\n" + +user_id+; little-endian uint32 from
14
+ # the first 4 digest bytes, then +(value / 0xFFFFFFFF) * 100+.
15
+ #
16
+ # @param user_id [String]
17
+ # @param feature_key [String]
18
+ # @return [Float]
19
+ def compute_percentile(user_id, feature_key)
20
+ digest = Digest::SHA256.digest("#{feature_key}\n#{user_id}")
21
+ value = digest.byteslice(0, 4).unpack1("V")
22
+ (value.to_f / 0xFFFFFFFF) * 100.0
23
+ end
24
+
25
+ # Percentage gate for segment filters; missing or ≤0 fails closed.
26
+ #
27
+ # @param percentage [Numeric, nil]
28
+ # @param feature_key [String]
29
+ # @param identity [String, nil]
30
+ # @return [Boolean]
31
+ def segment_percentage_passes?(percentage, feature_key, identity)
32
+ return false if percentage.nil? || percentage <= 0
33
+ return true if percentage >= 100
34
+ return compute_percentile(identity, feature_key) < percentage if identity && !identity.empty?
35
+
36
+ SecureRandom.random_number * 100 < percentage
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ # Best-effort User-Agent parse result for segment filters.
5
+ class ParsedUserAgent
6
+ # @return [String]
7
+ attr_reader :browser_family, :os_family, :device_family
8
+
9
+ def initialize(browser_family:, os_family:, device_family:)
10
+ @browser_family = browser_family
11
+ @os_family = os_family
12
+ @device_family = device_family
13
+ end
14
+ end
15
+
16
+ # Best-effort UA parsing (parity with toggly-eval / Java / Python).
17
+ module UserAgentParser
18
+ module_function
19
+
20
+ # @param user_agent [String, nil]
21
+ # @return [ParsedUserAgent, nil]
22
+ def parse(user_agent)
23
+ return nil if user_agent.nil? || user_agent.empty?
24
+
25
+ ParsedUserAgent.new(
26
+ browser_family: detect_browser(user_agent),
27
+ os_family: detect_os(user_agent),
28
+ device_family: detect_device(user_agent)
29
+ )
30
+ end
31
+
32
+ def detect_browser(user_agent)
33
+ return "Edge" if user_agent.include?("Edg/") || user_agent.include?("EdgiOS/")
34
+ return "Opera" if user_agent.include?("OPR/") || user_agent.include?("Opera")
35
+ return "Chrome" if user_agent.include?("Chrome/") || user_agent.include?("CriOS/")
36
+ return "Firefox" if user_agent.include?("Firefox/") || user_agent.include?("FxiOS/")
37
+ if user_agent.include?("Safari/") && user_agent.include?("Version/") &&
38
+ !user_agent.include?("Chrome") && !user_agent.include?("Chromium")
39
+ return "Safari"
40
+ end
41
+
42
+ "Other"
43
+ end
44
+ module_function :detect_browser
45
+
46
+ def detect_os(user_agent)
47
+ return "Android" if user_agent.include?("Android")
48
+ if user_agent.include?("iPhone") || user_agent.include?("iPad") || user_agent.include?("iPod") ||
49
+ user_agent.include?("CPU iPhone OS") || user_agent.include?("CPU OS")
50
+ return "iOS"
51
+ end
52
+ return "Mac OS" if user_agent.include?("Mac OS X") || user_agent.include?("Macintosh")
53
+ return "Windows" if user_agent.include?("Windows")
54
+ return "Linux" if user_agent.include?("Linux")
55
+
56
+ "Other"
57
+ end
58
+ module_function :detect_os
59
+
60
+ def detect_device(user_agent)
61
+ return "iPhone" if user_agent.include?("iPhone")
62
+ return "iPad" if user_agent.include?("iPad")
63
+ return "iPod" if user_agent.include?("iPod")
64
+
65
+ "Other"
66
+ end
67
+ module_function :detect_device
68
+ end
69
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toggly
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/toggly.rb CHANGED
@@ -2,10 +2,15 @@
2
2
 
3
3
  require_relative "toggly/version"
4
4
  require_relative "toggly/config"
5
+ require_relative "toggly/request_context"
6
+ require_relative "toggly/http_request_mapper"
7
+ require_relative "toggly/sticky_hash"
8
+ require_relative "toggly/user_agent_parser"
5
9
  require_relative "toggly/context"
6
10
  require_relative "toggly/errors"
7
11
  require_relative "toggly/feature_definition"
8
12
  require_relative "toggly/evaluators/base"
13
+ require_relative "toggly/evaluators/segment_helpers"
9
14
  require_relative "toggly/evaluators/always_on"
10
15
  require_relative "toggly/evaluators/always_off"
11
16
  require_relative "toggly/evaluators/percentage"
@@ -13,6 +18,12 @@ require_relative "toggly/evaluators/targeting"
13
18
  require_relative "toggly/evaluators/time_window"
14
19
  require_relative "toggly/evaluators/contextual_targeting"
15
20
  require_relative "toggly/evaluators/context_property"
21
+ require_relative "toggly/evaluators/browser_family"
22
+ require_relative "toggly/evaluators/browser_language"
23
+ require_relative "toggly/evaluators/country"
24
+ require_relative "toggly/evaluators/device_type"
25
+ require_relative "toggly/evaluators/operating_system"
26
+ require_relative "toggly/evaluators/user_claims"
16
27
  require_relative "toggly/entity_context"
17
28
  require_relative "toggly/registry"
18
29
  require_relative "toggly/evaluation_engine"
@@ -38,7 +49,9 @@ require_relative "toggly/client"
38
49
  # context = Toggly::Context.new(
39
50
  # identity: "user-123",
40
51
  # groups: ["beta-testers"],
41
- # traits: { country: "US" }
52
+ # claims: { "role" => "admin" },
53
+ # request: Toggly::RequestContext.new(country: "US"),
54
+ # traits: { plan: "enterprise" }
42
55
  # )
43
56
  #
44
57
  # if client.enabled?("premium-feature", context: context)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toggly
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ops.ai
@@ -32,16 +32,27 @@ files:
32
32
  - lib/toggly/evaluators/always_off.rb
33
33
  - lib/toggly/evaluators/always_on.rb
34
34
  - lib/toggly/evaluators/base.rb
35
+ - lib/toggly/evaluators/browser_family.rb
36
+ - lib/toggly/evaluators/browser_language.rb
35
37
  - lib/toggly/evaluators/context_property.rb
36
38
  - lib/toggly/evaluators/contextual_targeting.rb
39
+ - lib/toggly/evaluators/country.rb
40
+ - lib/toggly/evaluators/device_type.rb
41
+ - lib/toggly/evaluators/operating_system.rb
37
42
  - lib/toggly/evaluators/percentage.rb
43
+ - lib/toggly/evaluators/segment_helpers.rb
38
44
  - lib/toggly/evaluators/targeting.rb
39
45
  - lib/toggly/evaluators/time_window.rb
46
+ - lib/toggly/evaluators/user_claims.rb
40
47
  - lib/toggly/feature_definition.rb
48
+ - lib/toggly/http_request_mapper.rb
41
49
  - lib/toggly/registry.rb
50
+ - lib/toggly/request_context.rb
42
51
  - lib/toggly/snapshot_providers/base.rb
43
52
  - lib/toggly/snapshot_providers/file.rb
44
53
  - lib/toggly/snapshot_providers/memory.rb
54
+ - lib/toggly/sticky_hash.rb
55
+ - lib/toggly/user_agent_parser.rb
45
56
  - lib/toggly/version.rb
46
57
  homepage: https://toggly.io
47
58
  licenses: