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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 72aeb10f4210bd27edd0af4c28ff67a4b27614ca346e5abc2a0dab75d0c84c42
4
- data.tar.gz: c8ca1990e07652f56452b536f9843beca1a604b325226ac65472505256b9cfda
3
+ metadata.gz: 5c74197f202ea8a52c598c91b8bfb70dcc101c7a63d48404f3b2d48d1b38a64d
4
+ data.tar.gz: 62f38b52d867749b78a3db84e9d9da13c5d3ff4be37199a2b50fba0907198468
5
5
  SHA512:
6
- metadata.gz: f4d26440eaaee74badcc323407267eb475216185c9a724d7966a01277d53908c9bcb4386abd5acf438b7e2667244c71f8d9042aec951258c31550a308f2f0b25
7
- data.tar.gz: 8c1ce51ec8f7d1df3000ce7960e510f3fc8d2a1f7326c371bbea8421154e452afea141f1f50111b340c6c528f499af0a65e1c84b571ccda5ec8748fc31dfa682
6
+ metadata.gz: 5ee1fff4f6e74c8a305f00d33622b899f8f1bd6495589f95455e92ad8014a71de0ab8d590edc0742aab3ec563076ad8b523614d106f5f315709434c048bb9c0e
7
+ data.tar.gz: ee1a650aa4f1a0837b6187213796d972f6b18459d019645859f80a613be9a20940525ac012725e098fbe57e35e4174b45ec9dfa2a17c2aa2037e90c0ccd2aa79
data/README.md CHANGED
@@ -1,6 +1,28 @@
1
- # confeature
1
+ <!-- SPDX-License-Identifier: MIT -->
2
2
 
3
- This name is reserved for the Confeature Ruby SDK, a drop-in compatible
4
- alternative to ConfigCat's Ruby SDK. Version 0.0.0 is a placeholder with no code.
3
+ # confeature (Ruby)
5
4
 
6
- See https://confeature.io.
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