toggly 0.6.0 → 1.0.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 +33 -0
- data/README.md +22 -0
- data/lib/toggly/client/snapshot_support.rb +5 -39
- data/lib/toggly/client.rb +40 -84
- data/lib/toggly/config.rb +0 -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 +3 -1
- metadata +4 -2
- data/lib/toggly/evaluated_variant.rb +0 -88
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
# A single named variant of a feature flag, as sent on the catalog
|
|
5
|
+
# (`definitions` / `definitions-signed`) wire alongside `filters`.
|
|
6
|
+
#
|
|
7
|
+
# Matches the shape produced by `Microsoft.FeatureManagement`'s
|
|
8
|
+
# `VariantDefinition` (`Name`, `ConfigurationValue`, `StatusOverride`).
|
|
9
|
+
class FeatureVariant
|
|
10
|
+
# Valid `StatusOverride` values (MF `StatusOverride` enum as strings).
|
|
11
|
+
STATUS_OVERRIDES = %w[None Enabled Disabled].freeze
|
|
12
|
+
|
|
13
|
+
# @return [String] Variant name
|
|
14
|
+
attr_reader :name
|
|
15
|
+
|
|
16
|
+
# @return [Object, nil] Untyped configuration payload for this variant
|
|
17
|
+
attr_reader :configuration_value
|
|
18
|
+
|
|
19
|
+
# @return [String] "None" | "Enabled" | "Disabled"
|
|
20
|
+
attr_reader :status_override
|
|
21
|
+
|
|
22
|
+
def initialize(name:, configuration_value: nil, status_override: "None")
|
|
23
|
+
@name = name.to_s
|
|
24
|
+
@configuration_value = configuration_value
|
|
25
|
+
@status_override = STATUS_OVERRIDES.include?(status_override.to_s) ? status_override.to_s : "None"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param hash [Hash] Variant hash (camelCase wire or snake_case snapshot)
|
|
29
|
+
# @return [FeatureVariant]
|
|
30
|
+
def self.from_hash(hash)
|
|
31
|
+
hash = symbolize_keys(hash)
|
|
32
|
+
configuration_value = if hash.key?(:configurationValue)
|
|
33
|
+
hash[:configurationValue]
|
|
34
|
+
else
|
|
35
|
+
hash[:configuration_value]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
new(
|
|
39
|
+
name: hash[:name],
|
|
40
|
+
configuration_value: configuration_value,
|
|
41
|
+
status_override: hash[:statusOverride] || hash[:status_override] || "None"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Boolean] True when this variant forces the feature to be
|
|
46
|
+
# reported enabled regardless of the base filter-evaluated state.
|
|
47
|
+
def enabled_override?
|
|
48
|
+
@status_override == "Enabled"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] True when this variant forces the feature to be
|
|
52
|
+
# reported disabled regardless of the base filter-evaluated state.
|
|
53
|
+
def disabled_override?
|
|
54
|
+
@status_override == "Disabled"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Hash]
|
|
58
|
+
def to_h
|
|
59
|
+
{
|
|
60
|
+
name: @name,
|
|
61
|
+
configuration_value: @configuration_value,
|
|
62
|
+
status_override: @status_override
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ==(other)
|
|
67
|
+
other.is_a?(FeatureVariant) &&
|
|
68
|
+
@name == other.name &&
|
|
69
|
+
@configuration_value == other.configuration_value &&
|
|
70
|
+
@status_override == other.status_override
|
|
71
|
+
end
|
|
72
|
+
alias eql? ==
|
|
73
|
+
|
|
74
|
+
def hash
|
|
75
|
+
[@name, @configuration_value, @status_override].hash
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.symbolize_keys(hash)
|
|
79
|
+
return {} unless hash.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
82
|
+
end
|
|
83
|
+
private_class_method :symbolize_keys
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Allocation rules for assigning variants to users/groups/percentile
|
|
87
|
+
# buckets, matching `Microsoft.FeatureManagement`'s `Allocation` schema.
|
|
88
|
+
class FeatureVariantAllocation
|
|
89
|
+
# @return [String, nil] Variant assigned when the feature is enabled and
|
|
90
|
+
# no user/group/percentile allocation matched.
|
|
91
|
+
attr_reader :default_when_enabled
|
|
92
|
+
|
|
93
|
+
# @return [String, nil] Variant assigned when the feature is disabled.
|
|
94
|
+
attr_reader :default_when_disabled
|
|
95
|
+
|
|
96
|
+
# @return [String, nil] Seed for the percentile hash; falls back to
|
|
97
|
+
# "allocation\n{featureName}" when nil.
|
|
98
|
+
attr_reader :seed
|
|
99
|
+
|
|
100
|
+
# @return [Array<Hash>] `[{ variant:, users: [...] }, ...]`
|
|
101
|
+
attr_reader :user
|
|
102
|
+
|
|
103
|
+
# @return [Array<Hash>] `[{ variant:, groups: [...] }, ...]`
|
|
104
|
+
attr_reader :group
|
|
105
|
+
|
|
106
|
+
# @return [Array<Hash>] `[{ variant:, from:, to: }, ...]`
|
|
107
|
+
attr_reader :percentile
|
|
108
|
+
|
|
109
|
+
def initialize(default_when_enabled: nil, default_when_disabled: nil, seed: nil, user: [], group: [], percentile: [])
|
|
110
|
+
@default_when_enabled = default_when_enabled
|
|
111
|
+
@default_when_disabled = default_when_disabled
|
|
112
|
+
@seed = seed
|
|
113
|
+
@user = Array(user)
|
|
114
|
+
@group = Array(group)
|
|
115
|
+
@percentile = Array(percentile)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @param hash [Hash, nil]
|
|
119
|
+
# @return [FeatureVariantAllocation, nil] nil when +hash+ is nil (no allocation configured)
|
|
120
|
+
def self.from_hash(hash)
|
|
121
|
+
return nil unless hash.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
hash = symbolize_keys(hash)
|
|
124
|
+
new(
|
|
125
|
+
default_when_enabled: hash[:defaultWhenEnabled] || hash[:default_when_enabled],
|
|
126
|
+
default_when_disabled: hash[:defaultWhenDisabled] || hash[:default_when_disabled],
|
|
127
|
+
seed: hash[:seed],
|
|
128
|
+
user: parse_entries(hash[:user], :users),
|
|
129
|
+
group: parse_entries(hash[:group], :groups),
|
|
130
|
+
percentile: parse_percentile_entries(hash[:percentile])
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @return [Hash]
|
|
135
|
+
def to_h
|
|
136
|
+
{
|
|
137
|
+
default_when_enabled: @default_when_enabled,
|
|
138
|
+
default_when_disabled: @default_when_disabled,
|
|
139
|
+
seed: @seed,
|
|
140
|
+
user: @user,
|
|
141
|
+
group: @group,
|
|
142
|
+
percentile: @percentile
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def ==(other)
|
|
147
|
+
other.is_a?(FeatureVariantAllocation) &&
|
|
148
|
+
@default_when_enabled == other.default_when_enabled &&
|
|
149
|
+
@default_when_disabled == other.default_when_disabled &&
|
|
150
|
+
@seed == other.seed &&
|
|
151
|
+
@user == other.user &&
|
|
152
|
+
@group == other.group &&
|
|
153
|
+
@percentile == other.percentile
|
|
154
|
+
end
|
|
155
|
+
alias eql? ==
|
|
156
|
+
|
|
157
|
+
def hash
|
|
158
|
+
[@default_when_enabled, @default_when_disabled, @seed, @user, @group, @percentile].hash
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.parse_entries(raw, members_key)
|
|
162
|
+
Array(raw).filter_map do |entry|
|
|
163
|
+
next unless entry.is_a?(Hash)
|
|
164
|
+
|
|
165
|
+
entry = symbolize_keys(entry)
|
|
166
|
+
{ variant: entry[:variant], members_key => Array(entry[members_key]).map(&:to_s) }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
private_class_method :parse_entries
|
|
170
|
+
|
|
171
|
+
def self.parse_percentile_entries(raw)
|
|
172
|
+
Array(raw).filter_map do |entry|
|
|
173
|
+
next unless entry.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
entry = symbolize_keys(entry)
|
|
176
|
+
{ variant: entry[:variant], from: entry[:from].to_f, to: entry[:to].to_f }
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
private_class_method :parse_percentile_entries
|
|
180
|
+
|
|
181
|
+
def self.symbolize_keys(hash)
|
|
182
|
+
return {} unless hash.is_a?(Hash)
|
|
183
|
+
|
|
184
|
+
hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
185
|
+
end
|
|
186
|
+
private_class_method :symbolize_keys
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -40,21 +40,6 @@ module Toggly
|
|
|
40
40
|
false
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
# Save evaluated variants snapshot (dual-rail, used when
|
|
44
|
-
# `config.enable_variants` is true). Default: no-op. Override in
|
|
45
|
-
# subclasses to persist variants across restarts.
|
|
46
|
-
#
|
|
47
|
-
# @param variants [Hash<String, EvaluatedVariantDef>] Variants to save
|
|
48
|
-
# @param metadata [Hash] Optional metadata
|
|
49
|
-
def save_variants(_variants, _metadata = {}); end
|
|
50
|
-
|
|
51
|
-
# Load evaluated variants snapshot (dual-rail).
|
|
52
|
-
#
|
|
53
|
-
# @return [Hash, nil] Hash with :variants and :metadata, or nil if not available
|
|
54
|
-
def load_variants
|
|
55
|
-
nil
|
|
56
|
-
end
|
|
57
|
-
|
|
58
43
|
protected
|
|
59
44
|
|
|
60
45
|
# Serialize definitions to a storable format
|
|
@@ -77,26 +62,6 @@ module Toggly
|
|
|
77
62
|
hash[definition.feature_key] = definition
|
|
78
63
|
end
|
|
79
64
|
end
|
|
80
|
-
|
|
81
|
-
# Serialize evaluated variants to a storable format
|
|
82
|
-
#
|
|
83
|
-
# @param variants [Hash<String, EvaluatedVariantDef>] Variants
|
|
84
|
-
# @return [Hash<String, Hash>]
|
|
85
|
-
def serialize_variants(variants)
|
|
86
|
-
variants.transform_values(&:to_h)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# Deserialize evaluated variants from stored format
|
|
90
|
-
#
|
|
91
|
-
# @param data [Hash<String, Hash>] Serialized variants
|
|
92
|
-
# @return [Hash<String, EvaluatedVariantDef>]
|
|
93
|
-
def deserialize_variants(data)
|
|
94
|
-
return {} unless data.is_a?(Hash)
|
|
95
|
-
|
|
96
|
-
data.each_with_object({}) do |(key, value), hash|
|
|
97
|
-
hash[key.to_s] = EvaluatedVariantDef.from_hash(value)
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
65
|
end
|
|
101
66
|
end
|
|
102
67
|
end
|
|
@@ -12,14 +12,10 @@ module Toggly
|
|
|
12
12
|
# @return [String] Path to the snapshot file
|
|
13
13
|
attr_reader :path
|
|
14
14
|
|
|
15
|
-
# @return [String] Path to the evaluated-variants snapshot file (dual-rail)
|
|
16
|
-
attr_reader :variants_path
|
|
17
|
-
|
|
18
15
|
# @param path [String] Path to the snapshot file
|
|
19
16
|
def initialize(path:)
|
|
20
17
|
super()
|
|
21
18
|
@path = path
|
|
22
|
-
@variants_path = derive_variants_path(path)
|
|
23
19
|
@mutex = Mutex.new
|
|
24
20
|
end
|
|
25
21
|
|
|
@@ -70,7 +66,6 @@ module Toggly
|
|
|
70
66
|
def clear
|
|
71
67
|
@mutex.synchronize do
|
|
72
68
|
FileUtils.rm_f(@path)
|
|
73
|
-
FileUtils.rm_f(@variants_path)
|
|
74
69
|
end
|
|
75
70
|
rescue StandardError => e
|
|
76
71
|
raise SnapshotError, "Failed to clear snapshot: #{e.message}"
|
|
@@ -83,48 +78,6 @@ module Toggly
|
|
|
83
78
|
::File.exist?(@path)
|
|
84
79
|
end
|
|
85
80
|
|
|
86
|
-
# Save evaluated variants to file using atomic write (dual-rail)
|
|
87
|
-
#
|
|
88
|
-
# @param variants [Hash<String, EvaluatedVariantDef>] Variants
|
|
89
|
-
# @param metadata [Hash] Optional metadata
|
|
90
|
-
def save_variants(variants, metadata = {})
|
|
91
|
-
@mutex.synchronize do
|
|
92
|
-
ensure_directory_exists
|
|
93
|
-
|
|
94
|
-
data = {
|
|
95
|
-
"variants" => serialize_variants(variants),
|
|
96
|
-
"metadata" => metadata.merge("saved_at" => Time.now.utc.iso8601)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
temp_path = "#{@variants_path}.tmp"
|
|
100
|
-
::File.write(temp_path, JSON.pretty_generate(data))
|
|
101
|
-
::File.rename(temp_path, @variants_path)
|
|
102
|
-
end
|
|
103
|
-
rescue StandardError => e
|
|
104
|
-
raise SnapshotError, "Failed to save variants snapshot: #{e.message}"
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
# Load evaluated variants from file (dual-rail)
|
|
108
|
-
#
|
|
109
|
-
# @return [Hash, nil] Hash with :variants and :metadata
|
|
110
|
-
def load_variants
|
|
111
|
-
@mutex.synchronize do
|
|
112
|
-
return nil unless ::File.exist?(@variants_path)
|
|
113
|
-
|
|
114
|
-
content = ::File.read(@variants_path)
|
|
115
|
-
data = JSON.parse(content)
|
|
116
|
-
|
|
117
|
-
{
|
|
118
|
-
variants: deserialize_variants(data["variants"]),
|
|
119
|
-
metadata: symbolize_keys(data["metadata"] || {})
|
|
120
|
-
}
|
|
121
|
-
end
|
|
122
|
-
rescue JSON::ParserError => e
|
|
123
|
-
raise SnapshotError, "Failed to parse variants snapshot: #{e.message}"
|
|
124
|
-
rescue StandardError => e
|
|
125
|
-
raise SnapshotError, "Failed to load variants snapshot: #{e.message}"
|
|
126
|
-
end
|
|
127
|
-
|
|
128
81
|
private
|
|
129
82
|
|
|
130
83
|
def ensure_directory_exists
|
|
@@ -132,13 +85,6 @@ module Toggly
|
|
|
132
85
|
FileUtils.mkdir_p(dir) unless ::File.directory?(dir)
|
|
133
86
|
end
|
|
134
87
|
|
|
135
|
-
def derive_variants_path(path)
|
|
136
|
-
dir = ::File.dirname(path)
|
|
137
|
-
ext = ::File.extname(path)
|
|
138
|
-
base = ::File.basename(path, ext)
|
|
139
|
-
::File.join(dir, "#{base}_variants#{ext}")
|
|
140
|
-
end
|
|
141
|
-
|
|
142
88
|
def symbolize_keys(hash)
|
|
143
89
|
return {} unless hash.is_a?(Hash)
|
|
144
90
|
|
|
@@ -9,7 +9,6 @@ module Toggly
|
|
|
9
9
|
def initialize
|
|
10
10
|
super
|
|
11
11
|
@data = nil
|
|
12
|
-
@variants_data = nil
|
|
13
12
|
@mutex = Mutex.new
|
|
14
13
|
end
|
|
15
14
|
|
|
@@ -44,7 +43,6 @@ module Toggly
|
|
|
44
43
|
def clear
|
|
45
44
|
@mutex.synchronize do
|
|
46
45
|
@data = nil
|
|
47
|
-
@variants_data = nil
|
|
48
46
|
end
|
|
49
47
|
end
|
|
50
48
|
|
|
@@ -56,33 +54,6 @@ module Toggly
|
|
|
56
54
|
!@data.nil?
|
|
57
55
|
end
|
|
58
56
|
end
|
|
59
|
-
|
|
60
|
-
# Save evaluated variants to memory (dual-rail)
|
|
61
|
-
#
|
|
62
|
-
# @param variants [Hash<String, EvaluatedVariantDef>] Variants
|
|
63
|
-
# @param metadata [Hash] Optional metadata
|
|
64
|
-
def save_variants(variants, metadata = {})
|
|
65
|
-
@mutex.synchronize do
|
|
66
|
-
@variants_data = {
|
|
67
|
-
variants: serialize_variants(variants),
|
|
68
|
-
metadata: metadata.merge(saved_at: Time.now.utc.iso8601)
|
|
69
|
-
}
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Load evaluated variants from memory (dual-rail)
|
|
74
|
-
#
|
|
75
|
-
# @return [Hash, nil] Hash with :variants and :metadata
|
|
76
|
-
def load_variants
|
|
77
|
-
@mutex.synchronize do
|
|
78
|
-
return nil unless @variants_data
|
|
79
|
-
|
|
80
|
-
{
|
|
81
|
-
variants: deserialize_variants(@variants_data[:variants]),
|
|
82
|
-
metadata: @variants_data[:metadata]
|
|
83
|
-
}
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
57
|
end
|
|
87
58
|
end
|
|
88
59
|
end
|
|
@@ -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"
|
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: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ops.ai
|
|
@@ -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
|