quonfig 0.0.6 → 0.0.8

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/VERSION +1 -1
  4. data/lib/quonfig/client.rb +109 -2
  5. data/lib/quonfig/context.rb +10 -1
  6. data/lib/quonfig/datadir.rb +2 -4
  7. data/lib/quonfig/errors/decryption_error.rb +20 -0
  8. data/lib/quonfig/errors/env_var_parse_error.rb +8 -1
  9. data/lib/quonfig/errors/invalid_environment_error.rb +19 -0
  10. data/lib/quonfig/errors/missing_environment_error.rb +18 -0
  11. data/lib/quonfig/evaluator.rb +64 -2
  12. data/lib/quonfig/http_connection.rb +1 -1
  13. data/lib/quonfig/resolver.rb +187 -2
  14. data/lib/quonfig/stdlib_formatter.rb +95 -0
  15. data/lib/quonfig/telemetry/context_shape.rb +33 -0
  16. data/lib/quonfig/telemetry/context_shape_aggregator.rb +82 -0
  17. data/lib/quonfig/telemetry/evaluation_summaries_aggregator.rb +119 -0
  18. data/lib/quonfig/telemetry/example_contexts_aggregator.rb +101 -0
  19. data/lib/quonfig/telemetry/telemetry_reporter.rb +200 -0
  20. data/lib/quonfig.rb +8 -0
  21. data/quonfig.gemspec +20 -4
  22. data/test/integration/test_context_precedence.rb +35 -117
  23. data/test/integration/test_datadir_environment.rb +15 -37
  24. data/test/integration/test_enabled.rb +157 -463
  25. data/test/integration/test_enabled_with_contexts.rb +19 -49
  26. data/test/integration/test_get.rb +43 -131
  27. data/test/integration/test_get_feature_flag.rb +7 -13
  28. data/test/integration/test_get_or_raise.rb +19 -45
  29. data/test/integration/test_get_weighted_values.rb +9 -4
  30. data/test/integration/test_helpers.rb +499 -4
  31. data/test/integration/test_post.rb +15 -5
  32. data/test/integration/test_telemetry.rb +63 -21
  33. data/test/test_client_telemetry.rb +132 -0
  34. data/test/test_context.rb +4 -1
  35. data/test/test_context_shape.rb +37 -0
  36. data/test/test_context_shape_aggregator.rb +126 -0
  37. data/test/test_datadir.rb +6 -2
  38. data/test/test_evaluation_summaries_aggregator.rb +180 -0
  39. data/test/test_example_contexts_aggregator.rb +119 -0
  40. data/test/test_http_connection.rb +1 -1
  41. data/test/test_resolver.rb +149 -2
  42. data/test/test_should_log.rb +186 -0
  43. data/test/test_stdlib_formatter.rb +195 -0
  44. data/test/test_telemetry_reporter.rb +209 -0
  45. metadata +19 -3
  46. data/scripts/generate_integration_tests.rb +0 -362
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 875b035905394220e72fec4fc1afc3bc62ad6e2975b745098fa4482e1dc348d0
4
- data.tar.gz: bd957d0ec077f3a49daf719deb3ea4db64d1b23a69f6264c91385ad21ff06720
3
+ metadata.gz: 18707780a8ad33c299973f4de01bc0b76d10161902f977b9771c10c2d4a23fde
4
+ data.tar.gz: 6980b904632659b97f16636e9a317bc8b3e8cbf02cc0b8f75ecf51325b8ba472
5
5
  SHA512:
6
- metadata.gz: 9a592d367c361e42e57c42b0ca0929927808e834b5f876545e17436403b11eb5abe08d2996317e5ec5360a1df2228b48a247e22304d6fcda809c3ffee0e3f772
7
- data.tar.gz: f3f9b9b1135ab775861164fb89701e6a3cf6b5cb23078d55de89604b13081ce71b7fe00bed192cd9d9134d64fd406fec954efa793e62a2fb558a8ca3cddbec4f
6
+ metadata.gz: 74c6a59b81fd222ddd522e52301ea74a1f9bc76b18ecbfe50c01a46e21539bdc5cea6a38a927938fb497f6003a64a2e796d177f2ed3ee527d5891f6cd9ca63a4
7
+ data.tar.gz: 94b1c109e6db3df2ea115e62f80f099430752779faa9655ee6f53c20c614fa42ccdc8e350342985cb09861e286bd91b857381508849a81fada865927c98f7f22
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.8 - 2026-04-26
4
+
5
+ - **Fix (gemspec): drop deleted `scripts/` entry from manifest** — regenerated
6
+ juwelier gemspec so `gem build` no longer fails on the missing
7
+ `scripts/generate_integration_tests.rb` file. Also untracked stray
8
+ `.DS_Store`. v0.0.7 was tagged but never published due to this bug.
9
+
10
+ ## 0.0.7 - 2026-04-26
11
+
12
+ - **New: `client.enabled?` / `client.default` / `client.client_construction` integration helpers** —
13
+ Adds aggregator helpers used by the cross-SDK post + telemetry integration suites.
14
+ - **New: telemetry eval-summaries aggregator + `at_exit` drain (qfg-9x7)** —
15
+ Periodically batches evaluation summaries and drains them on process exit so
16
+ short-lived scripts still report telemetry.
17
+ - **New: context telemetry aggregators ported from sdk-node** — context shapes
18
+ and example-contexts ship through the same aggregator path as sdk-node and
19
+ sdk-go.
20
+ - **New errors: `DecryptionError`, `MissingEnvironmentError`,
21
+ `InvalidEnvironmentError`** — explicit error classes raised from the resolver
22
+ and datadir loaders.
23
+ - **Resolver: provided ENV_VAR resolution + coercion (qfg-08q)** — config values
24
+ marked `provided` now resolve from the environment at evaluation time and are
25
+ coerced to the declared value type.
26
+ - **Fix (resolver): raise on missing key, decode weighted/duration/decryption** —
27
+ `get_or_raise` now raises `MissingDefaultError` for unknown keys, and weighted /
28
+ duration / decryption value types decode correctly through the JSON resolver.
29
+ - **Fix (context): `grouped_key` drops anonymous contexts** — anonymous contexts
30
+ are no longer mixed into the grouped-context key, matching sdk-node and sdk-go.
31
+
3
32
  ## 0.0.6 - 2026-04-22
4
33
 
5
34
  - **New: `Quonfig::StdlibFormatter` + `client.stdlib_formatter(logger_name:)`** —
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.6
1
+ 0.0.8
@@ -21,7 +21,7 @@ module Quonfig
21
21
  LOG = Quonfig::InternalLogger.new(self)
22
22
 
23
23
  attr_reader :options, :resolver, :store, :evaluator, :instance_hash,
24
- :config_loader
24
+ :config_loader, :telemetry_reporter
25
25
 
26
26
  def initialize(options = nil, store: nil, **option_kwargs)
27
27
  @options =
@@ -41,6 +41,7 @@ module Quonfig
41
41
  @sse_client = nil
42
42
  @poll_thread = nil
43
43
  @stopped = false
44
+ @telemetry_reporter = nil
44
45
 
45
46
  # If the caller injected a store, we're in test/bootstrap mode; skip I/O.
46
47
  return if store
@@ -50,15 +51,27 @@ module Quonfig
50
51
  else
51
52
  initialize_network_mode
52
53
  end
54
+
55
+ initialize_telemetry
53
56
  end
54
57
 
55
58
  # ---- Lookup --------------------------------------------------------
56
59
 
57
60
  def get(key, default = NO_DEFAULT_PROVIDED, jit_context = NO_DEFAULT_PROVIDED)
58
61
  ctx = build_context(jit_context)
59
- result = @resolver.get(key, ctx)
62
+ record_context_for_telemetry(ctx)
63
+ result =
64
+ begin
65
+ @resolver.get(key, ctx)
66
+ rescue Quonfig::Errors::MissingDefaultError
67
+ # The Resolver raises (matching Quonfig.get_or_raise semantics).
68
+ # The Client's get applies the caller-provided default *or* the
69
+ # configured on_no_default policy via handle_missing.
70
+ nil
71
+ end
60
72
  return handle_missing(key, default) if result.nil?
61
73
 
74
+ record_evaluation_for_telemetry(result)
62
75
  result.unwrapped_value
63
76
  end
64
77
 
@@ -223,6 +236,13 @@ module Quonfig
223
236
  thread = @poll_thread
224
237
  @poll_thread = nil
225
238
  thread&.kill
239
+
240
+ begin
241
+ @telemetry_reporter&.stop
242
+ rescue StandardError => e
243
+ LOG.debug "Error stopping telemetry reporter: #{e.message}"
244
+ end
245
+ @telemetry_reporter = nil
226
246
  end
227
247
 
228
248
  def fork
@@ -235,6 +255,93 @@ module Quonfig
235
255
 
236
256
  private
237
257
 
258
+ # Construct and start the telemetry reporter if the options permit it.
259
+ # The reporter runs on a background thread and periodically POSTs
260
+ # context-shape and example-context batches to +telemetry_destination+.
261
+ def initialize_telemetry
262
+ shape_aggregator = nil
263
+ example_aggregator = nil
264
+ summaries_aggregator = nil
265
+
266
+ if @options.collect_max_shapes.to_i > 0
267
+ shape_aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(
268
+ max_shapes: @options.collect_max_shapes
269
+ )
270
+ end
271
+
272
+ if @options.collect_max_example_contexts.to_i > 0
273
+ example_aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(
274
+ max_contexts: @options.collect_max_example_contexts
275
+ )
276
+ end
277
+
278
+ if @options.collect_max_evaluation_summaries.to_i > 0
279
+ summaries_aggregator = Quonfig::Telemetry::EvaluationSummariesAggregator.new(
280
+ max_keys: @options.collect_max_evaluation_summaries
281
+ )
282
+ end
283
+
284
+ return if shape_aggregator.nil? && example_aggregator.nil? && summaries_aggregator.nil?
285
+
286
+ @telemetry_reporter = Quonfig::Telemetry::TelemetryReporter.new(
287
+ options: @options,
288
+ instance_hash: @instance_hash,
289
+ context_shape_aggregator: shape_aggregator,
290
+ example_contexts_aggregator: example_aggregator,
291
+ evaluation_summaries_aggregator: summaries_aggregator,
292
+ sync_interval: @options.collect_sync_interval
293
+ )
294
+
295
+ return unless @telemetry_reporter.enabled?
296
+
297
+ @telemetry_reporter.start
298
+ rescue StandardError => e
299
+ LOG.warn "[quonfig] Telemetry init failed: #{e.class}: #{e.message}"
300
+ @telemetry_reporter = nil
301
+ end
302
+
303
+ # Feed a matched EvalResult into the evaluation_summaries aggregator.
304
+ # A no-op when telemetry is disabled or eval-summaries collection is off.
305
+ def record_evaluation_for_telemetry(result)
306
+ return if @telemetry_reporter.nil?
307
+ return if result.nil?
308
+
309
+ config = result.config
310
+ return if config.nil?
311
+
312
+ @telemetry_reporter.record_evaluation(
313
+ config_id: config_field(config, :id),
314
+ config_key: config_field(config, :key),
315
+ config_type: config_field(config, :type),
316
+ conditional_value_index: result.rule_index,
317
+ weighted_value_index: nil,
318
+ selected_value: result.unwrapped_value,
319
+ reason: result.wire_reason
320
+ )
321
+ rescue StandardError => e
322
+ LOG.debug "[quonfig] Telemetry record_evaluation error: #{e.class}: #{e.message}"
323
+ end
324
+
325
+ def config_field(config, key)
326
+ return nil if config.nil?
327
+
328
+ config[key.to_s] || config[key.to_sym]
329
+ end
330
+
331
+ # Feed every evaluated context into the telemetry aggregators. A no-op
332
+ # when telemetry is disabled or no aggregators are active.
333
+ def record_context_for_telemetry(context)
334
+ return if @telemetry_reporter.nil?
335
+ return if context.nil?
336
+
337
+ context_obj = context.is_a?(Quonfig::Context) ? context : Quonfig::Context.new(context)
338
+ return if context_obj.blank?
339
+
340
+ @telemetry_reporter.record(context_obj)
341
+ rescue StandardError => e
342
+ LOG.debug "[quonfig] Telemetry record error: #{e.class}: #{e.message}"
343
+ end
344
+
238
345
  def load_datadir_into_store
239
346
  envelope = Quonfig::Datadir.load_envelope(@options.datadir, @options.environment)
240
347
  envelope.configs.each { |cfg| @store.set(cfg['key'], cfg) }
@@ -85,8 +85,17 @@ module Quonfig
85
85
  @contexts[name.to_s] || NamedContext.new(name, {})
86
86
  end
87
87
 
88
+ # Concatenate each named context's `key` (or `trackingId`) value into
89
+ # a stable identifier used for example-contexts dedupe. Mirrors
90
+ # sdk-node's groupedKey: contexts that don't have a `key` property
91
+ # contribute nothing — the resulting string is empty for "anonymous"
92
+ # contexts so the example aggregator can drop them entirely.
88
93
  def grouped_key
89
- @contexts.map { |_, ctx| ctx.key }.sort.join('|')
94
+ @contexts.values.map do |ctx|
95
+ h = ctx.to_h
96
+ v = h['key'] || h[:key] || h['trackingId'] || h[:trackingId]
97
+ v.nil? ? nil : v.to_s
98
+ end.compact.reject(&:empty?).sort.join('|')
90
99
  end
91
100
 
92
101
  include Comparable
@@ -59,8 +59,7 @@ module Quonfig
59
59
  environment ||= ENV['QUONFIG_ENVIRONMENT']
60
60
 
61
61
  if environment.nil? || environment.empty?
62
- raise ArgumentError,
63
- '[quonfig] Environment required for datadir mode; set the `environment` option or QUONFIG_ENVIRONMENT env var'
62
+ raise Quonfig::Errors::MissingEnvironmentError
64
63
  end
65
64
 
66
65
  unless File.exist?(quonfig_path)
@@ -70,8 +69,7 @@ module Quonfig
70
69
  environments = JSON.parse(File.read(quonfig_path)).fetch('environments', [])
71
70
 
72
71
  if !environments.empty? && !environments.include?(environment)
73
- raise ArgumentError,
74
- "[quonfig] Environment \"#{environment}\" not found in workspace; available environments: #{environments.join(', ')}"
72
+ raise Quonfig::Errors::InvalidEnvironmentError.new(environment, environments)
75
73
  end
76
74
 
77
75
  environment
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ module Errors
5
+ # Raised when a confidential config's ciphertext cannot be decrypted —
6
+ # either the configured `decryptWith` key is missing/empty, or the
7
+ # AES-GCM payload itself is malformed/tampered.
8
+ #
9
+ # Mirrors sdk-python's QuonfigDecryptionError. Sdk-node currently
10
+ # raises plain `Error` for the same path; this class is the Ruby
11
+ # equivalent of the dedicated exception type.
12
+ class DecryptionError < Quonfig::Error
13
+ def initialize(key, cause = nil)
14
+ message = "Decryption failed for config '#{key}'"
15
+ message += ": #{cause}" if cause && !cause.to_s.empty?
16
+ super(message)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -4,7 +4,14 @@ module Quonfig
4
4
  module Errors
5
5
  class EnvVarParseError < Quonfig::Error
6
6
  def initialize(env_var, config, env_var_name)
7
- super("Evaluating #{config.key} couldn't coerce #{env_var_name} of #{env_var} to #{config.value_type}")
7
+ key, value_type =
8
+ if config.is_a?(Hash)
9
+ [config[:key] || config['key'],
10
+ config[:value_type] || config['value_type'] || config['valueType']]
11
+ else
12
+ [config.key, config.value_type]
13
+ end
14
+ super("Evaluating #{key} couldn't coerce #{env_var_name} of #{env_var} to #{value_type}")
8
15
  end
9
16
  end
10
17
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ module Errors
5
+ # Raised when the requested environment (via `environment:` option or
6
+ # QUONFIG_ENVIRONMENT) isn't listed in the workspace's `quonfig.json`.
7
+ # Catches typos like `"prdoduction"` early instead of silently
8
+ # evaluating against default rules.
9
+ class InvalidEnvironmentError < Quonfig::Error
10
+ def initialize(environment, available = nil)
11
+ message = "[quonfig] Environment \"#{environment}\" not found in workspace"
12
+ if available && !Array(available).empty?
13
+ message += "; available environments: #{Array(available).join(', ')}"
14
+ end
15
+ super(message)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ module Errors
5
+ # Raised when datadir mode is engaged but no environment was supplied
6
+ # (neither the `environment:` option nor the QUONFIG_ENVIRONMENT env
7
+ # var is set). Datadir mode requires an explicit environment; without
8
+ # one the loader cannot pick the right environment row from each
9
+ # config's `environments` array.
10
+ class MissingEnvironmentError < Quonfig::Error
11
+ def initialize(message = nil)
12
+ message ||= '[quonfig] Environment required for datadir mode; ' \
13
+ 'set the `environment` option or QUONFIG_ENVIRONMENT env var'
14
+ super(message)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -409,14 +409,54 @@ module Quonfig
409
409
  # raw JSON Value hash (#value) and a coerced Ruby value (#unwrapped_value).
410
410
  # The test suite and integration helpers consume both shapes.
411
411
  class EvalResult
412
+ # Integer reason codes for the api-telemetry EvalSummaries wire
413
+ # format. Match sdk-node/src/reason.ts.
414
+ REASON_UNKNOWN = 0
415
+ REASON_STATIC = 1
416
+ REASON_TARGETING_MATCH = 2
417
+ REASON_SPLIT = 3
418
+
412
419
  attr_reader :value, :rule_index, :config
420
+ attr_accessor :weighted_value_index
413
421
 
414
- def initialize(value:, rule_index:, config:)
422
+ def initialize(value:, rule_index:, config:, weighted_value_index: nil)
415
423
  @value = value
416
424
  @rule_index = rule_index
417
425
  @config = config
426
+ @weighted_value_index = weighted_value_index
418
427
  end
419
428
 
429
+ # Integer reason code for telemetry. Mirrors sdk-node's computeReason:
430
+ # SPLIT when a weighted variant is picked, STATIC when the first rule
431
+ # of a config with no targeting rules matched, otherwise TARGETING_MATCH.
432
+ def wire_reason
433
+ return REASON_SPLIT unless @weighted_value_index.nil?
434
+ return REASON_STATIC if @rule_index == 0 && !EvalResult.send(:targeting_rules?, @config)
435
+
436
+ REASON_TARGETING_MATCH
437
+ end
438
+
439
+ # True if any rule on the config (default or environment) has a
440
+ # non-ALWAYS_TRUE criterion. Used to decide STATIC vs TARGETING_MATCH.
441
+ def self.targeting_rules?(config)
442
+ return false if config.nil?
443
+
444
+ rules = []
445
+ %i[default environment].each do |section_key|
446
+ section = config[section_key.to_s] || config[section_key]
447
+ next if section.nil?
448
+
449
+ section_rules = section['rules'] || section[:rules] || []
450
+ rules.concat(section_rules)
451
+ end
452
+
453
+ rules.any? do |rule|
454
+ criteria = rule['criteria'] || rule[:criteria] || []
455
+ criteria.any? { |c| (c['operator'] || c[:operator]) != 'ALWAYS_TRUE' }
456
+ end
457
+ end
458
+ private_class_method :targeting_rules?
459
+
420
460
  # Raw underlying value without type coercion.
421
461
  def raw_value
422
462
  return nil if @value.nil?
@@ -446,7 +486,7 @@ module Quonfig
446
486
  when 'string' then raw.to_s
447
487
  when 'string_list' then raw.is_a?(Array) ? raw.map(&:to_s) : []
448
488
  when 'log_level' then raw.is_a?(Numeric) ? raw : raw.to_s
449
- when 'duration' then raw.to_s
489
+ when 'duration' then duration_to_millis(raw)
450
490
  when 'json'
451
491
  # JSON values must be native JS/Ruby types on the wire.
452
492
  raw
@@ -460,5 +500,27 @@ module Quonfig
460
500
  def value_type
461
501
  type
462
502
  end
503
+
504
+ private
505
+
506
+ # Coerce an ISO 8601 duration value (e.g. "PT0.2S", "P1DT6H2M1.5S")
507
+ # to integer milliseconds. The wire format may either be a bare ISO
508
+ # string in `value` or the structured `{ seconds, nanos }` proto-style
509
+ # shape. Mirrors sdk-node Resolver#unwrapValue duration branch.
510
+ def duration_to_millis(raw)
511
+ case raw
512
+ when Numeric
513
+ raw.to_i
514
+ when String
515
+ seconds = Quonfig::Duration.parse(raw)
516
+ (seconds * 1000).round
517
+ when Hash
518
+ secs = (raw['seconds'] || raw[:seconds] || 0).to_f
519
+ nanos = (raw['nanos'] || raw[:nanos] || 0).to_f
520
+ (secs * 1000 + nanos / 1_000_000.0).round
521
+ else
522
+ raw
523
+ end
524
+ end
463
525
  end
464
526
  end
@@ -5,7 +5,7 @@ require 'json'
5
5
 
6
6
  module Quonfig
7
7
  class HttpConnection
8
- SDK_VERSION = 'ruby-0.1.0'
8
+ SDK_VERSION = "ruby-#{Quonfig::VERSION}".freeze
9
9
 
10
10
  JSON_HEADERS = {
11
11
  'Content-Type' => 'application/json',
@@ -14,6 +14,8 @@ module Quonfig
14
14
  # production read path (with config_loader, SSE updates, telemetry), see
15
15
  # Quonfig::ConfigResolver — the two coexist during the JSON migration.
16
16
  class Resolver
17
+ TRUE_VALUES = %w[true 1 t yes].freeze
18
+
17
19
  attr_reader :store, :evaluator
18
20
  attr_accessor :project_env_id
19
21
 
@@ -26,11 +28,55 @@ module Quonfig
26
28
  @store.get(key)
27
29
  end
28
30
 
31
+ # Look up +key+ and evaluate against +context+. Mirrors Quonfig.get_or_raise
32
+ # semantics: if the key is unknown to the store, raise
33
+ # Quonfig::Errors::MissingDefaultError so callers can distinguish "no
34
+ # such config" from "config matched a nil/false value". Tests that want
35
+ # the legacy "return nil if absent" shape can rescue and recover (see
36
+ # IntegrationTestHelpers.assert_resolved, which folds a missing-key
37
+ # raise into the test's expected default).
29
38
  def get(key, context = nil)
30
39
  config = raw(key)
31
- return nil unless config
40
+ raise Quonfig::Errors::MissingDefaultError.new(key) if config.nil?
41
+
42
+ eval_result = @evaluator.evaluate_config(config, context, resolver: self)
43
+ return nil if eval_result.nil?
44
+
45
+ weighted_index = nil
46
+ resolved_value = resolve_value(eval_result.value, config, context) do |idx|
47
+ weighted_index = idx
48
+ end
49
+ EvalResult.new(
50
+ value: resolved_value,
51
+ rule_index: eval_result.rule_index,
52
+ config: config,
53
+ weighted_value_index: weighted_index
54
+ )
55
+ end
56
+
57
+ # Post-evaluation value resolution. Mirrors sdk-node Resolver#resolveValue
58
+ # and sdk-go resolver.Resolve:
59
+ # - "provided" + ENV_VAR → read ENV[lookup], coerce to config's valueType
60
+ # - confidential + decryptWith → look up the key config, decrypt
61
+ # - everything else passes through unchanged
62
+ def resolve_value(value, config, context = nil, &on_weighted_index)
63
+ return nil if value.nil?
32
64
 
33
- @evaluator.evaluate_config(config, context, resolver: self)
65
+ type = vget(value, :type, 'type')
66
+
67
+ if type == 'provided'
68
+ return resolve_provided(value, config)
69
+ end
70
+
71
+ if type == 'weighted_values'
72
+ return resolve_weighted(value, config, context, &on_weighted_index)
73
+ end
74
+
75
+ confidential = vget(value, :confidential, 'confidential')
76
+ decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
77
+ return resolve_decryption(value, config, context, decrypt_with) if confidential && decrypt_with && !decrypt_with.to_s.empty?
78
+
79
+ value
34
80
  end
35
81
 
36
82
  # Integration shims for code that expects a ConfigResolver. Keep these
@@ -38,5 +84,144 @@ module Quonfig
38
84
  def symbolize_json_names?
39
85
  false
40
86
  end
87
+
88
+ private
89
+
90
+ def vget(hash, *keys)
91
+ return nil if hash.nil?
92
+
93
+ keys.each do |k|
94
+ return hash[k] if hash.is_a?(Hash) && hash.key?(k)
95
+ end
96
+ nil
97
+ end
98
+
99
+ def config_key(config)
100
+ return nil if config.nil?
101
+
102
+ vget(config, :key, 'key')
103
+ end
104
+
105
+ def config_value_type(config)
106
+ return nil if config.nil?
107
+
108
+ vget(config, :value_type, 'value_type', 'valueType', :valueType)
109
+ end
110
+
111
+ def resolve_provided(value, config)
112
+ provided = vget(value, :value, 'value')
113
+ return value if provided.nil?
114
+
115
+ source = vget(provided, :source, 'source')
116
+ lookup = vget(provided, :lookup, 'lookup')
117
+ return value if source != 'ENV_VAR' || lookup.nil? || lookup.to_s.empty?
118
+
119
+ env_value = ENV[lookup.to_s]
120
+ if env_value.nil?
121
+ raise Quonfig::Errors::MissingEnvVarError,
122
+ %(Environment variable "#{lookup}" not set for config "#{config_key(config)}")
123
+ end
124
+
125
+ value_type = config_value_type(config)
126
+ coerced = coerce_env_value(env_value, value_type, config, lookup)
127
+ {
128
+ 'type' => coerced_value_type(value_type),
129
+ 'value' => coerced
130
+ }
131
+ end
132
+
133
+ # Pick a weighted variant. Mirrors sdk-node Resolver#resolveWeightedValues
134
+ # and sdk-go resolveWeightedValues: hash the configured context property
135
+ # (or fall back to a per-call random) into [0,1), then walk the variant
136
+ # weights until cumulative weight >= bucket. Recurses through
137
+ # resolve_value so nested provided/encrypted variants work too.
138
+ def resolve_weighted(value, config, context, &on_weighted_index)
139
+ payload = vget(value, :value, 'value') || {}
140
+ weighted = vget(payload, :weightedValues, 'weightedValues', :weighted_values, 'weighted_values')
141
+ return value unless weighted.is_a?(Array) && !weighted.empty?
142
+
143
+ hash_property = vget(payload, :hashByPropertyName, 'hashByPropertyName',
144
+ :hash_by_property_name, 'hash_by_property_name')
145
+ hash_value = nil
146
+ if hash_property && context
147
+ ctx_value =
148
+ if context.respond_to?(:get)
149
+ context.get(hash_property.to_s)
150
+ elsif context.is_a?(Hash)
151
+ ctx_obj = Quonfig::Context.new(context)
152
+ ctx_obj.get(hash_property.to_s)
153
+ end
154
+ hash_value = ctx_value.to_s unless ctx_value.nil?
155
+ end
156
+
157
+ cfg_key = config_key(config)
158
+ picker = Quonfig::WeightedValueResolver.new(weighted, cfg_key, hash_value)
159
+ variant, index = picker.resolve
160
+ on_weighted_index&.call(index)
161
+ variant_value = vget(variant, :value, 'value')
162
+ resolve_value(variant_value, config, context, &on_weighted_index)
163
+ end
164
+
165
+ # Recursively resolve the decryption-key config (it may itself be a
166
+ # provided ENV_VAR), then AES-GCM decrypt the value with that key.
167
+ def resolve_decryption(value, config, context, decrypt_with)
168
+ key_cfg = @store.get(decrypt_with)
169
+ raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" not found) if key_cfg.nil?
170
+
171
+ key_match = @evaluator.evaluate_config(key_cfg, context, resolver: self)
172
+ raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" did not match) if key_match.nil?
173
+
174
+ resolved_key = resolve_value(key_match.value, key_cfg, context)
175
+ secret_key = vget(resolved_key, :value, 'value').to_s
176
+ raise Quonfig::Error, %(Decryption key from "#{decrypt_with}" is empty) if secret_key.empty?
177
+
178
+ ciphertext = vget(value, :value, 'value').to_s
179
+ begin
180
+ plaintext = Quonfig::Encryption.new(secret_key).decrypt(ciphertext)
181
+ rescue StandardError => e
182
+ raise Quonfig::Errors::DecryptionError.new(config_key(config), e.message)
183
+ end
184
+
185
+ {
186
+ 'type' => 'string',
187
+ 'value' => plaintext,
188
+ 'confidential' => true
189
+ }
190
+ end
191
+
192
+ # Coerce a raw env var string to the SDK type declared by the config.
193
+ # Matches sdk-node coerceValue (string/int/double/bool/string_list)
194
+ # and sdk-go coerceValue (string/int/double/bool). Anything else falls
195
+ # through as a string.
196
+ def coerce_env_value(env_value, value_type, config, lookup)
197
+ case value_type
198
+ when 'string', nil, ''
199
+ env_value
200
+ when 'int'
201
+ Integer(env_value, 10)
202
+ when 'double'
203
+ Float(env_value)
204
+ when 'bool'
205
+ TRUE_VALUES.include?(env_value.downcase)
206
+ when 'string_list'
207
+ env_value.split(/\s*,\s*/)
208
+ when 'duration'
209
+ env_value
210
+ else
211
+ env_value
212
+ end
213
+ rescue ArgumentError, TypeError
214
+ raise Quonfig::Errors::EnvVarParseError.new(env_value, config, lookup)
215
+ end
216
+
217
+ def coerced_value_type(value_type)
218
+ case value_type
219
+ when 'int' then 'int'
220
+ when 'double' then 'double'
221
+ when 'bool' then 'bool'
222
+ when 'string_list' then 'string_list'
223
+ else 'string'
224
+ end
225
+ end
41
226
  end
42
227
  end