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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
require "base64"
|
|
6
|
+
require "digest"
|
|
7
|
+
|
|
8
|
+
module Confeature
|
|
9
|
+
module Internal
|
|
10
|
+
# SDK-key format helpers + cache-key derivation.
|
|
11
|
+
#
|
|
12
|
+
# SECURITY (Constitution security clause + spec 037 FR-008): SDK
|
|
13
|
+
# keys are secrets. They MUST NEVER appear verbatim in any log
|
|
14
|
+
# line. Every log site routes through `redact` first.
|
|
15
|
+
#
|
|
16
|
+
# The cache-key algorithm mirrors `packages/sdk-shared/src/sdk-key.ts`
|
|
17
|
+
# (SHA-256 → base64url → first 32 chars). Cross-language fixture
|
|
18
|
+
# verification lives in `spec/cache_key_fixtures_spec.rb`.
|
|
19
|
+
module SdkKey
|
|
20
|
+
# Wire formats of a valid Confeature SDK key — both are accepted
|
|
21
|
+
# (#802):
|
|
22
|
+
#
|
|
23
|
+
# - `configcat-sdk-1/<22 url-safe chars>/<22 url-safe chars>` —
|
|
24
|
+
# minted today; ConfigCat's own shape, so the stock `configcat`
|
|
25
|
+
# gem accepts the key too.
|
|
26
|
+
# - `confeature-sdk-1-<32 url-safe chars>-<32 url-safe chars>` —
|
|
27
|
+
# minted before #802; still served until the key is rotated.
|
|
28
|
+
#
|
|
29
|
+
# Mirrors `CONFEATURE_SDK_KEY_PATTERN` in
|
|
30
|
+
# `packages/sdk-shared/src/sdk-key.ts`. The shared accept/reject
|
|
31
|
+
# cases live in
|
|
32
|
+
# `packages/test-fixtures-sdk-parity/fixtures/sdk-key-shapes.json`.
|
|
33
|
+
CONFEATURE_SDK_KEY_PATTERN = %r{
|
|
34
|
+
\A
|
|
35
|
+
(?:
|
|
36
|
+
configcat-sdk-1/[A-Za-z0-9_-]{22}/[A-Za-z0-9_-]{22}
|
|
37
|
+
|
|
|
38
|
+
confeature-sdk-1-[A-Za-z0-9_-]{32}-[A-Za-z0-9_-]{32}
|
|
39
|
+
)
|
|
40
|
+
\z
|
|
41
|
+
}x
|
|
42
|
+
|
|
43
|
+
# Envelope prefix for derived cache keys. Identical across every
|
|
44
|
+
# Confeature SDK; mirrors the TS implementation's constant.
|
|
45
|
+
CACHE_KEY_PREFIX = "confeature:cache:v1:"
|
|
46
|
+
|
|
47
|
+
# Return true iff `sdk_key` matches either Confeature SDK-key format
|
|
48
|
+
# (the ConfigCat-shaped `configcat-sdk-1/...` key or the legacy
|
|
49
|
+
# `confeature-sdk-1-...` key).
|
|
50
|
+
#
|
|
51
|
+
# A `false` result does NOT cause the SDK to raise; it triggers an
|
|
52
|
+
# `INVALID_SDK_KEY` log line (which, per the security clause,
|
|
53
|
+
# includes only the redacted form).
|
|
54
|
+
def self.valid_confeature_key_format?(sdk_key)
|
|
55
|
+
return false unless sdk_key.is_a?(String)
|
|
56
|
+
|
|
57
|
+
# Matched on the raw bytes: a key in a broken or non-ASCII-
|
|
58
|
+
# compatible encoding is simply not well-shaped, never an
|
|
59
|
+
# `ArgumentError` / `Encoding::CompatibilityError` (#803).
|
|
60
|
+
CONFEATURE_SDK_KEY_PATTERN.match?(sdk_key.b)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Return a privacy-preserving rendering of `sdk_key` for logs.
|
|
64
|
+
#
|
|
65
|
+
# Strategy: keep ~10% of the key visible at each end (minimum 4
|
|
66
|
+
# chars), mask the middle. For short / unusual keys (< 16 chars)
|
|
67
|
+
# and nil, fall back to a full mask. NEVER returns the raw key.
|
|
68
|
+
#
|
|
69
|
+
# The `/` separators of a ConfigCat-shaped key are plain characters
|
|
70
|
+
# here: for a 61-char `configcat-sdk-1/<22>/<22>` key the head is
|
|
71
|
+
# `config` (no secret) and the tail is the last 6 secret chars —
|
|
72
|
+
# fewer than the 8 shown for a legacy 83-char key.
|
|
73
|
+
#
|
|
74
|
+
# Mirrors `redactSdkKey` from packages/sdk-shared/src/sdk-key.ts.
|
|
75
|
+
def self.redact(sdk_key)
|
|
76
|
+
return "[redacted]" if sdk_key.nil? || !sdk_key.is_a?(String) || sdk_key.empty?
|
|
77
|
+
|
|
78
|
+
key = printable(sdk_key)
|
|
79
|
+
return "[redacted]" if key.nil? || key.length < 16
|
|
80
|
+
|
|
81
|
+
visible = [4, (key.length / 10.0).floor].max
|
|
82
|
+
head = key[0, visible]
|
|
83
|
+
tail = key[-visible, visible]
|
|
84
|
+
"#{head}…[redacted]…#{tail}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# `sdk_key` as valid UTF-8 (bad bytes replaced), so interpolating
|
|
88
|
+
# it next to the UTF-8 "…" never raises an encoding error (#803).
|
|
89
|
+
# A valid UTF-8 key is returned unchanged. nil when no conversion
|
|
90
|
+
# exists.
|
|
91
|
+
def self.printable(sdk_key)
|
|
92
|
+
return sdk_key.scrub if sdk_key.encoding == Encoding::UTF_8
|
|
93
|
+
|
|
94
|
+
sdk_key.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
95
|
+
rescue EncodingError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
private_class_method :printable
|
|
99
|
+
|
|
100
|
+
# Derive a stable, opaque cache key from an SDK key.
|
|
101
|
+
#
|
|
102
|
+
# Format: `confeature:cache:v1:<base64url(sha256(sdk_key))[0, 32]>`.
|
|
103
|
+
#
|
|
104
|
+
# Output is byte-identical to the TS workspace's async
|
|
105
|
+
# `deriveCacheKey` (Web Crypto's `subtle.digest("SHA-256")`).
|
|
106
|
+
# The cross-language fixture at
|
|
107
|
+
# `packages/test-fixtures-sdk-parity/fixtures/cache-key-fixtures.json`
|
|
108
|
+
# pins ≥ 5 (sdkKey, expectedCacheKey) pairs and the Ruby unit test
|
|
109
|
+
# asserts string equality for every entry.
|
|
110
|
+
#
|
|
111
|
+
# SECURITY: the SDK key is HASHED, never stored raw. Even an
|
|
112
|
+
# attacker with filesystem access to the cache directory cannot
|
|
113
|
+
# read the SDK key from a written cache file's name.
|
|
114
|
+
def self.derive_cache_key(sdk_key)
|
|
115
|
+
digest = Digest::SHA256.digest(sdk_key.to_s.b)
|
|
116
|
+
b64 = Base64.urlsafe_encode64(digest, padding: false)
|
|
117
|
+
"#{CACHE_KEY_PREFIX}#{b64[0, 32]}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
require_relative "comparators"
|
|
6
|
+
|
|
7
|
+
module Confeature
|
|
8
|
+
module Internal
|
|
9
|
+
# Segment-condition resolution (v6 + legacy v5). Ported from the spec-117 TS
|
|
10
|
+
# reference (`packages/sdk-shared/src/segments.ts`) and the spec-118 Python
|
|
11
|
+
# port, itself from common-js `RolloutEvaluator.evaluateSegmentCondition`.
|
|
12
|
+
#
|
|
13
|
+
# A segment condition (`{ "s" => index, "c" => comparator }`) references a
|
|
14
|
+
# segment by index into the config's top-level `s[]` (`{ "n" => name,
|
|
15
|
+
# "r" => [...] }`) and a comparator (`IsIn` / `IsNotIn`). The segment's `r[]`
|
|
16
|
+
# entries are **bare user conditions**, AND-grouped: the user IS-IN the
|
|
17
|
+
# segment iff every condition matches. The decision is then optionally
|
|
18
|
+
# negated by `IsNotIn`.
|
|
19
|
+
#
|
|
20
|
+
# The **sensitive-comparator context salt is the segment NAME** (`n`), NOT
|
|
21
|
+
# the setting key — the crucial divergence from a user condition; getting it
|
|
22
|
+
# wrong silently breaks every hashed segment row.
|
|
23
|
+
#
|
|
24
|
+
# Three-valued outcome: `true` (in segment per the comparator), `false` (not
|
|
25
|
+
# in segment), or a `Comparators::ValidationError`. An error is returned
|
|
26
|
+
# **un-negated** — `IsNotIn` never flips an error into a match.
|
|
27
|
+
module Segments
|
|
28
|
+
SEGMENT_IS_IN = 0
|
|
29
|
+
SEGMENT_IS_NOT_IN = 1
|
|
30
|
+
|
|
31
|
+
# A malformed segment reference / name — a fatal config error (fail-closed,
|
|
32
|
+
# caught by the evaluator's rescue-all).
|
|
33
|
+
class SegmentReferenceError < StandardError; end
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# Resolve one segment condition. `evaluate_user_condition` is injected (the
|
|
38
|
+
# evaluator owns it) so this module has no back-reference to the evaluator;
|
|
39
|
+
# it evaluates one segment `r[]` user condition with the segment NAME as
|
|
40
|
+
# the context salt.
|
|
41
|
+
def evaluate_segment_condition(condition, segments, has_user, surface_missing_user, evaluate_user_condition)
|
|
42
|
+
# No user → the whole segment condition is the missing-user error
|
|
43
|
+
# (surfaced once per context), exactly like a user condition.
|
|
44
|
+
return Comparators.error(surface_missing_user.call) unless has_user
|
|
45
|
+
|
|
46
|
+
index = condition["s"]
|
|
47
|
+
unless index.is_a?(Integer) && !boolean?(index) && index >= 0 && index < segments.length
|
|
48
|
+
raise SegmentReferenceError, "Segment reference is invalid."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
segment = segments[index]
|
|
52
|
+
segment_name = segment["n"]
|
|
53
|
+
unless segment_name.is_a?(String) && !segment_name.empty?
|
|
54
|
+
raise SegmentReferenceError,
|
|
55
|
+
"Segment name is missing."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The segment's `r[]` is an AND-group of BARE user conditions; the
|
|
59
|
+
# context salt is the segment NAME. Short-circuit on first non-match.
|
|
60
|
+
in_segment = true
|
|
61
|
+
(segment["r"] || []).each do |uc|
|
|
62
|
+
in_segment = evaluate_user_condition.call(uc, segment_name)
|
|
63
|
+
break if in_segment != true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# An error is returned un-negated — IsNotIn never converts an error into
|
|
67
|
+
# a match (matches common-js: the comparator switch only runs when the
|
|
68
|
+
# segment result is a clean bool).
|
|
69
|
+
return in_segment unless [true, false].include?(in_segment)
|
|
70
|
+
|
|
71
|
+
condition["c"] == SEGMENT_IS_NOT_IN ? !in_segment : in_segment
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def boolean?(value)
|
|
75
|
+
[true, false].include?(value)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
module Confeature
|
|
6
|
+
module Internal
|
|
7
|
+
# ConfigCat-semantics semver parse + compare. Ported field-for-field from
|
|
8
|
+
# the spec-117 TS reference (`packages/sdk-shared/src/semver.ts`) and the
|
|
9
|
+
# spec-118 Python port (`apps/sdk-python/.../semver.py`), which themselves
|
|
10
|
+
# vendor ConfigCat `common-js` `Semver.ts`. A Ruby semver gem is NOT used —
|
|
11
|
+
# ConfigCat's strict-parse + precedence edge cases diverge from those gems
|
|
12
|
+
# and would fail the vendored `testmatrix_semantic*` precedence rows.
|
|
13
|
+
#
|
|
14
|
+
# Key behaviours:
|
|
15
|
+
# - **Strict (non-loose) parse** of the FULL grammar
|
|
16
|
+
# `v?MAJOR.MINOR.PATCH(-prerelease)?(+build)?`, each core component a
|
|
17
|
+
# `0|[1-9]\d*` numeric identifier; an optional leading `v` is allowed.
|
|
18
|
+
# - **Parse on trim.** Callers parse `value.strip`; {.parse} also trims.
|
|
19
|
+
# - **Unparseable → nil**, never an exception. The caller treats nil as a
|
|
20
|
+
# validation error (the rule errors and does not match — FR-010), NOT a
|
|
21
|
+
# silent false.
|
|
22
|
+
# - **Precedence**: numeric major.minor.patch first; then pre-release. A
|
|
23
|
+
# version WITHOUT a pre-release ranks HIGHER than one WITH a pre-release.
|
|
24
|
+
# Pre-release identifiers compared field-by-field — numeric vs numeric
|
|
25
|
+
# numerically; a numeric identifier < an alphanumeric identifier;
|
|
26
|
+
# alphanumeric compared ASCII-lexically; when all preceding fields are
|
|
27
|
+
# equal, MORE fields rank higher than fewer.
|
|
28
|
+
# - **Build metadata is ignored** for precedence (SemVer §10).
|
|
29
|
+
module Semver
|
|
30
|
+
NUMERIC_IDENTIFIER = '0|[1-9]\d*'
|
|
31
|
+
NON_NUMERIC_IDENTIFIER = '\d*[a-zA-Z-][a-zA-Z0-9-]*'
|
|
32
|
+
MAIN_VERSION = "(#{NUMERIC_IDENTIFIER})\\.(#{NUMERIC_IDENTIFIER})\\.(#{NUMERIC_IDENTIFIER})".freeze
|
|
33
|
+
PRERELEASE_IDENTIFIER = "(?:#{NUMERIC_IDENTIFIER}|#{NON_NUMERIC_IDENTIFIER})".freeze
|
|
34
|
+
PRERELEASE = "(?:-(#{PRERELEASE_IDENTIFIER}(?:\\.#{PRERELEASE_IDENTIFIER})*))".freeze
|
|
35
|
+
BUILD_IDENTIFIER = "[0-9A-Za-z-]+"
|
|
36
|
+
BUILD = "(?:\\+(#{BUILD_IDENTIFIER}(?:\\.#{BUILD_IDENTIFIER})*))".freeze
|
|
37
|
+
FULL = /\Av?#{MAIN_VERSION}#{PRERELEASE}?#{BUILD}?\z/
|
|
38
|
+
|
|
39
|
+
MAX_LENGTH = 256
|
|
40
|
+
# Mirrors JS Number.MAX_SAFE_INTEGER (the TS reference's bound).
|
|
41
|
+
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
42
|
+
|
|
43
|
+
NUMERIC_RE = /\A[0-9]+\z/
|
|
44
|
+
|
|
45
|
+
# A parsed semver with a ConfigCat-precedence #compare.
|
|
46
|
+
#
|
|
47
|
+
# `prerelease` is an Array of Integer (numeric ids) | String;
|
|
48
|
+
# `build` is an Array of String (ignored for precedence).
|
|
49
|
+
Version = Struct.new(:version, :major, :minor, :patch, :prerelease, :build, keyword_init: true) do
|
|
50
|
+
# Compare against `other`: <0 lower, 0 equal, >0 higher. Build metadata
|
|
51
|
+
# is ignored. Mirrors common-js `SemVer.compare` → `compareMain ||
|
|
52
|
+
# comparePre`.
|
|
53
|
+
def compare(other)
|
|
54
|
+
return 0 if other.version == version
|
|
55
|
+
|
|
56
|
+
main = compare_main(other)
|
|
57
|
+
return main unless main.zero?
|
|
58
|
+
|
|
59
|
+
compare_pre(other)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def compare_main(other)
|
|
65
|
+
c = Semver.compare_identifiers(major, other.major)
|
|
66
|
+
return c unless c.zero?
|
|
67
|
+
|
|
68
|
+
c = Semver.compare_identifiers(minor, other.minor)
|
|
69
|
+
return c unless c.zero?
|
|
70
|
+
|
|
71
|
+
Semver.compare_identifiers(patch, other.patch)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def compare_pre(other)
|
|
75
|
+
# A version WITHOUT a pre-release is higher-precedence than one WITH.
|
|
76
|
+
return -1 if !prerelease.empty? && other.prerelease.empty?
|
|
77
|
+
return 1 if prerelease.empty? && !other.prerelease.empty?
|
|
78
|
+
return 0 if prerelease.empty? && other.prerelease.empty?
|
|
79
|
+
|
|
80
|
+
i = 0
|
|
81
|
+
loop do
|
|
82
|
+
a = i < prerelease.length ? prerelease[i] : nil
|
|
83
|
+
b = i < other.prerelease.length ? other.prerelease[i] : nil
|
|
84
|
+
return 0 if a.nil? && b.nil?
|
|
85
|
+
return 1 if b.nil?
|
|
86
|
+
return -1 if a.nil?
|
|
87
|
+
|
|
88
|
+
if a == b
|
|
89
|
+
i += 1
|
|
90
|
+
next
|
|
91
|
+
end
|
|
92
|
+
return Semver.compare_identifiers(a, b)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
module_function
|
|
98
|
+
|
|
99
|
+
# Compare two pre-release / core identifiers (common-js
|
|
100
|
+
# `compareIdentifiers`): both numeric → numeric compare; a numeric
|
|
101
|
+
# identifier is lower-precedence than an alphanumeric one; otherwise
|
|
102
|
+
# ASCII-lexical.
|
|
103
|
+
def compare_identifiers(a, b)
|
|
104
|
+
anum = !NUMERIC_RE.match(a.to_s).nil?
|
|
105
|
+
bnum = !NUMERIC_RE.match(b.to_s).nil?
|
|
106
|
+
av = a
|
|
107
|
+
bv = b
|
|
108
|
+
if anum && bnum
|
|
109
|
+
av = a.to_i
|
|
110
|
+
bv = b.to_i
|
|
111
|
+
end
|
|
112
|
+
return 0 if av == bv
|
|
113
|
+
return -1 if anum && !bnum
|
|
114
|
+
return 1 if bnum && !anum
|
|
115
|
+
|
|
116
|
+
av < bv ? -1 : 1
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Parse a version string under ConfigCat's strict grammar. Returns nil on
|
|
120
|
+
# any failure (length, grammar, out-of-range core component) — never
|
|
121
|
+
# raises. Trims the input first (parse on trim). The caller surfaces nil
|
|
122
|
+
# as a validation error (FR-010), never a silent match.
|
|
123
|
+
def parse(version)
|
|
124
|
+
trimmed = version.strip
|
|
125
|
+
return nil if trimmed.length > MAX_LENGTH
|
|
126
|
+
|
|
127
|
+
m = FULL.match(trimmed)
|
|
128
|
+
return nil if m.nil?
|
|
129
|
+
|
|
130
|
+
major = m[1].to_i
|
|
131
|
+
minor = m[2].to_i
|
|
132
|
+
patch = m[3].to_i
|
|
133
|
+
return nil if major.negative? || major > MAX_SAFE_INTEGER
|
|
134
|
+
return nil if minor.negative? || minor > MAX_SAFE_INTEGER
|
|
135
|
+
return nil if patch.negative? || patch > MAX_SAFE_INTEGER
|
|
136
|
+
|
|
137
|
+
prerelease = parse_prerelease(m[4])
|
|
138
|
+
build = m[5] ? m[5].split(".") : []
|
|
139
|
+
|
|
140
|
+
canonical = "#{major}.#{minor}.#{patch}"
|
|
141
|
+
canonical += "-#{prerelease.join(".")}" unless prerelease.empty?
|
|
142
|
+
Version.new(
|
|
143
|
+
version: canonical,
|
|
144
|
+
major: major, minor: minor, patch: patch,
|
|
145
|
+
prerelease: prerelease, build: build
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Numberify numeric pre-release ids (matching common-js).
|
|
150
|
+
def parse_prerelease(raw)
|
|
151
|
+
return [] if raw.nil?
|
|
152
|
+
|
|
153
|
+
raw.split(".").map do |ident|
|
|
154
|
+
if NUMERIC_RE.match(ident)
|
|
155
|
+
num = ident.to_i
|
|
156
|
+
next num if num >= 0 && num < MAX_SAFE_INTEGER
|
|
157
|
+
end
|
|
158
|
+
ident
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
require "logger"
|
|
6
|
+
|
|
7
|
+
module Confeature
|
|
8
|
+
# Confeature log levels. Int values align with stdlib `Logger`
|
|
9
|
+
# severity constants so customers can swap between Confeature levels
|
|
10
|
+
# and stdlib levels transparently.
|
|
11
|
+
module LogLevel
|
|
12
|
+
DEBUG = ::Logger::DEBUG # 0
|
|
13
|
+
INFO = ::Logger::INFO # 1
|
|
14
|
+
WARNING = ::Logger::WARN # 2
|
|
15
|
+
ERROR = ::Logger::ERROR # 3
|
|
16
|
+
OFF = ::Logger::UNKNOWN + 1 # 6 — above every stdlib level
|
|
17
|
+
|
|
18
|
+
ALL = [DEBUG, INFO, WARNING, ERROR, OFF].freeze
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
require "logger"
|
|
6
|
+
|
|
7
|
+
require_relative "log_level"
|
|
8
|
+
|
|
9
|
+
module Confeature
|
|
10
|
+
# Pluggable logger surface.
|
|
11
|
+
#
|
|
12
|
+
# Customers integrate by either:
|
|
13
|
+
# - keeping the default stderr-backed logger (wraps stdlib
|
|
14
|
+
# `::Logger.new($stderr)`), or
|
|
15
|
+
# - subclassing `Confeature::Logger` and passing the instance via
|
|
16
|
+
# the client's `logger:` constructor kwarg.
|
|
17
|
+
#
|
|
18
|
+
# The default level is WARNING (mirrors `configcat`'s default).
|
|
19
|
+
class Logger
|
|
20
|
+
attr_reader :level
|
|
21
|
+
|
|
22
|
+
def initialize(level: LogLevel::WARNING, io: $stderr)
|
|
23
|
+
@level = level
|
|
24
|
+
@logger = ::Logger.new(io)
|
|
25
|
+
@logger.progname = "confeature"
|
|
26
|
+
@logger.level = level_to_stdlib(level)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def error(message)
|
|
30
|
+
@logger.error(message) if @level <= LogLevel::ERROR
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def warning(message)
|
|
34
|
+
@logger.warn(message) if @level <= LogLevel::WARNING
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def info(message)
|
|
38
|
+
@logger.info(message) if @level <= LogLevel::INFO
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def debug(message)
|
|
42
|
+
@logger.debug(message) if @level <= LogLevel::DEBUG
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def level_to_stdlib(level)
|
|
48
|
+
# LogLevel::OFF maps above ::Logger::UNKNOWN so nothing fires.
|
|
49
|
+
return ::Logger::UNKNOWN + 1 if level == LogLevel::OFF
|
|
50
|
+
|
|
51
|
+
level
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Build the SDK's default logger — stderr-backed, WARNING level.
|
|
56
|
+
def self.default_logger
|
|
57
|
+
Logger.new
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
module Confeature
|
|
6
|
+
# Polling-mode factory + value types.
|
|
7
|
+
#
|
|
8
|
+
# Mirrors the upstream `configcat` gem's `PollingMode` module: three
|
|
9
|
+
# factory methods produce frozen value objects that the client wires
|
|
10
|
+
# into the matching poll service (AutoPoll → daemon thread, LazyLoad
|
|
11
|
+
# → on-demand staleness check, ManualPoll → caller-driven).
|
|
12
|
+
module PollingMode
|
|
13
|
+
AutoPoll = Struct.new(:poll_interval_seconds, :max_init_wait_time_seconds, keyword_init: true) do
|
|
14
|
+
def to_s
|
|
15
|
+
"AutoPoll(interval=#{poll_interval_seconds}s, " \
|
|
16
|
+
"max_init_wait=#{max_init_wait_time_seconds}s)"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
LazyLoad = Struct.new(:cache_refresh_interval_seconds, keyword_init: true) do
|
|
21
|
+
def to_s
|
|
22
|
+
"LazyLoad(refresh=#{cache_refresh_interval_seconds}s)"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
ManualPoll = Class.new do
|
|
27
|
+
def to_s
|
|
28
|
+
"ManualPoll"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ==(other)
|
|
32
|
+
other.is_a?(ManualPoll)
|
|
33
|
+
end
|
|
34
|
+
alias_method :eql?, :==
|
|
35
|
+
|
|
36
|
+
def hash
|
|
37
|
+
ManualPoll.hash
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Build an AutoPoll value with the standard ConfigCat defaults.
|
|
42
|
+
def self.auto_poll(poll_interval: 60, max_init_wait_time: 5)
|
|
43
|
+
AutoPoll.new(
|
|
44
|
+
poll_interval_seconds: poll_interval,
|
|
45
|
+
max_init_wait_time_seconds: max_init_wait_time
|
|
46
|
+
).freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Build a LazyLoad value with the standard ConfigCat default.
|
|
50
|
+
def self.lazy_load(cache_refresh_interval: 60)
|
|
51
|
+
LazyLoad.new(cache_refresh_interval_seconds: cache_refresh_interval).freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Singleton ManualPoll value — identity-stable for cheap `equal?`
|
|
55
|
+
# short-circuits when the client checks the active mode.
|
|
56
|
+
MANUAL_POLL = ManualPoll.new.freeze
|
|
57
|
+
|
|
58
|
+
def self.manual_poll
|
|
59
|
+
MANUAL_POLL
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
require_relative "evaluation_details"
|
|
6
|
+
require_relative "internal/evaluator"
|
|
7
|
+
require_relative "internal/override_eval"
|
|
8
|
+
|
|
9
|
+
module Confeature
|
|
10
|
+
# Immutable view of the last-fetched config + a reference to overrides.
|
|
11
|
+
#
|
|
12
|
+
# All methods are synchronous. `get_value` consults overrides FIRST
|
|
13
|
+
# via `Internal::OverrideEval.evaluate_sync`, then falls through to
|
|
14
|
+
# the cached config + boolean evaluator. This is the spec-036
|
|
15
|
+
# correctness path that the Ruby SDK inherits from day one.
|
|
16
|
+
#
|
|
17
|
+
# `fetch_time` is captured at snapshot construction time so
|
|
18
|
+
# `EvaluationDetails.fetch_time` is immutable across the snapshot's
|
|
19
|
+
# life (spec 086 / #266).
|
|
20
|
+
class Snapshot
|
|
21
|
+
def initialize(config:, flag_overrides:, default_user:, fetch_time: nil)
|
|
22
|
+
@config = config
|
|
23
|
+
@flag_overrides = flag_overrides
|
|
24
|
+
@default_user = default_user
|
|
25
|
+
@fetch_time = fetch_time
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def get_value(key, default, user = nil)
|
|
30
|
+
# Override path FIRST — spec 036 correctness. Route through the
|
|
31
|
+
# full-config-aware router (spec 135 / #431) so a rule-bearing
|
|
32
|
+
# override source resolves targeting rules; the flat-map source is
|
|
33
|
+
# unchanged.
|
|
34
|
+
effective_user = user || @default_user
|
|
35
|
+
found, value = Internal::OverrideEval.evaluate(@flag_overrides, key, default, effective_user)
|
|
36
|
+
return value if found
|
|
37
|
+
|
|
38
|
+
result = Internal::Evaluator.evaluate(
|
|
39
|
+
@config, key, default, effective_user
|
|
40
|
+
)
|
|
41
|
+
result.value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def get_all_keys
|
|
45
|
+
keys = []
|
|
46
|
+
if @config.is_a?(Hash)
|
|
47
|
+
settings = @config["f"]
|
|
48
|
+
keys.concat(settings.keys) if settings.is_a?(Hash)
|
|
49
|
+
end
|
|
50
|
+
if @flag_overrides
|
|
51
|
+
begin
|
|
52
|
+
override_map = @flag_overrides.data_source.get_overrides_sync
|
|
53
|
+
rescue StandardError
|
|
54
|
+
override_map = nil
|
|
55
|
+
end
|
|
56
|
+
keys.concat(override_map.keys) if override_map.is_a?(Hash)
|
|
57
|
+
end
|
|
58
|
+
keys.uniq.sort
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Spec 086 / #266 — typed-envelope mirror of `get_value`.
|
|
62
|
+
#
|
|
63
|
+
# For LocalOnly consumers, the override map is the only source of
|
|
64
|
+
# truth: a key present in the map yields the typed value with
|
|
65
|
+
# `error: nil`; a missing key yields the default with
|
|
66
|
+
# `error: "KEY_NOT_FOUND"`. The override-eval helper returns the
|
|
67
|
+
# default on both KEY_NOT_FOUND and TYPE_MISMATCH branches,
|
|
68
|
+
# indistinguishable from a legitimate override whose value equals
|
|
69
|
+
# the default — Snapshot re-queries the map to disambiguate.
|
|
70
|
+
#
|
|
71
|
+
# Non-LocalOnly clients route through the evaluator; the
|
|
72
|
+
# `EvaluationResult.error` string propagates verbatim into the
|
|
73
|
+
# envelope's `error` field.
|
|
74
|
+
def get_value_details(key, default, user = nil)
|
|
75
|
+
effective_user = user || @default_user
|
|
76
|
+
return local_only_details(key, default, effective_user) if local_only?
|
|
77
|
+
|
|
78
|
+
result = Internal::Evaluator.evaluate(@config, key, default, effective_user)
|
|
79
|
+
matched_rule = result.matched_targeting_rule && TargetingRule.new(result.matched_targeting_rule)
|
|
80
|
+
matched_option = result.matched_percentage_option && PercentageOption.new(result.matched_percentage_option)
|
|
81
|
+
EvaluationDetails.new(
|
|
82
|
+
key: key,
|
|
83
|
+
value: result.value,
|
|
84
|
+
variation_id: result.variation_id,
|
|
85
|
+
user: effective_user,
|
|
86
|
+
is_default_value: !result.error.nil?,
|
|
87
|
+
error: result.error,
|
|
88
|
+
fetch_time: @fetch_time,
|
|
89
|
+
matched_targeting_rule: matched_rule,
|
|
90
|
+
matched_percentage_option: matched_option
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def local_only?
|
|
97
|
+
return false if @flag_overrides.nil?
|
|
98
|
+
|
|
99
|
+
@flag_overrides.behaviour == OverrideBehaviour::LOCAL_ONLY
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def local_only_details(key, default, effective_user)
|
|
103
|
+
# Full-config override source → resolve through the real evaluator
|
|
104
|
+
# so targeting rules apply AND the served `variation_id` survives
|
|
105
|
+
# to the PUBLIC details envelope (spec 152 / #475, US2; closes the
|
|
106
|
+
# #395 gap class). Dispatch on the source TYPE, never the resolved
|
|
107
|
+
# value, mirroring `get_value`'s `OverrideEval.evaluate` router.
|
|
108
|
+
found_full, result = Internal::OverrideEval.evaluate_full_config_override_details_sync(
|
|
109
|
+
@flag_overrides, key, default, effective_user
|
|
110
|
+
)
|
|
111
|
+
if found_full
|
|
112
|
+
matched_rule = result.matched_targeting_rule && TargetingRule.new(result.matched_targeting_rule)
|
|
113
|
+
matched_option = result.matched_percentage_option && PercentageOption.new(result.matched_percentage_option)
|
|
114
|
+
return EvaluationDetails.new(
|
|
115
|
+
key: key,
|
|
116
|
+
value: result.value,
|
|
117
|
+
variation_id: result.variation_id,
|
|
118
|
+
user: effective_user,
|
|
119
|
+
is_default_value: !result.error.nil?,
|
|
120
|
+
error: result.error,
|
|
121
|
+
fetch_time: @fetch_time,
|
|
122
|
+
matched_targeting_rule: matched_rule,
|
|
123
|
+
matched_percentage_option: matched_option
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
found, value = Internal::OverrideEval.evaluate_sync(@flag_overrides, key, default)
|
|
128
|
+
data_source = @flag_overrides.data_source
|
|
129
|
+
overrides_map =
|
|
130
|
+
if data_source.respond_to?(:get_overrides_sync)
|
|
131
|
+
begin
|
|
132
|
+
data_source.get_overrides_sync
|
|
133
|
+
rescue StandardError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
present = overrides_map.is_a?(Hash) && overrides_map.key?(key)
|
|
138
|
+
|
|
139
|
+
if found && present
|
|
140
|
+
EvaluationDetails.new(
|
|
141
|
+
key: key,
|
|
142
|
+
value: value,
|
|
143
|
+
user: effective_user,
|
|
144
|
+
fetch_time: @fetch_time
|
|
145
|
+
)
|
|
146
|
+
else
|
|
147
|
+
EvaluationDetails.new(
|
|
148
|
+
key: key,
|
|
149
|
+
value: default,
|
|
150
|
+
is_default_value: true,
|
|
151
|
+
error: "KEY_NOT_FOUND",
|
|
152
|
+
user: effective_user,
|
|
153
|
+
fetch_time: @fetch_time
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|