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 +4 -4
- data/CHANGELOG.md +23 -0
- data/lib/toggly/context.rb +135 -9
- data/lib/toggly/evaluation_engine.rb +4 -3
- data/lib/toggly/evaluators/always_off.rb +5 -1
- data/lib/toggly/evaluators/always_on.rb +5 -1
- data/lib/toggly/evaluators/base.rb +21 -3
- data/lib/toggly/evaluators/browser_family.rb +27 -0
- data/lib/toggly/evaluators/browser_language.rb +26 -0
- data/lib/toggly/evaluators/context_property.rb +5 -1
- data/lib/toggly/evaluators/contextual_targeting.rb +4 -0
- data/lib/toggly/evaluators/country.rb +30 -0
- data/lib/toggly/evaluators/device_type.rb +27 -0
- data/lib/toggly/evaluators/operating_system.rb +31 -0
- data/lib/toggly/evaluators/percentage.rb +11 -49
- data/lib/toggly/evaluators/segment_helpers.rb +98 -0
- data/lib/toggly/evaluators/targeting.rb +61 -14
- data/lib/toggly/evaluators/time_window.rb +14 -9
- data/lib/toggly/evaluators/user_claims.rb +27 -0
- data/lib/toggly/feature_definition.rb +1 -2
- data/lib/toggly/http_request_mapper.rb +64 -0
- data/lib/toggly/registry.rb +25 -6
- data/lib/toggly/request_context.rb +77 -0
- data/lib/toggly/sticky_hash.rb +39 -0
- data/lib/toggly/user_agent_parser.rb +69 -0
- data/lib/toggly/version.rb +1 -1
- data/lib/toggly.rb +14 -1
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7b199467e0cb56245072bf1b0bda7b00ddf7931be0c87e691dac159287f1304e
|
|
4
|
+
data.tar.gz: 184d195d8caf6321358d2bda8894f8134d1bd2d5ddc3eca6dfd92c7c7c19595b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/toggly/context.rb
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
|
-
#
|
|
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
|
-
#
|
|
15
|
-
|
|
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] || "
|
|
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] || "
|
|
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
|
-
|
|
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
|
-
"
|
|
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)
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
"
|
|
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)
|
|
@@ -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
|
|
8
|
-
#
|
|
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
|
-
"
|
|
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
|
|
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 =
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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, "
|
|
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, "
|
|
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
|
data/lib/toggly/registry.rb
CHANGED
|
@@ -12,13 +12,26 @@ module Toggly
|
|
|
12
12
|
register_defaults
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
#
|
|
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
|
-
|
|
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[
|
|
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?(
|
|
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(
|
|
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
|
data/lib/toggly/version.rb
CHANGED
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
|
-
#
|
|
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.
|
|
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:
|