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,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
require "json"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
require_relative "sdk_key"
|
|
10
|
+
|
|
11
|
+
module Confeature
|
|
12
|
+
module Internal
|
|
13
|
+
# HTTP layer for fetching `config_v6.json` from a Confeature CDN.
|
|
14
|
+
#
|
|
15
|
+
# Wraps stdlib `Net::HTTP` with:
|
|
16
|
+
# - User-Agent: `confeature-ruby-sdk/{VERSION}` (FR-012).
|
|
17
|
+
# - If-None-Match support for 304 reuse (FR-011).
|
|
18
|
+
# - Configurable request timeout (default 30 s, FR-007).
|
|
19
|
+
# - SDK key redaction in every log line (FR-013): the raw key is
|
|
20
|
+
# NEVER interpolated into messages; `SdkKey.redact` is the only
|
|
21
|
+
# path.
|
|
22
|
+
#
|
|
23
|
+
# `FetchResult.status` is one of:
|
|
24
|
+
# - :fetched — HTTP 200, fresh body + new ETag.
|
|
25
|
+
# - :not_modified — HTTP 304, caller keeps its cached body.
|
|
26
|
+
# - :failed — network error / non-2xx / timeout. `error`
|
|
27
|
+
# carries a FIXED, key-free reason code (#803 FR-002).
|
|
28
|
+
#
|
|
29
|
+
# The request URL carries the SDK key, as in every ConfigCat SDK —
|
|
30
|
+
# the `configcat` gem builds `<base>/configuration-files/<sdk_key>/
|
|
31
|
+
# config_v6.json` (configcat-8.0.1 `lib/configcat/configfetcher.rb:168`).
|
|
32
|
+
# So no exception text is ever passed on (it may quote the URL): only
|
|
33
|
+
# the exception's class picks a fixed reason code.
|
|
34
|
+
class ConfigFetcher
|
|
35
|
+
DEFAULT_BASE_URL = "https://cdn-global.confeature.io"
|
|
36
|
+
|
|
37
|
+
# Reason code when the key cannot be placed in a path segment
|
|
38
|
+
# ("", "." or "..") — the fetcher refuses to fetch (#803 FR-001).
|
|
39
|
+
INVALID_SDK_KEY = "INVALID_SDK_KEY"
|
|
40
|
+
|
|
41
|
+
# Reason code when the base URL cannot form an absolute http(s)
|
|
42
|
+
# URL — no request is made (#803 FR-002; sdk-go's code).
|
|
43
|
+
INVALID_REQUEST = "invalid_request"
|
|
44
|
+
|
|
45
|
+
# Transport failures -> fixed, key-free reason codes (#803 FR-002;
|
|
46
|
+
# mirrors sdk-go's `classifyError`). Order matters: a timeout is
|
|
47
|
+
# checked first (`Net::OpenTimeout` etc. descend from
|
|
48
|
+
# `Timeout::Error`).
|
|
49
|
+
TIMEOUT_ERRORS = [Timeout::Error].freeze
|
|
50
|
+
CONNECTION_RESET_ERRORS = [
|
|
51
|
+
EOFError, Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE, IOError
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
# Bytes kept as-is in a path segment — Go's `url.PathEscape` set:
|
|
55
|
+
# unreserved chars plus `$ & + : = @`. Everything else
|
|
56
|
+
# (including `/`, `?`, `;`, `,`, `%`) is percent-encoded.
|
|
57
|
+
PATH_SEGMENT_UNSAFE = /[^A-Za-z0-9\-_.~$&+:=@]/n
|
|
58
|
+
|
|
59
|
+
FetchResult = Struct.new(:status, :body, :etag, :error, keyword_init: true)
|
|
60
|
+
|
|
61
|
+
attr_reader :url, :user_agent
|
|
62
|
+
|
|
63
|
+
def initialize(sdk_key:, version:, logger:, base_url: nil, request_timeout_seconds: 30.0)
|
|
64
|
+
raise ArgumentError, "request_timeout_seconds must be > 0" if request_timeout_seconds <= 0
|
|
65
|
+
|
|
66
|
+
@sdk_key = sdk_key
|
|
67
|
+
@logger = logger
|
|
68
|
+
@timeout = request_timeout_seconds
|
|
69
|
+
@user_agent = "confeature-ruby-sdk/#{version}"
|
|
70
|
+
# Every trailing "/" goes, so `<base>//configuration-files` never
|
|
71
|
+
# happens. The URL is NOT parsed here: a bad base URL must not
|
|
72
|
+
# raise from the constructor (a `URI::InvalidURIError` quotes the
|
|
73
|
+
# whole URL, key included). `fetch` parses it and maps a failure
|
|
74
|
+
# to the fixed `invalid_request` code (#803 FR-002).
|
|
75
|
+
@base_url = (base_url || DEFAULT_BASE_URL).to_s.sub(%r{/+\z}, "")
|
|
76
|
+
@url = self.class.config_url(@base_url, sdk_key)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The ONE function that builds the config URL (#803 FR-001) —
|
|
80
|
+
# ConfigCat's `<base>/configuration-files/<sdkKey>/config_v6.json`.
|
|
81
|
+
#
|
|
82
|
+
# - A key matching the shared shape (`sdk-key-shapes.json`) goes
|
|
83
|
+
# in RAW: split on `/`, each part path-segment-escaped (a no-op
|
|
84
|
+
# for a valid key), so `configcat-sdk-1/<22>/<22>` is three
|
|
85
|
+
# segments — the stock `configcat` gem's exact bytes.
|
|
86
|
+
# - Any other key is escaped into ONE segment (Go's
|
|
87
|
+
# `url.PathEscape`). When that segment is "", "." or "..", no
|
|
88
|
+
# escaper keeps it in place: returns nil and the fetcher refuses
|
|
89
|
+
# to fetch.
|
|
90
|
+
def self.config_url(base_url, sdk_key)
|
|
91
|
+
key = sdk_key.to_s
|
|
92
|
+
segments =
|
|
93
|
+
if SdkKey.valid_confeature_key_format?(key)
|
|
94
|
+
key.split("/").map { |part| escape_path_segment(part) }
|
|
95
|
+
else
|
|
96
|
+
segment = escape_path_segment(key)
|
|
97
|
+
return nil if ["", ".", ".."].include?(segment)
|
|
98
|
+
|
|
99
|
+
[segment]
|
|
100
|
+
end
|
|
101
|
+
"#{base_url}/configuration-files/#{segments.join("/")}/config_v6.json"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.escape_path_segment(part)
|
|
105
|
+
part.b.gsub(PATH_SEGMENT_UNSAFE) { |byte| format("%%%02X", byte.ord) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Reduce a transport / body-read exception to a FIXED, key-free
|
|
109
|
+
# reason code (#803 FR-002). The message is never read.
|
|
110
|
+
def self.classify_error(exc)
|
|
111
|
+
return "timeout" if TIMEOUT_ERRORS.any? { |klass| exc.is_a?(klass) }
|
|
112
|
+
return "connection_reset" if CONNECTION_RESET_ERRORS.any? { |klass| exc.is_a?(klass) }
|
|
113
|
+
|
|
114
|
+
"network_error"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Fetch the config. Never raises — fail-closed.
|
|
118
|
+
def fetch(etag = nil)
|
|
119
|
+
return refuse_invalid_key if @url.nil?
|
|
120
|
+
|
|
121
|
+
uri = request_uri
|
|
122
|
+
return fail_with(INVALID_REQUEST) if uri.nil?
|
|
123
|
+
|
|
124
|
+
http = build_http(uri)
|
|
125
|
+
request = Net::HTTP::Get.new(uri.request_uri, build_headers(etag))
|
|
126
|
+
response = http.request(request)
|
|
127
|
+
parse(response)
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
handle_exception(e)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# No persistent connection in v1 (one connection per fetch keeps
|
|
133
|
+
# the implementation tiny and matches the synchronous polling
|
|
134
|
+
# semantics). Method retained for API symmetry with the other
|
|
135
|
+
# SDKs and to give future connection-pooling somewhere to land.
|
|
136
|
+
def close
|
|
137
|
+
# No-op in v1.
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# The parsed request URL, or nil when the base URL cannot form an
|
|
143
|
+
# absolute http(s) URL. The `URI::Error` is dropped here, unread:
|
|
144
|
+
# its message quotes the whole URL, key included.
|
|
145
|
+
#
|
|
146
|
+
# The key path `config_url` builds never holds a raw `?` or `#` (a
|
|
147
|
+
# well-shaped key has none; `escape_path_segment` escapes them), so
|
|
148
|
+
# a query or fragment here can only come from a base URL holding
|
|
149
|
+
# `?` or `#` — which would move the key out of the path, into the
|
|
150
|
+
# query string (sent, and logged by proxies as a parameter) or the
|
|
151
|
+
# fragment (never sent). Such a base is refused: no request (as
|
|
152
|
+
# sdk-dart's `_parseTarget`).
|
|
153
|
+
def request_uri
|
|
154
|
+
uri = URI.parse(@url)
|
|
155
|
+
return nil unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
156
|
+
return nil unless uri.query.nil? && uri.fragment.nil?
|
|
157
|
+
|
|
158
|
+
uri
|
|
159
|
+
rescue URI::Error
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def build_http(uri)
|
|
164
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
165
|
+
http.use_ssl = uri.scheme == "https"
|
|
166
|
+
http.open_timeout = @timeout
|
|
167
|
+
http.read_timeout = @timeout
|
|
168
|
+
http.write_timeout = @timeout if http.respond_to?(:write_timeout=)
|
|
169
|
+
http
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def build_headers(etag)
|
|
173
|
+
headers = {
|
|
174
|
+
"User-Agent" => @user_agent,
|
|
175
|
+
"Accept" => "application/json"
|
|
176
|
+
}
|
|
177
|
+
headers["If-None-Match"] = etag if etag && !etag.empty?
|
|
178
|
+
headers
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def parse(response)
|
|
182
|
+
case response
|
|
183
|
+
when Net::HTTPNotModified
|
|
184
|
+
FetchResult.new(status: :not_modified, etag: response["etag"])
|
|
185
|
+
when Net::HTTPSuccess
|
|
186
|
+
parse_success(response)
|
|
187
|
+
else
|
|
188
|
+
reason = "HTTP #{response.code}"
|
|
189
|
+
@logger.error(
|
|
190
|
+
"ConfigFetcher: fetch failed for sdkKey=#{SdkKey.redact(@sdk_key)} reason=#{reason}"
|
|
191
|
+
)
|
|
192
|
+
FetchResult.new(status: :failed, error: reason)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def parse_success(response)
|
|
197
|
+
body = JSON.parse(response.body)
|
|
198
|
+
unless body.is_a?(Hash)
|
|
199
|
+
@logger.error(
|
|
200
|
+
"ConfigFetcher: fetch returned non-object JSON for " \
|
|
201
|
+
"sdkKey=#{SdkKey.redact(@sdk_key)}"
|
|
202
|
+
)
|
|
203
|
+
return FetchResult.new(status: :failed, error: "MALFORMED_JSON")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Shape validation: a valid `config_v6.json` carries the
|
|
207
|
+
# `{p, f, s}` top-level shape. sdk-node / sdk-python / sdk-go
|
|
208
|
+
# tightened the same check so the corpus-wide
|
|
209
|
+
# `wire-cases.json::wrong-shape-200` case surfaces the same
|
|
210
|
+
# `parseFailed` everywhere (epic #146 / issue #148). A 200
|
|
211
|
+
# with a body that does not match the contract is a stronger
|
|
212
|
+
# signal of CDN drift than a 5xx and deserves its own typed
|
|
213
|
+
# error class. Probing the `f` key alone is sufficient — it
|
|
214
|
+
# is the only mandatory top-level key in the schema.
|
|
215
|
+
unless body.key?("f")
|
|
216
|
+
@logger.error(
|
|
217
|
+
"ConfigFetcher: fetch returned JSON without 'f' key for " \
|
|
218
|
+
"sdkKey=#{SdkKey.redact(@sdk_key)}"
|
|
219
|
+
)
|
|
220
|
+
return FetchResult.new(status: :failed, error: "MALFORMED_JSON")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
FetchResult.new(status: :fetched, body: body, etag: response["etag"])
|
|
224
|
+
rescue JSON::ParserError => e
|
|
225
|
+
@logger.error(
|
|
226
|
+
"ConfigFetcher: fetch returned non-JSON body for " \
|
|
227
|
+
"sdkKey=#{SdkKey.redact(@sdk_key)}: #{e.class}"
|
|
228
|
+
)
|
|
229
|
+
FetchResult.new(status: :failed, error: "MALFORMED_JSON")
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def refuse_invalid_key
|
|
233
|
+
@logger.error(
|
|
234
|
+
"ConfigFetcher: #{INVALID_SDK_KEY} for sdkKey=#{SdkKey.redact(@sdk_key)} " \
|
|
235
|
+
"— the key cannot form a request path; not fetching"
|
|
236
|
+
)
|
|
237
|
+
FetchResult.new(status: :failed, error: INVALID_SDK_KEY)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def handle_exception(exc)
|
|
241
|
+
# The URL holds the SDK key and exception messages may quote it:
|
|
242
|
+
# only the exception's class picks a fixed reason code.
|
|
243
|
+
fail_with(self.class.classify_error(exc))
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def fail_with(reason)
|
|
247
|
+
@logger.error(
|
|
248
|
+
"ConfigFetcher: fetch failed for sdkKey=#{SdkKey.redact(@sdk_key)} reason=#{reason}"
|
|
249
|
+
)
|
|
250
|
+
FetchResult.new(status: :failed, error: reason)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "comparators"
|
|
7
|
+
require_relative "prerequisites"
|
|
8
|
+
require_relative "segments"
|
|
9
|
+
|
|
10
|
+
module Confeature
|
|
11
|
+
module Internal
|
|
12
|
+
# ConfigCat-compatible evaluation-log trace builder (ADR 0003 / epic #494,
|
|
13
|
+
# Ruby slice of #501).
|
|
14
|
+
#
|
|
15
|
+
# Ruby port of the spec-117 TS reference
|
|
16
|
+
# (`packages/sdk-shared/src/eval-log.ts` `EvaluateLogBuilder`), the Python
|
|
17
|
+
# port (`apps/sdk-python/src/confeature_client/eval_log.py`), and the Go port
|
|
18
|
+
# (`apps/sdk-go/internal/evaluator/eval_log.go`). Produces the byte-identical
|
|
19
|
+
# indented textual trace ConfigCat's SDKs emit during flag evaluation — its
|
|
20
|
+
# `evaluationlog/` corpus (replayed via
|
|
21
|
+
# `packages/test-fixtures-sdk-parity/fixtures/parity-evaluationlog.json`) is
|
|
22
|
+
# the byte-oracle. This builder mirrors the TS class line-for-line so the
|
|
23
|
+
# federation ports stay consistent.
|
|
24
|
+
#
|
|
25
|
+
# The trace is emitted via the trace logger at INFO under event id 5000
|
|
26
|
+
# (ConfigCat's verbosity contract) — see `Evaluator.evaluate_traced`. The
|
|
27
|
+
# strings are ported VERBATIM from the canonical (Principle II).
|
|
28
|
+
module EvalLog
|
|
29
|
+
# ConfigCat's event id for the "config evaluated" INFO log line. Captured
|
|
30
|
+
# as `... [5000] ...`, matching the upstream oracle.
|
|
31
|
+
EVAL_LOG_EVENT_ID = "5000"
|
|
32
|
+
|
|
33
|
+
# ConfigCat indents each nested eval-log line by two spaces per level.
|
|
34
|
+
INDENT_UNIT = " "
|
|
35
|
+
|
|
36
|
+
# Placeholders ConfigCat substitutes for malformed condition parts (parity
|
|
37
|
+
# with configcat-common's EvaluateLogBuilder).
|
|
38
|
+
INVALID_VALUE_PLACEHOLDER = "<invalid value>"
|
|
39
|
+
INVALID_NAME_PLACEHOLDER = "<invalid name>"
|
|
40
|
+
INVALID_OPERATOR_PLACEHOLDER = "<invalid operator>"
|
|
41
|
+
|
|
42
|
+
# Max IS-ONE-OF / list items rendered before truncation (configcat parity).
|
|
43
|
+
STRING_LIST_MAX_LENGTH = 10
|
|
44
|
+
|
|
45
|
+
C = Comparators
|
|
46
|
+
|
|
47
|
+
# Per-comparator eval-log metadata (configcat-common parity), as
|
|
48
|
+
# `[token, kind, sensitive]` — `token` is the operator rendering, `kind`
|
|
49
|
+
# selects the condition value slot ("l"=list, "s"=string, "n"=number,
|
|
50
|
+
# "dt"=datetime), `sensitive` masks the value as `<hashed value>`. One
|
|
51
|
+
# table drives the condition rendering, mirroring eval-log.ts
|
|
52
|
+
# COMPARATOR_META.
|
|
53
|
+
COMPARATOR_META = {
|
|
54
|
+
C::TEXT_IS_ONE_OF => ["IS ONE OF", "l", false],
|
|
55
|
+
C::TEXT_IS_NOT_ONE_OF => ["IS NOT ONE OF", "l", false],
|
|
56
|
+
C::TEXT_CONTAINS_ANY_OF => ["CONTAINS ANY OF", "l", false],
|
|
57
|
+
C::TEXT_NOT_CONTAINS_ANY_OF => ["NOT CONTAINS ANY OF", "l", false],
|
|
58
|
+
C::SEMVER_IS_ONE_OF => ["IS ONE OF", "l", false],
|
|
59
|
+
C::SEMVER_IS_NOT_ONE_OF => ["IS NOT ONE OF", "l", false],
|
|
60
|
+
C::SEMVER_LESS => ["<", "s", false],
|
|
61
|
+
C::SEMVER_LESS_OR_EQUALS => ["<=", "s", false],
|
|
62
|
+
C::SEMVER_GREATER => [">", "s", false],
|
|
63
|
+
C::SEMVER_GREATER_OR_EQUALS => [">=", "s", false],
|
|
64
|
+
C::NUMBER_EQUALS => ["=", "n", false],
|
|
65
|
+
C::NUMBER_NOT_EQUALS => ["!=", "n", false],
|
|
66
|
+
C::NUMBER_LESS => ["<", "n", false],
|
|
67
|
+
C::NUMBER_LESS_OR_EQUALS => ["<=", "n", false],
|
|
68
|
+
C::NUMBER_GREATER => [">", "n", false],
|
|
69
|
+
C::NUMBER_GREATER_OR_EQUALS => [">=", "n", false],
|
|
70
|
+
C::SENSITIVE_TEXT_IS_ONE_OF => ["IS ONE OF", "l", true],
|
|
71
|
+
C::SENSITIVE_TEXT_IS_NOT_ONE_OF => ["IS NOT ONE OF", "l", true],
|
|
72
|
+
C::DATETIME_BEFORE => ["BEFORE", "dt", false],
|
|
73
|
+
C::DATETIME_AFTER => ["AFTER", "dt", false],
|
|
74
|
+
C::SENSITIVE_TEXT_EQUALS => ["EQUALS", "s", true],
|
|
75
|
+
C::SENSITIVE_TEXT_NOT_EQUALS => ["NOT EQUALS", "s", true],
|
|
76
|
+
C::SENSITIVE_TEXT_STARTS_WITH_ANY_OF => ["STARTS WITH ANY OF", "l", true],
|
|
77
|
+
C::SENSITIVE_TEXT_NOT_STARTS_WITH_ANY_OF => ["NOT STARTS WITH ANY OF", "l", true],
|
|
78
|
+
C::SENSITIVE_TEXT_ENDS_WITH_ANY_OF => ["ENDS WITH ANY OF", "l", true],
|
|
79
|
+
C::SENSITIVE_TEXT_NOT_ENDS_WITH_ANY_OF => ["NOT ENDS WITH ANY OF", "l", true],
|
|
80
|
+
C::SENSITIVE_ARRAY_CONTAINS_ANY_OF => ["ARRAY CONTAINS ANY OF", "l", true],
|
|
81
|
+
C::SENSITIVE_ARRAY_NOT_CONTAINS_ANY_OF => ["ARRAY NOT CONTAINS ANY OF", "l", true],
|
|
82
|
+
C::TEXT_EQUALS => ["EQUALS", "s", false],
|
|
83
|
+
C::TEXT_NOT_EQUALS => ["NOT EQUALS", "s", false],
|
|
84
|
+
C::TEXT_STARTS_WITH_ANY_OF => ["STARTS WITH ANY OF", "l", false],
|
|
85
|
+
C::TEXT_NOT_STARTS_WITH_ANY_OF => ["NOT STARTS WITH ANY OF", "l", false],
|
|
86
|
+
C::TEXT_ENDS_WITH_ANY_OF => ["ENDS WITH ANY OF", "l", false],
|
|
87
|
+
C::TEXT_NOT_ENDS_WITH_ANY_OF => ["NOT ENDS WITH ANY OF", "l", false],
|
|
88
|
+
C::ARRAY_CONTAINS_ANY_OF => ["ARRAY CONTAINS ANY OF", "l", false],
|
|
89
|
+
C::ARRAY_NOT_CONTAINS_ANY_OF => ["ARRAY NOT CONTAINS ANY OF", "l", false]
|
|
90
|
+
}.freeze
|
|
91
|
+
|
|
92
|
+
module_function
|
|
93
|
+
|
|
94
|
+
# Render a `UserComparator` as ConfigCat's eval-log operator token.
|
|
95
|
+
def format_user_comparator(comparator)
|
|
96
|
+
meta = COMPARATOR_META[comparator]
|
|
97
|
+
meta.nil? ? INVALID_OPERATOR_PLACEHOLDER : meta[0]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Format a string list exactly as configcat-common's `formatStringList`:
|
|
101
|
+
# `'a', 'b'` joined by `, `, with an optional truncation appendix once
|
|
102
|
+
# `max_length` is exceeded.
|
|
103
|
+
def format_string_list(items, max_length)
|
|
104
|
+
return "" if items.empty?
|
|
105
|
+
|
|
106
|
+
appendix = ""
|
|
107
|
+
shown = items
|
|
108
|
+
if max_length.positive? && items.length > max_length
|
|
109
|
+
shown = items[0, max_length]
|
|
110
|
+
count = items.length - max_length
|
|
111
|
+
word = count == 1 ? "value" : "values"
|
|
112
|
+
appendix = ", ... <#{count} more #{word}>"
|
|
113
|
+
end
|
|
114
|
+
"'#{shown.join("', '")}'#{appendix}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Format a resolved SETTING value for the eval log (ConfigCat parity):
|
|
118
|
+
# boolean → `true`/`false` (lowercase), string → verbatim, number → its
|
|
119
|
+
# canonical decimal string. Mirrors the `'<value>'` token in ConfigCat's
|
|
120
|
+
# `Returning '<value>'.` footer (eval-log.ts `settingValueToString`).
|
|
121
|
+
def setting_value_to_log_string(value)
|
|
122
|
+
return value ? "true" : "false" if [true, false].include?(value)
|
|
123
|
+
return value if value.is_a?(String)
|
|
124
|
+
return Comparators.number_to_canonical_string(value) if value.is_a?(Numeric)
|
|
125
|
+
|
|
126
|
+
""
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Render a Unix-seconds value as ConfigCat's eval-log datetime suffix:
|
|
130
|
+
# JS `new Date(value*1000).toISOString()`, e.g. `2023-04-01T00:00:00.000Z`
|
|
131
|
+
# (always millisecond precision, UTC). Returns nil for non-finite values
|
|
132
|
+
# (NaN / ±Inf), matching the JS `isNaN(date)` guard.
|
|
133
|
+
def unix_seconds_to_iso(seconds)
|
|
134
|
+
f = seconds.to_f
|
|
135
|
+
return nil if f.nan? || f.infinite?
|
|
136
|
+
|
|
137
|
+
ms = (f * 1000).round
|
|
138
|
+
t = Time.at(ms / 1000, ms % 1000, :millisecond, in: "+00:00")
|
|
139
|
+
"#{t.strftime("%Y-%m-%dT%H:%M:%S")}.#{format("%03d", ms % 1000)}Z"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Three-valued THEN consequence (MATCH / no match / error string). Mirrors
|
|
143
|
+
# TS's `boolean | string`. `kind` is `:true` / `:false` / `:error`;
|
|
144
|
+
# `err_text` carries the reason for the error case.
|
|
145
|
+
MatchOrError = Struct.new(:kind, :err_text)
|
|
146
|
+
|
|
147
|
+
def match_true
|
|
148
|
+
MatchOrError.new(:match, nil)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def match_false
|
|
152
|
+
MatchOrError.new(:no_match, nil)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def match_error(text)
|
|
156
|
+
MatchOrError.new(:errored, text)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Ruby analogue of eval-log.ts `EvaluateLogBuilder`. indent/new_line/append
|
|
160
|
+
# primitives + the condition-rendering helpers. `eol` defaults to "\n" —
|
|
161
|
+
# the byte the upstream oracle uses.
|
|
162
|
+
class Builder
|
|
163
|
+
def initialize(eol: "\n")
|
|
164
|
+
@chunks = []
|
|
165
|
+
@indent_level = 0
|
|
166
|
+
@eol = eol
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Append text to the current line.
|
|
170
|
+
def append(text)
|
|
171
|
+
@chunks << text
|
|
172
|
+
self
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Break to a new line at the current indent, then optionally append text.
|
|
176
|
+
def new_line(text = nil)
|
|
177
|
+
@chunks << (@eol + (INDENT_UNIT * @indent_level))
|
|
178
|
+
@chunks << text unless text.nil?
|
|
179
|
+
self
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def increase_indent
|
|
183
|
+
@indent_level += 1
|
|
184
|
+
self
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def decrease_indent
|
|
188
|
+
@indent_level -= 1 if @indent_level.positive?
|
|
189
|
+
self
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# ---- condition formatting --------------------------------------------
|
|
193
|
+
|
|
194
|
+
# Render one user condition (`User.<attr> <COMP> <value>`). The compact
|
|
195
|
+
# condition carries the value in `l` (list), `s` (string /
|
|
196
|
+
# sensitive-equals), or `d` (number / datetime), selected by the
|
|
197
|
+
# comparator kind.
|
|
198
|
+
def append_user_condition(condition)
|
|
199
|
+
attr = condition["a"].is_a?(String) ? condition["a"] : INVALID_NAME_PLACEHOLDER
|
|
200
|
+
cmp = condition["c"]
|
|
201
|
+
cmp = -1 unless cmp.is_a?(Integer) && ![true, false].include?(cmp)
|
|
202
|
+
meta = COMPARATOR_META[cmp]
|
|
203
|
+
return append_unknown_user_condition(attr, cmp, condition) if meta.nil?
|
|
204
|
+
|
|
205
|
+
_, kind, sensitive = meta
|
|
206
|
+
dispatch_user_condition(attr, cmp, kind, sensitive, condition)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Append a single condition's boolean result (` => true` / ` => false…`).
|
|
210
|
+
def append_condition_consequence(result)
|
|
211
|
+
append(" => #{bool_to_log_string(result)}")
|
|
212
|
+
result ? self : append(", skipping the remaining AND conditions")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Append a targeting rule's THEN consequence. `then_value` is the rule's
|
|
216
|
+
# formatted served value (e.g. `Dog`) for a fixed-value rule, or nil for
|
|
217
|
+
# a rule serving `% options`. `match` is MATCH / no match / an error.
|
|
218
|
+
def append_targeting_rule_consequence(then_value, match, newline)
|
|
219
|
+
increase_indent
|
|
220
|
+
# `newline` (the param) must NOT shadow the `new_line` builder method:
|
|
221
|
+
# break to a fresh line when the rule had conditions, else a single
|
|
222
|
+
# space before `THEN` (mirrors eval-log.ts appendTargetingRuleConsequence).
|
|
223
|
+
if newline
|
|
224
|
+
new_line
|
|
225
|
+
else
|
|
226
|
+
append(" ")
|
|
227
|
+
end
|
|
228
|
+
append("THEN")
|
|
229
|
+
append(then_value.nil? ? " % options" : " '#{then_value}'")
|
|
230
|
+
append(" => ")
|
|
231
|
+
case match.kind
|
|
232
|
+
when :match then append("MATCH, applying rule")
|
|
233
|
+
when :no_match then append("no match")
|
|
234
|
+
else append(match.err_text)
|
|
235
|
+
end
|
|
236
|
+
decrease_indent
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Append a prerequisite-flag condition (`Flag 'X' EQUALS 'Y'`).
|
|
240
|
+
def append_prerequisite_flag_condition(prereq_key, comparator, comparison_value)
|
|
241
|
+
op = comparator == Prerequisites::PREREQUISITE_NOT_EQUALS ? "NOT EQUALS" : "EQUALS"
|
|
242
|
+
append("Flag '#{prereq_key}' #{op} '#{comparison_value}'")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Append a segment condition (`User IS [NOT] IN SEGMENT 'X'`).
|
|
246
|
+
def append_segment_condition(segment_name, comparator)
|
|
247
|
+
op = comparator == Segments::SEGMENT_IS_NOT_IN ? "IS NOT IN SEGMENT" : "IS IN SEGMENT"
|
|
248
|
+
append("User #{op} '#{segment_name}'")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Append a bare boolean condition result (`true` / `false`).
|
|
252
|
+
def append_condition_result(result)
|
|
253
|
+
append(bool_to_log_string(result))
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def to_s
|
|
257
|
+
@chunks.join
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
private
|
|
261
|
+
|
|
262
|
+
def dispatch_user_condition(attr, cmp, kind, sensitive, condition)
|
|
263
|
+
case kind
|
|
264
|
+
when "l"
|
|
265
|
+
lv = condition["l"]
|
|
266
|
+
append_user_condition_string_list(attr, cmp, lv.is_a?(Array) ? lv : nil, sensitive)
|
|
267
|
+
when "s"
|
|
268
|
+
append_user_condition_string(attr, cmp, condition["s"], sensitive)
|
|
269
|
+
when "n"
|
|
270
|
+
dv = condition["d"]
|
|
271
|
+
append_user_condition_number(attr, cmp, dv&.to_f, false)
|
|
272
|
+
else # "dt"
|
|
273
|
+
dv = condition["d"]
|
|
274
|
+
append_user_condition_number(attr, cmp, dv&.to_f, true)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Unknown comparator: fall back to the first present value slot.
|
|
279
|
+
def append_unknown_user_condition(attr, cmp, condition)
|
|
280
|
+
sv = condition["s"]
|
|
281
|
+
return append_user_condition_core(attr, cmp, sv) if sv.is_a?(String)
|
|
282
|
+
|
|
283
|
+
dv = condition["d"]
|
|
284
|
+
return append_user_condition_core(attr, cmp, Comparators.number_to_canonical_string(dv.to_f)) unless dv.nil?
|
|
285
|
+
|
|
286
|
+
lv = condition["l"]
|
|
287
|
+
return append_user_condition_core(attr, cmp, lv[0].to_s) if lv.is_a?(Array) && !lv.empty?
|
|
288
|
+
|
|
289
|
+
append_user_condition_core(attr, cmp, "")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def append_user_condition_core(attr, comparator, value)
|
|
293
|
+
value = INVALID_VALUE_PLACEHOLDER if value == ""
|
|
294
|
+
append("User.#{attr} #{EvalLog.format_user_comparator(comparator)} '#{value}'")
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def append_user_condition_string(attr, comparator, value, is_sensitive)
|
|
298
|
+
return append_user_condition_core(attr, comparator, "") unless value.is_a?(String)
|
|
299
|
+
return append_user_condition_core(attr, comparator, "<hashed value>") if is_sensitive
|
|
300
|
+
|
|
301
|
+
append_user_condition_core(attr, comparator, value)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def append_user_condition_string_list(attr, comparator, values, is_sensitive)
|
|
305
|
+
return append_user_condition_core(attr, comparator, "") unless values.is_a?(Array)
|
|
306
|
+
|
|
307
|
+
comparator_text = EvalLog.format_user_comparator(comparator)
|
|
308
|
+
if is_sensitive
|
|
309
|
+
word = values.length == 1 ? "value" : "values"
|
|
310
|
+
return append("User.#{attr} #{comparator_text} [<#{values.length} hashed #{word}>]")
|
|
311
|
+
end
|
|
312
|
+
list_text = EvalLog.format_string_list(values, STRING_LIST_MAX_LENGTH)
|
|
313
|
+
append("User.#{attr} #{comparator_text} [#{list_text}]")
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def append_user_condition_number(attr, comparator, value, is_datetime)
|
|
317
|
+
return append_user_condition_core(attr, comparator, "") if value.nil?
|
|
318
|
+
|
|
319
|
+
comparator_text = EvalLog.format_user_comparator(comparator)
|
|
320
|
+
value_text = Comparators.number_to_canonical_string(value)
|
|
321
|
+
if is_datetime
|
|
322
|
+
iso = EvalLog.unix_seconds_to_iso(value)
|
|
323
|
+
return append("User.#{attr} #{comparator_text} '#{value_text}' (#{iso} UTC)") unless iso.nil?
|
|
324
|
+
end
|
|
325
|
+
append("User.#{attr} #{comparator_text} '#{value_text}'")
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def bool_to_log_string(value)
|
|
329
|
+
value ? "true" : "false"
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|