smplkit 3.0.51 → 3.0.52
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 +1 -1
- data/lib/smplkit/client.rb +0 -1
- data/lib/smplkit/config/client.rb +403 -254
- data/lib/smplkit/config/helpers.rb +32 -0
- data/lib/smplkit/errors.rb +17 -3
- data/lib/smplkit/management/client.rb +9 -50
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9a564f091d96cfc222d7e5035f55bd9ddf23e6d8baf30c790de18790712eb698
|
|
4
|
+
data.tar.gz: 151c871e8025acface481cd7acd7714486923e38a2ec9cc802cf0769886c4293
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e501e7044027b6678d9dd01bd2b77f565fa9f8edc9d386678e73cee9fb50234882c88e98e2e13862938f2a6b5b4cce4fecbd6e79d848710f842bd37f2fc2fb4d
|
|
7
|
+
data.tar.gz: 738d7a3681708c49b28d45af2a747c798a48fa20acc1ce71782baa363259e0068e2f6bc34e891a6aadc5ea080f2b4133507c3a011f2e62d3935bdcf7a8d72cbb
|
data/README.md
CHANGED
|
@@ -50,7 +50,7 @@ flag = manage.flags.new_boolean_flag(
|
|
|
50
50
|
"checkout-v2", default: false, description: "Controls rollout"
|
|
51
51
|
)
|
|
52
52
|
flag.add_rule(
|
|
53
|
-
Smplkit::Rule.new("Enable for enterprise users", environment: "
|
|
53
|
+
Smplkit::Rule.new("Enable for enterprise users", environment: "production")
|
|
54
54
|
.when("user.plan", Smplkit::Op::EQ, "enterprise")
|
|
55
55
|
.serve(true)
|
|
56
56
|
)
|
data/lib/smplkit/client.rb
CHANGED
|
@@ -2,142 +2,225 @@
|
|
|
2
2
|
|
|
3
3
|
module Smplkit
|
|
4
4
|
module Config
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
5
|
+
# Module-level helpers for the runtime config client. Extracted so they
|
|
6
|
+
# can be unit-tested without spinning up the full client.
|
|
7
|
+
module Discovery
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Map a runtime value to a Config item type. Used both when binding a
|
|
11
|
+
# Hash/Struct target and when supplying a default to +get(id, key,
|
|
12
|
+
# default)+. +true+/+false+ are checked first because Ruby's
|
|
13
|
+
# +Numeric+/+Integer+ tests would not accidentally claim them.
|
|
14
|
+
def value_to_item_type(value)
|
|
15
|
+
case value
|
|
16
|
+
when true, false then "BOOLEAN"
|
|
17
|
+
when Numeric then "NUMBER"
|
|
18
|
+
else "STRING"
|
|
19
|
+
end
|
|
14
20
|
end
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
# Walk a bound target, returning +[key, type, value, description]+
|
|
23
|
+
# tuples flattened to dot-notation. Nested Hashes / Structs are
|
|
24
|
+
# descended into; everything else is treated as an opaque leaf.
|
|
25
|
+
def iter_items(target, prefix: "")
|
|
26
|
+
if target.is_a?(Hash)
|
|
27
|
+
iter_hash_items(target, prefix: prefix)
|
|
28
|
+
elsif target.is_a?(Struct)
|
|
29
|
+
iter_struct_items(target, prefix: prefix)
|
|
30
|
+
else
|
|
31
|
+
[]
|
|
32
|
+
end
|
|
20
33
|
end
|
|
21
|
-
alias eql? ==
|
|
22
|
-
|
|
23
|
-
def hash = [key, source, deleted].hash
|
|
24
|
-
end
|
|
25
34
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
def iter_hash_items(hash, prefix: "")
|
|
36
|
+
out = []
|
|
37
|
+
hash.each do |raw_key, value|
|
|
38
|
+
flat_key = "#{prefix}#{raw_key}"
|
|
39
|
+
if value.is_a?(Hash) || value.is_a?(Struct)
|
|
40
|
+
out.concat(iter_items(value, prefix: "#{flat_key}."))
|
|
41
|
+
else
|
|
42
|
+
out << [flat_key, value_to_item_type(value), value, nil]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
out
|
|
36
46
|
end
|
|
37
47
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
def iter_struct_items(struct, prefix: "")
|
|
49
|
+
out = []
|
|
50
|
+
struct.members.each do |member|
|
|
51
|
+
value = struct[member]
|
|
52
|
+
flat_key = "#{prefix}#{member}"
|
|
53
|
+
if value.is_a?(Hash) || value.is_a?(Struct)
|
|
54
|
+
out.concat(iter_items(value, prefix: "#{flat_key}."))
|
|
55
|
+
else
|
|
56
|
+
out << [flat_key, value_to_item_type(value), value, nil]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
out
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Apply a server-pushed value to a bound target in place. Walks the
|
|
63
|
+
# dotted key path to the leaf's parent and assigns the value via
|
|
64
|
+
# +Hash#[]=+ or +Struct#[]=+. Bails silently if any intermediate is
|
|
65
|
+
# missing or not a supported container — the server may have items
|
|
66
|
+
# that don't line up with what the bound target declared.
|
|
67
|
+
def apply_change_to_target(target, dotted_key, value)
|
|
68
|
+
parts = dotted_key.split(".")
|
|
69
|
+
current = walk_to_leaf_parent(target, parts[0..-2])
|
|
70
|
+
return if current.nil?
|
|
71
|
+
|
|
72
|
+
last = parts.last
|
|
73
|
+
if current.is_a?(Struct)
|
|
74
|
+
assign_struct_member(current, last, value)
|
|
75
|
+
elsif current.is_a?(Hash)
|
|
76
|
+
current[last] = value
|
|
77
|
+
end
|
|
78
|
+
end
|
|
43
79
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
80
|
+
def walk_to_leaf_parent(target, parts)
|
|
81
|
+
current = target
|
|
82
|
+
parts.each do |part|
|
|
83
|
+
case current
|
|
84
|
+
when Struct
|
|
85
|
+
sym = part.to_sym
|
|
86
|
+
return nil unless current.members.include?(sym)
|
|
47
87
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
88
|
+
current = current[sym]
|
|
89
|
+
when Hash
|
|
90
|
+
return nil unless current.key?(part)
|
|
51
91
|
|
|
52
|
-
|
|
53
|
-
|
|
92
|
+
current = current[part]
|
|
93
|
+
else
|
|
94
|
+
return nil
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
current
|
|
54
98
|
end
|
|
55
99
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
100
|
+
def assign_struct_member(struct, name, value)
|
|
101
|
+
sym = name.to_sym
|
|
102
|
+
return unless struct.members.include?(sym)
|
|
59
103
|
|
|
60
|
-
|
|
61
|
-
# The cache is fully invalidated for this key — the next read
|
|
62
|
-
# re-resolves from the parent client.
|
|
63
|
-
@client._invalidate(@key)
|
|
64
|
-
self
|
|
104
|
+
struct[sym] = value
|
|
65
105
|
end
|
|
106
|
+
end
|
|
66
107
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
# call within the process, then returns the resolved value. When the
|
|
72
|
-
# resolved value can't be coerced to the getter's type — including
|
|
73
|
-
# the "not yet set on the server" case — the in-code default is
|
|
74
|
-
# returned and a debug message is logged.
|
|
75
|
-
# ------------------------------------------------------------------
|
|
108
|
+
# Describes a single config value change. Frozen — fields are set at
|
|
109
|
+
# construction and cannot be mutated afterward.
|
|
110
|
+
class ConfigChangeEvent
|
|
111
|
+
attr_reader :config_id, :item_key, :old_value, :new_value, :source
|
|
76
112
|
|
|
77
|
-
def
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
113
|
+
def initialize(config_id:, item_key:, old_value:, new_value:, source:)
|
|
114
|
+
@config_id = config_id
|
|
115
|
+
@item_key = item_key
|
|
116
|
+
@old_value = old_value
|
|
117
|
+
@new_value = new_value
|
|
118
|
+
@source = source
|
|
119
|
+
freeze
|
|
120
|
+
end
|
|
81
121
|
|
|
82
|
-
|
|
122
|
+
def ==(other)
|
|
123
|
+
other.is_a?(ConfigChangeEvent) &&
|
|
124
|
+
config_id == other.config_id && item_key == other.item_key &&
|
|
125
|
+
old_value == other.old_value && new_value == other.new_value &&
|
|
126
|
+
source == other.source
|
|
83
127
|
end
|
|
128
|
+
alias eql? ==
|
|
84
129
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
value = current_values[item_key.to_s]
|
|
88
|
-
return default if value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
89
|
-
return value if value.is_a?(Integer)
|
|
90
|
-
return value.to_i if value.is_a?(Float) && value == value.floor
|
|
130
|
+
def hash = [config_id, item_key, old_value, new_value, source].hash
|
|
131
|
+
end
|
|
91
132
|
|
|
92
|
-
|
|
133
|
+
# A live, read-only, dict-like view of a config's resolved values.
|
|
134
|
+
#
|
|
135
|
+
# Returned by +ConfigClient#get(id)+ (single-arg form). Always reflects
|
|
136
|
+
# the latest server-pushed state — every read sees current values.
|
|
137
|
+
#
|
|
138
|
+
# Supports +[]+, +key?+, +keys+, +values+, +each_pair+, +to_h+, +size+,
|
|
139
|
+
# and method-style attribute access for keys that don't collide with
|
|
140
|
+
# built-in method names. Use subscript (+proxy["values"]+) for keys
|
|
141
|
+
# that do collide.
|
|
142
|
+
#
|
|
143
|
+
# For typed access via a Struct schema, use +ConfigClient#bind+ —
|
|
144
|
+
# bound objects stay live on the same cache, with no proxy indirection.
|
|
145
|
+
class LiveConfigProxy
|
|
146
|
+
# Methods that live on the proxy itself; never resolved against the
|
|
147
|
+
# cached values dictionary.
|
|
148
|
+
OWN_METHODS = %i[config_id keys values each_pair each items to_h size length
|
|
149
|
+
key? include? has_key? on_change get].freeze
|
|
150
|
+
|
|
151
|
+
def initialize(client, config_id)
|
|
152
|
+
@client = client
|
|
153
|
+
@config_id = config_id
|
|
93
154
|
end
|
|
94
155
|
|
|
95
|
-
|
|
96
|
-
register_item(item_key, "NUMBER", default, description)
|
|
97
|
-
value = current_values[item_key.to_s]
|
|
98
|
-
return default if value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
99
|
-
return value.to_f if value.is_a?(Numeric)
|
|
156
|
+
attr_reader :config_id
|
|
100
157
|
|
|
101
|
-
|
|
102
|
-
|
|
158
|
+
def keys = current_values.keys
|
|
159
|
+
def values = current_values.values
|
|
160
|
+
def each_pair(&) = current_values.each_pair(&)
|
|
161
|
+
alias each each_pair
|
|
162
|
+
def items = current_values.to_a
|
|
163
|
+
def to_h = current_values.dup
|
|
164
|
+
def size = current_values.size
|
|
165
|
+
alias length size
|
|
166
|
+
def key?(key) = current_values.key?(key.to_s)
|
|
167
|
+
alias include? key?
|
|
168
|
+
alias has_key? key?
|
|
103
169
|
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
value = current_values[item_key.to_s]
|
|
107
|
-
value.is_a?(String) ? value : default
|
|
170
|
+
def [](key)
|
|
171
|
+
current_values[key.to_s]
|
|
108
172
|
end
|
|
109
173
|
|
|
110
|
-
def
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
snap.key?(item_key.to_s) ? snap[item_key.to_s] : default
|
|
174
|
+
def get(key, default = nil)
|
|
175
|
+
values = current_values
|
|
176
|
+
values.key?(key.to_s) ? values[key.to_s] : default
|
|
114
177
|
end
|
|
115
178
|
|
|
116
179
|
def on_change(item_key = nil, &)
|
|
117
180
|
if item_key.nil?
|
|
118
|
-
@client.on_change(@
|
|
181
|
+
@client.on_change(@config_id, &)
|
|
119
182
|
else
|
|
120
|
-
@client.
|
|
183
|
+
@client.on_change(@config_id, item_key: item_key.to_s, &)
|
|
121
184
|
end
|
|
122
185
|
end
|
|
123
186
|
|
|
124
|
-
|
|
187
|
+
def respond_to_missing?(name, include_private = false)
|
|
188
|
+
return true if OWN_METHODS.include?(name)
|
|
125
189
|
|
|
126
|
-
|
|
127
|
-
@client._resolve_now(@key)
|
|
190
|
+
current_values.key?(name.to_s) || super
|
|
128
191
|
end
|
|
129
192
|
|
|
130
|
-
def
|
|
131
|
-
|
|
193
|
+
def method_missing(name, *args)
|
|
194
|
+
snapshot = current_values
|
|
195
|
+
key = name.to_s
|
|
196
|
+
return snapshot[key] if snapshot.key?(key) && args.empty?
|
|
197
|
+
|
|
198
|
+
super
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def to_s = "#<Smplkit::Config::LiveConfigProxy config_id=#{@config_id.inspect}>"
|
|
202
|
+
alias inspect to_s
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def current_values
|
|
207
|
+
@client._cached_values(@config_id)
|
|
132
208
|
end
|
|
133
209
|
end
|
|
134
210
|
|
|
135
|
-
# Synchronous
|
|
211
|
+
# Synchronous runtime client for Smpl Config.
|
|
136
212
|
#
|
|
137
|
-
# Obtained via +Smplkit::Client#config+. Exposes
|
|
138
|
-
#
|
|
139
|
-
#
|
|
213
|
+
# Obtained via +Smplkit::Client#config+. Exposes +#bind+ (the
|
|
214
|
+
# recommended declarative API), +#get+ (lookup-only escape hatch),
|
|
215
|
+
# +#refresh+, and +#on_change+. Management/CRUD lives on
|
|
216
|
+
# +Smplkit::Client#manage.config+.
|
|
140
217
|
class ConfigClient
|
|
218
|
+
# Sentinel used to distinguish "argument not supplied" from "argument
|
|
219
|
+
# supplied as nil" on +#get+. A frozen +Object+ is sufficient — we
|
|
220
|
+
# only ever identity-compare with +equal?+.
|
|
221
|
+
MISSING = Object.new.freeze
|
|
222
|
+
private_constant :MISSING
|
|
223
|
+
|
|
141
224
|
def initialize(parent, manage:, metrics:)
|
|
142
225
|
@parent = parent
|
|
143
226
|
@manage = manage
|
|
@@ -145,137 +228,149 @@ module Smplkit
|
|
|
145
228
|
@environment = parent._environment
|
|
146
229
|
@service = parent._service
|
|
147
230
|
|
|
148
|
-
@
|
|
149
|
-
@
|
|
150
|
-
@proxies = {}
|
|
151
|
-
@
|
|
152
|
-
@
|
|
153
|
-
@item_listeners = Hash.new { |h, k| h[k] = Hash.new { |hh, kk| hh[kk] = [] } }
|
|
231
|
+
@config_cache = {} # config_key -> { item_key => resolved_value }
|
|
232
|
+
@raw_config_store = {} # config_key -> Smplkit::Config::Config
|
|
233
|
+
@proxies = {} # config_key -> LiveConfigProxy
|
|
234
|
+
@bindings = {} # config_key -> Hash | Struct (bound target)
|
|
235
|
+
@listeners = [] # [callback, config_id_or_nil, item_key_or_nil]
|
|
154
236
|
@connected = false
|
|
155
237
|
@lock = Mutex.new
|
|
238
|
+
@ws_manager = nil
|
|
156
239
|
end
|
|
157
240
|
|
|
241
|
+
# Eagerly initialize the runtime. Flushes any buffered discovery
|
|
242
|
+
# declarations, fetches the full config list, resolves values for the
|
|
243
|
+
# SDK's current environment into the local cache, and subscribes to
|
|
244
|
+
# +config_changed+ / +config_deleted+ / +configs_changed+ events on
|
|
245
|
+
# the shared WebSocket.
|
|
246
|
+
#
|
|
247
|
+
# Idempotent — safe to call multiple times. Invoked automatically on
|
|
248
|
+
# the first +#get+ or +#bind+ call.
|
|
158
249
|
def start
|
|
159
250
|
return if @connected
|
|
160
251
|
|
|
161
252
|
@environment = @parent._environment
|
|
162
253
|
|
|
163
|
-
# Per ADR-037 §2.14: flush
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
254
|
+
# Per ADR-037 §2.14: flush pending discovery declarations BEFORE
|
|
255
|
+
# the initial fetch so newly-declared configs show up in the cache.
|
|
256
|
+
begin
|
|
257
|
+
@manage&.config&.flush
|
|
258
|
+
rescue StandardError => e
|
|
259
|
+
Smplkit.debug("config", "pre-start discovery flush failed: #{e.class}: #{e.message}")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
do_refresh("initial")
|
|
263
|
+
@connected = true
|
|
168
264
|
|
|
169
265
|
@ws_manager = @parent._ensure_ws
|
|
170
266
|
@ws_manager.on("config_changed") { |data| handle_config_changed(data) }
|
|
171
267
|
@ws_manager.on("config_deleted") { |data| handle_config_deleted(data) }
|
|
172
|
-
@
|
|
268
|
+
@ws_manager.on("configs_changed") { |data| handle_configs_changed(data) }
|
|
173
269
|
end
|
|
174
270
|
|
|
175
|
-
|
|
176
|
-
|
|
271
|
+
# Bind a Hash or Struct to a config id; return the same object back, live.
|
|
272
|
+
#
|
|
273
|
+
# Declarative, code-first API. Two flavors:
|
|
274
|
+
#
|
|
275
|
+
# * +Hash+: keys present are leaves to register, with their values as
|
|
276
|
+
# the in-code defaults. Nested Hashes flatten to dot-notation. Keys
|
|
277
|
+
# the caller wants to inherit from +parent:+ are simply omitted.
|
|
278
|
+
# * +Struct+: every member is registered as an explicit override.
|
|
279
|
+
# Ruby Structs do not track which members were "explicitly set" vs
|
|
280
|
+
# defaulted, so there is no Hash-style omit-to-inherit. For
|
|
281
|
+
# omit-to-inherit, use a Hash target.
|
|
282
|
+
#
|
|
283
|
+
# On first call the schema and values are registered with the server.
|
|
284
|
+
# After the local cache is populated, any server-side overrides for
|
|
285
|
+
# this config are applied to the bound object in place. WebSocket
|
|
286
|
+
# events thereafter mutate the bound object in place — readers always
|
|
287
|
+
# see the current resolved value with no indirection.
|
|
288
|
+
#
|
|
289
|
+
# Idempotent. Repeat calls with the same +id+ return the
|
|
290
|
+
# originally-bound object; the new +config+ argument is ignored.
|
|
291
|
+
def bind(id, target, parent: nil)
|
|
292
|
+
unless target.is_a?(Hash) || target.is_a?(Struct)
|
|
293
|
+
raise TypeError, "bind() requires a Hash or Struct; got #{target.class.name}"
|
|
294
|
+
end
|
|
177
295
|
|
|
178
|
-
|
|
179
|
-
raise Smplkit::NotFoundError, "Config #{config_key.inspect} not found" if snapshot.nil?
|
|
180
|
-
return snapshot if model_class.nil?
|
|
296
|
+
return @bindings[id] if @bindings.key?(id)
|
|
181
297
|
|
|
182
|
-
|
|
183
|
-
end
|
|
298
|
+
parent_id = resolve_parent_id(parent)
|
|
184
299
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
# next flush. Unlike +#get+, this method does not raise +NotFoundError+
|
|
192
|
-
# when the id is absent — discovery handles that case.
|
|
193
|
-
def get_or_create(config_id, parent: nil, name: nil, description: nil)
|
|
194
|
-
parent_id =
|
|
195
|
-
case parent
|
|
196
|
-
when nil then nil
|
|
197
|
-
when String then parent
|
|
198
|
-
when LiveConfigProxy then parent.config_id
|
|
199
|
-
else
|
|
200
|
-
raise ArgumentError,
|
|
201
|
-
"parent must be a String id or LiveConfigProxy; got #{parent.class.name}"
|
|
202
|
-
end
|
|
203
|
-
_observe_config_declaration(config_id, parent: parent_id, name: name, description: description)
|
|
204
|
-
start unless @connected
|
|
205
|
-
cached_proxy(config_id)
|
|
206
|
-
end
|
|
300
|
+
if target.is_a?(Struct)
|
|
301
|
+
class_name = target.class.name
|
|
302
|
+
config_name = class_name&.split("::")&.last
|
|
303
|
+
else
|
|
304
|
+
config_name = nil
|
|
305
|
+
end
|
|
207
306
|
|
|
208
|
-
|
|
209
|
-
typed_get(item_key, default, config) { |v| v.is_a?(String) ? v : v.to_s }
|
|
210
|
-
end
|
|
307
|
+
_observe_config_declaration(id, parent: parent_id, name: config_name, description: nil)
|
|
211
308
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
v.is_a?(Numeric) ? v : Float(v)
|
|
215
|
-
rescue StandardError
|
|
216
|
-
default
|
|
309
|
+
Discovery.iter_items(target).each do |item_key, item_type, value, description|
|
|
310
|
+
_observe_item_declaration(id, item_key, item_type, value, description)
|
|
217
311
|
end
|
|
218
|
-
end
|
|
219
312
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
313
|
+
# Register the binding BEFORE start() so any WS dispatch that fires
|
|
314
|
+
# during the initial fetch finds it.
|
|
315
|
+
@bindings[id] = target
|
|
223
316
|
|
|
224
|
-
|
|
225
|
-
|
|
317
|
+
start unless @connected
|
|
318
|
+
sync_target_from_cache(target, id)
|
|
319
|
+
target
|
|
226
320
|
end
|
|
227
321
|
|
|
228
|
-
|
|
322
|
+
# Read a config (full) or a single value within a config.
|
|
323
|
+
#
|
|
324
|
+
# Three forms dispatched by argument count:
|
|
325
|
+
#
|
|
326
|
+
# get("id") # LiveConfigProxy (raises NotFoundError)
|
|
327
|
+
# get("id", "key") # value (raises NotFoundError / KeyError)
|
|
328
|
+
# get("id", "key", default) # value or default; auto-registers (never raises)
|
|
329
|
+
def get(id, key = MISSING, default = MISSING)
|
|
229
330
|
start unless @connected
|
|
230
331
|
|
|
231
|
-
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
def on_change(config_key = nil, &block)
|
|
235
|
-
raise ArgumentError, "on_change requires a block" unless block
|
|
332
|
+
return get_full_config(id) if key.equal?(MISSING)
|
|
236
333
|
|
|
237
|
-
|
|
238
|
-
@global_listeners << block
|
|
239
|
-
else
|
|
240
|
-
@key_listeners[config_key] << block
|
|
241
|
-
end
|
|
242
|
-
block
|
|
334
|
+
get_single_value(id, key.to_s, default)
|
|
243
335
|
end
|
|
244
336
|
|
|
245
|
-
|
|
246
|
-
|
|
337
|
+
# Register a change listener.
|
|
338
|
+
#
|
|
339
|
+
# Three forms:
|
|
340
|
+
#
|
|
341
|
+
# client.config.on_change { |event| ... } # global
|
|
342
|
+
# client.config.on_change("id") { |event| ... } # config-scoped
|
|
343
|
+
# client.config.on_change("id", item_key: "key") { |event| ... } # item-scoped
|
|
344
|
+
def on_change(config_id = nil, item_key: nil, &block)
|
|
345
|
+
raise ArgumentError, "on_change requires a block" unless block
|
|
247
346
|
|
|
248
|
-
@
|
|
347
|
+
@listeners << [block, config_id, item_key&.to_s]
|
|
249
348
|
block
|
|
250
349
|
end
|
|
251
350
|
|
|
351
|
+
# Re-fetch all configs and update resolved values, firing change
|
|
352
|
+
# listeners for anything that differs from the previous state.
|
|
252
353
|
def refresh
|
|
253
|
-
@
|
|
254
|
-
|
|
255
|
-
@raw_chains.clear
|
|
256
|
-
end
|
|
257
|
-
fire_change_listeners_all("manual")
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
def _resolve_now(config_key)
|
|
261
|
-
resolve(config_key) || {}
|
|
354
|
+
start unless @connected
|
|
355
|
+
do_refresh("manual")
|
|
262
356
|
end
|
|
263
357
|
|
|
264
358
|
def _close
|
|
265
|
-
# No durable resources;
|
|
359
|
+
# No durable resources owned by this sub-client; the parent client
|
|
360
|
+
# tears down the WebSocket and management transports.
|
|
266
361
|
end
|
|
267
362
|
|
|
268
|
-
#
|
|
269
|
-
|
|
363
|
+
# Internal: return (a copy of) the resolved values for a config id.
|
|
364
|
+
# Used by +LiveConfigProxy+.
|
|
365
|
+
def _cached_values(config_id)
|
|
270
366
|
@lock.synchronize do
|
|
271
|
-
@
|
|
272
|
-
@raw_chains.delete(config_key)
|
|
367
|
+
(@config_cache[config_id] || {}).dup
|
|
273
368
|
end
|
|
274
369
|
end
|
|
275
370
|
|
|
276
371
|
# Internal: queue a config declaration with the management buffer.
|
|
277
372
|
def _observe_config_declaration(config_id, parent:, name:, description:)
|
|
278
|
-
@manage
|
|
373
|
+
@manage&.config&.register_config(
|
|
279
374
|
config_id,
|
|
280
375
|
service: @service,
|
|
281
376
|
environment: @environment,
|
|
@@ -287,123 +382,177 @@ module Smplkit
|
|
|
287
382
|
|
|
288
383
|
# Internal: queue a config item declaration with the management buffer.
|
|
289
384
|
def _observe_item_declaration(config_id, item_key, item_type, default, description)
|
|
290
|
-
@manage
|
|
385
|
+
@manage&.config&.register_config_item(config_id, item_key, item_type, default, description)
|
|
291
386
|
end
|
|
292
387
|
|
|
293
388
|
private
|
|
294
389
|
|
|
295
|
-
def
|
|
390
|
+
def resolve_parent_id(parent)
|
|
391
|
+
return nil if parent.nil?
|
|
392
|
+
|
|
393
|
+
@bindings.each { |cid, bound| return cid if bound.equal?(parent) }
|
|
394
|
+
raise ArgumentError,
|
|
395
|
+
"bind(): parent must be an object previously returned from client.config.bind(). " \
|
|
396
|
+
"Bind the parent first."
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def get_full_config(id)
|
|
296
400
|
@lock.synchronize do
|
|
297
|
-
|
|
401
|
+
raise Smplkit::NotFoundError, "Config with id '#{id}' not found" unless @config_cache.key?(id)
|
|
298
402
|
end
|
|
403
|
+
@metrics&.record("config.resolutions", unit: "resolutions", dimensions: { "config" => id })
|
|
404
|
+
cached_proxy(id)
|
|
299
405
|
end
|
|
300
406
|
|
|
301
|
-
def
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
value = snapshot[key]
|
|
307
|
-
if value.nil? && snapshot.is_a?(Hash)
|
|
308
|
-
# Fallback: support callers that constructed an explicitly-nested
|
|
309
|
-
# snapshot (rare — typed model bindings only).
|
|
310
|
-
parts = key.split(".")
|
|
311
|
-
if parts.length > 1
|
|
312
|
-
value = parts.reduce(snapshot) do |scope, k|
|
|
313
|
-
break nil unless scope.is_a?(Hash)
|
|
314
|
-
|
|
315
|
-
scope[k]
|
|
316
|
-
end
|
|
317
|
-
end
|
|
407
|
+
def get_single_value(id, key, default)
|
|
408
|
+
has_default = !default.equal?(MISSING)
|
|
409
|
+
if has_default
|
|
410
|
+
_observe_config_declaration(id, parent: nil, name: nil, description: nil)
|
|
411
|
+
_observe_item_declaration(id, key, Discovery.value_to_item_type(default), default, nil)
|
|
318
412
|
end
|
|
319
|
-
return default if value.nil?
|
|
320
413
|
|
|
321
|
-
|
|
414
|
+
values = @lock.synchronize { @config_cache[id]&.dup }
|
|
415
|
+
if values.nil?
|
|
416
|
+
return default if has_default
|
|
417
|
+
|
|
418
|
+
raise Smplkit::NotFoundError, "Config with id '#{id}' not found"
|
|
419
|
+
end
|
|
420
|
+
unless values.key?(key)
|
|
421
|
+
return default if has_default
|
|
422
|
+
|
|
423
|
+
raise KeyError, "Config item '#{key}' not found in config '#{id}'"
|
|
424
|
+
end
|
|
425
|
+
values[key]
|
|
322
426
|
end
|
|
323
427
|
|
|
324
|
-
def
|
|
428
|
+
def cached_proxy(config_id)
|
|
325
429
|
@lock.synchronize do
|
|
326
|
-
@
|
|
430
|
+
@proxies[config_id] ||= LiveConfigProxy.new(self, config_id)
|
|
327
431
|
end
|
|
328
432
|
end
|
|
329
433
|
|
|
330
|
-
def
|
|
434
|
+
def sync_target_from_cache(target, config_id)
|
|
435
|
+
cache = @lock.synchronize { (@config_cache[config_id] || {}).dup }
|
|
436
|
+
cache.each do |dotted_key, value|
|
|
437
|
+
Discovery.apply_change_to_target(target, dotted_key, value)
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def do_refresh(source)
|
|
442
|
+
configs = @manage.config.list
|
|
443
|
+
new_cache, new_store = resolve_all(configs)
|
|
444
|
+
old_cache = nil
|
|
331
445
|
@lock.synchronize do
|
|
332
|
-
|
|
446
|
+
old_cache = @config_cache
|
|
447
|
+
@config_cache = new_cache
|
|
448
|
+
@raw_config_store = new_store
|
|
333
449
|
end
|
|
450
|
+
fire_change_listeners(old_cache, new_cache, source: source)
|
|
451
|
+
end
|
|
334
452
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
453
|
+
def resolve_all(configs)
|
|
454
|
+
by_id = configs.to_h { |c| [c.id, c] }
|
|
455
|
+
new_cache = {}
|
|
456
|
+
new_store = {}
|
|
457
|
+
configs.each do |cfg|
|
|
458
|
+
chain = Helpers.build_chain(cfg, by_id)
|
|
459
|
+
new_cache[cfg.key] = Helpers.resolve_chain(chain, @environment)
|
|
460
|
+
new_store[cfg.key] = cfg
|
|
461
|
+
end
|
|
462
|
+
[new_cache, new_store]
|
|
463
|
+
end
|
|
341
464
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
465
|
+
def fire_change_listeners(old_cache, new_cache, source:)
|
|
466
|
+
all_config_ids = old_cache.keys | new_cache.keys
|
|
467
|
+
all_config_ids.each do |cfg_id|
|
|
468
|
+
old_items = old_cache[cfg_id] || {}
|
|
469
|
+
new_items = new_cache[cfg_id] || {}
|
|
470
|
+
target = @bindings[cfg_id]
|
|
471
|
+
fire_config_changes(cfg_id, old_items, new_items, target, source)
|
|
346
472
|
end
|
|
347
|
-
snapshot.dup
|
|
348
473
|
end
|
|
349
474
|
|
|
350
|
-
def
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
475
|
+
def fire_config_changes(cfg_id, old_items, new_items, target, source)
|
|
476
|
+
all_keys = old_items.keys | new_items.keys
|
|
477
|
+
all_keys.each do |i_key|
|
|
478
|
+
old_val = old_items[i_key]
|
|
479
|
+
new_val = new_items[i_key]
|
|
480
|
+
next if old_val == new_val
|
|
481
|
+
|
|
482
|
+
Discovery.apply_change_to_target(target, i_key, new_val) unless target.nil?
|
|
483
|
+
@metrics&.record("config.changes", unit: "changes", dimensions: { "config" => cfg_id })
|
|
484
|
+
event = ConfigChangeEvent.new(
|
|
485
|
+
config_id: cfg_id, item_key: i_key,
|
|
486
|
+
old_value: old_val, new_value: new_val, source: source
|
|
487
|
+
)
|
|
488
|
+
dispatch_event(event, cfg_id, i_key)
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def dispatch_event(event, cfg_id, i_key)
|
|
493
|
+
@listeners.each do |callback, ck_filter, ik_filter|
|
|
494
|
+
next if !ck_filter.nil? && ck_filter != cfg_id
|
|
495
|
+
next if !ik_filter.nil? && ik_filter != i_key
|
|
496
|
+
|
|
497
|
+
begin
|
|
498
|
+
callback.call(event)
|
|
499
|
+
rescue StandardError => e
|
|
500
|
+
Smplkit.debug("config", "on_change listener raised: #{e.class}: #{e.message}")
|
|
501
|
+
end
|
|
502
|
+
end
|
|
359
503
|
end
|
|
360
504
|
|
|
361
505
|
def handle_config_changed(data)
|
|
362
506
|
key = data["key"] || data["id"]
|
|
363
507
|
return unless key
|
|
364
508
|
|
|
509
|
+
begin
|
|
510
|
+
cfg = @manage.config.get(key)
|
|
511
|
+
rescue Smplkit::NotFoundError
|
|
512
|
+
# Treat as a deletion — the resource is gone.
|
|
513
|
+
handle_config_deleted(data)
|
|
514
|
+
return
|
|
515
|
+
rescue StandardError => e
|
|
516
|
+
Smplkit.debug("config", "failed to fetch config #{key.inspect}: #{e.class}: #{e.message}")
|
|
517
|
+
return
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
new_store = nil
|
|
365
521
|
@lock.synchronize do
|
|
366
|
-
@
|
|
367
|
-
|
|
522
|
+
new_store = @raw_config_store.dup
|
|
523
|
+
new_store[key] = cfg
|
|
368
524
|
end
|
|
369
|
-
|
|
525
|
+
rebuild_from_store(new_store, source: "websocket")
|
|
370
526
|
end
|
|
371
527
|
|
|
372
528
|
def handle_config_deleted(data)
|
|
373
529
|
key = data["key"] || data["id"]
|
|
374
530
|
return unless key
|
|
375
531
|
|
|
532
|
+
new_store = nil
|
|
376
533
|
@lock.synchronize do
|
|
377
|
-
@
|
|
378
|
-
|
|
534
|
+
new_store = @raw_config_store.dup
|
|
535
|
+
return unless new_store.delete(key)
|
|
379
536
|
end
|
|
380
|
-
|
|
537
|
+
rebuild_from_store(new_store, source: "websocket")
|
|
381
538
|
end
|
|
382
539
|
|
|
383
|
-
def
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
rescue StandardError => e
|
|
388
|
-
Smplkit.debug("config", "listener raised: #{e.class}: #{e.message}")
|
|
389
|
-
end
|
|
390
|
-
# Item-scoped listeners — fire for every registered item on the
|
|
391
|
-
# changed config. We don't diff old vs new values here because
|
|
392
|
-
# the Ruby cache is invalidated wholesale per config; item-scoped
|
|
393
|
-
# listeners on this SDK fire on the "any change to this config"
|
|
394
|
-
# signal, mirroring the +LiveConfigProxy.on_change(item_key)+
|
|
395
|
-
# contract.
|
|
396
|
-
@item_listeners[config_key].each_value do |listeners|
|
|
397
|
-
listeners.each do |cb|
|
|
398
|
-
cb.call(event)
|
|
399
|
-
rescue StandardError => e
|
|
400
|
-
Smplkit.debug("config", "item listener raised: #{e.class}: #{e.message}")
|
|
401
|
-
end
|
|
402
|
-
end
|
|
540
|
+
def handle_configs_changed(_data)
|
|
541
|
+
do_refresh("websocket")
|
|
542
|
+
rescue StandardError => e
|
|
543
|
+
Smplkit.debug("config", "configs_changed refresh failed: #{e.class}: #{e.message}")
|
|
403
544
|
end
|
|
404
545
|
|
|
405
|
-
def
|
|
406
|
-
|
|
546
|
+
def rebuild_from_store(store, source:)
|
|
547
|
+
configs = store.values
|
|
548
|
+
new_cache, new_store = resolve_all(configs)
|
|
549
|
+
old_cache = nil
|
|
550
|
+
@lock.synchronize do
|
|
551
|
+
old_cache = @config_cache
|
|
552
|
+
@config_cache = new_cache
|
|
553
|
+
@raw_config_store = new_store
|
|
554
|
+
end
|
|
555
|
+
fire_change_listeners(old_cache, new_cache, source: source)
|
|
407
556
|
end
|
|
408
557
|
end
|
|
409
558
|
end
|
|
@@ -61,6 +61,38 @@ module Smplkit
|
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
# Build the parent chain (child-first, root-last) for a +Config+,
|
|
65
|
+
# walking +parent_id+ pointers across the +by_id+ map. Mirrors the
|
|
66
|
+
# Python SDK's client-side chain construction.
|
|
67
|
+
def build_chain(target, by_id)
|
|
68
|
+
chain = []
|
|
69
|
+
current = target
|
|
70
|
+
loop do
|
|
71
|
+
chain << config_to_chain_entry(current)
|
|
72
|
+
parent_id = current.parent_id
|
|
73
|
+
break if parent_id.nil? || parent_id == ""
|
|
74
|
+
|
|
75
|
+
parent = by_id[parent_id]
|
|
76
|
+
break unless parent
|
|
77
|
+
|
|
78
|
+
current = parent
|
|
79
|
+
end
|
|
80
|
+
chain
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Build a single chain entry (the +id+/+items+/+environments+ Hash
|
|
84
|
+
# shape used by +resolve_chain+) from a +Config+ domain model.
|
|
85
|
+
def config_to_chain_entry(config)
|
|
86
|
+
items_hash = config.items.to_h do |item|
|
|
87
|
+
[item.name,
|
|
88
|
+
{ "value" => item.value, "type" => item.type, "description" => item.description }.compact]
|
|
89
|
+
end
|
|
90
|
+
environments = config.environments.each_with_object({}) do |(env_key, env_obj), out|
|
|
91
|
+
out[env_key] = { "values" => env_obj.values_raw }
|
|
92
|
+
end
|
|
93
|
+
{ "id" => config.id, "items" => items_hash, "environments" => environments }
|
|
94
|
+
end
|
|
95
|
+
|
|
64
96
|
# Resolve the full configuration for an environment given a config chain.
|
|
65
97
|
#
|
|
66
98
|
# Walks from root (last element) to child (first element), accumulating
|
data/lib/smplkit/errors.rb
CHANGED
|
@@ -4,22 +4,32 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module Smplkit
|
|
6
6
|
# A single error object from the server's JSON:API +errors+ array.
|
|
7
|
+
#
|
|
8
|
+
# +code+ is the application-specific machine-readable error code (e.g.
|
|
9
|
+
# +environment_unmanaged+); per JSON:API §7 and ADR-014, smplkit sets
|
|
10
|
+
# this on every error so callers can branch without string-matching
|
|
11
|
+
# the human +detail+. +meta+ carries additional structured context
|
|
12
|
+
# (e.g. <tt>{"environment" => "staging"}</tt>).
|
|
7
13
|
class ApiErrorDetail
|
|
8
|
-
attr_reader :status, :title, :detail, :source
|
|
14
|
+
attr_reader :status, :code, :title, :detail, :source, :meta
|
|
9
15
|
|
|
10
|
-
def initialize(status: nil, title: nil, detail: nil, source: nil)
|
|
16
|
+
def initialize(status: nil, code: nil, title: nil, detail: nil, source: nil, meta: nil)
|
|
11
17
|
@status = status
|
|
18
|
+
@code = code
|
|
12
19
|
@title = title
|
|
13
20
|
@detail = detail
|
|
14
21
|
@source = source || {}
|
|
22
|
+
@meta = meta || {}
|
|
15
23
|
end
|
|
16
24
|
|
|
17
25
|
def to_h
|
|
18
26
|
h = {}
|
|
19
27
|
h["status"] = @status unless @status.nil?
|
|
28
|
+
h["code"] = @code unless @code.nil?
|
|
20
29
|
h["title"] = @title unless @title.nil?
|
|
21
30
|
h["detail"] = @detail unless @detail.nil?
|
|
22
31
|
h["source"] = @source unless @source.empty?
|
|
32
|
+
h["meta"] = @meta unless @meta.empty?
|
|
23
33
|
h
|
|
24
34
|
end
|
|
25
35
|
|
|
@@ -85,11 +95,15 @@ module Smplkit
|
|
|
85
95
|
raw_errors.filter_map do |item|
|
|
86
96
|
next unless item.is_a?(Hash)
|
|
87
97
|
|
|
98
|
+
source = item["source"]
|
|
99
|
+
meta = item["meta"]
|
|
88
100
|
ApiErrorDetail.new(
|
|
89
101
|
status: item["status"],
|
|
102
|
+
code: item["code"],
|
|
90
103
|
title: item["title"],
|
|
91
104
|
detail: item["detail"],
|
|
92
|
-
source:
|
|
105
|
+
source: source.is_a?(Hash) ? source : {},
|
|
106
|
+
meta: meta.is_a?(Hash) ? meta : {}
|
|
93
107
|
)
|
|
94
108
|
end
|
|
95
109
|
rescue JSON::ParserError, EncodingError
|
|
@@ -461,8 +461,9 @@ module Smplkit
|
|
|
461
461
|
# ---------------------------------------------------------------
|
|
462
462
|
|
|
463
463
|
# Queue a configuration declaration for bulk-discovery upload.
|
|
464
|
-
# Called
|
|
465
|
-
#
|
|
464
|
+
# Called from +ConfigClient#bind+ and +ConfigClient#get(id, key,
|
|
465
|
+
# default)+. Threshold-flushes on a background thread once the
|
|
466
|
+
# pending buffer reaches the flush size.
|
|
466
467
|
def register_config(config_id, service:, environment:, parent: nil,
|
|
467
468
|
name: nil, description: nil)
|
|
468
469
|
@buffer.declare(config_id, service: service, environment: environment,
|
|
@@ -546,41 +547,16 @@ module Smplkit
|
|
|
546
547
|
Smplkit::Config::Helpers.config_from_json(self, ResourceShim.from_model(response.data))
|
|
547
548
|
end
|
|
548
549
|
|
|
549
|
-
#
|
|
550
|
-
#
|
|
551
|
-
#
|
|
552
|
-
|
|
553
|
-
# Walks every page of +list_configs+ so an account with more than
|
|
554
|
-
# +RUNTIME_PAGE_SIZE+ configs still resolves chains correctly.
|
|
555
|
-
def fetch_chain(target_key)
|
|
556
|
-
all_configs = fetch_all_configs
|
|
557
|
-
by_key = all_configs.to_h { |c| [c.key, c] }
|
|
558
|
-
by_id = all_configs.to_h { |c| [c.id, c] }
|
|
559
|
-
|
|
560
|
-
current = by_key[target_key]
|
|
561
|
-
return [] unless current
|
|
562
|
-
|
|
563
|
-
chain = []
|
|
564
|
-
loop do
|
|
565
|
-
chain << config_to_chain_entry(current)
|
|
566
|
-
parent_id = current.parent_id
|
|
567
|
-
break if parent_id.nil? || parent_id == ""
|
|
568
|
-
|
|
569
|
-
parent = by_id[parent_id]
|
|
570
|
-
break unless parent
|
|
571
|
-
|
|
572
|
-
current = parent
|
|
573
|
-
end
|
|
574
|
-
chain
|
|
575
|
-
end
|
|
576
|
-
|
|
577
|
-
private
|
|
578
|
-
|
|
579
|
-
def fetch_all_configs
|
|
550
|
+
# Walk every page of +list_configs+ so an account with more than
|
|
551
|
+
# +RUNTIME_PAGE_SIZE+ configs still resolves to the complete set. Used
|
|
552
|
+
# by the runtime client to refresh the resolved cache.
|
|
553
|
+
def list_all
|
|
580
554
|
rows = PaginatedFetch.collect { |opts| @api.list_configs(opts) }
|
|
581
555
|
rows.map { |r| Smplkit::Config::Helpers.config_from_json(self, ResourceShim.from_model(r)) }
|
|
582
556
|
end
|
|
583
557
|
|
|
558
|
+
private
|
|
559
|
+
|
|
584
560
|
def config_body(config)
|
|
585
561
|
SmplkitGeneratedClient::Config::ConfigResponse.new(
|
|
586
562
|
data: SmplkitGeneratedClient::Config::ConfigResource.new(
|
|
@@ -621,23 +597,6 @@ module Smplkit
|
|
|
621
597
|
end
|
|
622
598
|
end
|
|
623
599
|
|
|
624
|
-
def config_to_chain_entry(config)
|
|
625
|
-
items_hash = {}
|
|
626
|
-
config.items.each do |item|
|
|
627
|
-
items_hash[item.name] = {
|
|
628
|
-
"value" => item.value,
|
|
629
|
-
"type" => item.type,
|
|
630
|
-
"description" => item.description
|
|
631
|
-
}.compact
|
|
632
|
-
end
|
|
633
|
-
|
|
634
|
-
environments = config.environments.each_with_object({}) do |(env_key, env_obj), out|
|
|
635
|
-
out[env_key] = { "values" => env_obj.values_raw }
|
|
636
|
-
end
|
|
637
|
-
|
|
638
|
-
{ "id" => config.id, "items" => items_hash, "environments" => environments }
|
|
639
|
-
end
|
|
640
|
-
|
|
641
600
|
def bulk_items_to_wire(items_hash)
|
|
642
601
|
return nil if items_hash.nil? || items_hash.empty?
|
|
643
602
|
|