confeature 0.0.0 → 0.1.0
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 +4 -4
- data/README.md +26 -4
- data/lib/confeature/client.rb +397 -0
- data/lib/confeature/evaluation_details.rb +89 -0
- data/lib/confeature/flag_overrides.rb +186 -0
- data/lib/confeature/hooks.rb +84 -0
- data/lib/confeature/internal/cache.rb +166 -0
- data/lib/confeature/internal/comparators.rb +483 -0
- data/lib/confeature/internal/config_fetcher.rb +254 -0
- data/lib/confeature/internal/eval_log.rb +334 -0
- data/lib/confeature/internal/eval_logger.rb +140 -0
- data/lib/confeature/internal/evaluator.rb +866 -0
- data/lib/confeature/internal/hashing.rb +89 -0
- data/lib/confeature/internal/override_eval.rb +184 -0
- data/lib/confeature/internal/poll_service.rb +218 -0
- data/lib/confeature/internal/prerequisites.rb +88 -0
- data/lib/confeature/internal/sdk_key.rb +121 -0
- data/lib/confeature/internal/segments.rb +79 -0
- data/lib/confeature/internal/semver.rb +163 -0
- data/lib/confeature/log_level.rb +20 -0
- data/lib/confeature/logger.rb +59 -0
- data/lib/confeature/polling_mode.rb +62 -0
- data/lib/confeature/snapshot.rb +158 -0
- data/lib/confeature/user.rb +52 -0
- data/lib/confeature/version.rb +11 -0
- data/lib/confeature.rb +39 -1
- metadata +70 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5c74197f202ea8a52c598c91b8bfb70dcc101c7a63d48404f3b2d48d1b38a64d
|
|
4
|
+
data.tar.gz: 62f38b52d867749b78a3db84e9d9da13c5d3ff4be37199a2b50fba0907198468
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5ee1fff4f6e74c8a305f00d33622b899f8f1bd6495589f95455e92ad8014a71de0ab8d590edc0742aab3ec563076ad8b523614d106f5f315709434c048bb9c0e
|
|
7
|
+
data.tar.gz: ee1a650aa4f1a0837b6187213796d972f6b18459d019645859f80a613be9a20940525ac012725e098fbe57e35e4174b45ec9dfa2a17c2aa2037e90c0ccd2aa79
|
data/README.md
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
|
-
|
|
1
|
+
<!-- SPDX-License-Identifier: MIT -->
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
alternative to ConfigCat's Ruby SDK. Version 0.0.0 is a placeholder with no code.
|
|
3
|
+
# confeature (Ruby)
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
Ruby SDK for [Confeature](https://confeature.io). Drop-in compatible
|
|
6
|
+
with the upstream [`configcat`](https://rubygems.org/gems/configcat)
|
|
7
|
+
gem — the public surface (class names, option keywords, polling
|
|
8
|
+
modes, override semantics) mirrors `configcat` 1:1
|
|
9
|
+
(a project rule, checked by its conformance tests).
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
require "confeature"
|
|
13
|
+
|
|
14
|
+
Confeature::Client.new(sdk_key: "configcat-sdk-1/<22 chars>/<22 chars>") do |client|
|
|
15
|
+
enabled = client.get_value("feature.dark-mode", false)
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
SDK keys are minted in ConfigCat's own shape,
|
|
20
|
+
`configcat-sdk-1/<22 chars>/<22 chars>`. Keys minted earlier, shaped
|
|
21
|
+
`confeature-sdk-1-<32 chars>-<32 chars>`, are still accepted.
|
|
22
|
+
`Confeature.valid_confeature_key_format?(key)` returns `true` for both
|
|
23
|
+
shapes; a key that matches neither only logs `INVALID_SDK_KEY` (with the
|
|
24
|
+
key redacted) and the client carries on.
|
|
25
|
+
|
|
26
|
+
Full reference: <https://docs.confeature.io/sdks/ruby/>.
|
|
27
|
+
|
|
28
|
+
Licensed under MIT.
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
require_relative "evaluation_details"
|
|
6
|
+
require_relative "flag_overrides"
|
|
7
|
+
require_relative "hooks"
|
|
8
|
+
require_relative "internal/cache"
|
|
9
|
+
require_relative "internal/config_fetcher"
|
|
10
|
+
require_relative "internal/evaluator"
|
|
11
|
+
require_relative "internal/override_eval"
|
|
12
|
+
require_relative "internal/poll_service"
|
|
13
|
+
require_relative "internal/sdk_key"
|
|
14
|
+
require_relative "logger"
|
|
15
|
+
require_relative "polling_mode"
|
|
16
|
+
require_relative "snapshot"
|
|
17
|
+
require_relative "version"
|
|
18
|
+
|
|
19
|
+
module Confeature
|
|
20
|
+
# Top-level entry point — drop-in equivalent of the upstream
|
|
21
|
+
# `configcat` gem's `ConfigCatClient`.
|
|
22
|
+
#
|
|
23
|
+
# Constructor kwargs mirror the upstream gem's option set:
|
|
24
|
+
# sdk_key: required non-empty Confeature SDK key.
|
|
25
|
+
# polling_mode: PollingMode.auto_poll(...) by default.
|
|
26
|
+
# cache_directory: filesystem cache dir.
|
|
27
|
+
# :memory → memory-only.
|
|
28
|
+
# nil → platform default (~/.cache/confeature).
|
|
29
|
+
# request_timeout_seconds: per-request timeout (default 30 s).
|
|
30
|
+
# logger: a Confeature::Logger instance.
|
|
31
|
+
# flag_overrides: optional FlagOverrides (LOCAL_ONLY in v1).
|
|
32
|
+
# base_url: CDN base URL override (self-hosted).
|
|
33
|
+
# default_user: default User consulted when get_value's `user`
|
|
34
|
+
# arg is nil.
|
|
35
|
+
#
|
|
36
|
+
# `#get_value` resolves the four `SettingType` discriminators
|
|
37
|
+
# (spec 072, issue #259):
|
|
38
|
+
# - `t=0` (boolean) → expects a `TrueClass` / `FalseClass` default;
|
|
39
|
+
# - `t=1` (string) → expects a `String` default;
|
|
40
|
+
# - `t=2` (int) → expects an `Integer` default;
|
|
41
|
+
# - `t=3` (double) → expects a `Float` default — strict, Ruby's
|
|
42
|
+
# `Float === 5` is false so an `Integer` default refuses with
|
|
43
|
+
# `TYPE_MISMATCH` (deliberate, matches ConfigCat-Ruby behaviour).
|
|
44
|
+
# Type-mismatched defaults round-trip as-is with `TYPE_MISMATCH`
|
|
45
|
+
# under the hood. Genuinely unknown `t` discriminators (e.g. `99`)
|
|
46
|
+
# still resolve as `UNSUPPORTED_TYPE`.
|
|
47
|
+
class Client # rubocop:disable Metrics/ClassLength
|
|
48
|
+
def initialize(sdk_key:,
|
|
49
|
+
polling_mode: nil,
|
|
50
|
+
cache_directory: nil,
|
|
51
|
+
request_timeout_seconds: 30.0,
|
|
52
|
+
logger: nil,
|
|
53
|
+
flag_overrides: nil,
|
|
54
|
+
base_url: nil,
|
|
55
|
+
default_user: nil)
|
|
56
|
+
raise ArgumentError, "sdk_key is required and must be a non-empty String" if invalid_sdk_key?(sdk_key)
|
|
57
|
+
if request_timeout_seconds <= 0
|
|
58
|
+
raise ArgumentError,
|
|
59
|
+
"request_timeout_seconds must be > 0; got #{request_timeout_seconds.inspect}"
|
|
60
|
+
end
|
|
61
|
+
validate_flag_overrides!(flag_overrides)
|
|
62
|
+
validate_logger!(logger)
|
|
63
|
+
|
|
64
|
+
@sdk_key = sdk_key
|
|
65
|
+
@logger = logger || Confeature.default_logger
|
|
66
|
+
@flag_overrides = flag_overrides
|
|
67
|
+
@default_user = default_user
|
|
68
|
+
@closed = false
|
|
69
|
+
# Spec 097 / #267 — mirrors ConfigCat::ConfigCatClient's offline flag.
|
|
70
|
+
# Set via `set_offline` / `set_online`; read by `fetch_and_apply`,
|
|
71
|
+
# `force_refresh`, `offline?`. Plain Ruby ivar — protected by @lock
|
|
72
|
+
# alongside the rest of the state.
|
|
73
|
+
@offline = false
|
|
74
|
+
# Spec 108 / #268 — mirrors ConfigCat's Hooks bus. Four hook points
|
|
75
|
+
# (client_ready, config_changed, flag_evaluated, error) wired below
|
|
76
|
+
# in fetch_and_apply / get_value_details / initialize cache restore.
|
|
77
|
+
@hooks = Hooks.new(logger: @logger)
|
|
78
|
+
@client_ready_fired = false
|
|
79
|
+
@lock = Mutex.new
|
|
80
|
+
|
|
81
|
+
log_invalid_format(sdk_key)
|
|
82
|
+
|
|
83
|
+
@cache_key = Internal::SdkKey.derive_cache_key(sdk_key)
|
|
84
|
+
@cache = Internal::Cache.build(
|
|
85
|
+
cache_directory: cache_directory,
|
|
86
|
+
cache_key: @cache_key,
|
|
87
|
+
sdk_key: sdk_key,
|
|
88
|
+
logger: @logger
|
|
89
|
+
)
|
|
90
|
+
@config = nil
|
|
91
|
+
@etag = nil
|
|
92
|
+
# Wall-clock time of the active config's last successful fetch /
|
|
93
|
+
# cache restore. Surfaced via `EvaluationDetails.fetch_time`
|
|
94
|
+
# (spec 086 / #266). `nil` until a config loads.
|
|
95
|
+
@fetch_time = nil
|
|
96
|
+
existing = @cache.read
|
|
97
|
+
if existing
|
|
98
|
+
@config = existing.body
|
|
99
|
+
@etag = existing.etag
|
|
100
|
+
@fetch_time = Time.at(existing.fetched_at) if existing.fetched_at
|
|
101
|
+
# Hook bus (spec 108 / #268) — cache hit fires client_ready once.
|
|
102
|
+
fire_client_ready_once
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
@mode = polling_mode || PollingMode.auto_poll
|
|
106
|
+
@fetcher = Internal::ConfigFetcher.new(
|
|
107
|
+
sdk_key: sdk_key,
|
|
108
|
+
version: Confeature::VERSION,
|
|
109
|
+
logger: @logger,
|
|
110
|
+
base_url: base_url,
|
|
111
|
+
request_timeout_seconds: request_timeout_seconds
|
|
112
|
+
)
|
|
113
|
+
@service = Internal::PollService.build(@mode)
|
|
114
|
+
|
|
115
|
+
# LocalOnly customers don't fetch — every call is served by the
|
|
116
|
+
# override-eval path. Skipping `service.start` keeps tests +
|
|
117
|
+
# LocalOnly customers offline.
|
|
118
|
+
return if local_only_mode?
|
|
119
|
+
|
|
120
|
+
@service.start(method(:fetch_and_apply))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# ---- Public API --------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def get_value(key, default, user = nil)
|
|
126
|
+
return default if @closed
|
|
127
|
+
|
|
128
|
+
# Override path FIRST — spec 036 correctness. The router (spec 135 /
|
|
129
|
+
# #431) threads the user so a full-config override source resolves
|
|
130
|
+
# targeting rules through the real evaluator; the flat-map source
|
|
131
|
+
# keeps the byte-identical value-only path.
|
|
132
|
+
effective_user = user || @default_user
|
|
133
|
+
found, value = Internal::OverrideEval.evaluate(@flag_overrides, key, default, effective_user)
|
|
134
|
+
return value if found
|
|
135
|
+
|
|
136
|
+
begin
|
|
137
|
+
@service.maybe_fetch_for_get_value
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
@logger.warning(
|
|
140
|
+
"Client#get_value: maybe_fetch_for_get_value raised " \
|
|
141
|
+
"(sdkKey=#{Internal::SdkKey.redact(@sdk_key)}): #{e.class}"
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
result = Internal::Evaluator.evaluate(@config, key, default, effective_user)
|
|
146
|
+
result.value
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def snapshot
|
|
150
|
+
Snapshot.new(
|
|
151
|
+
config: @config,
|
|
152
|
+
flag_overrides: @flag_overrides,
|
|
153
|
+
default_user: @default_user,
|
|
154
|
+
fetch_time: @fetch_time
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Spec 086 / #266 — typed-envelope mirror of `get_value`. Routes
|
|
159
|
+
# through the snapshot path so override semantics + fetch_time
|
|
160
|
+
# capture stay consistent across the two accessors.
|
|
161
|
+
#
|
|
162
|
+
# Closed clients return an envelope with the supplied default,
|
|
163
|
+
# `is_default_value: true`, and `error: "CLIENT_CLOSED"` (matches
|
|
164
|
+
# the closed-client read semantics of `get_value`).
|
|
165
|
+
def get_value_details(key, default, user = nil)
|
|
166
|
+
return EvaluationDetails.from_error(key, default, error: "CLIENT_CLOSED") if @closed
|
|
167
|
+
|
|
168
|
+
begin
|
|
169
|
+
@service.maybe_fetch_for_get_value
|
|
170
|
+
rescue StandardError => e
|
|
171
|
+
@logger.warning(
|
|
172
|
+
"Client#get_value_details: maybe_fetch_for_get_value raised " \
|
|
173
|
+
"(sdkKey=#{Internal::SdkKey.redact(@sdk_key)}): #{e.class}"
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
details = snapshot.get_value_details(key, default, user)
|
|
178
|
+
@hooks.invoke_on_flag_evaluated(details)
|
|
179
|
+
details
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Spec 108 / #268 — returns the hooks bus for subscribing lifecycle events.
|
|
183
|
+
# Mirrors ConfigCat's `ConfigCatClient#hooks`.
|
|
184
|
+
attr_reader :hooks
|
|
185
|
+
|
|
186
|
+
# Spec 086 / #266 — evaluates every known flag for `user` and
|
|
187
|
+
# returns a Hash<String, Object>.
|
|
188
|
+
#
|
|
189
|
+
# Iterates the union of cached-config flag keys + override keys
|
|
190
|
+
# (the same set the snapshot's `get_all_keys` returns). Per-key
|
|
191
|
+
# value is resolved via `get_value(key, nil, user)`; keys whose
|
|
192
|
+
# resolved value is `nil` are omitted, mirroring upstream's
|
|
193
|
+
# exclusion semantics. Closed clients return an empty Hash.
|
|
194
|
+
def get_all_values(user = nil)
|
|
195
|
+
return {} if @closed
|
|
196
|
+
|
|
197
|
+
begin
|
|
198
|
+
@service.maybe_fetch_for_get_value
|
|
199
|
+
rescue StandardError => e
|
|
200
|
+
@logger.warning(
|
|
201
|
+
"Client#get_all_values: maybe_fetch_for_get_value raised " \
|
|
202
|
+
"(sdkKey=#{Internal::SdkKey.redact(@sdk_key)}): #{e.class}"
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
snap = snapshot
|
|
207
|
+
snap.get_all_keys.each_with_object({}) do |key, acc|
|
|
208
|
+
value = snap.get_value(key, nil, user)
|
|
209
|
+
acc[key] = value unless value.nil?
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def force_refresh
|
|
214
|
+
return Internal::PollService::RefreshResult.new(success: false, error: "CLIENT_CLOSED") if @closed
|
|
215
|
+
return Internal::PollService::RefreshResult.new(success: true) if local_only_mode?
|
|
216
|
+
if @offline
|
|
217
|
+
# Spec 097 / #267 — no HTTP while offline.
|
|
218
|
+
return Internal::PollService::RefreshResult.new(success: false, error: "OFFLINE_MODE")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# ManualPoll's start was deferred for LocalOnly; ensure the
|
|
222
|
+
# service has the callback wired before forcing a refresh.
|
|
223
|
+
ensure_service_started
|
|
224
|
+
|
|
225
|
+
@service.force_refresh
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Spec 097 / #267 slice 8/14 — re-enables HTTP polling. Idempotent.
|
|
229
|
+
# Mirrors ConfigCat::ConfigCatClient#set_online. Closed clients log
|
|
230
|
+
# warning + no-op. LocalOnly clients log warning + no-op.
|
|
231
|
+
def set_online
|
|
232
|
+
if @closed
|
|
233
|
+
@logger.warning(
|
|
234
|
+
"Client#set_online: called after close (sdkKey=#{Internal::SdkKey.redact(@sdk_key)})"
|
|
235
|
+
)
|
|
236
|
+
return
|
|
237
|
+
end
|
|
238
|
+
if local_only_mode?
|
|
239
|
+
@logger.warning(
|
|
240
|
+
"Client#set_online: client is configured to use the LOCAL_ONLY override " \
|
|
241
|
+
"behaviour, thus set_online has no effect (sdkKey=#{Internal::SdkKey.redact(@sdk_key)})"
|
|
242
|
+
)
|
|
243
|
+
return
|
|
244
|
+
end
|
|
245
|
+
@offline = false
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Spec 097 / #267 slice 8/14 — suspends HTTP polling; serves the
|
|
250
|
+
# cached config. Idempotent. Mirrors ConfigCat::ConfigCatClient#set_offline.
|
|
251
|
+
# Closed clients log warning + no-op. LocalOnly clients are a silent no-op.
|
|
252
|
+
def set_offline
|
|
253
|
+
if @closed
|
|
254
|
+
@logger.warning(
|
|
255
|
+
"Client#set_offline: called after close (sdkKey=#{Internal::SdkKey.redact(@sdk_key)})"
|
|
256
|
+
)
|
|
257
|
+
return
|
|
258
|
+
end
|
|
259
|
+
return if local_only_mode?
|
|
260
|
+
|
|
261
|
+
@offline = true
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Spec 097 / #267 slice 8/14 — true when HTTP polling is suspended.
|
|
266
|
+
# Mirrors ConfigCat::ConfigCatClient#offline?. Closed clients return
|
|
267
|
+
# true. LocalOnly clients return true.
|
|
268
|
+
def offline?
|
|
269
|
+
return true if @closed
|
|
270
|
+
return true if local_only_mode?
|
|
271
|
+
|
|
272
|
+
@offline
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def close
|
|
276
|
+
return if @closed
|
|
277
|
+
|
|
278
|
+
@closed = true
|
|
279
|
+
begin
|
|
280
|
+
@service.stop(timeout: 5.0)
|
|
281
|
+
rescue StandardError => e
|
|
282
|
+
@logger.warning(
|
|
283
|
+
"Client#close: poll service stop raised " \
|
|
284
|
+
"(sdkKey=#{Internal::SdkKey.redact(@sdk_key)}): #{e.class}"
|
|
285
|
+
)
|
|
286
|
+
end
|
|
287
|
+
begin
|
|
288
|
+
@fetcher.close
|
|
289
|
+
rescue StandardError => e
|
|
290
|
+
@logger.warning(
|
|
291
|
+
"Client#close: fetcher close raised " \
|
|
292
|
+
"(sdkKey=#{Internal::SdkKey.redact(@sdk_key)}): #{e.class}"
|
|
293
|
+
)
|
|
294
|
+
end
|
|
295
|
+
nil
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# ---- Internals ---------------------------------------------------
|
|
299
|
+
|
|
300
|
+
private
|
|
301
|
+
|
|
302
|
+
def invalid_sdk_key?(sdk_key)
|
|
303
|
+
!sdk_key.is_a?(String) || sdk_key.empty?
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def validate_flag_overrides!(flag_overrides)
|
|
307
|
+
return if flag_overrides.nil?
|
|
308
|
+
return if flag_overrides.is_a?(FlagOverrides)
|
|
309
|
+
|
|
310
|
+
raise TypeError, "flag_overrides must be a Confeature::FlagOverrides instance or nil"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def validate_logger!(logger)
|
|
314
|
+
return if logger.nil?
|
|
315
|
+
return if logger.is_a?(Confeature::Logger)
|
|
316
|
+
|
|
317
|
+
raise TypeError, "logger must be a Confeature::Logger instance or nil"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def log_invalid_format(sdk_key)
|
|
321
|
+
return if Internal::SdkKey.valid_confeature_key_format?(sdk_key)
|
|
322
|
+
|
|
323
|
+
@logger.warning(
|
|
324
|
+
"INVALID_SDK_KEY format for sdkKey=#{Internal::SdkKey.redact(sdk_key)}. " \
|
|
325
|
+
"Continuing — the SDK is lenient about key format at runtime."
|
|
326
|
+
)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def local_only_mode?
|
|
330
|
+
return false if @flag_overrides.nil?
|
|
331
|
+
|
|
332
|
+
@flag_overrides.behaviour == OverrideBehaviour::LOCAL_ONLY
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def ensure_service_started
|
|
336
|
+
# `start` is idempotent — re-calling is cheap.
|
|
337
|
+
@service.start(method(:fetch_and_apply))
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def fetch_and_apply
|
|
341
|
+
# Spec 097 / #267 — skip HTTP while offline. AutoPoll / LazyLoad /
|
|
342
|
+
# ManualPoll all route here, so a single gate suffices.
|
|
343
|
+
return Internal::ConfigFetcher::FetchResult.new(status: :failed, error: "OFFLINE_MODE") if @offline
|
|
344
|
+
|
|
345
|
+
result = @fetcher.fetch(@etag)
|
|
346
|
+
new_config = nil
|
|
347
|
+
@lock.synchronize do
|
|
348
|
+
case result.status
|
|
349
|
+
when :fetched
|
|
350
|
+
if result.body
|
|
351
|
+
@config = result.body
|
|
352
|
+
@etag = result.etag
|
|
353
|
+
# Stamp `@fetch_time` for `EvaluationDetails.fetch_time`
|
|
354
|
+
# (spec 086 / #266).
|
|
355
|
+
@fetch_time = Time.now
|
|
356
|
+
new_config = result.body
|
|
357
|
+
@cache.write(
|
|
358
|
+
Internal::Cache::CacheEntry.new(
|
|
359
|
+
body: result.body,
|
|
360
|
+
etag: result.etag,
|
|
361
|
+
fetched_at: Internal::Cache.now_seconds
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
end
|
|
365
|
+
when :not_modified
|
|
366
|
+
@etag = result.etag if result.etag
|
|
367
|
+
# `not_modified` still confirms freshness — refresh stamp.
|
|
368
|
+
@fetch_time = Time.now
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
# Hook bus (spec 108 / #268) — fire outside the lock so listeners
|
|
372
|
+
# can re-enter the client without deadlock.
|
|
373
|
+
case result.status
|
|
374
|
+
when :fetched
|
|
375
|
+
if new_config
|
|
376
|
+
@hooks.invoke_on_config_changed(new_config)
|
|
377
|
+
fire_client_ready_once
|
|
378
|
+
end
|
|
379
|
+
when :not_modified
|
|
380
|
+
fire_client_ready_once
|
|
381
|
+
when :failed
|
|
382
|
+
@hooks.invoke_on_error(result.error || "Config fetch failed.")
|
|
383
|
+
end
|
|
384
|
+
result
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def fire_client_ready_once
|
|
388
|
+
should_fire = @lock.synchronize do
|
|
389
|
+
next false if @client_ready_fired
|
|
390
|
+
|
|
391
|
+
@client_ready_fired = true
|
|
392
|
+
true
|
|
393
|
+
end
|
|
394
|
+
@hooks.invoke_on_client_ready if should_fire
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
module Confeature
|
|
6
|
+
# Typed result envelope returned by
|
|
7
|
+
# `Confeature::Client#get_value_details` +
|
|
8
|
+
# `Confeature::Snapshot#get_value_details`.
|
|
9
|
+
#
|
|
10
|
+
# Spec 086 / closes #266 for the sdk-ruby slice. Field layout mirrors
|
|
11
|
+
# upstream `configcat` 8.x's `ConfigCat::EvaluationDetails` 1:1 so a
|
|
12
|
+
# migrating consumer reads the same attribute names in the same
|
|
13
|
+
# positions: a customer migrating from ConfigCat to Confeature renames
|
|
14
|
+
# only the `require` line.
|
|
15
|
+
#
|
|
16
|
+
# Stable error codes populating `error` on fail branches match the
|
|
17
|
+
# evaluator's tier-0 set:
|
|
18
|
+
# `KEY_NOT_FOUND` / `TYPE_MISMATCH` / `MALFORMED_VALUE` /
|
|
19
|
+
# `UNSUPPORTED_TYPE` / `NO_CONFIG` / `MALFORMED_CONFIG` /
|
|
20
|
+
# `VALUE_NOT_BOOL` / `CLIENT_CLOSED`.
|
|
21
|
+
#
|
|
22
|
+
# `fetch_time` is a `Time` instance (UTC) at which the active config
|
|
23
|
+
# was last fetched from the CDN / restored from the on-disk cache.
|
|
24
|
+
# `nil` until any config has loaded.
|
|
25
|
+
#
|
|
26
|
+
# `matched_targeting_rule` + `matched_percentage_option` are populated
|
|
27
|
+
# by the spec-120 evaluation engine when a targeting rule / percentage
|
|
28
|
+
# option decided the value (FR-011); `nil` otherwise (no rule matched,
|
|
29
|
+
# or a served fallback / default).
|
|
30
|
+
class EvaluationDetails
|
|
31
|
+
attr_reader :key, :value, :variation_id, :fetch_time, :user,
|
|
32
|
+
:is_default_value, :error,
|
|
33
|
+
:matched_targeting_rule, :matched_percentage_option
|
|
34
|
+
|
|
35
|
+
def initialize(key:, value:, variation_id: nil, fetch_time: nil, user: nil,
|
|
36
|
+
is_default_value: false, error: nil,
|
|
37
|
+
matched_targeting_rule: nil, matched_percentage_option: nil)
|
|
38
|
+
@key = key
|
|
39
|
+
@value = value
|
|
40
|
+
@variation_id = variation_id
|
|
41
|
+
@fetch_time = fetch_time
|
|
42
|
+
@user = user
|
|
43
|
+
@is_default_value = is_default_value
|
|
44
|
+
@error = error
|
|
45
|
+
@matched_targeting_rule = matched_targeting_rule
|
|
46
|
+
@matched_percentage_option = matched_percentage_option
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Convenience factory for fail-closed branches: closed client,
|
|
50
|
+
# caller-supplied invalid input, etc. Mirrors upstream
|
|
51
|
+
# `ConfigCat::EvaluationDetails.from_error` 1:1.
|
|
52
|
+
def self.from_error(key, value, error:, variation_id: nil)
|
|
53
|
+
new(key: key, value: value, variation_id: variation_id,
|
|
54
|
+
is_default_value: true, error: error)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The targeting rule that decided an evaluated value (FR-011). Mirrors
|
|
59
|
+
# upstream `configcat`'s `TargetingRule` shape: it exposes the raw
|
|
60
|
+
# `config_v6` rule Hash (`conditions`, `served_value`, `percentage_options`)
|
|
61
|
+
# so a migrating consumer can introspect the matched rule. Per FR-017 the
|
|
62
|
+
# detail surfaces resolved identity only — not a textual evaluation trace.
|
|
63
|
+
class TargetingRule
|
|
64
|
+
attr_reader :conditions, :served_value, :percentage_options
|
|
65
|
+
|
|
66
|
+
# @param rule [Hash] the matched `config_v6` targeting-rule Hash (`c,s?,p?`).
|
|
67
|
+
def initialize(rule)
|
|
68
|
+
rule = {} unless rule.is_a?(Hash)
|
|
69
|
+
@conditions = rule["c"]
|
|
70
|
+
@served_value = rule["s"]
|
|
71
|
+
@percentage_options = rule["p"]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The percentage option that decided an evaluated value (FR-011). Mirrors
|
|
76
|
+
# upstream `configcat`'s `PercentageOption`: `percentage` (width),
|
|
77
|
+
# `variation_id`, and the raw served `value` envelope.
|
|
78
|
+
class PercentageOption
|
|
79
|
+
attr_reader :percentage, :value, :variation_id
|
|
80
|
+
|
|
81
|
+
# @param option [Hash] the matched `config_v6` percentage-option Hash (`p,v,i`).
|
|
82
|
+
def initialize(option)
|
|
83
|
+
option = {} unless option.is_a?(Hash)
|
|
84
|
+
@percentage = option["p"]
|
|
85
|
+
@value = option["v"]
|
|
86
|
+
@variation_id = option["i"]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|