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 +4 -4
- data/CHANGELOG.md +29 -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 +14 -3
|
@@ -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)
|