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,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
# Immutable context-bound view over a Quonfig::Client. Every lookup uses the
|
|
5
|
+
# bound context as the jit_context passed down to the resolver.
|
|
6
|
+
class BoundClient
|
|
7
|
+
attr_reader :client, :context
|
|
8
|
+
|
|
9
|
+
def initialize(client, context)
|
|
10
|
+
@client = client
|
|
11
|
+
@context = context || {}
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def get_string(key, default: NO_DEFAULT_PROVIDED)
|
|
16
|
+
@client.get_string(key, default: default, context: @context)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def get_int(key, default: NO_DEFAULT_PROVIDED)
|
|
20
|
+
@client.get_int(key, default: default, context: @context)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def get_float(key, default: NO_DEFAULT_PROVIDED)
|
|
24
|
+
@client.get_float(key, default: default, context: @context)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def get_bool(key, default: NO_DEFAULT_PROVIDED)
|
|
28
|
+
@client.get_bool(key, default: default, context: @context)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def get_string_list(key, default: NO_DEFAULT_PROVIDED)
|
|
32
|
+
@client.get_string_list(key, default: default, context: @context)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def get_duration(key, default: NO_DEFAULT_PROVIDED)
|
|
36
|
+
@client.get_duration(key, default: default, context: @context)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def get_json(key, default: NO_DEFAULT_PROVIDED)
|
|
40
|
+
@client.get_json(key, default: default, context: @context)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def enabled?(feature_name)
|
|
44
|
+
@client.enabled?(feature_name, @context)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns a new BoundClient whose bound context is the merge of this
|
|
48
|
+
# bound context and +additional+. Merge is one level deep per named
|
|
49
|
+
# context (mirrors sdk-node's mergeContexts): later values override
|
|
50
|
+
# earlier within the same named context; keys unique to each side are
|
|
51
|
+
# preserved.
|
|
52
|
+
def in_context(additional)
|
|
53
|
+
self.class.new(@client, merge_contexts(@context, additional || {}))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def inspect
|
|
57
|
+
"#<Quonfig::BoundClient context=#{@context.inspect}>"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def merge_contexts(left, right)
|
|
63
|
+
merged = {}
|
|
64
|
+
left.each { |name, ctx| merged[name] = ctx.dup }
|
|
65
|
+
right.each do |name, ctx|
|
|
66
|
+
merged[name] = merged[name] ? merged[name].merge(ctx) : ctx.dup
|
|
67
|
+
end
|
|
68
|
+
merged
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
class CachingHttpConnection
|
|
5
|
+
CACHE_SIZE = 2.freeze
|
|
6
|
+
CacheEntry = Struct.new(:data, :etag, :expires_at)
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def cache
|
|
10
|
+
@cache ||= FixedSizeHash.new(CACHE_SIZE)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def reset_cache!
|
|
14
|
+
@cache = FixedSizeHash.new(CACHE_SIZE)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(uri, api_key)
|
|
19
|
+
@connection = HttpConnection.new(uri, api_key)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def get(path)
|
|
23
|
+
now = Time.now.to_i
|
|
24
|
+
cache_key = "#{@connection.uri}#{path}"
|
|
25
|
+
cached = self.class.cache[cache_key]
|
|
26
|
+
|
|
27
|
+
# Check if we have a valid cached response
|
|
28
|
+
if cached&.data && cached.expires_at && now < cached.expires_at
|
|
29
|
+
return Faraday::Response.new(
|
|
30
|
+
status: 200,
|
|
31
|
+
body: cached.data,
|
|
32
|
+
response_headers: {
|
|
33
|
+
'ETag' => cached.etag,
|
|
34
|
+
'X-Cache' => 'HIT',
|
|
35
|
+
'X-Cache-Expires-At' => cached.expires_at.to_s
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Make request with conditional GET if we have an ETag
|
|
41
|
+
response = if cached&.etag
|
|
42
|
+
@connection.get(path, { 'If-None-Match' => cached.etag })
|
|
43
|
+
else
|
|
44
|
+
@connection.get(path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Handle 304 Not Modified
|
|
48
|
+
if response.status == 304 && cached&.data
|
|
49
|
+
return Faraday::Response.new(
|
|
50
|
+
status: 200,
|
|
51
|
+
body: cached.data,
|
|
52
|
+
response_headers: {
|
|
53
|
+
'ETag' => cached.etag,
|
|
54
|
+
'X-Cache' => 'HIT',
|
|
55
|
+
'X-Cache-Expires-At' => cached.expires_at.to_s
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Parse caching headers
|
|
61
|
+
cache_control = response.headers['Cache-Control'].to_s
|
|
62
|
+
etag = response.headers['ETag']
|
|
63
|
+
|
|
64
|
+
# Always add X-Cache header
|
|
65
|
+
response.headers['X-Cache'] = 'MISS'
|
|
66
|
+
|
|
67
|
+
# Don't cache if no-store is present
|
|
68
|
+
return response if cache_control.include?('no-store')
|
|
69
|
+
|
|
70
|
+
# Calculate expiration
|
|
71
|
+
max_age = cache_control.match(/max-age=(\d+)/)&.captures&.first&.to_i
|
|
72
|
+
expires_at = max_age ? now + max_age : nil
|
|
73
|
+
|
|
74
|
+
# Cache the response if we have caching headers
|
|
75
|
+
if etag || expires_at
|
|
76
|
+
self.class.cache[cache_key] = CacheEntry.new(
|
|
77
|
+
response.body,
|
|
78
|
+
etag,
|
|
79
|
+
expires_at
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
response
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Delegate other methods to the underlying connection
|
|
87
|
+
def post(path, body)
|
|
88
|
+
@connection.post(path, body)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def uri
|
|
92
|
+
@connection.uri
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Quonfig
|
|
6
|
+
# Public Quonfig SDK client.
|
|
7
|
+
#
|
|
8
|
+
# Wires the new JSON stack: Quonfig::ConfigStore + Quonfig::Evaluator +
|
|
9
|
+
# Quonfig::Resolver. The legacy protobuf-driven ConfigClient/ConfigResolver
|
|
10
|
+
# path was removed in qfg-dk6.32. Network-mode (HTTP fetch + SSE updates) is
|
|
11
|
+
# not yet wired through Client; today the supported entry points are
|
|
12
|
+
# +datadir:+ (offline workspace) and +store:+ (caller-supplied
|
|
13
|
+
# Quonfig::ConfigStore, used by tests).
|
|
14
|
+
class Client
|
|
15
|
+
LOG = Quonfig::InternalLogger.new(self)
|
|
16
|
+
|
|
17
|
+
attr_reader :options, :resolver, :store, :evaluator, :instance_hash
|
|
18
|
+
|
|
19
|
+
def initialize(options = nil, store: nil, **option_kwargs)
|
|
20
|
+
@options =
|
|
21
|
+
if options.is_a?(Quonfig::Options)
|
|
22
|
+
options
|
|
23
|
+
elsif options.is_a?(Hash)
|
|
24
|
+
Quonfig::Options.new(options.merge(option_kwargs))
|
|
25
|
+
else
|
|
26
|
+
Quonfig::Options.new(option_kwargs)
|
|
27
|
+
end
|
|
28
|
+
@global_context = normalize_context(@options.global_context)
|
|
29
|
+
@instance_hash = SecureRandom.uuid
|
|
30
|
+
@store = store || build_store
|
|
31
|
+
@evaluator = Quonfig::Evaluator.new(@store, env_id: @options.environment)
|
|
32
|
+
@resolver = Quonfig::Resolver.new(@store, @evaluator)
|
|
33
|
+
@semantic_logger_filters = {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# ---- Lookup --------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
def get(key, default = NO_DEFAULT_PROVIDED, jit_context = NO_DEFAULT_PROVIDED)
|
|
39
|
+
ctx = build_context(jit_context)
|
|
40
|
+
result = @resolver.get(key, ctx)
|
|
41
|
+
return handle_missing(key, default) if result.nil?
|
|
42
|
+
|
|
43
|
+
result.unwrapped_value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def get_string(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
|
|
47
|
+
typed_get(key, String, default: default, context: context)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def get_int(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
|
|
51
|
+
typed_get(key, Integer, default: default, context: context)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def get_float(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
|
|
55
|
+
typed_get(key, Float, default: default, context: context)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def get_bool(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
|
|
59
|
+
typed_get(key, :bool, default: default, context: context)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def get_string_list(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
|
|
63
|
+
typed_get(key, :string_list, default: default, context: context)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def get_duration(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
|
|
67
|
+
typed_get(key, :duration, default: default, context: context)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def get_json(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
|
|
71
|
+
typed_get(key, :json, default: default, context: context)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
|
|
75
|
+
value = get(feature_name, false, jit_context)
|
|
76
|
+
value == true || value == 'true'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def defined?(key)
|
|
80
|
+
!@store.get(key).nil?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def keys
|
|
84
|
+
@store.keys
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# ---- Context binding ----------------------------------------------
|
|
88
|
+
|
|
89
|
+
def in_context(properties)
|
|
90
|
+
bound = Quonfig::BoundClient.new(self, properties)
|
|
91
|
+
block_given? ? yield(bound) : bound
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def with_context(properties, &block)
|
|
95
|
+
if block_given?
|
|
96
|
+
in_context(properties, &block)
|
|
97
|
+
else
|
|
98
|
+
Quonfig::BoundClient.new(self, properties)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ---- Filters & helpers --------------------------------------------
|
|
103
|
+
|
|
104
|
+
def semantic_logger_filter(config_key:)
|
|
105
|
+
@semantic_logger_filters[config_key] ||=
|
|
106
|
+
Quonfig::SemanticLoggerFilter.new(self, config_key: config_key)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def on_update(&block)
|
|
110
|
+
@on_update = block
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def stop
|
|
114
|
+
# No background threads in datadir mode; placeholder for the future
|
|
115
|
+
# SSE/poll path so callers can use this method symmetrically.
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def fork
|
|
119
|
+
self.class.new(@options.for_fork)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def inspect
|
|
123
|
+
"#<Quonfig::Client:#{object_id} environment=#{@options.environment.inspect}>"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def build_store
|
|
129
|
+
if @options.datadir
|
|
130
|
+
Quonfig::Datadir.load_store(@options.datadir, @options.environment)
|
|
131
|
+
else
|
|
132
|
+
Quonfig::ConfigStore.new
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def build_context(jit_context)
|
|
137
|
+
jit = jit_context == NO_DEFAULT_PROVIDED ? nil : normalize_context(jit_context)
|
|
138
|
+
merge_contexts(@global_context, jit)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def normalize_context(ctx)
|
|
142
|
+
return {} if ctx.nil?
|
|
143
|
+
return ctx if ctx.is_a?(Hash)
|
|
144
|
+
|
|
145
|
+
raise ArgumentError, "Quonfig context must be a Hash, got #{ctx.class}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# One-level-deep merge per named context (mirrors sdk-node's mergeContexts):
|
|
149
|
+
# later values override earlier within the same named context; keys unique
|
|
150
|
+
# to each side are preserved.
|
|
151
|
+
def merge_contexts(left, right)
|
|
152
|
+
return right || {} if left.nil? || left.empty?
|
|
153
|
+
return left if right.nil? || right.empty?
|
|
154
|
+
|
|
155
|
+
merged = {}
|
|
156
|
+
left.each { |name, ctx| merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx }
|
|
157
|
+
right.each do |name, ctx|
|
|
158
|
+
if merged[name].is_a?(Hash) && ctx.is_a?(Hash)
|
|
159
|
+
merged[name] = merged[name].merge(ctx)
|
|
160
|
+
else
|
|
161
|
+
merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
merged
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def handle_missing(key, default)
|
|
168
|
+
return default if default != NO_DEFAULT_PROVIDED
|
|
169
|
+
|
|
170
|
+
if @options.on_no_default == Quonfig::Options::ON_NO_DEFAULT::RAISE
|
|
171
|
+
raise Quonfig::Errors::MissingDefaultError, key
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def typed_get(key, expected_type, default:, context:)
|
|
178
|
+
jit = context == NO_DEFAULT_PROVIDED ? NO_DEFAULT_PROVIDED : context
|
|
179
|
+
value = get(key, default, jit)
|
|
180
|
+
|
|
181
|
+
# Missing path: resolver returned the caller's default (or nil under
|
|
182
|
+
# on_no_default=:return_nil) — skip type coercion.
|
|
183
|
+
return value if default != NO_DEFAULT_PROVIDED && value.equal?(default)
|
|
184
|
+
return nil if value.nil?
|
|
185
|
+
|
|
186
|
+
coerce_and_check(key, value, expected_type)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def coerce_and_check(key, value, expected_type)
|
|
190
|
+
case expected_type
|
|
191
|
+
when :bool
|
|
192
|
+
unless value == true || value == false
|
|
193
|
+
raise Quonfig::Errors::TypeMismatchError.new(key, 'Boolean', value)
|
|
194
|
+
end
|
|
195
|
+
value
|
|
196
|
+
when :string_list
|
|
197
|
+
arr = value.is_a?(Array) ? value : nil
|
|
198
|
+
unless arr && arr.all? { |v| v.is_a?(String) }
|
|
199
|
+
raise Quonfig::Errors::TypeMismatchError.new(key, 'Array<String>', value)
|
|
200
|
+
end
|
|
201
|
+
arr
|
|
202
|
+
when :duration
|
|
203
|
+
return value.to_i if value.is_a?(Numeric)
|
|
204
|
+
if value.is_a?(String)
|
|
205
|
+
return (Quonfig::Duration.parse(value) * 1000).to_i
|
|
206
|
+
end
|
|
207
|
+
raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', value)
|
|
208
|
+
when :json
|
|
209
|
+
# JSON values are returned as-is (Hash, Array, or scalar from the wire).
|
|
210
|
+
value
|
|
211
|
+
when Class
|
|
212
|
+
unless value.is_a?(expected_type)
|
|
213
|
+
raise Quonfig::Errors::TypeMismatchError.new(key, "expected #{expected_type}", value)
|
|
214
|
+
end
|
|
215
|
+
value
|
|
216
|
+
else
|
|
217
|
+
value
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Quonfig
|
|
6
|
+
class ConfigLoader
|
|
7
|
+
LOG = Quonfig::InternalLogger.new(self)
|
|
8
|
+
|
|
9
|
+
CONFIGS_PATH = '/api/v2/configs'
|
|
10
|
+
|
|
11
|
+
attr_reader :etag
|
|
12
|
+
|
|
13
|
+
def initialize(base_client)
|
|
14
|
+
@base_client = base_client
|
|
15
|
+
@options = base_client.options
|
|
16
|
+
@api_config = Concurrent::Map.new
|
|
17
|
+
@etag = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Fetch configs from /api/v2/configs with ETag / If-None-Match caching.
|
|
21
|
+
#
|
|
22
|
+
# Returns one of:
|
|
23
|
+
# :updated — 200 response; @api_config and @etag replaced
|
|
24
|
+
# :not_modified — 304 response; cache still valid
|
|
25
|
+
# :failed — every configured source failed
|
|
26
|
+
def fetch!
|
|
27
|
+
Array(@options.config_api_urls).each do |api_url|
|
|
28
|
+
result = fetch_from(api_url)
|
|
29
|
+
return result if result != :failed
|
|
30
|
+
end
|
|
31
|
+
:failed
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def calc_config
|
|
35
|
+
rtn = {}
|
|
36
|
+
@api_config.each_key do |k|
|
|
37
|
+
rtn[k] = @api_config[k]
|
|
38
|
+
end
|
|
39
|
+
rtn
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def set(config, source)
|
|
43
|
+
@api_config[config.key] = { source: source, config: config }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def rm(key)
|
|
47
|
+
@api_config.delete(key)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def fetch_from(source)
|
|
53
|
+
conn = Quonfig::HttpConnection.new(source, @options.sdk_key)
|
|
54
|
+
headers = {}
|
|
55
|
+
headers['If-None-Match'] = @etag if @etag
|
|
56
|
+
response = conn.get(CONFIGS_PATH, headers)
|
|
57
|
+
|
|
58
|
+
case response.status
|
|
59
|
+
when 200
|
|
60
|
+
new_etag = response.headers['ETag'] || response.headers['etag']
|
|
61
|
+
envelope = parse_envelope(response.body)
|
|
62
|
+
replace_api_config(envelope, source)
|
|
63
|
+
@etag = new_etag
|
|
64
|
+
:updated
|
|
65
|
+
when 304
|
|
66
|
+
LOG.debug "Configs not modified (304) from #{source}"
|
|
67
|
+
:not_modified
|
|
68
|
+
else
|
|
69
|
+
LOG.info "Config fetch failed: status #{response.status} from #{source}"
|
|
70
|
+
:failed
|
|
71
|
+
end
|
|
72
|
+
rescue Faraday::ConnectionFailed => e
|
|
73
|
+
LOG.debug "Connection failure fetching configs from #{source}: #{e.message}"
|
|
74
|
+
:failed
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
LOG.warn "Unexpected error fetching configs from #{source}: #{e.message}"
|
|
77
|
+
:failed
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def parse_envelope(body)
|
|
81
|
+
data = body.is_a?(String) ? JSON.parse(body) : body
|
|
82
|
+
Quonfig::ConfigEnvelope.new(
|
|
83
|
+
configs: data['configs'] || [],
|
|
84
|
+
meta: data['meta'] || {}
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def replace_api_config(envelope, source)
|
|
89
|
+
next_map = Concurrent::Map.new
|
|
90
|
+
envelope.configs.each do |cfg|
|
|
91
|
+
key = config_key(cfg)
|
|
92
|
+
next if key.nil?
|
|
93
|
+
next_map[key] = { source: source, config: cfg }
|
|
94
|
+
end
|
|
95
|
+
@api_config = next_map
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def config_key(cfg)
|
|
99
|
+
return cfg['key'] || cfg[:key] if cfg.is_a?(Hash)
|
|
100
|
+
cfg.respond_to?(:key) ? cfg.key : nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
# In-memory store of configs keyed by config key.
|
|
5
|
+
#
|
|
6
|
+
# Mirrors sdk-node's ConfigStore (src/store.ts). Integration tests and the
|
|
7
|
+
# new Resolver/Evaluator trio construct this directly, independent of any
|
|
8
|
+
# Client/ConfigLoader plumbing.
|
|
9
|
+
class ConfigStore
|
|
10
|
+
def initialize(initial_configs = nil)
|
|
11
|
+
@lock = Concurrent::ReadWriteLock.new
|
|
12
|
+
@configs = Concurrent::Map.new
|
|
13
|
+
return unless initial_configs
|
|
14
|
+
|
|
15
|
+
initial_configs.each { |k, v| @configs[k] = v }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def get(key)
|
|
19
|
+
@configs[key]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def set(key, config)
|
|
23
|
+
@lock.with_write_lock { @configs[key] = config }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def clear
|
|
27
|
+
@lock.with_write_lock do
|
|
28
|
+
@configs.keys.each { |k| @configs.delete(k) }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def keys
|
|
33
|
+
@configs.keys
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def all_configs
|
|
37
|
+
h = {}
|
|
38
|
+
@configs.each_pair { |k, v| h[k] = v }
|
|
39
|
+
h
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
# Quonfig context: a two-level Hash (named-context → property → value) wrapped
|
|
5
|
+
# for evaluator consumption. The Evaluator accepts either a plain Hash or a
|
|
6
|
+
# Quonfig::Context — this class exists mostly to flatten lookups (`get`)
|
|
7
|
+
# into the dotted "context-name.property" form criterion rules use.
|
|
8
|
+
class Context
|
|
9
|
+
BLANK_CONTEXT_NAME = ''
|
|
10
|
+
|
|
11
|
+
class NamedContext
|
|
12
|
+
attr_reader :name
|
|
13
|
+
|
|
14
|
+
def initialize(name, hash)
|
|
15
|
+
@name = name.to_s
|
|
16
|
+
@hash = hash.transform_keys(&:to_s)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_h
|
|
20
|
+
@hash
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def key
|
|
24
|
+
"#{@name}:#{@hash['key']}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def merge!(other)
|
|
28
|
+
other.each { |k, v| @hash[k.to_s] = v }
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :contexts
|
|
34
|
+
|
|
35
|
+
def initialize(hash = {})
|
|
36
|
+
@contexts = {}
|
|
37
|
+
@flattened = {}
|
|
38
|
+
|
|
39
|
+
raise ArgumentError, 'must be a Hash' unless hash.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
hash.each do |name, values|
|
|
42
|
+
unless values.is_a?(Hash)
|
|
43
|
+
# Legacy shorthand — pre-named-contexts callers passed a flat Hash.
|
|
44
|
+
values = { name => values }
|
|
45
|
+
name = BLANK_CONTEXT_NAME
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@contexts[name.to_s] = NamedContext.new(name, values)
|
|
49
|
+
values.each do |key, value|
|
|
50
|
+
@flattened[name.to_s + '.' + key.to_s] = value
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def blank?
|
|
56
|
+
@contexts.empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def set(name, hash)
|
|
60
|
+
@contexts[name.to_s] = NamedContext.new(name, hash)
|
|
61
|
+
hash.each do |key, value|
|
|
62
|
+
@flattened[name.to_s + '.' + key.to_s] = value
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def get(property_key, scope: nil)
|
|
67
|
+
property_key = BLANK_CONTEXT_NAME + '.' + property_key unless property_key.include?('.')
|
|
68
|
+
@flattened[property_key]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_h
|
|
72
|
+
@contexts.transform_values(&:to_h)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_s
|
|
76
|
+
"#<Quonfig::Context:#{object_id} #{to_h}>"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def clear
|
|
80
|
+
@contexts = {}
|
|
81
|
+
@flattened = {}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def context(name)
|
|
85
|
+
@contexts[name.to_s] || NamedContext.new(name, {})
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def grouped_key
|
|
89
|
+
@contexts.map { |_, ctx| ctx.key }.sort.join('|')
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
include Comparable
|
|
93
|
+
def <=>(other)
|
|
94
|
+
if other.is_a?(Quonfig::Context)
|
|
95
|
+
to_h <=> other.to_h
|
|
96
|
+
else
|
|
97
|
+
super
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|