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,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Quonfig
6
+ # Loads a Quonfig workspace from the local filesystem (offline / datadir
7
+ # mode). Mirrors sdk-node/src/datadir.ts.
8
+ #
9
+ # The workspace directory layout matches integration-test-data:
10
+ # <datadir>/quonfig.json
11
+ # <datadir>/configs/*.json
12
+ # <datadir>/feature-flags/*.json
13
+ # <datadir>/segments/*.json
14
+ # <datadir>/schemas/*.json
15
+ # <datadir>/log-levels/*.json
16
+ #
17
+ # Each <type>/*.json file is a WorkspaceConfigDocument. The loader projects
18
+ # it down to the ConfigResponse shape that the SSE/HTTP delivery path emits,
19
+ # so ConfigStore consumes both transports uniformly.
20
+ module Datadir
21
+ CONFIG_SUBDIRS = %w[configs feature-flags segments schemas log-levels].freeze
22
+
23
+ module_function
24
+
25
+ # Read every config JSON in `datadir`, project to ConfigResponse hashes,
26
+ # and wrap in a ConfigEnvelope. Does no network I/O.
27
+ def load_envelope(datadir, environment = nil)
28
+ env_id = resolve_environment(File.join(datadir, 'quonfig.json'), environment)
29
+ configs = []
30
+
31
+ CONFIG_SUBDIRS.each do |subdir|
32
+ dir = File.join(datadir, subdir)
33
+ next unless Dir.exist?(dir)
34
+
35
+ Dir.children(dir)
36
+ .select { |name| name.end_with?('.json') }
37
+ .sort
38
+ .each do |filename|
39
+ raw = JSON.parse(File.read(File.join(dir, filename)))
40
+ configs << to_config_response(raw, env_id)
41
+ end
42
+ end
43
+
44
+ Quonfig::ConfigEnvelope.new(
45
+ configs: configs,
46
+ meta: { 'version' => "datadir:#{datadir}", 'environment' => env_id }
47
+ )
48
+ end
49
+
50
+ # Convenience: load the envelope and populate a fresh ConfigStore.
51
+ def load_store(datadir, environment = nil)
52
+ envelope = load_envelope(datadir, environment)
53
+ store = Quonfig::ConfigStore.new
54
+ envelope.configs.each { |cfg| store.set(cfg['key'], cfg) }
55
+ store
56
+ end
57
+
58
+ def resolve_environment(quonfig_path, environment)
59
+ environment ||= ENV['QUONFIG_ENVIRONMENT']
60
+
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'
64
+ end
65
+
66
+ unless File.exist?(quonfig_path)
67
+ raise ArgumentError, "[quonfig] Datadir is missing quonfig.json: #{quonfig_path}"
68
+ end
69
+
70
+ environments = JSON.parse(File.read(quonfig_path)).fetch('environments', [])
71
+
72
+ if !environments.empty? && !environments.include?(environment)
73
+ raise ArgumentError,
74
+ "[quonfig] Environment \"#{environment}\" not found in workspace; available environments: #{environments.join(', ')}"
75
+ end
76
+
77
+ environment
78
+ end
79
+
80
+ def to_config_response(raw, env_id)
81
+ environment = Array(raw['environments']).find { |e| e['id'] == env_id }
82
+ type = raw['type']
83
+
84
+ {
85
+ 'id' => raw['id'] || '',
86
+ 'key' => raw['key'],
87
+ 'type' => type,
88
+ 'valueType' => raw['valueType'],
89
+ 'sendToClientSdk' => effective_send_to_client_sdk(type, raw['sendToClientSdk']),
90
+ 'default' => raw['default'] || { 'rules' => [] },
91
+ 'environment' => environment
92
+ }
93
+ end
94
+
95
+ def effective_send_to_client_sdk(type, raw)
96
+ return true if type == 'feature_flag'
97
+
98
+ raw || false
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ class Duration
5
+ PATTERN = /P(?:(?<days>\d+(?:\.\d+)?)D)?(?:T(?:(?<hours>\d+(?:\.\d+)?)H)?(?:(?<minutes>\d+(?:\.\d+)?)M)?(?:(?<seconds>\d+(?:\.\d+)?)S)?)?/
6
+ MINUTES_IN_SECONDS = 60
7
+ HOURS_IN_SECONDS = 60 * MINUTES_IN_SECONDS
8
+ DAYS_IN_SECONDS = 24 * HOURS_IN_SECONDS
9
+
10
+ def initialize(definition)
11
+ @seconds = self.class.parse(definition)
12
+ end
13
+
14
+ def self.parse(definition)
15
+ match = PATTERN.match(definition)
16
+ return 0 unless match
17
+
18
+ days = match[:days]&.to_f || 0
19
+ hours = match[:hours]&.to_f || 0
20
+ minutes = match[:minutes]&.to_f || 0
21
+ seconds = match[:seconds]&.to_f || 0
22
+
23
+ (days * DAYS_IN_SECONDS + hours * HOURS_IN_SECONDS + minutes * MINUTES_IN_SECONDS + seconds)
24
+ end
25
+
26
+ def in_seconds
27
+ @seconds
28
+ end
29
+
30
+ def in_minutes
31
+ in_seconds / 60.0
32
+ end
33
+
34
+ def in_hours
35
+ in_minutes / 60.0
36
+ end
37
+
38
+ def in_days
39
+ in_hours / 24.0
40
+ end
41
+
42
+ def in_weeks
43
+ in_days / 7.0
44
+ end
45
+
46
+ def to_i
47
+ in_seconds.to_i
48
+ end
49
+
50
+ def to_f
51
+ in_seconds.to_f
52
+ end
53
+
54
+ def as_json
55
+ { ms: in_seconds * 1000, seconds: in_seconds }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ class Encryption
5
+ CIPHER_TYPE = "aes-256-gcm" # 32/12
6
+ SEPARATOR = "--"
7
+ AUTH_TAG_LENGTH = 16
8
+
9
+ # Hexadecimal format ensures that generated keys are representable with
10
+ # plain text
11
+ #
12
+ # To convert back to the original string with the desired length:
13
+ # [ value ].pack("H*")
14
+ def self.generate_new_hex_key
15
+ generate_random_key.unpack("H*")[0]
16
+ end
17
+
18
+ def initialize(key_string_hex)
19
+ @key = [key_string_hex].pack("H*")
20
+ end
21
+
22
+ def encrypt(clear_text)
23
+ cipher = OpenSSL::Cipher.new(CIPHER_TYPE)
24
+ cipher.encrypt
25
+ iv = cipher.random_iv
26
+
27
+ # load them into the cipher
28
+ cipher.key = @key
29
+ cipher.iv = iv
30
+ cipher.auth_data = ""
31
+
32
+ # encrypt the message
33
+ encrypted = cipher.update(clear_text)
34
+ encrypted << cipher.final
35
+ tag = cipher.auth_tag
36
+
37
+ # pack and join
38
+ [encrypted, iv, tag].map { |p| p.unpack("H*")[0] }.join(SEPARATOR)
39
+ end
40
+
41
+ def decrypt(encrypted_string)
42
+ encrypted_data, iv, auth_tag = encrypted_string.split(SEPARATOR).map { |p| [p].pack("H*") }
43
+
44
+ # Currently the OpenSSL bindings do not raise an error if auth_tag is
45
+ # truncated, which would allow an attacker to easily forge it. See
46
+ # https://github.com/ruby/openssl/issues/63
47
+ if auth_tag.bytesize != AUTH_TAG_LENGTH
48
+ raise "truncated auth_tag"
49
+ end
50
+
51
+ cipher = OpenSSL::Cipher.new(CIPHER_TYPE)
52
+ cipher.decrypt
53
+ cipher.key = @key
54
+ cipher.iv = iv
55
+
56
+ cipher.auth_tag = auth_tag
57
+
58
+ # and decrypt it
59
+ decrypted = cipher.update(encrypted_data)
60
+ decrypted << cipher.final
61
+ decrypted
62
+ end
63
+
64
+ private
65
+
66
+ def self.generate_random_key
67
+ SecureRandom.random_bytes(key_length)
68
+ end
69
+
70
+ def self.key_length
71
+ OpenSSL::Cipher.new(CIPHER_TYPE).key_len
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ class Error < StandardError
5
+ end
6
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ module Errors
5
+ class EnvVarParseError < Quonfig::Error
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}")
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ module Errors
5
+ class InitializationTimeoutError < Quonfig::Error
6
+ def initialize(timeout_sec, key)
7
+ message = "Quonfig couldn't initialize in #{timeout_sec} second timeout. Trying to fetch key `#{key}`."
8
+ super(message)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ module Errors
5
+ class InvalidSdkKeyError < Quonfig::Error
6
+ def initialize(key)
7
+ if key.nil? || key.empty?
8
+ message = 'No SDK key. Set QUONFIG_BACKEND_SDK_KEY env var or use QUONFIG_DATAFILE'
9
+
10
+ super(message)
11
+ else
12
+ message = "Your SDK key format is invalid. Expecting something like 123-development-yourapikey-SDK. You provided `#{key}`"
13
+
14
+ super(message)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ module Errors
5
+ class MissingDefaultError < Quonfig::Error
6
+ def initialize(key)
7
+ message = "No value found for key '#{key}' and no default was provided.\n\nIf you'd prefer returning `nil` rather than raising when this occurs, modify the `on_no_default` value you provide in your Quonfig::Options."
8
+
9
+ super(message)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ module Errors
5
+ class MissingEnvVarError < Quonfig::Error
6
+ def initialize(message)
7
+ super(message)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ module Errors
5
+ class TypeMismatchError < Quonfig::Error
6
+ def initialize(key, expected, actual_value)
7
+ super("Quonfig value for key '#{key}' expected #{expected}, got #{actual_value.class}: #{actual_value.inspect}")
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ module Errors
5
+ class UninitializedError < Quonfig::Error
6
+ def initialize(key=nil)
7
+ message = "Use Quonfig.initialize before calling Quonfig.get #{key}"
8
+
9
+ super(message)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ # Records the result of evaluating a config's criteria and forensics for reporting
5
+ class Evaluation
6
+ attr_reader :value, :context
7
+
8
+ def initialize(config:, value:, value_index:, config_row_index:, context:, resolver:, conditional_value: nil)
9
+ @config = config
10
+ @value = value
11
+ @value_index = value_index
12
+ @config_row_index = config_row_index
13
+ @context = context
14
+ @resolver = resolver
15
+ @conditional_value = conditional_value
16
+ end
17
+
18
+ def reason
19
+ @reason ||= @conditional_value ?
20
+ Quonfig::Reason.compute(
21
+ config: @config,
22
+ conditional_value: @conditional_value,
23
+ weighted_value_index: deepest_value.weighted_value_index
24
+ ) :
25
+ Quonfig::Reason::UNKNOWN
26
+ end
27
+
28
+ def unwrapped_value
29
+ deepest_value.unwrap
30
+ end
31
+
32
+ def reportable_value
33
+ deepest_value.reportable_value
34
+ end
35
+
36
+ def report_and_return(evaluation_summary_aggregator)
37
+ report(evaluation_summary_aggregator)
38
+
39
+ unwrapped_value
40
+ end
41
+
42
+ private
43
+
44
+ def report(evaluation_summary_aggregator)
45
+ return if @config.config_type == :LOG_LEVEL
46
+
47
+ evaluation_summary_aggregator&.record(
48
+ config_key: @config.key,
49
+ config_type: @config.config_type,
50
+ counter: {
51
+ config_id: @config.id,
52
+ config_row_index: @config_row_index,
53
+ conditional_value_index: @value_index,
54
+ selected_value: deepest_value.reportable_wrapped_value,
55
+ weighted_value_index: deepest_value.weighted_value_index,
56
+ selected_index: nil # TODO
57
+ })
58
+ end
59
+
60
+ def deepest_value
61
+ @deepest_value ||= Quonfig::ConfigValueUnwrapper.deepest_value(@value, @config, @context, @resolver)
62
+ end
63
+ end
64
+ end