toggly 0.6.0 → 1.1.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 +47 -0
- data/README.md +28 -0
- data/lib/toggly/client/snapshot_support.rb +5 -39
- data/lib/toggly/client.rb +80 -80
- data/lib/toggly/config.rb +30 -34
- data/lib/toggly/definitions_provider.rb +0 -167
- data/lib/toggly/feature_definition.rb +20 -3
- data/lib/toggly/feature_variant.rb +188 -0
- data/lib/toggly/snapshot_providers/base.rb +0 -35
- data/lib/toggly/snapshot_providers/file.rb +0 -54
- data/lib/toggly/snapshot_providers/memory.rb +0 -29
- data/lib/toggly/variant_allocator.rb +161 -0
- data/lib/toggly/variant_result.rb +51 -0
- data/lib/toggly/version.rb +1 -1
- data/lib/toggly.rb +26 -1
- metadata +5 -3
- data/lib/toggly/evaluated_variant.rb +0 -88
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Toggly
|
|
6
|
+
# Catalog-local, MF-parity variant allocator.
|
|
7
|
+
#
|
|
8
|
+
# Assigns a feature variant purely from a {FeatureDefinition}'s
|
|
9
|
+
# `variants` / `allocation` and a targeting context (`identity` + `groups`),
|
|
10
|
+
# bit-for-bit matching `Microsoft.FeatureManagement` 4.7.0's
|
|
11
|
+
# `FeatureManager#GetVariantAsync`. Verified against the shared
|
|
12
|
+
# `variant-allocator-corpus/cases.json` gold corpus.
|
|
13
|
+
#
|
|
14
|
+
# This replaces the `evaluated-variants-signed` dual-rail: there is no
|
|
15
|
+
# network call here — the caller supplies the already filter-evaluated
|
|
16
|
+
# `enabled` boolean (the same value `Client#enabled?` would return for
|
|
17
|
+
# this feature/context) and this module does the rest locally.
|
|
18
|
+
module VariantAllocator
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Assignment reasons (mirrors MF `AssignmentReason`).
|
|
22
|
+
REASON_NONE = "None"
|
|
23
|
+
REASON_USER = "User"
|
|
24
|
+
REASON_GROUP = "Group"
|
|
25
|
+
REASON_PERCENTILE = "Percentile"
|
|
26
|
+
REASON_DEFAULT_WHEN_ENABLED = "DefaultWhenEnabled"
|
|
27
|
+
REASON_DEFAULT_WHEN_DISABLED = "DefaultWhenDisabled"
|
|
28
|
+
|
|
29
|
+
# Result of a variant assignment.
|
|
30
|
+
#
|
|
31
|
+
# @!attribute variant_name [String, nil] Assigned variant name, or nil if unassigned
|
|
32
|
+
# @!attribute configuration_value [Object, nil] The assigned variant's configuration payload
|
|
33
|
+
# @!attribute enabled [Boolean] Effective enabled flag after StatusOverride is applied
|
|
34
|
+
# @!attribute reason [String] One of the REASON_* constants above
|
|
35
|
+
Assignment = Struct.new(:variant_name, :configuration_value, :enabled, :reason, keyword_init: true)
|
|
36
|
+
|
|
37
|
+
# Assign a variant for a feature + targeting context.
|
|
38
|
+
#
|
|
39
|
+
# @param definition [FeatureDefinition] Feature definition (variants + allocation)
|
|
40
|
+
# @param enabled [Boolean] The filter-evaluated enabled state for this context
|
|
41
|
+
# (i.e. what `EvaluationEngine#evaluate` returns for this definition/context)
|
|
42
|
+
# @param identity [String, nil] Targeting user id
|
|
43
|
+
# @param groups [Array<String>] Targeting groups
|
|
44
|
+
# @param ignore_case [Boolean] MF `TargetingEvaluationOptions.IgnoreCase` parity knob.
|
|
45
|
+
# Defaults to `false`, matching Microsoft.FeatureManagement's default.
|
|
46
|
+
# @return [Assignment]
|
|
47
|
+
def assign(definition, enabled:, identity: nil, groups: [], ignore_case: false)
|
|
48
|
+
variants = definition&.variants || []
|
|
49
|
+
allocation = definition&.allocation
|
|
50
|
+
|
|
51
|
+
return build_assignment(nil, variants, enabled, REASON_NONE) if variants.empty?
|
|
52
|
+
|
|
53
|
+
unless enabled
|
|
54
|
+
variant_name = allocation&.default_when_disabled
|
|
55
|
+
return build_assignment(variant_name, variants, enabled, REASON_DEFAULT_WHEN_DISABLED)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
variant_name, reason = resolve_enabled_allocation(
|
|
59
|
+
allocation, identity, groups, ignore_case, definition.feature_key
|
|
60
|
+
)
|
|
61
|
+
build_assignment(variant_name, variants, enabled, reason)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def resolve_enabled_allocation(allocation, identity, groups, ignore_case, feature_key)
|
|
65
|
+
return [nil, REASON_DEFAULT_WHEN_ENABLED] unless allocation
|
|
66
|
+
|
|
67
|
+
user_variant = match_user(allocation.user, identity, ignore_case)
|
|
68
|
+
return [user_variant, REASON_USER] if user_variant
|
|
69
|
+
|
|
70
|
+
group_variant = match_group(allocation.group, groups, ignore_case)
|
|
71
|
+
return [group_variant, REASON_GROUP] if group_variant
|
|
72
|
+
|
|
73
|
+
percentile_variant = match_percentile(allocation.percentile, allocation.seed, identity, feature_key, ignore_case)
|
|
74
|
+
return [percentile_variant, REASON_PERCENTILE] if percentile_variant
|
|
75
|
+
|
|
76
|
+
[allocation.default_when_enabled, REASON_DEFAULT_WHEN_ENABLED]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def match_user(entries, identity, ignore_case)
|
|
80
|
+
return nil if identity.nil? || identity.to_s.empty?
|
|
81
|
+
|
|
82
|
+
Array(entries).each do |entry|
|
|
83
|
+
users = Array(entry[:users])
|
|
84
|
+
matched = if ignore_case
|
|
85
|
+
users.any? { |u| u.to_s.casecmp?(identity.to_s) }
|
|
86
|
+
else
|
|
87
|
+
users.include?(identity.to_s)
|
|
88
|
+
end
|
|
89
|
+
return entry[:variant] if matched
|
|
90
|
+
end
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def match_group(entries, groups, ignore_case)
|
|
95
|
+
context_groups = Array(groups).map(&:to_s)
|
|
96
|
+
return nil if context_groups.empty?
|
|
97
|
+
|
|
98
|
+
Array(entries).each do |entry|
|
|
99
|
+
entry_groups = Array(entry[:groups]).map(&:to_s)
|
|
100
|
+
matched = if ignore_case
|
|
101
|
+
entry_groups.any? { |eg| context_groups.any? { |cg| cg.casecmp?(eg) } }
|
|
102
|
+
else
|
|
103
|
+
entry_groups.intersect?(context_groups)
|
|
104
|
+
end
|
|
105
|
+
return entry[:variant] if matched
|
|
106
|
+
end
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def match_percentile(entries, seed, identity, feature_key, ignore_case)
|
|
111
|
+
list = Array(entries)
|
|
112
|
+
return nil if list.empty?
|
|
113
|
+
|
|
114
|
+
pct = compute_percentile(identity, seed, feature_key, ignore_case)
|
|
115
|
+
list.each do |entry|
|
|
116
|
+
from = entry[:from].to_f
|
|
117
|
+
to = entry[:to].to_f
|
|
118
|
+
matched = to >= 100.0 ? pct >= from : (pct >= from && pct < to)
|
|
119
|
+
return entry[:variant] if matched
|
|
120
|
+
end
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# MF-parity percentile hash.
|
|
125
|
+
#
|
|
126
|
+
# `contextId = "{userId}\n{hint}"`, `hint = seed` (if non-nil) else
|
|
127
|
+
# `"allocation\n{featureName}"`. SHA-256 over the UTF-8 bytes; the first
|
|
128
|
+
# 4 bytes read as a little-endian uint32; `pct = marker / 0xFFFFFFFF * 100`.
|
|
129
|
+
#
|
|
130
|
+
# @return [Float] Percentile in [0, 100]
|
|
131
|
+
def compute_percentile(identity, seed, feature_key, ignore_case)
|
|
132
|
+
user_id = identity.to_s
|
|
133
|
+
user_id = user_id.downcase if ignore_case
|
|
134
|
+
hint = seed.nil? ? "allocation\n#{feature_key}" : seed.to_s
|
|
135
|
+
context_id = "#{user_id}\n#{hint}"
|
|
136
|
+
|
|
137
|
+
digest = Digest::SHA256.digest(context_id)
|
|
138
|
+
marker = digest.byteslice(0, 4).unpack1("V") # little-endian uint32; unpack1("V") is endian-explicit
|
|
139
|
+
(marker.to_f / 0xFFFFFFFF) * 100.0
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def build_assignment(variant_name, variants, base_enabled, reason)
|
|
143
|
+
variant = Array(variants).find { |v| v.name == variant_name }
|
|
144
|
+
effective_enabled =
|
|
145
|
+
if variant&.enabled_override?
|
|
146
|
+
true
|
|
147
|
+
elsif variant&.disabled_override?
|
|
148
|
+
false
|
|
149
|
+
else
|
|
150
|
+
base_enabled
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
Assignment.new(
|
|
154
|
+
variant_name: variant_name,
|
|
155
|
+
configuration_value: variant&.configuration_value,
|
|
156
|
+
enabled: effective_enabled,
|
|
157
|
+
reason: reason
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
# Assigned variant for a feature, returned by `Client#get_variant`.
|
|
5
|
+
#
|
|
6
|
+
# Assignment is computed locally from the feature's catalog
|
|
7
|
+
# `variants` / `allocation` (see {VariantAllocator}), matching
|
|
8
|
+
# `Microsoft.FeatureManagement`'s `IVariantFeatureManager` bit-for-bit.
|
|
9
|
+
# There is no network round-trip: this is not the `enable_variants`
|
|
10
|
+
# dual-rail from earlier releases (removed; see CHANGELOG).
|
|
11
|
+
class VariantResult
|
|
12
|
+
# @return [String] Variant name
|
|
13
|
+
attr_reader :name
|
|
14
|
+
|
|
15
|
+
# @return [Object, nil] Untyped configuration payload for this variant
|
|
16
|
+
attr_reader :configuration_value
|
|
17
|
+
|
|
18
|
+
# @return [Boolean] Effective enabled flag for this feature/context after
|
|
19
|
+
# the variant's `StatusOverride` is applied. This can differ from
|
|
20
|
+
# `Client#enabled?`, which stays filter-based only; use this field when
|
|
21
|
+
# you need MF-identical `GetVariantAsync`-style effective-enabled
|
|
22
|
+
# semantics (e.g. a variant with `StatusOverride: "Enabled"` on an
|
|
23
|
+
# otherwise-disabled feature).
|
|
24
|
+
attr_reader :enabled
|
|
25
|
+
|
|
26
|
+
# @return [String] Assignment reason: "User" | "Group" | "Percentile" |
|
|
27
|
+
# "DefaultWhenEnabled" | "DefaultWhenDisabled"
|
|
28
|
+
attr_reader :reason
|
|
29
|
+
|
|
30
|
+
def initialize(name:, configuration_value: nil, enabled: true, reason: nil)
|
|
31
|
+
@name = name
|
|
32
|
+
@configuration_value = configuration_value
|
|
33
|
+
@enabled = enabled
|
|
34
|
+
@reason = reason
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Hash]
|
|
38
|
+
def to_h
|
|
39
|
+
{ name: @name, configuration_value: @configuration_value, enabled: @enabled, reason: @reason }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ==(other)
|
|
43
|
+
other.is_a?(VariantResult) &&
|
|
44
|
+
@name == other.name &&
|
|
45
|
+
@configuration_value == other.configuration_value &&
|
|
46
|
+
@enabled == other.enabled &&
|
|
47
|
+
@reason == other.reason
|
|
48
|
+
end
|
|
49
|
+
alias eql? ==
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/toggly/version.rb
CHANGED
data/lib/toggly.rb
CHANGED
|
@@ -9,8 +9,10 @@ require_relative "toggly/sticky_hash"
|
|
|
9
9
|
require_relative "toggly/user_agent_parser"
|
|
10
10
|
require_relative "toggly/context"
|
|
11
11
|
require_relative "toggly/errors"
|
|
12
|
+
require_relative "toggly/feature_variant"
|
|
12
13
|
require_relative "toggly/feature_definition"
|
|
13
|
-
require_relative "toggly/
|
|
14
|
+
require_relative "toggly/variant_allocator"
|
|
15
|
+
require_relative "toggly/variant_result"
|
|
14
16
|
require_relative "toggly/evaluators/base"
|
|
15
17
|
require_relative "toggly/evaluators/segment_helpers"
|
|
16
18
|
require_relative "toggly/evaluators/always_on"
|
|
@@ -104,6 +106,29 @@ module Toggly
|
|
|
104
106
|
!enabled?(feature_key, context: context)
|
|
105
107
|
end
|
|
106
108
|
|
|
109
|
+
# Assign a catalog-local variant using the global client.
|
|
110
|
+
# See {Client#get_variant}.
|
|
111
|
+
#
|
|
112
|
+
# @param feature_key [String, Symbol]
|
|
113
|
+
# @param context [Context, nil]
|
|
114
|
+
# @return [VariantResult, nil]
|
|
115
|
+
def get_variant(feature_key, context: nil)
|
|
116
|
+
raise Error, "Toggly not configured. Call Toggly.configure first." unless @client
|
|
117
|
+
|
|
118
|
+
@client.get_variant(feature_key, context: context)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Configuration value for the assigned variant. See {Client#get_variant_value}.
|
|
122
|
+
#
|
|
123
|
+
# @param feature_key [String, Symbol]
|
|
124
|
+
# @param context [Context, nil]
|
|
125
|
+
# @return [Object, nil]
|
|
126
|
+
def get_variant_value(feature_key, context: nil)
|
|
127
|
+
raise Error, "Toggly not configured. Call Toggly.configure first." unless @client
|
|
128
|
+
|
|
129
|
+
@client.get_variant_value(feature_key, context: context)
|
|
130
|
+
end
|
|
131
|
+
|
|
107
132
|
# Reset the global client (mainly for testing)
|
|
108
133
|
def reset!
|
|
109
134
|
@client&.close
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: toggly
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ops.ai
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: High-performance Ruby SDK for Toggly feature flag management. Works with
|
|
14
14
|
or without Toggly.io.
|
|
@@ -31,7 +31,6 @@ files:
|
|
|
31
31
|
- lib/toggly/definitions_provider.rb
|
|
32
32
|
- lib/toggly/entity_context.rb
|
|
33
33
|
- lib/toggly/errors.rb
|
|
34
|
-
- lib/toggly/evaluated_variant.rb
|
|
35
34
|
- lib/toggly/evaluation_engine.rb
|
|
36
35
|
- lib/toggly/evaluators/always_off.rb
|
|
37
36
|
- lib/toggly/evaluators/always_on.rb
|
|
@@ -49,6 +48,7 @@ files:
|
|
|
49
48
|
- lib/toggly/evaluators/time_window.rb
|
|
50
49
|
- lib/toggly/evaluators/user_claims.rb
|
|
51
50
|
- lib/toggly/feature_definition.rb
|
|
51
|
+
- lib/toggly/feature_variant.rb
|
|
52
52
|
- lib/toggly/http_request_mapper.rb
|
|
53
53
|
- lib/toggly/registry.rb
|
|
54
54
|
- lib/toggly/request_context.rb
|
|
@@ -67,6 +67,8 @@ files:
|
|
|
67
67
|
- lib/toggly/telemetry/runtime_flush.rb
|
|
68
68
|
- lib/toggly/telemetry/usage_batcher.rb
|
|
69
69
|
- lib/toggly/user_agent_parser.rb
|
|
70
|
+
- lib/toggly/variant_allocator.rb
|
|
71
|
+
- lib/toggly/variant_result.rb
|
|
70
72
|
- lib/toggly/version.rb
|
|
71
73
|
- proto/metrics.proto
|
|
72
74
|
- proto/usage.proto
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Toggly
|
|
4
|
-
# Server-evaluated variant entry for a single feature flag, as returned by
|
|
5
|
-
# `evaluated-variants-signed`. This is distinct from `FeatureDefinition`
|
|
6
|
-
# (client-side rules): the server has already picked the winning variant.
|
|
7
|
-
class EvaluatedVariantDef
|
|
8
|
-
# @return [Boolean] Whether the feature is enabled for this assignment
|
|
9
|
-
attr_reader :enabled
|
|
10
|
-
|
|
11
|
-
# @return [String, nil] Assigned variant name, if any
|
|
12
|
-
attr_reader :variant
|
|
13
|
-
|
|
14
|
-
# @return [Object, nil] Configuration payload for the variant (shape depends on the feature)
|
|
15
|
-
attr_reader :configuration_value
|
|
16
|
-
|
|
17
|
-
def initialize(enabled:, variant: nil, configuration_value: nil)
|
|
18
|
-
@enabled = enabled ? true : false
|
|
19
|
-
@variant = variant
|
|
20
|
-
@configuration_value = configuration_value
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# Create from a hash (API response or snapshot; camelCase or snake_case).
|
|
24
|
-
#
|
|
25
|
-
# @param hash [Hash]
|
|
26
|
-
# @return [EvaluatedVariantDef]
|
|
27
|
-
def self.from_hash(hash)
|
|
28
|
-
hash = symbolize_keys(hash)
|
|
29
|
-
configuration_value = if hash.key?(:configurationValue)
|
|
30
|
-
hash[:configurationValue]
|
|
31
|
-
else
|
|
32
|
-
hash[:configuration_value]
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
new(
|
|
36
|
-
enabled: hash[:enabled],
|
|
37
|
-
variant: hash[:variant],
|
|
38
|
-
configuration_value: configuration_value
|
|
39
|
-
)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# Convert to a hash for serialization (snapshot persistence).
|
|
43
|
-
#
|
|
44
|
-
# @return [Hash]
|
|
45
|
-
def to_h
|
|
46
|
-
{
|
|
47
|
-
enabled: @enabled,
|
|
48
|
-
variant: @variant,
|
|
49
|
-
configuration_value: @configuration_value
|
|
50
|
-
}
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def ==(other)
|
|
54
|
-
other.is_a?(EvaluatedVariantDef) &&
|
|
55
|
-
@enabled == other.enabled &&
|
|
56
|
-
@variant == other.variant &&
|
|
57
|
-
@configuration_value == other.configuration_value
|
|
58
|
-
end
|
|
59
|
-
alias eql? ==
|
|
60
|
-
|
|
61
|
-
def self.symbolize_keys(hash)
|
|
62
|
-
return hash unless hash.is_a?(Hash)
|
|
63
|
-
|
|
64
|
-
hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
65
|
-
end
|
|
66
|
-
private_class_method :symbolize_keys
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Assigned variant name and configuration value for a feature, returned by
|
|
70
|
-
# `Client#get_variant` when `Config#enable_variants` is true.
|
|
71
|
-
class VariantResult
|
|
72
|
-
# @return [String] Variant name assigned by the server
|
|
73
|
-
attr_reader :name
|
|
74
|
-
|
|
75
|
-
# @return [Object, nil] Optional configuration payload for the variant
|
|
76
|
-
attr_reader :configuration_value
|
|
77
|
-
|
|
78
|
-
def initialize(name:, configuration_value: nil)
|
|
79
|
-
@name = name
|
|
80
|
-
@configuration_value = configuration_value
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# @return [Hash]
|
|
84
|
-
def to_h
|
|
85
|
-
{ name: @name, configuration_value: @configuration_value }
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|