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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/rules/constitution.md +81 -0
  3. data/.claude/rules/git-safety.md +11 -0
  4. data/.claude/rules/issue-tracking.md +13 -0
  5. data/.claude/rules/testing-workflow.md +28 -0
  6. data/.envrc.sample +3 -0
  7. data/.github/CODEOWNERS +2 -0
  8. data/.github/pull_request_template.md +8 -0
  9. data/.github/workflows/push_gem.yml +49 -0
  10. data/.github/workflows/ruby.yml +60 -0
  11. data/.github/workflows/test.yaml +40 -0
  12. data/.rubocop.yml +13 -0
  13. data/.tool-versions +1 -0
  14. data/CHANGELOG.md +301 -0
  15. data/CLAUDE.md +29 -0
  16. data/CODEOWNERS +1 -0
  17. data/Gemfile +26 -0
  18. data/Gemfile.lock +177 -0
  19. data/LICENSE.txt +20 -0
  20. data/README.md +213 -0
  21. data/Rakefile +64 -0
  22. data/VERSION +1 -0
  23. data/dev/allocation_stats +60 -0
  24. data/dev/benchmark +40 -0
  25. data/dev/console +12 -0
  26. data/dev/script_setup.rb +18 -0
  27. data/lib/quonfig/bound_client.rb +71 -0
  28. data/lib/quonfig/caching_http_connection.rb +95 -0
  29. data/lib/quonfig/client.rb +221 -0
  30. data/lib/quonfig/config_envelope.rb +5 -0
  31. data/lib/quonfig/config_loader.rb +103 -0
  32. data/lib/quonfig/config_store.rb +42 -0
  33. data/lib/quonfig/context.rb +101 -0
  34. data/lib/quonfig/datadir.rb +101 -0
  35. data/lib/quonfig/duration.rb +58 -0
  36. data/lib/quonfig/encryption.rb +74 -0
  37. data/lib/quonfig/error.rb +6 -0
  38. data/lib/quonfig/errors/env_var_parse_error.rb +11 -0
  39. data/lib/quonfig/errors/initialization_timeout_error.rb +12 -0
  40. data/lib/quonfig/errors/invalid_sdk_key_error.rb +19 -0
  41. data/lib/quonfig/errors/missing_default_error.rb +13 -0
  42. data/lib/quonfig/errors/missing_env_var_error.rb +11 -0
  43. data/lib/quonfig/errors/type_mismatch_error.rb +11 -0
  44. data/lib/quonfig/errors/uninitialized_error.rb +13 -0
  45. data/lib/quonfig/evaluation.rb +64 -0
  46. data/lib/quonfig/evaluator.rb +464 -0
  47. data/lib/quonfig/exponential_backoff.rb +21 -0
  48. data/lib/quonfig/fixed_size_hash.rb +14 -0
  49. data/lib/quonfig/http_connection.rb +46 -0
  50. data/lib/quonfig/internal_logger.rb +173 -0
  51. data/lib/quonfig/murmer3.rb +50 -0
  52. data/lib/quonfig/options.rb +194 -0
  53. data/lib/quonfig/periodic_sync.rb +74 -0
  54. data/lib/quonfig/quonfig.rb +58 -0
  55. data/lib/quonfig/rate_limit_cache.rb +41 -0
  56. data/lib/quonfig/reason.rb +39 -0
  57. data/lib/quonfig/resolver.rb +42 -0
  58. data/lib/quonfig/semantic_logger_filter.rb +90 -0
  59. data/lib/quonfig/semver.rb +132 -0
  60. data/lib/quonfig/sse_config_client.rb +135 -0
  61. data/lib/quonfig/time_helpers.rb +7 -0
  62. data/lib/quonfig/types.rb +56 -0
  63. data/lib/quonfig/weighted_value_resolver.rb +49 -0
  64. data/lib/quonfig.rb +57 -0
  65. data/quonfig.gemspec +149 -0
  66. data/scripts/generate_integration_tests.rb +362 -0
  67. data/test/fixtures/datafile.json +87 -0
  68. data/test/integration/test_context_precedence.rb +194 -0
  69. data/test/integration/test_datadir_environment.rb +76 -0
  70. data/test/integration/test_enabled.rb +784 -0
  71. data/test/integration/test_enabled_with_contexts.rb +94 -0
  72. data/test/integration/test_get.rb +224 -0
  73. data/test/integration/test_get_feature_flag.rb +34 -0
  74. data/test/integration/test_get_or_raise.rb +86 -0
  75. data/test/integration/test_get_weighted_values.rb +29 -0
  76. data/test/integration/test_helpers.rb +139 -0
  77. data/test/integration/test_helpers_test.rb +73 -0
  78. data/test/integration/test_post.rb +34 -0
  79. data/test/integration/test_telemetry.rb +114 -0
  80. data/test/support/common_helpers.rb +106 -0
  81. data/test/support/mock_base_client.rb +27 -0
  82. data/test/support/mock_config_loader.rb +1 -0
  83. data/test/test_bound_client.rb +109 -0
  84. data/test/test_caching_http_connection.rb +218 -0
  85. data/test/test_client.rb +255 -0
  86. data/test/test_config_loader.rb +70 -0
  87. data/test/test_context.rb +136 -0
  88. data/test/test_datadir.rb +199 -0
  89. data/test/test_duration.rb +37 -0
  90. data/test/test_encryption.rb +16 -0
  91. data/test/test_evaluator.rb +285 -0
  92. data/test/test_exponential_backoff.rb +44 -0
  93. data/test/test_fixed_size_hash.rb +119 -0
  94. data/test/test_helper.rb +17 -0
  95. data/test/test_http_connection.rb +79 -0
  96. data/test/test_internal_logger.rb +34 -0
  97. data/test/test_options.rb +167 -0
  98. data/test/test_rate_limit_cache.rb +44 -0
  99. data/test/test_reason.rb +79 -0
  100. data/test/test_rename.rb +65 -0
  101. data/test/test_resolver.rb +144 -0
  102. data/test/test_semantic_logger_filter.rb +123 -0
  103. data/test/test_semver.rb +108 -0
  104. data/test/test_sse_config_client.rb +297 -0
  105. data/test/test_typed_getters.rb +131 -0
  106. data/test/test_types.rb +141 -0
  107. data/test/test_weighted_value_resolver.rb +84 -0
  108. 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