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.
@@ -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.0"
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)