quonfig 0.0.2
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 +7 -0
- data/.claude/rules/constitution.md +81 -0
- data/.claude/rules/git-safety.md +11 -0
- data/.claude/rules/issue-tracking.md +13 -0
- data/.claude/rules/testing-workflow.md +28 -0
- data/.envrc.sample +3 -0
- data/.github/CODEOWNERS +2 -0
- data/.github/pull_request_template.md +8 -0
- data/.github/workflows/push_gem.yml +49 -0
- data/.github/workflows/ruby.yml +60 -0
- data/.github/workflows/test.yaml +40 -0
- data/.rubocop.yml +13 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +301 -0
- data/CLAUDE.md +29 -0
- data/CODEOWNERS +1 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +177 -0
- data/LICENSE.txt +20 -0
- data/README.md +213 -0
- data/Rakefile +64 -0
- data/VERSION +1 -0
- data/dev/allocation_stats +60 -0
- data/dev/benchmark +40 -0
- data/dev/console +12 -0
- data/dev/script_setup.rb +18 -0
- data/lib/quonfig/bound_client.rb +71 -0
- data/lib/quonfig/caching_http_connection.rb +95 -0
- data/lib/quonfig/client.rb +221 -0
- data/lib/quonfig/config_envelope.rb +5 -0
- data/lib/quonfig/config_loader.rb +103 -0
- data/lib/quonfig/config_store.rb +42 -0
- data/lib/quonfig/context.rb +101 -0
- data/lib/quonfig/datadir.rb +101 -0
- data/lib/quonfig/duration.rb +58 -0
- data/lib/quonfig/encryption.rb +74 -0
- data/lib/quonfig/error.rb +6 -0
- data/lib/quonfig/errors/env_var_parse_error.rb +11 -0
- data/lib/quonfig/errors/initialization_timeout_error.rb +12 -0
- data/lib/quonfig/errors/invalid_sdk_key_error.rb +19 -0
- data/lib/quonfig/errors/missing_default_error.rb +13 -0
- data/lib/quonfig/errors/missing_env_var_error.rb +11 -0
- data/lib/quonfig/errors/type_mismatch_error.rb +11 -0
- data/lib/quonfig/errors/uninitialized_error.rb +13 -0
- data/lib/quonfig/evaluation.rb +64 -0
- data/lib/quonfig/evaluator.rb +464 -0
- data/lib/quonfig/exponential_backoff.rb +21 -0
- data/lib/quonfig/fixed_size_hash.rb +14 -0
- data/lib/quonfig/http_connection.rb +46 -0
- data/lib/quonfig/internal_logger.rb +173 -0
- data/lib/quonfig/murmer3.rb +50 -0
- data/lib/quonfig/options.rb +194 -0
- data/lib/quonfig/periodic_sync.rb +74 -0
- data/lib/quonfig/quonfig.rb +58 -0
- data/lib/quonfig/rate_limit_cache.rb +41 -0
- data/lib/quonfig/reason.rb +39 -0
- data/lib/quonfig/resolver.rb +42 -0
- data/lib/quonfig/semantic_logger_filter.rb +90 -0
- data/lib/quonfig/semver.rb +132 -0
- data/lib/quonfig/sse_config_client.rb +135 -0
- data/lib/quonfig/time_helpers.rb +7 -0
- data/lib/quonfig/types.rb +56 -0
- data/lib/quonfig/weighted_value_resolver.rb +49 -0
- data/lib/quonfig.rb +57 -0
- data/quonfig.gemspec +149 -0
- data/scripts/generate_integration_tests.rb +362 -0
- data/test/fixtures/datafile.json +87 -0
- data/test/integration/test_context_precedence.rb +194 -0
- data/test/integration/test_datadir_environment.rb +76 -0
- data/test/integration/test_enabled.rb +784 -0
- data/test/integration/test_enabled_with_contexts.rb +94 -0
- data/test/integration/test_get.rb +224 -0
- data/test/integration/test_get_feature_flag.rb +34 -0
- data/test/integration/test_get_or_raise.rb +86 -0
- data/test/integration/test_get_weighted_values.rb +29 -0
- data/test/integration/test_helpers.rb +139 -0
- data/test/integration/test_helpers_test.rb +73 -0
- data/test/integration/test_post.rb +34 -0
- data/test/integration/test_telemetry.rb +114 -0
- data/test/support/common_helpers.rb +106 -0
- data/test/support/mock_base_client.rb +27 -0
- data/test/support/mock_config_loader.rb +1 -0
- data/test/test_bound_client.rb +109 -0
- data/test/test_caching_http_connection.rb +218 -0
- data/test/test_client.rb +255 -0
- data/test/test_config_loader.rb +70 -0
- data/test/test_context.rb +136 -0
- data/test/test_datadir.rb +199 -0
- data/test/test_duration.rb +37 -0
- data/test/test_encryption.rb +16 -0
- data/test/test_evaluator.rb +285 -0
- data/test/test_exponential_backoff.rb +44 -0
- data/test/test_fixed_size_hash.rb +119 -0
- data/test/test_helper.rb +17 -0
- data/test/test_http_connection.rb +79 -0
- data/test/test_internal_logger.rb +34 -0
- data/test/test_options.rb +167 -0
- data/test/test_rate_limit_cache.rb +44 -0
- data/test/test_reason.rb +79 -0
- data/test/test_rename.rb +65 -0
- data/test/test_resolver.rb +144 -0
- data/test/test_semantic_logger_filter.rb +123 -0
- data/test/test_semver.rb +108 -0
- data/test/test_sse_config_client.rb +297 -0
- data/test/test_typed_getters.rb +131 -0
- data/test/test_types.rb +141 -0
- data/test/test_weighted_value_resolver.rb +84 -0
- metadata +311 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module Quonfig
|
|
6
|
+
# Evaluates configs pulled from a ConfigStore against a Context.
|
|
7
|
+
#
|
|
8
|
+
# Public API shape mirrors sdk-node's Evaluator (src/evaluator.ts):
|
|
9
|
+
# evaluator = Quonfig::Evaluator.new(store)
|
|
10
|
+
# result = evaluator.evaluate_config(cfg, context, resolver: resolver)
|
|
11
|
+
#
|
|
12
|
+
# Since qfg-dk6.10 this class owns the full operator matrix against the JSON
|
|
13
|
+
# Criterion shape (propertyName / operator / valueToMatch). It accepts
|
|
14
|
+
# configs in either of two shapes:
|
|
15
|
+
#
|
|
16
|
+
# - The ConfigResponse hash produced by Quonfig::Datadir.to_config_response
|
|
17
|
+
# and IntegrationTestHelpers.to_config_response — symbol or string keys at
|
|
18
|
+
# the top level (id, key, type, value_type/valueType, default, environment)
|
|
19
|
+
# with JSON rules/criteria inside as plain hashes with string keys.
|
|
20
|
+
# The legacy protobuf-shaped Config object is no longer supported.
|
|
21
|
+
#
|
|
22
|
+
# evaluate_config returns an EvalResult that exposes the matched value via
|
|
23
|
+
# #unwrapped_value (coerced into a native Ruby type per value.type) and
|
|
24
|
+
# #value (the raw JSON Value hash). If nothing matches it returns nil, which
|
|
25
|
+
# Resolver#get relays to callers.
|
|
26
|
+
class Evaluator
|
|
27
|
+
# Operator constants — kept as strings for direct comparison with the wire
|
|
28
|
+
# format (no symbol conversion on the hot path).
|
|
29
|
+
OP_NOT_SET = 'NOT_SET'
|
|
30
|
+
OP_ALWAYS_TRUE = 'ALWAYS_TRUE'
|
|
31
|
+
OP_PROP_IS_ONE_OF = 'PROP_IS_ONE_OF'
|
|
32
|
+
OP_PROP_IS_NOT_ONE_OF = 'PROP_IS_NOT_ONE_OF'
|
|
33
|
+
OP_PROP_STARTS_WITH_ONE_OF = 'PROP_STARTS_WITH_ONE_OF'
|
|
34
|
+
OP_PROP_DOES_NOT_START_WITH_ONE_OF = 'PROP_DOES_NOT_START_WITH_ONE_OF'
|
|
35
|
+
OP_PROP_ENDS_WITH_ONE_OF = 'PROP_ENDS_WITH_ONE_OF'
|
|
36
|
+
OP_PROP_DOES_NOT_END_WITH_ONE_OF = 'PROP_DOES_NOT_END_WITH_ONE_OF'
|
|
37
|
+
OP_PROP_CONTAINS_ONE_OF = 'PROP_CONTAINS_ONE_OF'
|
|
38
|
+
OP_PROP_DOES_NOT_CONTAIN_ONE_OF = 'PROP_DOES_NOT_CONTAIN_ONE_OF'
|
|
39
|
+
OP_PROP_MATCHES = 'PROP_MATCHES'
|
|
40
|
+
OP_PROP_DOES_NOT_MATCH = 'PROP_DOES_NOT_MATCH'
|
|
41
|
+
OP_HIERARCHICAL_MATCH = 'HIERARCHICAL_MATCH'
|
|
42
|
+
OP_IN_INT_RANGE = 'IN_INT_RANGE'
|
|
43
|
+
OP_PROP_GREATER_THAN = 'PROP_GREATER_THAN'
|
|
44
|
+
OP_PROP_GREATER_THAN_OR_EQUAL = 'PROP_GREATER_THAN_OR_EQUAL'
|
|
45
|
+
OP_PROP_LESS_THAN = 'PROP_LESS_THAN'
|
|
46
|
+
OP_PROP_LESS_THAN_OR_EQUAL = 'PROP_LESS_THAN_OR_EQUAL'
|
|
47
|
+
OP_PROP_BEFORE = 'PROP_BEFORE'
|
|
48
|
+
OP_PROP_AFTER = 'PROP_AFTER'
|
|
49
|
+
OP_PROP_SEMVER_LESS_THAN = 'PROP_SEMVER_LESS_THAN'
|
|
50
|
+
OP_PROP_SEMVER_EQUAL = 'PROP_SEMVER_EQUAL'
|
|
51
|
+
OP_PROP_SEMVER_GREATER_THAN = 'PROP_SEMVER_GREATER_THAN'
|
|
52
|
+
OP_IN_SEG = 'IN_SEG'
|
|
53
|
+
OP_NOT_IN_SEG = 'NOT_IN_SEG'
|
|
54
|
+
|
|
55
|
+
MAGIC_CURRENT_TIME_PROPS = %w[quonfig.current-time prefab.current-time reforge.current-time].freeze
|
|
56
|
+
|
|
57
|
+
attr_reader :store
|
|
58
|
+
attr_accessor :project_env_id, :env_id
|
|
59
|
+
|
|
60
|
+
def initialize(store, project_env_id: 0, env_id: nil, namespace: nil, base_client: nil)
|
|
61
|
+
@store = store
|
|
62
|
+
@project_env_id = project_env_id
|
|
63
|
+
@env_id = env_id
|
|
64
|
+
@namespace = namespace
|
|
65
|
+
@base_client = base_client
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Evaluate +config+ against +context+ and return an EvalResult (or nil if
|
|
69
|
+
# no rule matched). +context+ may be a Quonfig::Context or a plain Hash.
|
|
70
|
+
def evaluate_config(config, context, resolver: nil)
|
|
71
|
+
ctx = coerce_context(context)
|
|
72
|
+
env = config_environment(config)
|
|
73
|
+
|
|
74
|
+
if env && @env_id && env_id_of(env) == @env_id
|
|
75
|
+
match = evaluate_rules(env_rules(env), ctx, config)
|
|
76
|
+
return match if match
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
default_rules = default_rules_of(config)
|
|
80
|
+
match = evaluate_rules(default_rules, ctx, config)
|
|
81
|
+
return match if match
|
|
82
|
+
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# --- Shape coercion helpers -----------------------------------------
|
|
89
|
+
|
|
90
|
+
def coerce_context(context)
|
|
91
|
+
return context if context.is_a?(Quonfig::Context)
|
|
92
|
+
return Quonfig::Context.new({}) if context.nil?
|
|
93
|
+
|
|
94
|
+
Quonfig::Context.new(context)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def hget(hash, *keys)
|
|
98
|
+
return nil if hash.nil?
|
|
99
|
+
|
|
100
|
+
keys.each do |k|
|
|
101
|
+
return hash[k] if hash.key?(k)
|
|
102
|
+
sk = k.to_s
|
|
103
|
+
return hash[sk] if hash.key?(sk)
|
|
104
|
+
sym = k.to_sym
|
|
105
|
+
return hash[sym] if hash.key?(sym)
|
|
106
|
+
end
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def default_rules_of(config)
|
|
111
|
+
default = hget(config, :default)
|
|
112
|
+
rules = hget(default, :rules) || []
|
|
113
|
+
Array(rules)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def config_environment(config)
|
|
117
|
+
hget(config, :environment)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def env_id_of(env)
|
|
121
|
+
hget(env, :id)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def env_rules(env)
|
|
125
|
+
Array(hget(env, :rules) || [])
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# --- Rule evaluation ------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def evaluate_rules(rules, context, config)
|
|
131
|
+
rules.each_with_index do |rule, index|
|
|
132
|
+
criteria = Array(hget(rule, :criteria) || [])
|
|
133
|
+
next unless all_criteria_match?(criteria, context, config)
|
|
134
|
+
|
|
135
|
+
value_hash = hget(rule, :value)
|
|
136
|
+
return EvalResult.new(value: value_hash, rule_index: index, config: config)
|
|
137
|
+
end
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def all_criteria_match?(criteria, context, config)
|
|
142
|
+
criteria.all? { |c| evaluate_criterion(c, context, config) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# --- Per-operator evaluation ---------------------------------------
|
|
146
|
+
#
|
|
147
|
+
# Faithful port of sdk-node/src/operators.ts evaluateCriterion. Matches
|
|
148
|
+
# context-exists / missing-context semantics (e.g. PROP_IS_NOT_ONE_OF is
|
|
149
|
+
# true when context is missing).
|
|
150
|
+
def evaluate_criterion(criterion, context, config)
|
|
151
|
+
property_name = hget(criterion, :propertyName) || ''
|
|
152
|
+
operator = hget(criterion, :operator)
|
|
153
|
+
match_value = hget(criterion, :valueToMatch)
|
|
154
|
+
|
|
155
|
+
context_value, context_exists = lookup_context(context, property_name)
|
|
156
|
+
|
|
157
|
+
case operator
|
|
158
|
+
when OP_NOT_SET, nil
|
|
159
|
+
return false
|
|
160
|
+
|
|
161
|
+
when OP_ALWAYS_TRUE
|
|
162
|
+
return true
|
|
163
|
+
|
|
164
|
+
when OP_PROP_IS_ONE_OF, OP_PROP_IS_NOT_ONE_OF
|
|
165
|
+
if context_exists && match_value
|
|
166
|
+
match_strings = get_string_list(match_value)
|
|
167
|
+
if match_strings
|
|
168
|
+
context_strings = to_string_slice(context_value)
|
|
169
|
+
match_found = context_strings.any? { |cv| match_strings.include?(cv) }
|
|
170
|
+
return match_found == (operator == OP_PROP_IS_ONE_OF)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
return operator == OP_PROP_IS_NOT_ONE_OF
|
|
174
|
+
|
|
175
|
+
when OP_PROP_STARTS_WITH_ONE_OF, OP_PROP_DOES_NOT_START_WITH_ONE_OF
|
|
176
|
+
if context_exists && match_value
|
|
177
|
+
match_strings = get_string_list(match_value)
|
|
178
|
+
if match_strings
|
|
179
|
+
cv = to_s_nil(context_value)
|
|
180
|
+
match_found = match_strings.any? { |p| cv.start_with?(p) }
|
|
181
|
+
return match_found == (operator == OP_PROP_STARTS_WITH_ONE_OF)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
return operator == OP_PROP_DOES_NOT_START_WITH_ONE_OF
|
|
185
|
+
|
|
186
|
+
when OP_PROP_ENDS_WITH_ONE_OF, OP_PROP_DOES_NOT_END_WITH_ONE_OF
|
|
187
|
+
if context_exists && match_value
|
|
188
|
+
match_strings = get_string_list(match_value)
|
|
189
|
+
if match_strings
|
|
190
|
+
cv = to_s_nil(context_value)
|
|
191
|
+
match_found = match_strings.any? { |p| cv.end_with?(p) }
|
|
192
|
+
return match_found == (operator == OP_PROP_ENDS_WITH_ONE_OF)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
return operator == OP_PROP_DOES_NOT_END_WITH_ONE_OF
|
|
196
|
+
|
|
197
|
+
when OP_PROP_CONTAINS_ONE_OF, OP_PROP_DOES_NOT_CONTAIN_ONE_OF
|
|
198
|
+
if context_exists && match_value
|
|
199
|
+
match_strings = get_string_list(match_value)
|
|
200
|
+
if match_strings
|
|
201
|
+
cv = to_s_nil(context_value)
|
|
202
|
+
match_found = match_strings.any? { |p| cv.include?(p) }
|
|
203
|
+
return match_found == (operator == OP_PROP_CONTAINS_ONE_OF)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
return operator == OP_PROP_DOES_NOT_CONTAIN_ONE_OF
|
|
207
|
+
|
|
208
|
+
when OP_PROP_MATCHES, OP_PROP_DOES_NOT_MATCH
|
|
209
|
+
mv = hget(match_value, :value)
|
|
210
|
+
if context_exists && context_value.is_a?(String) && mv.is_a?(String)
|
|
211
|
+
begin
|
|
212
|
+
re = Regexp.new(mv)
|
|
213
|
+
matched = re.match?(context_value)
|
|
214
|
+
return matched == (operator == OP_PROP_MATCHES)
|
|
215
|
+
rescue RegexpError
|
|
216
|
+
return false
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
return false
|
|
220
|
+
|
|
221
|
+
when OP_HIERARCHICAL_MATCH
|
|
222
|
+
if context_exists && match_value
|
|
223
|
+
cv = to_s_nil(context_value)
|
|
224
|
+
mv = to_s_nil(hget(match_value, :value))
|
|
225
|
+
return cv.start_with?(mv)
|
|
226
|
+
end
|
|
227
|
+
return false
|
|
228
|
+
|
|
229
|
+
when OP_IN_INT_RANGE
|
|
230
|
+
if context_exists && match_value
|
|
231
|
+
start_v, end_v = extract_int_range(match_value)
|
|
232
|
+
num_val = to_float(context_value)
|
|
233
|
+
return num_val >= start_v && num_val < end_v unless num_val.nil?
|
|
234
|
+
end
|
|
235
|
+
return false
|
|
236
|
+
|
|
237
|
+
when OP_PROP_GREATER_THAN, OP_PROP_GREATER_THAN_OR_EQUAL,
|
|
238
|
+
OP_PROP_LESS_THAN, OP_PROP_LESS_THAN_OR_EQUAL
|
|
239
|
+
if context_exists && match_value && context_value.is_a?(Numeric)
|
|
240
|
+
mv = hget(match_value, :value)
|
|
241
|
+
return false unless numeric_value?(mv)
|
|
242
|
+
|
|
243
|
+
cmp = compare_numbers(context_value, mv)
|
|
244
|
+
return false if cmp.nil?
|
|
245
|
+
|
|
246
|
+
case operator
|
|
247
|
+
when OP_PROP_GREATER_THAN then return cmp > 0
|
|
248
|
+
when OP_PROP_GREATER_THAN_OR_EQUAL then return cmp >= 0
|
|
249
|
+
when OP_PROP_LESS_THAN then return cmp < 0
|
|
250
|
+
when OP_PROP_LESS_THAN_OR_EQUAL then return cmp <= 0
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
return false
|
|
254
|
+
|
|
255
|
+
when OP_PROP_BEFORE, OP_PROP_AFTER
|
|
256
|
+
if context_exists && match_value
|
|
257
|
+
context_millis = date_to_millis(context_value)
|
|
258
|
+
match_millis = date_to_millis(hget(match_value, :value))
|
|
259
|
+
if context_millis && match_millis
|
|
260
|
+
return operator == OP_PROP_BEFORE ? context_millis < match_millis : context_millis > match_millis
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
return false
|
|
264
|
+
|
|
265
|
+
when OP_PROP_SEMVER_LESS_THAN, OP_PROP_SEMVER_EQUAL, OP_PROP_SEMVER_GREATER_THAN
|
|
266
|
+
mv = hget(match_value, :value)
|
|
267
|
+
if context_exists && context_value.is_a?(String) && mv.is_a?(String)
|
|
268
|
+
sv_ctx = SemanticVersion.parse_quietly(context_value)
|
|
269
|
+
sv_mv = SemanticVersion.parse_quietly(mv)
|
|
270
|
+
if sv_ctx && sv_mv
|
|
271
|
+
cmp = (sv_ctx <=> sv_mv)
|
|
272
|
+
case operator
|
|
273
|
+
when OP_PROP_SEMVER_LESS_THAN then return cmp < 0
|
|
274
|
+
when OP_PROP_SEMVER_EQUAL then return cmp == 0
|
|
275
|
+
when OP_PROP_SEMVER_GREATER_THAN then return cmp > 0
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
return false
|
|
280
|
+
|
|
281
|
+
when OP_IN_SEG, OP_NOT_IN_SEG
|
|
282
|
+
if match_value
|
|
283
|
+
segment_key = to_s_nil(hget(match_value, :value))
|
|
284
|
+
found, result = resolve_segment(segment_key, context)
|
|
285
|
+
return operator == OP_NOT_IN_SEG unless found
|
|
286
|
+
|
|
287
|
+
return result == (operator == OP_IN_SEG)
|
|
288
|
+
end
|
|
289
|
+
return operator == OP_NOT_IN_SEG
|
|
290
|
+
|
|
291
|
+
else
|
|
292
|
+
return false
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def lookup_context(context, property_name)
|
|
297
|
+
if MAGIC_CURRENT_TIME_PROPS.include?(property_name)
|
|
298
|
+
return [(Time.now.utc.to_f * 1000).to_i, true]
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
if property_name.nil? || property_name.empty?
|
|
302
|
+
return [nil, false]
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
value = context.get(property_name)
|
|
306
|
+
[value, !value.nil?]
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# --- Segment resolution -------------------------------------------
|
|
310
|
+
|
|
311
|
+
def resolve_segment(segment_key, context)
|
|
312
|
+
return [false, false] if segment_key.nil? || segment_key.empty?
|
|
313
|
+
|
|
314
|
+
seg_config = @store.get(segment_key)
|
|
315
|
+
return [false, false] if seg_config.nil?
|
|
316
|
+
|
|
317
|
+
# Segments have no environment-specific rules in the JSON shape; we
|
|
318
|
+
# evaluate against default rules only (mirrors sdk-node behaviour —
|
|
319
|
+
# evaluate_config with env_id='' falls through to default).
|
|
320
|
+
match = evaluate_rules(default_rules_of(seg_config), context, seg_config)
|
|
321
|
+
return [false, false] if match.nil?
|
|
322
|
+
|
|
323
|
+
raw = match.raw_value
|
|
324
|
+
[true, !!raw]
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# --- Type coercion helpers ----------------------------------------
|
|
328
|
+
|
|
329
|
+
def to_s_nil(v)
|
|
330
|
+
return '' if v.nil?
|
|
331
|
+
|
|
332
|
+
v.to_s
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def to_string_slice(v)
|
|
336
|
+
return [] if v.nil?
|
|
337
|
+
return v.map { |i| to_s_nil(i) } if v.is_a?(Array)
|
|
338
|
+
|
|
339
|
+
[to_s_nil(v)]
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def get_string_list(value_hash)
|
|
343
|
+
return nil if value_hash.nil?
|
|
344
|
+
|
|
345
|
+
raw = hget(value_hash, :value)
|
|
346
|
+
return nil unless raw.is_a?(Array)
|
|
347
|
+
|
|
348
|
+
raw.map { |i| to_s_nil(i) }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def numeric_value?(v)
|
|
352
|
+
return true if v.is_a?(Numeric)
|
|
353
|
+
return false unless v.is_a?(String)
|
|
354
|
+
|
|
355
|
+
stripped = v.strip
|
|
356
|
+
return false if stripped.empty?
|
|
357
|
+
|
|
358
|
+
!Float(stripped, exception: false).nil?
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def to_float(v)
|
|
362
|
+
return v.to_f if v.is_a?(Numeric)
|
|
363
|
+
return nil unless v.is_a?(String)
|
|
364
|
+
|
|
365
|
+
f = Float(v, exception: false)
|
|
366
|
+
f
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def compare_numbers(a, b)
|
|
370
|
+
af = to_float(a)
|
|
371
|
+
bf = to_float(b)
|
|
372
|
+
return nil if af.nil? || bf.nil?
|
|
373
|
+
|
|
374
|
+
af <=> bf
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def extract_int_range(value_hash)
|
|
378
|
+
min = -(2**53) + 1 # approx Number.MIN_SAFE_INTEGER
|
|
379
|
+
max = (2**53) - 1
|
|
380
|
+
raw = hget(value_hash, :value)
|
|
381
|
+
return [min, max] unless raw.is_a?(Hash)
|
|
382
|
+
|
|
383
|
+
start_v = to_float(hget(raw, :start))
|
|
384
|
+
end_v = to_float(hget(raw, :end))
|
|
385
|
+
[start_v || min, end_v || max]
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def date_to_millis(val)
|
|
389
|
+
case val
|
|
390
|
+
when Integer, Float
|
|
391
|
+
val.to_i
|
|
392
|
+
when String
|
|
393
|
+
# Try ISO-8601 / RFC3339 first, fall back to integer-string.
|
|
394
|
+
begin
|
|
395
|
+
t = DateTime.parse(val)
|
|
396
|
+
return (t.to_time.to_f * 1000).to_i
|
|
397
|
+
rescue ArgumentError, TypeError
|
|
398
|
+
# not a date; try integer
|
|
399
|
+
end
|
|
400
|
+
n = Integer(val, exception: false)
|
|
401
|
+
n
|
|
402
|
+
else
|
|
403
|
+
nil
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Result of a matched config evaluation. Provides the caller with both the
|
|
409
|
+
# raw JSON Value hash (#value) and a coerced Ruby value (#unwrapped_value).
|
|
410
|
+
# The test suite and integration helpers consume both shapes.
|
|
411
|
+
class EvalResult
|
|
412
|
+
attr_reader :value, :rule_index, :config
|
|
413
|
+
|
|
414
|
+
def initialize(value:, rule_index:, config:)
|
|
415
|
+
@value = value
|
|
416
|
+
@rule_index = rule_index
|
|
417
|
+
@config = config
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Raw underlying value without type coercion.
|
|
421
|
+
def raw_value
|
|
422
|
+
return nil if @value.nil?
|
|
423
|
+
|
|
424
|
+
@value[:value] || @value['value']
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# The declared Value type ('string', 'int', 'bool', ...). Nil if unset.
|
|
428
|
+
def type
|
|
429
|
+
return nil if @value.nil?
|
|
430
|
+
|
|
431
|
+
@value[:type] || @value['type']
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Ruby-native value after type coercion. Mirrors sdk-node Resolver#unwrapValue.
|
|
435
|
+
def unwrapped_value
|
|
436
|
+
raw = raw_value
|
|
437
|
+
case type
|
|
438
|
+
when 'bool' then !!raw
|
|
439
|
+
when 'int'
|
|
440
|
+
return raw if raw.is_a?(Integer)
|
|
441
|
+
return raw.to_i if raw.is_a?(Numeric)
|
|
442
|
+
Integer(raw.to_s, 10)
|
|
443
|
+
when 'double'
|
|
444
|
+
return raw.to_f if raw.is_a?(Numeric)
|
|
445
|
+
Float(raw.to_s)
|
|
446
|
+
when 'string' then raw.to_s
|
|
447
|
+
when 'string_list' then raw.is_a?(Array) ? raw.map(&:to_s) : []
|
|
448
|
+
when 'log_level' then raw.is_a?(Numeric) ? raw : raw.to_s
|
|
449
|
+
when 'duration' then raw.to_s
|
|
450
|
+
when 'json'
|
|
451
|
+
# JSON values must be native JS/Ruby types on the wire.
|
|
452
|
+
raw
|
|
453
|
+
else
|
|
454
|
+
raw
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Convenience for callers that don't care about coercion — mirrors
|
|
459
|
+
# the {type, value} shape sdk-node emits.
|
|
460
|
+
def value_type
|
|
461
|
+
type
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
# This class implements exponential backoff with a maximum delay.
|
|
5
|
+
#
|
|
6
|
+
# This is the default sync interval for aggregators.
|
|
7
|
+
class ExponentialBackoff
|
|
8
|
+
def initialize(max_delay:, initial_delay: 2, multiplier: 2)
|
|
9
|
+
@initial_delay = initial_delay
|
|
10
|
+
@max_delay = max_delay
|
|
11
|
+
@multiplier = multiplier
|
|
12
|
+
@delay = initial_delay
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
delay = @delay
|
|
17
|
+
@delay = [@delay * @multiplier, @max_delay].min
|
|
18
|
+
delay
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Quonfig
|
|
3
|
+
class FixedSizeHash < Hash
|
|
4
|
+
def initialize(max_size)
|
|
5
|
+
@max_size = max_size
|
|
6
|
+
super()
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def []=(key, value)
|
|
10
|
+
shift if size >= @max_size && !key?(key) # Only evict if adding a new key
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Quonfig
|
|
7
|
+
class HttpConnection
|
|
8
|
+
SDK_VERSION = 'ruby-0.1.0'
|
|
9
|
+
|
|
10
|
+
JSON_HEADERS = {
|
|
11
|
+
'Content-Type' => 'application/json',
|
|
12
|
+
'Accept' => 'application/json',
|
|
13
|
+
'X-Quonfig-SDK-Version' => SDK_VERSION
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(uri, sdk_key)
|
|
17
|
+
@uri = uri
|
|
18
|
+
@sdk_key = sdk_key
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def uri
|
|
22
|
+
@uri
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def get(path, headers = {})
|
|
26
|
+
connection(headers).get(path)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def post(path, body)
|
|
30
|
+
connection.post(path, body.to_json)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def connection(headers = {})
|
|
34
|
+
merged = JSON_HEADERS.merge('Authorization' => auth_header).merge(headers)
|
|
35
|
+
Faraday.new(@uri) do |conn|
|
|
36
|
+
conn.headers.merge!(merged)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def auth_header
|
|
43
|
+
'Basic ' + Base64.strict_encode64("1:#{@sdk_key}")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|