smplkit 1.0.5
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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE +21 -0
- data/README.md +105 -0
- data/lib/smplkit/client.rb +218 -0
- data/lib/smplkit/config/client.rb +238 -0
- data/lib/smplkit/config/helpers.rb +108 -0
- data/lib/smplkit/config/models.rb +192 -0
- data/lib/smplkit/config_resolution.rb +202 -0
- data/lib/smplkit/context.rb +68 -0
- data/lib/smplkit/debug.rb +50 -0
- data/lib/smplkit/errors.rb +114 -0
- data/lib/smplkit/flags/client.rb +480 -0
- data/lib/smplkit/flags/helpers.rb +76 -0
- data/lib/smplkit/flags/models.rb +258 -0
- data/lib/smplkit/flags/types.rb +233 -0
- data/lib/smplkit/generators/install_generator.rb +42 -0
- data/lib/smplkit/helpers.rb +15 -0
- data/lib/smplkit/log_level.rb +57 -0
- data/lib/smplkit/logging/adapters/base.rb +63 -0
- data/lib/smplkit/logging/adapters/semantic_logger_adapter.rb +88 -0
- data/lib/smplkit/logging/adapters/stdlib_logger_adapter.rb +143 -0
- data/lib/smplkit/logging/client.rb +142 -0
- data/lib/smplkit/logging/helpers.rb +69 -0
- data/lib/smplkit/logging/levels.rb +86 -0
- data/lib/smplkit/logging/models.rb +124 -0
- data/lib/smplkit/logging/normalize.rb +16 -0
- data/lib/smplkit/logging/sources.rb +44 -0
- data/lib/smplkit/management/buffer.rb +111 -0
- data/lib/smplkit/management/client.rb +623 -0
- data/lib/smplkit/management/models.rb +133 -0
- data/lib/smplkit/management/types.rb +65 -0
- data/lib/smplkit/metrics.rb +78 -0
- data/lib/smplkit/railtie.rb +48 -0
- data/lib/smplkit/version.rb +5 -0
- data/lib/smplkit/ws.rb +92 -0
- data/lib/smplkit.rb +43 -0
- data/sig/smplkit.rbs +141 -0
- metadata +139 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
require "json"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module Smplkit
|
|
8
|
+
module Flags
|
|
9
|
+
# Describes a flag definition change. Frozen — fields are set at construction.
|
|
10
|
+
class FlagChangeEvent
|
|
11
|
+
attr_reader :id, :source, :deleted
|
|
12
|
+
|
|
13
|
+
def initialize(id:, source:, deleted: false)
|
|
14
|
+
@id = id
|
|
15
|
+
@source = source
|
|
16
|
+
@deleted = deleted
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def deleted? = @deleted
|
|
21
|
+
|
|
22
|
+
def ==(other)
|
|
23
|
+
other.is_a?(FlagChangeEvent) && id == other.id && source == other.source && deleted == other.deleted
|
|
24
|
+
end
|
|
25
|
+
alias eql? ==
|
|
26
|
+
|
|
27
|
+
def hash = [id, source, deleted].hash
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Thread-safe LRU resolution cache with hit/miss stats.
|
|
31
|
+
class ResolutionCache
|
|
32
|
+
DEFAULT_MAX_SIZE = 10_000
|
|
33
|
+
|
|
34
|
+
attr_reader :cache_hits, :cache_misses
|
|
35
|
+
|
|
36
|
+
def initialize(max_size: DEFAULT_MAX_SIZE)
|
|
37
|
+
@max_size = max_size
|
|
38
|
+
@cache = {}
|
|
39
|
+
@lock = Mutex.new
|
|
40
|
+
@cache_hits = 0
|
|
41
|
+
@cache_misses = 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def get(cache_key)
|
|
45
|
+
@lock.synchronize do
|
|
46
|
+
if @cache.key?(cache_key)
|
|
47
|
+
value = @cache.delete(cache_key)
|
|
48
|
+
@cache[cache_key] = value
|
|
49
|
+
@cache_hits += 1
|
|
50
|
+
[true, value]
|
|
51
|
+
else
|
|
52
|
+
@cache_misses += 1
|
|
53
|
+
[false, nil]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def put(cache_key, value)
|
|
59
|
+
@lock.synchronize do
|
|
60
|
+
@cache.delete(cache_key) if @cache.key?(cache_key)
|
|
61
|
+
@cache[cache_key] = value
|
|
62
|
+
@cache.shift while @cache.size > @max_size
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def clear
|
|
67
|
+
@lock.synchronize { @cache.clear }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Evaluation statistics for the flags runtime.
|
|
72
|
+
FlagStats = Struct.new(:cache_hits, :cache_misses, keyword_init: true)
|
|
73
|
+
|
|
74
|
+
# Synchronous flags runtime namespace.
|
|
75
|
+
#
|
|
76
|
+
# Obtained via +Smplkit::Client#flags+. Exposes typed handles
|
|
77
|
+
# (+boolean_flag+/+string_flag+/+number_flag+/+json_flag+) and runtime
|
|
78
|
+
# control (+refresh+, +stats+, +on_change+). CRUD has moved to
|
|
79
|
+
# +mgmt.flags.*+. Per-request context is set via
|
|
80
|
+
# +client.set_context([...])+.
|
|
81
|
+
class FlagsClient
|
|
82
|
+
def initialize(parent, manage:, metrics:, flags_base_url:, app_base_url:)
|
|
83
|
+
@parent = parent
|
|
84
|
+
@manage = manage
|
|
85
|
+
@metrics = metrics
|
|
86
|
+
@service = parent._service
|
|
87
|
+
@environment = parent._environment
|
|
88
|
+
@flags_base_url = flags_base_url
|
|
89
|
+
@app_base_url = app_base_url
|
|
90
|
+
|
|
91
|
+
@flag_store = {}
|
|
92
|
+
@connected = false
|
|
93
|
+
@cache = ResolutionCache.new
|
|
94
|
+
@handles = {}
|
|
95
|
+
@global_listeners = []
|
|
96
|
+
@key_listeners = Hash.new { |h, k| h[k] = [] }
|
|
97
|
+
@ws_manager = nil
|
|
98
|
+
@lock = Mutex.new
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def boolean_flag(id, default:)
|
|
102
|
+
register_handle(BooleanFlag, id, "BOOLEAN", default)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def string_flag(id, default:)
|
|
106
|
+
register_handle(StringFlag, id, "STRING", default)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def number_flag(id, default:)
|
|
110
|
+
register_handle(NumberFlag, id, "NUMERIC", default)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def json_flag(id, default:)
|
|
114
|
+
register_handle(JsonFlag, id, "JSON", default)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Eagerly initialize the flags subclient.
|
|
118
|
+
#
|
|
119
|
+
# Drains any pending flag-declaration buffer, fetches all flag
|
|
120
|
+
# definitions, opens the shared WebSocket and subscribes to
|
|
121
|
+
# +flag_changed+ / +flag_deleted+ / +flags_changed+ events.
|
|
122
|
+
#
|
|
123
|
+
# Idempotent — safe to call multiple times. Called automatically on
|
|
124
|
+
# first +flag.get+ evaluation if not invoked manually.
|
|
125
|
+
def start
|
|
126
|
+
return if @connected
|
|
127
|
+
|
|
128
|
+
@environment = @parent._environment
|
|
129
|
+
flush_flags_safely
|
|
130
|
+
refresh
|
|
131
|
+
@connected = true
|
|
132
|
+
|
|
133
|
+
@ws_manager = @parent._ensure_ws
|
|
134
|
+
@ws_manager.on("flag_changed") { |data| handle_flag_changed(data) }
|
|
135
|
+
@ws_manager.on("flag_deleted") { |data| handle_flag_deleted(data) }
|
|
136
|
+
@ws_manager.on("flags_changed") { |data| handle_flags_changed(data) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def refresh
|
|
140
|
+
fetch_all_flags
|
|
141
|
+
@cache.clear
|
|
142
|
+
fire_change_listeners_all("manual")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def stats
|
|
146
|
+
FlagStats.new(cache_hits: @cache.cache_hits, cache_misses: @cache.cache_misses)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Register a change listener.
|
|
150
|
+
#
|
|
151
|
+
# client.flags.on_change { |event| ... } # global
|
|
152
|
+
# client.flags.on_change("checkout-v2") { |e| ... } # flag-scoped
|
|
153
|
+
def on_change(flag_id = nil, &block)
|
|
154
|
+
raise ArgumentError, "on_change requires a block" unless block
|
|
155
|
+
|
|
156
|
+
if flag_id.nil?
|
|
157
|
+
@global_listeners << block
|
|
158
|
+
else
|
|
159
|
+
@key_listeners[flag_id] << block
|
|
160
|
+
end
|
|
161
|
+
block
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def _close
|
|
165
|
+
# No durable resources here — kept for symmetry with Python SDK.
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def _evaluate_handle(flag_id, default, context)
|
|
169
|
+
start unless @connected
|
|
170
|
+
|
|
171
|
+
eval_dict =
|
|
172
|
+
if context
|
|
173
|
+
@manage.contexts.register(context) if @manage.respond_to?(:contexts)
|
|
174
|
+
contexts_to_eval_dict(context)
|
|
175
|
+
else
|
|
176
|
+
current = Smplkit.request_context
|
|
177
|
+
current.empty? ? {} : contexts_to_eval_dict(current)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
eval_dict["service"] = { "key" => @service } if @service && !eval_dict.key?("service")
|
|
181
|
+
|
|
182
|
+
ctx_hash = hash_context(eval_dict)
|
|
183
|
+
cache_key = "#{flag_id}:#{ctx_hash}"
|
|
184
|
+
|
|
185
|
+
hit, cached_value = @cache.get(cache_key)
|
|
186
|
+
if hit
|
|
187
|
+
@metrics&.record("flags.cache_hits", unit: "hits")
|
|
188
|
+
@metrics&.record("flags.evaluations", unit: "evaluations", dimensions: { "flag" => flag_id })
|
|
189
|
+
return cached_value
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
flag_def = @flag_store[flag_id]
|
|
193
|
+
if flag_def.nil?
|
|
194
|
+
@cache.put(cache_key, default)
|
|
195
|
+
return default
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
value = evaluate_flag(flag_def, @environment, eval_dict)
|
|
199
|
+
value = default if value.nil?
|
|
200
|
+
@cache.put(cache_key, value)
|
|
201
|
+
@metrics&.record("flags.cache_misses", unit: "misses")
|
|
202
|
+
@metrics&.record("flags.evaluations", unit: "evaluations", dimensions: { "flag" => flag_id })
|
|
203
|
+
value
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
def register_handle(klass, id, type_name, default)
|
|
209
|
+
handle = klass.new(self, id: id, name: id, type: type_name, default: default)
|
|
210
|
+
@handles[id] = handle
|
|
211
|
+
if @manage.respond_to?(:flags)
|
|
212
|
+
@manage.flags.register(FlagDeclaration.new(
|
|
213
|
+
id: id, type: type_name, default: default,
|
|
214
|
+
service: @service, environment: @environment
|
|
215
|
+
))
|
|
216
|
+
end
|
|
217
|
+
handle
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def flush_flags_safely
|
|
221
|
+
@manage.flags.flush
|
|
222
|
+
rescue StandardError => e
|
|
223
|
+
Smplkit.debug("registration", "bulk flag registration failed: #{e.class}: #{e.message}")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def fetch_all_flags
|
|
227
|
+
flags = @parent._flags_transport.list_flags
|
|
228
|
+
@flag_store = flags.to_h { |f| [f["id"], f] }
|
|
229
|
+
rescue Smplkit::Error
|
|
230
|
+
raise
|
|
231
|
+
rescue StandardError => e
|
|
232
|
+
raise Smplkit::ConnectionError, "Failed to fetch flags: #{e.message}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def contexts_to_eval_dict(contexts)
|
|
236
|
+
contexts.to_h { |ctx| [ctx.type, ctx.to_eval_hash] }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def hash_context(eval_dict)
|
|
240
|
+
Digest::MD5.hexdigest(JSON.generate(deep_sort(eval_dict)))
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def deep_sort(value)
|
|
244
|
+
case value
|
|
245
|
+
when Hash
|
|
246
|
+
value.keys.sort_by(&:to_s).to_h { |k| [k, deep_sort(value[k])] }
|
|
247
|
+
when Array
|
|
248
|
+
value.map { |v| deep_sort(v) }
|
|
249
|
+
else
|
|
250
|
+
value
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def handle_flag_changed(data)
|
|
255
|
+
key = data["id"]
|
|
256
|
+
return unless key
|
|
257
|
+
|
|
258
|
+
pre = @flag_store[key]&.dup || {}
|
|
259
|
+
new_data = @parent._flags_transport.fetch_flag(key)
|
|
260
|
+
@flag_store[key] = new_data
|
|
261
|
+
@cache.clear
|
|
262
|
+
fire_change_listeners(key, "websocket") if pre != new_data
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def handle_flag_deleted(data)
|
|
266
|
+
key = data["id"]
|
|
267
|
+
return unless key
|
|
268
|
+
|
|
269
|
+
existed = @flag_store.key?(key)
|
|
270
|
+
@flag_store.delete(key)
|
|
271
|
+
@cache.clear
|
|
272
|
+
fire_change_listeners(key, "websocket", deleted: true) if existed
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def handle_flags_changed(_data)
|
|
276
|
+
pre_store = @flag_store.dup
|
|
277
|
+
begin
|
|
278
|
+
fetch_all_flags
|
|
279
|
+
rescue StandardError => e
|
|
280
|
+
Smplkit.debug("ws", "flags refresh after flags_changed failed: #{e.message}")
|
|
281
|
+
return
|
|
282
|
+
end
|
|
283
|
+
@cache.clear
|
|
284
|
+
post_store = @flag_store
|
|
285
|
+
all_keys = pre_store.keys | post_store.keys
|
|
286
|
+
changed = all_keys.reject { |k| pre_store[k] == post_store[k] }
|
|
287
|
+
return if changed.empty?
|
|
288
|
+
|
|
289
|
+
first_event = FlagChangeEvent.new(id: changed.first, source: "websocket")
|
|
290
|
+
@global_listeners.each do |cb|
|
|
291
|
+
cb.call(first_event)
|
|
292
|
+
rescue StandardError => e
|
|
293
|
+
Smplkit.debug("flags", "global listener raised: #{e.class}: #{e.message}")
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
changed.each do |k|
|
|
297
|
+
deleted = pre_store.key?(k) && !post_store.key?(k)
|
|
298
|
+
event = FlagChangeEvent.new(id: k, source: "websocket", deleted: deleted)
|
|
299
|
+
@key_listeners[k].each do |cb|
|
|
300
|
+
cb.call(event)
|
|
301
|
+
rescue StandardError => e
|
|
302
|
+
Smplkit.debug("flags", "scoped listener raised: #{e.class}: #{e.message}")
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def fire_change_listeners(flag_id, source, deleted: false)
|
|
308
|
+
return unless flag_id
|
|
309
|
+
|
|
310
|
+
event = FlagChangeEvent.new(id: flag_id, source: source, deleted: deleted)
|
|
311
|
+
(@global_listeners + @key_listeners[flag_id]).each do |cb|
|
|
312
|
+
cb.call(event)
|
|
313
|
+
rescue StandardError => e
|
|
314
|
+
Smplkit.debug("flags", "listener raised: #{e.class}: #{e.message}")
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def fire_change_listeners_all(source)
|
|
319
|
+
@flag_store.each_key { |id| fire_change_listeners(id, source) }
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Evaluate a flag definition against the given context.
|
|
323
|
+
#
|
|
324
|
+
# Follows ADR-022 §2.6 semantics:
|
|
325
|
+
# 1. Look up the environment. If missing, return flag-level default.
|
|
326
|
+
# 2. If disabled, return env default or flag default.
|
|
327
|
+
# 3. Iterate rules; first match wins.
|
|
328
|
+
# 4. No match -> env default or flag default.
|
|
329
|
+
def evaluate_flag(flag_def, environment, eval_dict)
|
|
330
|
+
flag_default = flag_def["default"]
|
|
331
|
+
environments = flag_def["environments"] || {}
|
|
332
|
+
|
|
333
|
+
return flag_default if environment.nil? || !environments.key?(environment)
|
|
334
|
+
|
|
335
|
+
env_config = environments[environment]
|
|
336
|
+
fallback = env_config.default.nil? ? flag_default : env_config.default
|
|
337
|
+
return fallback unless env_config.enabled
|
|
338
|
+
|
|
339
|
+
env_config.rules.each do |rule|
|
|
340
|
+
next if rule.logic.nil? || rule.logic.empty?
|
|
341
|
+
|
|
342
|
+
begin
|
|
343
|
+
result = JsonLogicEvaluator.apply(rule.logic, eval_dict)
|
|
344
|
+
return rule.value if result
|
|
345
|
+
rescue StandardError => e
|
|
346
|
+
Smplkit.debug("flags", "json logic evaluation error for rule: #{e.class}: #{e.message}")
|
|
347
|
+
next
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
fallback
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Vendored minimal JSON Logic evaluator covering the operators the smplkit
|
|
356
|
+
# platform ships in flag rules.
|
|
357
|
+
#
|
|
358
|
+
# Stays in-tree so the Ruby SDK doesn't depend on the +json_logic+ gem
|
|
359
|
+
# being correct — the Java SDK followed the same pattern. Operators
|
|
360
|
+
# supported: +==+, +!=+, +<+, +<=+, +>+, +>=+, +in+, +var+, +and+, +or+,
|
|
361
|
+
# +!+, +if+, +missing+, +none+.
|
|
362
|
+
module JsonLogicEvaluator
|
|
363
|
+
module_function
|
|
364
|
+
|
|
365
|
+
def apply(logic, data)
|
|
366
|
+
return logic unless logic.is_a?(Hash)
|
|
367
|
+
return logic if logic.empty?
|
|
368
|
+
|
|
369
|
+
op, values = logic.first
|
|
370
|
+
values = [values] unless values.is_a?(Array)
|
|
371
|
+
|
|
372
|
+
case op
|
|
373
|
+
when "var"
|
|
374
|
+
resolve_var(values[0], data, values[1])
|
|
375
|
+
when "and"
|
|
376
|
+
values.reduce(true) { |acc, v| acc && truthy?(apply(v, data)) }
|
|
377
|
+
when "or"
|
|
378
|
+
values.reduce(false) { |acc, v| acc || truthy?(apply(v, data)) }
|
|
379
|
+
when "!"
|
|
380
|
+
!truthy?(apply(values[0], data))
|
|
381
|
+
when "if"
|
|
382
|
+
eval_if(values, data)
|
|
383
|
+
when "==", "==="
|
|
384
|
+
apply(values[0], data) == apply(values[1], data)
|
|
385
|
+
when "!=", "!=="
|
|
386
|
+
apply(values[0], data) != apply(values[1], data)
|
|
387
|
+
when "<"
|
|
388
|
+
compare(values, data) { |a, b| a < b }
|
|
389
|
+
when "<="
|
|
390
|
+
compare(values, data) { |a, b| a <= b }
|
|
391
|
+
when ">"
|
|
392
|
+
compare(values, data) { |a, b| a > b }
|
|
393
|
+
when ">="
|
|
394
|
+
compare(values, data) { |a, b| a >= b }
|
|
395
|
+
when "in"
|
|
396
|
+
eval_in(values, data)
|
|
397
|
+
when "missing"
|
|
398
|
+
eval_missing(values, data)
|
|
399
|
+
when "none"
|
|
400
|
+
values_arr = apply(values[0], data) || []
|
|
401
|
+
inner = values[1]
|
|
402
|
+
values_arr.is_a?(Array) && values_arr.none? { |item| truthy?(apply(inner, item)) }
|
|
403
|
+
else
|
|
404
|
+
false
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def truthy?(value)
|
|
409
|
+
return false if value.nil?
|
|
410
|
+
return false if value == false
|
|
411
|
+
return false if value.is_a?(Numeric) && value.zero?
|
|
412
|
+
return false if value == ""
|
|
413
|
+
return false if value == []
|
|
414
|
+
|
|
415
|
+
true
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def resolve_var(path, data, default = nil)
|
|
419
|
+
return data if path.nil? || path == "" || path == []
|
|
420
|
+
|
|
421
|
+
keys = path.is_a?(Array) ? path : path.to_s.split(".")
|
|
422
|
+
keys.reduce(data) do |scope, key|
|
|
423
|
+
break default if scope.nil?
|
|
424
|
+
|
|
425
|
+
if scope.is_a?(Hash)
|
|
426
|
+
scope[key] || scope[key.to_s] || scope[key.to_sym]
|
|
427
|
+
elsif scope.is_a?(Array) && key.to_s =~ /\A\d+\z/
|
|
428
|
+
scope[key.to_i]
|
|
429
|
+
else
|
|
430
|
+
default
|
|
431
|
+
end
|
|
432
|
+
end || default
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def compare(values, data)
|
|
436
|
+
applied = values.map { |v| apply(v, data) }
|
|
437
|
+
return false if applied.any?(&:nil?)
|
|
438
|
+
|
|
439
|
+
if applied.length == 2
|
|
440
|
+
yield(applied[0], applied[1])
|
|
441
|
+
elsif applied.length == 3
|
|
442
|
+
yield(applied[0], applied[1]) && yield(applied[1], applied[2])
|
|
443
|
+
else
|
|
444
|
+
false
|
|
445
|
+
end
|
|
446
|
+
rescue ArgumentError, TypeError
|
|
447
|
+
false
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def eval_if(values, data)
|
|
451
|
+
i = 0
|
|
452
|
+
while i + 1 < values.length
|
|
453
|
+
return apply(values[i + 1], data) if truthy?(apply(values[i], data))
|
|
454
|
+
|
|
455
|
+
i += 2
|
|
456
|
+
end
|
|
457
|
+
i < values.length ? apply(values[i], data) : nil
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def eval_in(values, data)
|
|
461
|
+
needle = apply(values[0], data)
|
|
462
|
+
haystack = apply(values[1], data)
|
|
463
|
+
return false if haystack.nil?
|
|
464
|
+
|
|
465
|
+
haystack.include?(needle)
|
|
466
|
+
rescue NoMethodError, TypeError
|
|
467
|
+
false
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def eval_missing(values, data)
|
|
471
|
+
keys = values.is_a?(Array) ? values.flatten : [values]
|
|
472
|
+
keys.reject { |k| present?(resolve_var(k, data)) }
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def present?(value)
|
|
476
|
+
!(value.nil? || value == "")
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smplkit
|
|
4
|
+
module Flags
|
|
5
|
+
# Helpers that translate between server JSON and the SDK's wrapper model
|
|
6
|
+
# objects.
|
|
7
|
+
module Helpers
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Translate a JSON:API resource Hash into a flat dict the runtime cache
|
|
11
|
+
# can consume. The shape mirrors +smplkit.flags.helpers._flag_dict_from_json+
|
|
12
|
+
# in the Python SDK.
|
|
13
|
+
def flag_dict_from_json(resource)
|
|
14
|
+
attrs = resource["attributes"] || {}
|
|
15
|
+
environments = (attrs["environments"] || {}).each_with_object({}) do |(env_key, env_data), out|
|
|
16
|
+
out[env_key] = parse_env(env_data || {})
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
"id" => resource["id"] || attrs["id"],
|
|
21
|
+
"name" => attrs["name"],
|
|
22
|
+
"type" => attrs["type"],
|
|
23
|
+
"default" => attrs["default"],
|
|
24
|
+
"values" => parse_values(attrs["values"]),
|
|
25
|
+
"description" => attrs["description"],
|
|
26
|
+
"environments" => environments
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def parse_values(raw)
|
|
31
|
+
return nil if raw.nil?
|
|
32
|
+
|
|
33
|
+
raw.map { |v| FlagValue.new(name: v["name"], value: v["value"]) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def parse_env(env_data)
|
|
37
|
+
rules = (env_data["rules"] || []).map do |r|
|
|
38
|
+
FlagRule.new(
|
|
39
|
+
logic: r["logic"] || {},
|
|
40
|
+
value: r["value"],
|
|
41
|
+
description: r["description"]
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
FlagEnvironment.new(
|
|
45
|
+
enabled: env_data.fetch("enabled", true) ? true : false,
|
|
46
|
+
default: env_data["default"],
|
|
47
|
+
rules: rules
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Build the JSON body for a Flag create/update request.
|
|
52
|
+
def build_flag_request_body(flag)
|
|
53
|
+
environments = flag.environments.each_with_object({}) do |(env_key, env), out|
|
|
54
|
+
out[env_key] = {
|
|
55
|
+
"enabled" => env.enabled,
|
|
56
|
+
"default" => env.default,
|
|
57
|
+
"rules" => env.rules.map { |r| { "logic" => r.logic, "value" => r.value, "description" => r.description } }
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
attributes = {
|
|
62
|
+
"id" => flag.id,
|
|
63
|
+
"name" => flag.name,
|
|
64
|
+
"type" => flag.type,
|
|
65
|
+
"default" => flag.default,
|
|
66
|
+
"description" => flag.description,
|
|
67
|
+
"environments" => environments
|
|
68
|
+
}
|
|
69
|
+
values = flag.values
|
|
70
|
+
attributes["values"] = values.map { |v| { "name" => v.name, "value" => v.value } } if values
|
|
71
|
+
|
|
72
|
+
{ "data" => { "type" => "flag", "id" => flag.id, "attributes" => attributes.compact } }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|