smplkit 3.0.95 → 3.0.96
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/lib/smplkit/account/client.rb +121 -0
- data/lib/smplkit/account/models.rb +53 -0
- data/lib/smplkit/api_support.rb +83 -0
- data/lib/smplkit/audit/client.rb +9 -10
- data/lib/smplkit/{management/audit.rb → audit/forwarders.rb} +73 -76
- data/lib/smplkit/audit/models.rb +40 -1
- data/lib/smplkit/buffers.rb +235 -0
- data/lib/smplkit/client.rb +126 -67
- data/lib/smplkit/config/client.rb +617 -182
- data/lib/smplkit/config_resolution.rb +11 -5
- data/lib/smplkit/errors.rb +8 -0
- data/lib/smplkit/flags/client.rb +472 -114
- data/lib/smplkit/flags/types.rb +6 -7
- data/lib/smplkit/{management/jobs.rb → jobs/client.rb} +148 -89
- data/lib/smplkit/logging/client.rb +647 -192
- data/lib/smplkit/logging/helpers.rb +1 -0
- data/lib/smplkit/logging/models.rb +92 -1
- data/lib/smplkit/logging/sources.rb +1 -1
- data/lib/smplkit/platform/client.rb +472 -0
- data/lib/smplkit/platform/models.rb +182 -0
- data/lib/smplkit/{management → platform}/types.rb +7 -4
- data/lib/smplkit/transport.rb +99 -0
- data/lib/smplkit.rb +18 -6
- metadata +11 -7
- data/lib/smplkit/management/buffer.rb +0 -198
- data/lib/smplkit/management/client.rb +0 -1074
- data/lib/smplkit/management/models.rb +0 -178
data/lib/smplkit/flags/client.rb
CHANGED
|
@@ -1,9 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "concurrent"
|
|
4
3
|
require "json"
|
|
5
4
|
require "digest"
|
|
6
5
|
|
|
6
|
+
# The Smpl Flags client — one unified +FlagsClient+.
|
|
7
|
+
#
|
|
8
|
+
# Smpl Flags has two surfaces on a single client, mirroring how the config,
|
|
9
|
+
# audit, and jobs clients expose their full surface from one class:
|
|
10
|
+
#
|
|
11
|
+
# * *Management surface* — pure CRUD, no live connection: +new_boolean_flag+ /
|
|
12
|
+
# +new_string_flag+ / +new_number_flag+ / +new_json_flag+ constructors, +get+
|
|
13
|
+
# / +list+ / +delete+ CRUD, and the flag-declaration discovery buffer
|
|
14
|
+
# (+register+ / +flush+ / +flush_sync+ / +pending_count+). The client owns the
|
|
15
|
+
# discovery buffer directly.
|
|
16
|
+
# * *Live surface* — lazily connects to your running service on first use: the
|
|
17
|
+
# typed handle declarations (+boolean_flag+ / +string_flag+ / +number_flag+ /
|
|
18
|
+
# +json_flag+) whose +.get+ evaluates against the cached definitions, plus
|
|
19
|
+
# +refresh+ / +stats+ / +on_change+. The first live call transparently flushes
|
|
20
|
+
# discovery, fetches all flag definitions into the local cache, and opens the
|
|
21
|
+
# live-updates WebSocket — no explicit install step.
|
|
22
|
+
#
|
|
23
|
+
# The client supports two construction shapes:
|
|
24
|
+
#
|
|
25
|
+
# * *Wired* into +Smplkit::Client+ — borrows the parent's flags transport for
|
|
26
|
+
# both runtime fetch and CRUD, the parent's shared WebSocket for the live
|
|
27
|
+
# channel, and +client.platform.contexts+ for evaluation-context
|
|
28
|
+
# registration. This is the common path.
|
|
29
|
+
# * *Standalone* — +FlagsClient.new(api_key: ..., base_url: ..., ...)+ builds
|
|
30
|
+
# and owns its own flags transport and a contexts client (against its own app
|
|
31
|
+
# transport), and on first live use opens and owns its own WebSocket. +close+
|
|
32
|
+
# tears down only the owned transports and owned WebSocket.
|
|
7
33
|
module Smplkit
|
|
8
34
|
module Flags
|
|
9
35
|
# Describes a flag definition change. Frozen — fields are set at construction.
|
|
@@ -41,6 +67,7 @@ module Smplkit
|
|
|
41
67
|
@cache_misses = 0
|
|
42
68
|
end
|
|
43
69
|
|
|
70
|
+
# Return [hit, value]. Moves the key to end on hit.
|
|
44
71
|
def get(cache_key)
|
|
45
72
|
@lock.synchronize do
|
|
46
73
|
if @cache.key?(cache_key)
|
|
@@ -71,104 +98,277 @@ module Smplkit
|
|
|
71
98
|
# Evaluation statistics for the flags runtime.
|
|
72
99
|
FlagStats = Struct.new(:cache_hits, :cache_misses, keyword_init: true)
|
|
73
100
|
|
|
74
|
-
#
|
|
101
|
+
# Convert a list of Context objects to the nested evaluation dict.
|
|
102
|
+
def self.contexts_to_eval_dict(contexts)
|
|
103
|
+
contexts.to_h { |ctx| [ctx.type, ctx.to_eval_hash] }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Compute a stable hash for a context evaluation dict.
|
|
107
|
+
def self.hash_context(eval_dict)
|
|
108
|
+
Digest::MD5.hexdigest(JSON.generate(deep_sort(eval_dict)))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.deep_sort(value)
|
|
112
|
+
case value
|
|
113
|
+
when Hash
|
|
114
|
+
value.keys.sort_by(&:to_s).to_h { |k| [k, deep_sort(value[k])] }
|
|
115
|
+
when Array
|
|
116
|
+
value.map { |v| deep_sort(v) }
|
|
117
|
+
else
|
|
118
|
+
value
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Build standalone flags + app transports and resolve the app base URL.
|
|
75
123
|
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
124
|
+
# +base_url+/+api_key+ are used directly when supplied (the path a top-level
|
|
125
|
+
# client takes after it has already resolved them); otherwise the management
|
|
126
|
+
# config resolver fills in whatever is missing (+~/.smplkit+ / env vars /
|
|
127
|
+
# defaults). The app transport backs the standalone contexts client
|
|
128
|
+
# (evaluation-context registration); the app base URL is returned so a
|
|
129
|
+
# standalone client can open its own WebSocket against the event gateway.
|
|
130
|
+
def self.flags_transport(api_key:, base_url:, profile:, base_domain:, scheme:, debug:, extra_headers:)
|
|
131
|
+
cfg = ConfigResolution.resolve_management_config(
|
|
132
|
+
profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme, debug: debug
|
|
133
|
+
)
|
|
134
|
+
resolved_key = api_key.nil? ? cfg.api_key : api_key
|
|
135
|
+
merged = {}
|
|
136
|
+
merged.merge!(cfg.extra_headers || {})
|
|
137
|
+
merged.merge!(extra_headers || {})
|
|
138
|
+
tcfg = ConfigResolution::ResolvedManagementConfig.new(
|
|
139
|
+
api_key: resolved_key, base_domain: cfg.base_domain, scheme: cfg.scheme,
|
|
140
|
+
debug: cfg.debug, extra_headers: merged
|
|
141
|
+
)
|
|
142
|
+
app_url = ConfigResolution.service_url(cfg.scheme, "app", cfg.base_domain)
|
|
143
|
+
flags_http = Transport.build_api_client(SmplkitGeneratedClient::Flags, "flags", tcfg, base_url: base_url)
|
|
144
|
+
app_http = Transport.build_api_client(SmplkitGeneratedClient::App, "app", tcfg)
|
|
145
|
+
[flags_http, app_http, app_url, resolved_key]
|
|
146
|
+
end
|
|
84
147
|
|
|
85
|
-
|
|
148
|
+
# The Smpl Flags client (sync).
|
|
149
|
+
#
|
|
150
|
+
# One client exposes the full surface, reachable as +client.flags+
|
|
151
|
+
# (+Smplkit::Client+) or constructed directly:
|
|
152
|
+
#
|
|
153
|
+
# flags = Smplkit::FlagsClient.new(environment: "production")
|
|
154
|
+
# new_flag = flags.new_boolean_flag("beta", default: false)
|
|
155
|
+
# new_flag.save
|
|
156
|
+
# beta = flags.boolean_flag("beta", default: false)
|
|
157
|
+
# beta.get # => ...
|
|
158
|
+
#
|
|
159
|
+
# The management surface (+new_*+ / +get+ / +list+ / +delete+ and discovery)
|
|
160
|
+
# is pure CRUD. The live surface (+boolean_flag+ / +string_flag+ /
|
|
161
|
+
# +number_flag+ / +json_flag+ / +refresh+ / +stats+ / +on_change+) connects
|
|
162
|
+
# lazily on first use — the first call flushes discovery, fetches all flag
|
|
163
|
+
# definitions into the local cache, and opens the live-updates WebSocket. No
|
|
164
|
+
# explicit install step is required.
|
|
165
|
+
class FlagsClient
|
|
166
|
+
def initialize(api_key = nil, environment: nil, base_url: nil, profile: nil,
|
|
167
|
+
base_domain: nil, scheme: nil, debug: nil, extra_headers: nil,
|
|
168
|
+
parent: nil, transport: nil, contexts: nil, metrics: nil)
|
|
86
169
|
@parent = parent
|
|
87
|
-
@manage = manage
|
|
88
170
|
@metrics = metrics
|
|
89
|
-
@
|
|
90
|
-
@
|
|
91
|
-
@
|
|
92
|
-
|
|
171
|
+
@environment = parent.nil? ? environment : parent._environment
|
|
172
|
+
@service = parent&._service
|
|
173
|
+
@standalone_api_key = nil
|
|
174
|
+
if transport.nil?
|
|
175
|
+
@flags_http, app_http, @app_base_url, @standalone_api_key = Flags.flags_transport(
|
|
176
|
+
api_key: api_key, base_url: base_url, profile: profile,
|
|
177
|
+
base_domain: base_domain, scheme: scheme, debug: debug, extra_headers: extra_headers
|
|
178
|
+
)
|
|
179
|
+
# Standalone: build our own contexts client (and own its app transport).
|
|
180
|
+
@contexts = Platform::ContextsClient.new(app_http, ContextRegistrationBuffer.new)
|
|
181
|
+
else
|
|
182
|
+
@flags_http = transport
|
|
183
|
+
@app_base_url = nil
|
|
184
|
+
# Wired: borrow client.platform.contexts as the evaluation-context
|
|
185
|
+
# registration seam.
|
|
186
|
+
@contexts = contexts
|
|
187
|
+
end
|
|
188
|
+
@api = SmplkitGeneratedClient::Flags::FlagsApi.new(@flags_http)
|
|
93
189
|
|
|
190
|
+
# Discovery buffer is owned by this client (no management delegation).
|
|
191
|
+
@buffer = FlagRegistrationBuffer.new
|
|
192
|
+
|
|
193
|
+
# Live-surface state.
|
|
94
194
|
@flag_store = {}
|
|
95
195
|
@connected = false
|
|
96
196
|
@ws_subscribed = false
|
|
97
|
-
@next_start_attempt_at = 0.0
|
|
98
|
-
@start_retry_delay = INITIAL_START_RETRY_DELAY
|
|
99
197
|
@cache = ResolutionCache.new
|
|
100
198
|
@handles = {}
|
|
101
199
|
@global_listeners = []
|
|
102
200
|
@key_listeners = Hash.new { |h, k| h[k] = [] }
|
|
103
201
|
@ws_manager = nil
|
|
202
|
+
@owns_ws = false
|
|
104
203
|
@lock = Mutex.new
|
|
105
204
|
end
|
|
106
205
|
|
|
107
|
-
|
|
108
|
-
|
|
206
|
+
# ----------------------------------------------------------------
|
|
207
|
+
# Management surface: CRUD (no live connection)
|
|
208
|
+
# ----------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
# Return a new unsaved boolean +BooleanFlag+. Call +save+ to persist.
|
|
211
|
+
def new_boolean_flag(id, default:, name: nil, description: nil)
|
|
212
|
+
BooleanFlag.new(
|
|
213
|
+
self, id: id, name: name || Smplkit::Helpers.key_to_display_name(id),
|
|
214
|
+
type: "BOOLEAN", default: default,
|
|
215
|
+
values: [FlagValue.new(name: "True", value: true), FlagValue.new(name: "False", value: false)],
|
|
216
|
+
description: description
|
|
217
|
+
)
|
|
109
218
|
end
|
|
110
219
|
|
|
111
|
-
|
|
112
|
-
|
|
220
|
+
# Return a new unsaved string +StringFlag+. Call +save+ to persist.
|
|
221
|
+
def new_string_flag(id, default:, name: nil, description: nil, values: nil)
|
|
222
|
+
StringFlag.new(
|
|
223
|
+
self, id: id, name: name || Smplkit::Helpers.key_to_display_name(id),
|
|
224
|
+
type: "STRING", default: default, values: values, description: description
|
|
225
|
+
)
|
|
113
226
|
end
|
|
114
227
|
|
|
115
|
-
|
|
116
|
-
|
|
228
|
+
# Return a new unsaved numeric +NumberFlag+. Call +save+ to persist.
|
|
229
|
+
def new_number_flag(id, default:, name: nil, description: nil, values: nil)
|
|
230
|
+
NumberFlag.new(
|
|
231
|
+
self, id: id, name: name || Smplkit::Helpers.key_to_display_name(id),
|
|
232
|
+
type: "NUMERIC", default: default, values: values, description: description
|
|
233
|
+
)
|
|
117
234
|
end
|
|
118
235
|
|
|
119
|
-
|
|
120
|
-
|
|
236
|
+
# Return a new unsaved JSON +JsonFlag+. Call +save+ to persist.
|
|
237
|
+
def new_json_flag(id, default:, name: nil, description: nil, values: nil)
|
|
238
|
+
JsonFlag.new(
|
|
239
|
+
self, id: id, name: name || Smplkit::Helpers.key_to_display_name(id),
|
|
240
|
+
type: "JSON", default: default, values: values, description: description
|
|
241
|
+
)
|
|
121
242
|
end
|
|
122
243
|
|
|
123
|
-
#
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
#
|
|
129
|
-
# Idempotent — safe to call multiple times. Called automatically on
|
|
130
|
-
# first +flag.get+ evaluation if not invoked manually.
|
|
131
|
-
#
|
|
132
|
-
# If the flags-service is unhealthy (e.g. a coordinated rebuild where
|
|
133
|
-
# the app pod starts before the schema is loaded), the flush or refresh
|
|
134
|
-
# will fail. Pending declarations stay queued, the client remains
|
|
135
|
-
# disconnected, and the next call retries after an exponentially
|
|
136
|
-
# backed-off delay (capped at +MAX_START_RETRY_DELAY+ seconds).
|
|
137
|
-
# Evaluations during that window fall back to handle defaults.
|
|
138
|
-
def start
|
|
139
|
-
return if @connected
|
|
140
|
-
return if Process.clock_gettime(Process::CLOCK_MONOTONIC) < @next_start_attempt_at
|
|
244
|
+
# Fetch the editable +Flag+ resource by id.
|
|
245
|
+
def get(id)
|
|
246
|
+
response = ApiSupport::ErrorMapping.call { @api.get_flag(id) }
|
|
247
|
+
model_from_resource(ApiSupport::ResourceShim.from_model(response.data))
|
|
248
|
+
end
|
|
141
249
|
|
|
142
|
-
|
|
250
|
+
# List flags for the authenticated account.
|
|
251
|
+
def list(page_number: nil, page_size: nil)
|
|
252
|
+
opts = {}
|
|
253
|
+
opts[:page_number] = page_number unless page_number.nil?
|
|
254
|
+
opts[:page_size] = page_size unless page_size.nil?
|
|
255
|
+
response = ApiSupport::ErrorMapping.call { @api.list_flags(opts) }
|
|
256
|
+
(response.data || []).map { |r| model_from_resource(ApiSupport::ResourceShim.from_model(r)) }
|
|
257
|
+
end
|
|
143
258
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
259
|
+
# Delete a flag by id.
|
|
260
|
+
def delete(id)
|
|
261
|
+
ApiSupport::ErrorMapping.call { @api.delete_flag(id) }
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def _create_flag(flag)
|
|
266
|
+
response = ApiSupport::ErrorMapping.call { @api.create_flag(flag_body(flag)) }
|
|
267
|
+
model_from_resource(ApiSupport::ResourceShim.from_model(response.data))
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def _update_flag(flag)
|
|
271
|
+
response = ApiSupport::ErrorMapping.call { @api.update_flag(flag.id, flag_body(flag)) }
|
|
272
|
+
model_from_resource(ApiSupport::ResourceShim.from_model(response.data))
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# ----------------------------------------------------------------
|
|
276
|
+
# Management surface: discovery buffer (owned directly)
|
|
277
|
+
# ----------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
# Buffer flag declarations for bulk-discovery upload; optionally flush now.
|
|
280
|
+
def register(items, flush: false)
|
|
281
|
+
batch = items.is_a?(Array) ? items : [items]
|
|
282
|
+
batch.each { |d| @buffer.add(d) }
|
|
283
|
+
if flush
|
|
284
|
+
self.flush
|
|
149
285
|
return
|
|
150
286
|
end
|
|
287
|
+
return unless @buffer.pending_count >= FLAG_BATCH_FLUSH_SIZE
|
|
151
288
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
@next_start_attempt_at = 0.0
|
|
289
|
+
Thread.new { threshold_flush }
|
|
290
|
+
end
|
|
155
291
|
|
|
156
|
-
|
|
157
|
-
|
|
292
|
+
# POST pending declarations to the flags bulk endpoint.
|
|
293
|
+
#
|
|
294
|
+
# Items remain in the buffer until the request succeeds, so a flush
|
|
295
|
+
# against an unhealthy +flags+ service is automatically retried by the
|
|
296
|
+
# next +flush+ call (periodic background flush, install retry, or final
|
|
297
|
+
# flush on close).
|
|
298
|
+
def flush
|
|
299
|
+
batch = @buffer.peek
|
|
300
|
+
return if batch.empty?
|
|
158
301
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
@
|
|
162
|
-
|
|
302
|
+
body = build_flag_bulk_request(batch)
|
|
303
|
+
ApiSupport::ErrorMapping.call { @api.bulk_register_flags(body) }
|
|
304
|
+
@buffer.commit(batch.map { |b| b["id"] })
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Synchronous flush — alias of +flush+ for the periodic-flush path.
|
|
308
|
+
def flush_sync
|
|
309
|
+
flush
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Number of pending flag declarations awaiting flush.
|
|
313
|
+
def pending_count
|
|
314
|
+
@buffer.pending_count
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# ----------------------------------------------------------------
|
|
318
|
+
# Live surface: typed flag handles
|
|
319
|
+
# ----------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
# Declare a boolean flag handle for live evaluation. Connects lazily on first use.
|
|
322
|
+
def boolean_flag(id, default:)
|
|
323
|
+
ensure_connected
|
|
324
|
+
handle = BooleanFlag.new(self, id: id, name: id, type: "BOOLEAN", default: default)
|
|
325
|
+
@handles[id] = handle
|
|
326
|
+
observe_declaration(id, "BOOLEAN", default)
|
|
327
|
+
handle
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Declare a string flag handle for live evaluation. Connects lazily on first use.
|
|
331
|
+
def string_flag(id, default:)
|
|
332
|
+
ensure_connected
|
|
333
|
+
handle = StringFlag.new(self, id: id, name: id, type: "STRING", default: default)
|
|
334
|
+
@handles[id] = handle
|
|
335
|
+
observe_declaration(id, "STRING", default)
|
|
336
|
+
handle
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Declare a numeric flag handle for live evaluation. Connects lazily on first use.
|
|
340
|
+
def number_flag(id, default:)
|
|
341
|
+
ensure_connected
|
|
342
|
+
handle = NumberFlag.new(self, id: id, name: id, type: "NUMERIC", default: default)
|
|
343
|
+
@handles[id] = handle
|
|
344
|
+
observe_declaration(id, "NUMERIC", default)
|
|
345
|
+
handle
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Declare a JSON flag handle for live evaluation. Connects lazily on first use.
|
|
349
|
+
def json_flag(id, default:)
|
|
350
|
+
ensure_connected
|
|
351
|
+
handle = JsonFlag.new(self, id: id, name: id, type: "JSON", default: default)
|
|
352
|
+
@handles[id] = handle
|
|
353
|
+
observe_declaration(id, "JSON", default)
|
|
354
|
+
handle
|
|
163
355
|
end
|
|
164
356
|
|
|
357
|
+
# ----------------------------------------------------------------
|
|
358
|
+
# Live surface: refresh / stats / change listeners
|
|
359
|
+
# ----------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
# Re-fetch all flag definitions and clear cache.
|
|
362
|
+
#
|
|
363
|
+
# Connects lazily on first use — no explicit install step.
|
|
165
364
|
def refresh
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
fire_change_listeners_all("manual")
|
|
365
|
+
ensure_connected
|
|
366
|
+
do_refresh("manual")
|
|
169
367
|
end
|
|
170
368
|
|
|
369
|
+
# Return evaluation statistics. Connects lazily on first use.
|
|
171
370
|
def stats
|
|
371
|
+
ensure_connected
|
|
172
372
|
FlagStats.new(cache_hits: @cache.cache_hits, cache_misses: @cache.cache_misses)
|
|
173
373
|
end
|
|
174
374
|
|
|
@@ -176,7 +376,10 @@ module Smplkit
|
|
|
176
376
|
#
|
|
177
377
|
# client.flags.on_change { |event| ... } # global
|
|
178
378
|
# client.flags.on_change("checkout-v2") { |e| ... } # flag-scoped
|
|
379
|
+
#
|
|
380
|
+
# Connects lazily on first use — no explicit install step.
|
|
179
381
|
def on_change(flag_id = nil, &block)
|
|
382
|
+
ensure_connected
|
|
180
383
|
raise ArgumentError, "on_change requires a block" unless block
|
|
181
384
|
|
|
182
385
|
if flag_id.nil?
|
|
@@ -187,25 +390,54 @@ module Smplkit
|
|
|
187
390
|
block
|
|
188
391
|
end
|
|
189
392
|
|
|
190
|
-
|
|
191
|
-
|
|
393
|
+
# Release resources — only those this client owns.
|
|
394
|
+
#
|
|
395
|
+
# Tears down the owned WebSocket (standalone install). A wired client
|
|
396
|
+
# borrows the parent's transport, WebSocket, and contexts client and
|
|
397
|
+
# closes none of them.
|
|
398
|
+
def close
|
|
399
|
+
if @owns_ws && @ws_manager
|
|
400
|
+
@ws_manager.stop
|
|
401
|
+
@ws_manager = nil
|
|
402
|
+
@owns_ws = false
|
|
403
|
+
end
|
|
404
|
+
# Owned flags/app transports (standalone construction) release their
|
|
405
|
+
# Faraday connections on GC; there is no explicit shutdown to call.
|
|
406
|
+
nil
|
|
407
|
+
end
|
|
408
|
+
alias _close close
|
|
409
|
+
|
|
410
|
+
# Construct, yield to the block, and close on exit.
|
|
411
|
+
def self.open(**kwargs)
|
|
412
|
+
client = new(**kwargs)
|
|
413
|
+
begin
|
|
414
|
+
yield client
|
|
415
|
+
ensure
|
|
416
|
+
client.close
|
|
417
|
+
end
|
|
192
418
|
end
|
|
193
419
|
|
|
420
|
+
# Core evaluation used by flag handles (the +.get+ path).
|
|
421
|
+
#
|
|
422
|
+
# Connects lazily on first use so +flag.get+ works without an explicit
|
|
423
|
+
# install step.
|
|
194
424
|
def _evaluate_handle(flag_id, default, context)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
425
|
+
ensure_connected
|
|
426
|
+
if context
|
|
427
|
+
# Explicit context: register here. (Implicit set_context registers at
|
|
428
|
+
# the entry point, so the request-context branch below doesn't need
|
|
429
|
+
# to.)
|
|
430
|
+
@contexts&.register(context)
|
|
431
|
+
eval_dict = Flags.contexts_to_eval_dict(context)
|
|
432
|
+
else
|
|
433
|
+
contexts = Smplkit.request_context
|
|
434
|
+
eval_dict = contexts.empty? ? {} : Flags.contexts_to_eval_dict(contexts)
|
|
435
|
+
end
|
|
205
436
|
|
|
437
|
+
# Auto-inject service context if set and not already provided.
|
|
206
438
|
eval_dict["service"] = { "key" => @service } if @service && !eval_dict.key?("service")
|
|
207
439
|
|
|
208
|
-
ctx_hash = hash_context(eval_dict)
|
|
440
|
+
ctx_hash = Flags.hash_context(eval_dict)
|
|
209
441
|
cache_key = "#{flag_id}:#{ctx_hash}"
|
|
210
442
|
|
|
211
443
|
hit, cached_value = @cache.get(cache_key)
|
|
@@ -229,62 +461,131 @@ module Smplkit
|
|
|
229
461
|
value
|
|
230
462
|
end
|
|
231
463
|
|
|
464
|
+
# Internal: trigger lazy connect. Used by +Client#wait_until_ready+.
|
|
465
|
+
def _ensure_connected
|
|
466
|
+
ensure_connected
|
|
467
|
+
end
|
|
468
|
+
|
|
232
469
|
private
|
|
233
470
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
471
|
+
# Queue a declared flag with the owned discovery buffer.
|
|
472
|
+
def observe_declaration(flag_id, flag_type, default)
|
|
473
|
+
register(FlagDeclaration.new(
|
|
474
|
+
id: flag_id, type: flag_type, default: default,
|
|
475
|
+
service: @service, environment: @environment
|
|
476
|
+
))
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def threshold_flush
|
|
480
|
+
flush
|
|
481
|
+
rescue StandardError => e
|
|
482
|
+
Smplkit.debug("registration", "flag registration flush failed: #{e.class}: #{e.message}")
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def model_from_resource(resource)
|
|
486
|
+
d = Helpers.flag_dict_from_json(resource)
|
|
487
|
+
klass =
|
|
488
|
+
case d["type"]
|
|
489
|
+
when "BOOLEAN" then BooleanFlag
|
|
490
|
+
when "STRING" then StringFlag
|
|
491
|
+
when "NUMERIC" then NumberFlag
|
|
492
|
+
else JsonFlag
|
|
493
|
+
end
|
|
494
|
+
klass.new(
|
|
495
|
+
self,
|
|
496
|
+
id: d["id"], name: d["name"], type: d["type"], default: d["default"],
|
|
497
|
+
description: d["description"], values: d["values"], environments: d["environments"],
|
|
498
|
+
created_at: (resource["attributes"] || {})["created_at"],
|
|
499
|
+
updated_at: (resource["attributes"] || {})["updated_at"]
|
|
500
|
+
)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# ----------------------------------------------------------------
|
|
504
|
+
# Live surface: lazy connect + transport / WebSocket helpers
|
|
505
|
+
# ----------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
def ensure_ws
|
|
508
|
+
return @parent._ensure_ws unless @parent.nil?
|
|
509
|
+
|
|
510
|
+
if @ws_manager.nil?
|
|
511
|
+
@ws_manager = SharedWebSocket.new(
|
|
512
|
+
app_base_url: @app_base_url, api_key: @standalone_api_key, metrics: @metrics
|
|
513
|
+
)
|
|
514
|
+
@ws_manager.start
|
|
515
|
+
@owns_ws = true
|
|
242
516
|
end
|
|
243
|
-
|
|
517
|
+
@ws_manager
|
|
244
518
|
end
|
|
245
519
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
520
|
+
# Open the live connection to the running Smpl Flags service.
|
|
521
|
+
#
|
|
522
|
+
# Flushes any buffered discovery declarations, fetches all flag
|
|
523
|
+
# definitions into the local cache, opens the shared WebSocket, and
|
|
524
|
+
# subscribes to +flag_changed+ / +flag_deleted+ / +flags_changed+ events.
|
|
525
|
+
#
|
|
526
|
+
# Idempotent and internal — every live method calls it on first use, so
|
|
527
|
+
# the live surface auto-connects with no explicit step.
|
|
528
|
+
def ensure_connected
|
|
529
|
+
@parent&._ensure_started
|
|
530
|
+
return if @connected
|
|
531
|
+
|
|
532
|
+
# Flush discovered flags BEFORE fetching definitions so the fetch
|
|
533
|
+
# reflects them. Items stay in the buffer until the POST succeeds.
|
|
534
|
+
begin
|
|
535
|
+
flush
|
|
536
|
+
rescue StandardError => e
|
|
537
|
+
Smplkit.debug("flags", "discovery flush before connect failed: #{e.class}: #{e.message}")
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
fetch_all_flags
|
|
541
|
+
@cache.clear
|
|
542
|
+
@connected = true
|
|
543
|
+
|
|
544
|
+
@ws_manager = ensure_ws
|
|
545
|
+
return if @ws_subscribed
|
|
546
|
+
|
|
547
|
+
@ws_manager.on("flag_changed") { |data| handle_flag_changed(data) }
|
|
548
|
+
@ws_manager.on("flag_deleted") { |data| handle_flag_deleted(data) }
|
|
549
|
+
@ws_manager.on("flags_changed") { |data| handle_flags_changed(data) }
|
|
550
|
+
@ws_subscribed = true
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def do_refresh(_source)
|
|
554
|
+
fetch_all_flags
|
|
555
|
+
@cache.clear
|
|
556
|
+
fire_change_listeners_all("manual")
|
|
252
557
|
end
|
|
253
558
|
|
|
559
|
+
# ----------------------------------------------------------------
|
|
560
|
+
# Internal: flag store
|
|
561
|
+
# ----------------------------------------------------------------
|
|
562
|
+
|
|
254
563
|
def fetch_all_flags
|
|
255
|
-
flags =
|
|
564
|
+
flags = fetch_flags_list
|
|
256
565
|
@flag_store = flags.to_h { |f| [f["id"], f] }
|
|
257
|
-
rescue Smplkit::Error
|
|
258
|
-
raise
|
|
259
|
-
rescue StandardError => e
|
|
260
|
-
raise Smplkit::ConnectionError, "Failed to fetch flags: #{e.message}"
|
|
261
566
|
end
|
|
262
567
|
|
|
263
|
-
def
|
|
264
|
-
|
|
568
|
+
def fetch_flags_list
|
|
569
|
+
rows = ApiSupport::PaginatedFetch.collect { |opts| @api.list_flags(opts) }
|
|
570
|
+
rows.map { |r| Helpers.flag_dict_from_json(ApiSupport::ResourceShim.from_model(r)) }
|
|
265
571
|
end
|
|
266
572
|
|
|
267
|
-
|
|
268
|
-
|
|
573
|
+
# Fetch a single flag by key and return a store-format dict.
|
|
574
|
+
def fetch_flag_single_data(key)
|
|
575
|
+
response = ApiSupport::ErrorMapping.call { @api.get_flag(key) }
|
|
576
|
+
Helpers.flag_dict_from_json(ApiSupport::ResourceShim.from_model(response.data))
|
|
269
577
|
end
|
|
270
578
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
value.keys.sort_by(&:to_s).to_h { |k| [k, deep_sort(value[k])] }
|
|
275
|
-
when Array
|
|
276
|
-
value.map { |v| deep_sort(v) }
|
|
277
|
-
else
|
|
278
|
-
value
|
|
279
|
-
end
|
|
280
|
-
end
|
|
579
|
+
# ----------------------------------------------------------------
|
|
580
|
+
# Internal: event handlers (called by SharedWebSocket)
|
|
581
|
+
# ----------------------------------------------------------------
|
|
281
582
|
|
|
282
583
|
def handle_flag_changed(data)
|
|
283
584
|
key = data["id"]
|
|
284
585
|
return unless key
|
|
285
586
|
|
|
286
587
|
pre = @flag_store[key]&.dup || {}
|
|
287
|
-
new_data =
|
|
588
|
+
new_data = fetch_flag_single_data(key)
|
|
288
589
|
@flag_store[key] = new_data
|
|
289
590
|
@cache.clear
|
|
290
591
|
fire_change_listeners(key, "websocket") if pre != new_data
|
|
@@ -314,6 +615,7 @@ module Smplkit
|
|
|
314
615
|
changed = all_keys.reject { |k| pre_store[k] == post_store[k] }
|
|
315
616
|
return if changed.empty?
|
|
316
617
|
|
|
618
|
+
# Global listener fires once.
|
|
317
619
|
first_event = FlagChangeEvent.new(id: changed.first, source: "websocket")
|
|
318
620
|
@global_listeners.each do |cb|
|
|
319
621
|
cb.call(first_event)
|
|
@@ -321,6 +623,7 @@ module Smplkit
|
|
|
321
623
|
Smplkit.debug("flags", "global listener raised: #{e.class}: #{e.message}")
|
|
322
624
|
end
|
|
323
625
|
|
|
626
|
+
# Per-key listeners fire for each changed key.
|
|
324
627
|
changed.each do |k|
|
|
325
628
|
deleted = pre_store.key?(k) && !post_store.key?(k)
|
|
326
629
|
event = FlagChangeEvent.new(id: k, source: "websocket", deleted: deleted)
|
|
@@ -378,15 +681,68 @@ module Smplkit
|
|
|
378
681
|
|
|
379
682
|
fallback
|
|
380
683
|
end
|
|
684
|
+
|
|
685
|
+
def flag_body(flag)
|
|
686
|
+
SmplkitGeneratedClient::Flags::FlagResponse.new(
|
|
687
|
+
data: SmplkitGeneratedClient::Flags::FlagResource.new(
|
|
688
|
+
type: "flag",
|
|
689
|
+
id: flag.id,
|
|
690
|
+
attributes: SmplkitGeneratedClient::Flags::Flag.new(
|
|
691
|
+
name: flag.name,
|
|
692
|
+
type: flag.type,
|
|
693
|
+
default: flag.default,
|
|
694
|
+
description: flag.description,
|
|
695
|
+
values: flag_values_to_wire(flag.values),
|
|
696
|
+
environments: flag_envs_to_wire(flag.environments)
|
|
697
|
+
)
|
|
698
|
+
)
|
|
699
|
+
)
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def flag_values_to_wire(values)
|
|
703
|
+
# The server requires +values+ to be null (unconstrained) or a
|
|
704
|
+
# non-empty array (constrained) — an empty array is rejected, so an
|
|
705
|
+
# empty/cleared values list is sent as null.
|
|
706
|
+
return nil if values.nil? || values.empty?
|
|
707
|
+
|
|
708
|
+
values.map do |v|
|
|
709
|
+
SmplkitGeneratedClient::Flags::FlagValue.new(name: v.name, value: v.value)
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
def flag_envs_to_wire(environments)
|
|
714
|
+
return nil if environments.empty?
|
|
715
|
+
|
|
716
|
+
environments.each_with_object({}) do |(env_key, env_obj), out|
|
|
717
|
+
rules = env_obj.rules.map do |r|
|
|
718
|
+
SmplkitGeneratedClient::Flags::FlagRule.new(
|
|
719
|
+
logic: r.logic, value: r.value, description: r.description
|
|
720
|
+
)
|
|
721
|
+
end
|
|
722
|
+
out[env_key] = SmplkitGeneratedClient::Flags::FlagEnvironment.new(
|
|
723
|
+
enabled: env_obj.enabled, default: env_obj.default, rules: rules
|
|
724
|
+
)
|
|
725
|
+
end
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
def build_flag_bulk_request(batch)
|
|
729
|
+
flag_items = batch.map do |entry|
|
|
730
|
+
SmplkitGeneratedClient::Flags::FlagBulkItem.new(
|
|
731
|
+
id: entry["id"], type: entry["type"], default: entry["default"],
|
|
732
|
+
service: entry["service"], environment: entry["environment"]
|
|
733
|
+
)
|
|
734
|
+
end
|
|
735
|
+
SmplkitGeneratedClient::Flags::FlagBulkRequest.new(flags: flag_items)
|
|
736
|
+
end
|
|
381
737
|
end
|
|
382
738
|
|
|
383
739
|
# Vendored minimal JSON Logic evaluator covering the operators the smplkit
|
|
384
740
|
# platform ships in flag rules.
|
|
385
741
|
#
|
|
386
|
-
# Stays in-tree so the Ruby SDK doesn't depend on the +json_logic+ gem
|
|
387
|
-
#
|
|
388
|
-
#
|
|
389
|
-
#
|
|
742
|
+
# Stays in-tree so the Ruby SDK doesn't depend on the +json_logic+ gem being
|
|
743
|
+
# correct — the Java SDK followed the same pattern. Operators supported:
|
|
744
|
+
# +==+, +!=+, +<+, +<=+, +>+, +>=+, +in+, +var+, +and+, +or+, +!+, +if+,
|
|
745
|
+
# +missing+, +none+.
|
|
390
746
|
module JsonLogicEvaluator
|
|
391
747
|
module_function
|
|
392
748
|
|
|
@@ -505,4 +861,6 @@ module Smplkit
|
|
|
505
861
|
end
|
|
506
862
|
end
|
|
507
863
|
end
|
|
864
|
+
|
|
865
|
+
FlagsClient = Flags::FlagsClient
|
|
508
866
|
end
|