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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE +21 -0
  4. data/README.md +105 -0
  5. data/lib/smplkit/client.rb +218 -0
  6. data/lib/smplkit/config/client.rb +238 -0
  7. data/lib/smplkit/config/helpers.rb +108 -0
  8. data/lib/smplkit/config/models.rb +192 -0
  9. data/lib/smplkit/config_resolution.rb +202 -0
  10. data/lib/smplkit/context.rb +68 -0
  11. data/lib/smplkit/debug.rb +50 -0
  12. data/lib/smplkit/errors.rb +114 -0
  13. data/lib/smplkit/flags/client.rb +480 -0
  14. data/lib/smplkit/flags/helpers.rb +76 -0
  15. data/lib/smplkit/flags/models.rb +258 -0
  16. data/lib/smplkit/flags/types.rb +233 -0
  17. data/lib/smplkit/generators/install_generator.rb +42 -0
  18. data/lib/smplkit/helpers.rb +15 -0
  19. data/lib/smplkit/log_level.rb +57 -0
  20. data/lib/smplkit/logging/adapters/base.rb +63 -0
  21. data/lib/smplkit/logging/adapters/semantic_logger_adapter.rb +88 -0
  22. data/lib/smplkit/logging/adapters/stdlib_logger_adapter.rb +143 -0
  23. data/lib/smplkit/logging/client.rb +142 -0
  24. data/lib/smplkit/logging/helpers.rb +69 -0
  25. data/lib/smplkit/logging/levels.rb +86 -0
  26. data/lib/smplkit/logging/models.rb +124 -0
  27. data/lib/smplkit/logging/normalize.rb +16 -0
  28. data/lib/smplkit/logging/sources.rb +44 -0
  29. data/lib/smplkit/management/buffer.rb +111 -0
  30. data/lib/smplkit/management/client.rb +623 -0
  31. data/lib/smplkit/management/models.rb +133 -0
  32. data/lib/smplkit/management/types.rb +65 -0
  33. data/lib/smplkit/metrics.rb +78 -0
  34. data/lib/smplkit/railtie.rb +48 -0
  35. data/lib/smplkit/version.rb +5 -0
  36. data/lib/smplkit/ws.rb +92 -0
  37. data/lib/smplkit.rb +43 -0
  38. data/sig/smplkit.rbs +141 -0
  39. metadata +139 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0c6abfbfd0bf9044cafd8358b4c94e41b6b9e41586f8b4ee24aae08ea9f21d1c
4
+ data.tar.gz: 685a279e1eb56669618de0c016d84c0e408a616171120b7316ee187036920feb
5
+ SHA512:
6
+ metadata.gz: c1578d33740b18f05d94f329d81798986b7c499ff0d1343125c50a9876a93ca6f1938c5475bc54f9c540aa71756e912b3984cba542f065dc21f61e3d2d823c14
7
+ data.tar.gz: e0c4953edf27c8bda7fc83524b233b4bd0011d830d44677034a29823e38a2cb621697ca9d1a1053b13ffac3dfb9eb4128565a6ddb51b7caf4087d233ea3c9d4f
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ Initial Ruby SDK implementation. See README.md and ADR-046 for scope.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Smpl Solutions LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # smplkit Ruby SDK
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/smplkit.svg)](https://rubygems.org/gems/smplkit)
4
+ [![CI](https://github.com/smplkit/ruby-sdk/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/smplkit/ruby-sdk/actions/workflows/ci-cd.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ Official Ruby SDK for the [smplkit](https://www.smplkit.com) platform — flags, config, and logging APIs with runtime evaluation, live updates, and management operations.
8
+
9
+ The Ruby SDK mirrors the [Python SDK](https://github.com/smplkit/python-sdk) class-for-class. Ruby-specific deviations are documented in [ADR-046](https://github.com/smplkit/app/blob/main/docs/adrs/ADR-046-ruby-sdk.md).
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ gem install smplkit
15
+ ```
16
+
17
+ Or in a Gemfile:
18
+
19
+ ```ruby
20
+ gem "smplkit"
21
+ ```
22
+
23
+ Requires Ruby 3.3+.
24
+
25
+ ## Quick start
26
+
27
+ ### Runtime
28
+
29
+ ```ruby
30
+ require "smplkit"
31
+
32
+ Smplkit::Client.open(environment: "production", service: "my-svc") do |client|
33
+ checkout_v2 = client.flags.boolean_flag("checkout-v2", default: false)
34
+ client.wait_until_ready
35
+
36
+ client.set_context([Smplkit::Context.new("user", "u-1", plan: "enterprise")]) do
37
+ if checkout_v2.get
38
+ # show new checkout
39
+ end
40
+ end
41
+ end
42
+ ```
43
+
44
+ ### Management
45
+
46
+ ```ruby
47
+ manage = Smplkit::ManagementClient.new
48
+
49
+ flag = manage.flags.new_boolean_flag(
50
+ "checkout-v2", default: false, description: "Controls rollout"
51
+ )
52
+ flag.add_rule(
53
+ Smplkit::Rule.new("Enable for enterprise users", environment: "staging")
54
+ .when("user.plan", Smplkit::Op::EQ, "enterprise")
55
+ .serve(true)
56
+ )
57
+ flag.save
58
+ ```
59
+
60
+ ## Configuration
61
+
62
+ Resolution order, lowest to highest priority:
63
+
64
+ 1. SDK hardcoded defaults
65
+ 2. `~/.smplkit` config file (with `[common]` and profile sections)
66
+ 3. `SMPLKIT_*` environment variables
67
+ 4. Constructor arguments
68
+
69
+ See [ADR-021](https://github.com/smplkit/app/blob/main/docs/adrs/ADR-021-client-strategy.md) for details.
70
+
71
+ ## Logging adapters
72
+
73
+ Two adapters ship at launch (per ADR-046 §2.3):
74
+
75
+ | Adapter | Covers |
76
+ |-------------------|-------------------------------------------------------------------------|
77
+ | `stdlib-logger` | Ruby stdlib `Logger` (and Rails via `ActiveSupport::Logger`) |
78
+ | `semantic-logger` | The [`semantic_logger` gem](https://github.com/reidmorrison/semantic_logger) |
79
+
80
+ Custom adapters subclass `Smplkit::Logging::Adapters::Base`.
81
+
82
+ ## Rails integration
83
+
84
+ Add the gem and run:
85
+
86
+ ```bash
87
+ rails generate smplkit:install
88
+ ```
89
+
90
+ This creates `config/initializers/smplkit.rb` with documented examples for the per-request context provider and standard configuration knobs.
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ bundle install
96
+ bundle exec rspec # unit tests
97
+ bundle exec rubocop # lint
98
+ make generate # regenerate clients (requires Node.js + npx)
99
+ ```
100
+
101
+ The repository follows the standard smplkit "every commit lands on `main`" workflow — see CLAUDE.md.
102
+
103
+ ## License
104
+
105
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Smplkit
6
+ # Synchronous entry point for the smplkit SDK.
7
+ #
8
+ # client = Smplkit::Client.new(environment: "production", service: "my-svc")
9
+ # checkout_v2 = client.flags.boolean_flag("checkout-v2", default: false)
10
+ # if checkout_v2.get
11
+ # # ...
12
+ # end
13
+ # client.close
14
+ #
15
+ # Block form:
16
+ #
17
+ # Smplkit::Client.open(environment: "production", service: "my-svc") do |client|
18
+ # # ...
19
+ # end
20
+ #
21
+ # All parameters are optional. When omitted, the SDK resolves them from
22
+ # environment variables (+SMPLKIT_*+) or the +~/.smplkit+ configuration file.
23
+ # See ADR-021 for the full resolution algorithm.
24
+ #
25
+ # +Smplkit::Client+ is thread-safe by construction. Background work runs on
26
+ # internal SDK-owned threads; public methods block the calling thread and
27
+ # return values directly.
28
+ class Client
29
+ PERIODIC_FLUSH_INTERVAL = 60.0
30
+
31
+ attr_reader :manage, :config, :flags, :logging
32
+
33
+ # Construct, yield to the block, and close on exit.
34
+ def self.open(**)
35
+ client = new(**)
36
+ begin
37
+ yield client
38
+ ensure
39
+ client.close
40
+ end
41
+ end
42
+
43
+ def initialize(api_key: nil, environment: nil, service: nil, profile: nil,
44
+ base_domain: nil, scheme: nil, debug: nil, telemetry: nil)
45
+ cfg = ConfigResolution.resolve_config(
46
+ profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme,
47
+ environment: environment, service: service, debug: debug, telemetry: telemetry
48
+ )
49
+ Smplkit.enable_debug if cfg.debug
50
+
51
+ @api_key = cfg.api_key
52
+ @environment = cfg.environment
53
+ @service = cfg.service
54
+ @base_domain = cfg.base_domain
55
+ @scheme = cfg.scheme
56
+
57
+ masked_key = cfg.api_key.length > 10 ? "#{cfg.api_key[0, 10]}..." : cfg.api_key
58
+ Smplkit.debug(
59
+ "lifecycle",
60
+ "Client init: api_key=#{masked_key} env=#{cfg.environment.inspect} service=#{cfg.service.inspect} " \
61
+ "base_domain=#{cfg.base_domain.inspect} scheme=#{cfg.scheme.inspect} " \
62
+ "debug=#{cfg.debug} telemetry=#{cfg.telemetry}"
63
+ )
64
+
65
+ mgmt_cfg = ConfigResolution::ResolvedManagementConfig.new(
66
+ api_key: cfg.api_key, base_domain: cfg.base_domain, scheme: cfg.scheme, debug: cfg.debug
67
+ )
68
+ @manage = ManagementClient.from_resolved(mgmt_cfg)
69
+
70
+ app_url = ConfigResolution.service_url(cfg.scheme, "app", cfg.base_domain)
71
+ flags_url = ConfigResolution.service_url(cfg.scheme, "flags", cfg.base_domain)
72
+ logging_url = ConfigResolution.service_url(cfg.scheme, "logging", cfg.base_domain)
73
+ @app_base_url = app_url
74
+
75
+ @metrics = if cfg.telemetry
76
+ MetricsReporter.new(http_client: @manage._app_http,
77
+ environment: cfg.environment,
78
+ service: cfg.service)
79
+ end
80
+
81
+ @ws_manager = nil
82
+ @config = Config::ConfigClient.new(self, manage: @manage, metrics: @metrics)
83
+ @flags = Flags::FlagsClient.new(self, manage: @manage, metrics: @metrics,
84
+ flags_base_url: flags_url, app_base_url: app_url)
85
+ @logging = Logging::LoggingClient.new(self, manage: @manage, metrics: @metrics,
86
+ logging_base_url: logging_url, app_base_url: app_url)
87
+
88
+ @closed = false
89
+ schedule_periodic_flush
90
+
91
+ @init_thread = Thread.new(@manage, @environment, @service, @app_base_url) do |mgmt, env, svc, app_url|
92
+ register_service_context(mgmt, env, svc, app_url)
93
+ end
94
+ end
95
+
96
+ # Eagerly initialize the SDK and block until it is fully ready.
97
+ #
98
+ # Pre-fetches all flags and configs into the local cache, opens the
99
+ # live-updates WebSocket, and waits for the handshake to complete.
100
+ # After this returns, +flag.get+ / +client.config.get+ hit cache (no
101
+ # first-request connect tax) and any +on_change+ listeners receive every
102
+ # server event from this point forward.
103
+ #
104
+ # Logging integration is *not* installed here — call
105
+ # +client.logging.install+ separately if you want it.
106
+ def wait_until_ready(timeout: 10.0)
107
+ @flags.start
108
+ @config.start
109
+ ws = _ensure_ws
110
+ deadline = monotonic_now + timeout
111
+ while ws.connection_status != "connected"
112
+ if monotonic_now >= deadline
113
+ raise TimeoutError, "Live-updates websocket did not connect within #{timeout}s " \
114
+ "(status: #{ws.connection_status.inspect})"
115
+ end
116
+
117
+ sleep(0.05)
118
+ end
119
+ end
120
+
121
+ # Stash +contexts+ as the current request's evaluation context.
122
+ #
123
+ # Two usage shapes:
124
+ #
125
+ # # Fire-and-forget (typical middleware)
126
+ # client.set_context([Smplkit::Context.new("user", "u-123")])
127
+ #
128
+ # # Scoped block (impersonation or one-off override)
129
+ # client.set_context([Smplkit::Context.new("user", "impersonated")]) do
130
+ # # ...
131
+ # end
132
+ # # original context restored here
133
+ #
134
+ # Each unique +(type, key)+ is also queued for bulk registration on the
135
+ # management API.
136
+ def set_context(contexts, &block)
137
+ @manage.contexts.register(contexts) if contexts && !contexts.empty?
138
+
139
+ scope = Smplkit.set_request_context(contexts || [])
140
+ if block
141
+ scope.call(&block)
142
+ else
143
+ scope
144
+ end
145
+ end
146
+
147
+ def close
148
+ Smplkit.debug("lifecycle", "Client.close called")
149
+ @closed = true
150
+ @flush_timer&.shutdown
151
+ final_flush
152
+ @metrics&.close
153
+ @logging._close
154
+ @flags._close
155
+ @config._close
156
+ @ws_manager&.stop
157
+ @ws_manager = nil
158
+ @manage.close
159
+ end
160
+
161
+ # Internal accessors used by sub-clients --------------------------------
162
+
163
+ def _service = @service
164
+ def _environment = @environment
165
+ def _api_key = @api_key
166
+ def _app_base_url = @app_base_url
167
+ def _metrics = @metrics
168
+
169
+ def _ensure_ws
170
+ @_ensure_ws ||= begin
171
+ ws = SharedWebSocket.new(app_base_url: @app_base_url, api_key: @api_key, metrics: @metrics)
172
+ ws.start
173
+ ws
174
+ end
175
+ end
176
+
177
+ def _flags_transport = @manage.flags
178
+ def _config_transport = @manage.config
179
+
180
+ private
181
+
182
+ def monotonic_now
183
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
184
+ end
185
+
186
+ def schedule_periodic_flush
187
+ @flush_timer = Concurrent::TimerTask.new(execution_interval: PERIODIC_FLUSH_INTERVAL) do
188
+ next if @closed
189
+
190
+ begin
191
+ @manage.contexts.flush
192
+ @manage.flags.flush
193
+ @manage.loggers.flush
194
+ rescue StandardError => e
195
+ Smplkit.debug("registration", "periodic flush failed: #{e.class}: #{e.message}")
196
+ end
197
+ end
198
+ @flush_timer.execute
199
+ end
200
+
201
+ def final_flush
202
+ [@manage.contexts, @manage.flags, @manage.loggers].each do |ns|
203
+ ns.flush
204
+ rescue StandardError => e
205
+ Smplkit.debug("registration", "final flush failed: #{e.class}: #{e.message}")
206
+ end
207
+ end
208
+
209
+ def register_service_context(_mgmt, _env, _svc, _app_url)
210
+ # No-op stub: bulk-registers the service + environment with the platform.
211
+ # Wired up via the contexts buffer once the generated client layer is
212
+ # committed. Until then, the in-process buffer captures these and the
213
+ # next periodic flush attempts to send them.
214
+ rescue StandardError => e
215
+ Smplkit.debug("lifecycle", "register service context failed: #{e.class}: #{e.message}")
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smplkit
4
+ module Config
5
+ # Describes a config change event delivered to +on_change+ listeners.
6
+ class ConfigChangeEvent
7
+ attr_reader :key, :source, :deleted
8
+
9
+ def initialize(key:, source:, deleted: false)
10
+ @key = key
11
+ @source = source
12
+ @deleted = deleted
13
+ freeze
14
+ end
15
+
16
+ def deleted? = @deleted
17
+
18
+ def ==(other)
19
+ other.is_a?(ConfigChangeEvent) && key == other.key && source == other.source && deleted == other.deleted
20
+ end
21
+ alias eql? ==
22
+
23
+ def hash = [key, source, deleted].hash
24
+ end
25
+
26
+ # A live, dot-accessible view over a resolved configuration.
27
+ #
28
+ # Backed by the most-recent resolved Hash for a config key. +#get+ /
29
+ # +#[]+ return the resolved value at the time of access; +#refresh+
30
+ # rebuilds the snapshot from the underlying client.
31
+ class LiveConfigProxy
32
+ def initialize(client, key)
33
+ @client = client
34
+ @key = key
35
+ @snapshot = client._resolve_now(key)
36
+ end
37
+
38
+ def get(item_key, default = nil)
39
+ return @snapshot if item_key.nil?
40
+
41
+ keys = item_key.to_s.split(".")
42
+ keys.reduce(@snapshot) do |scope, k|
43
+ break default if scope.nil?
44
+
45
+ scope.is_a?(Hash) ? scope[k] : default
46
+ end || default
47
+ end
48
+
49
+ def [](item_key)
50
+ get(item_key)
51
+ end
52
+
53
+ def to_h
54
+ @snapshot.dup
55
+ end
56
+
57
+ def refresh
58
+ @snapshot = @client._resolve_now(@key)
59
+ self
60
+ end
61
+ end
62
+
63
+ # Synchronous config runtime namespace.
64
+ #
65
+ # Obtained via +Smplkit::Client#config+. Exposes typed accessors
66
+ # (+get_string+, +get_number+, +get_boolean+, +get_json+) and runtime
67
+ # control (+refresh+, +on_change+).
68
+ class ConfigClient
69
+ def initialize(parent, manage:, metrics:)
70
+ @parent = parent
71
+ @manage = manage
72
+ @metrics = metrics
73
+ @environment = parent._environment
74
+ @service = parent._service
75
+
76
+ @snapshots = {}
77
+ @raw_chains = {}
78
+ @global_listeners = []
79
+ @key_listeners = Hash.new { |h, k| h[k] = [] }
80
+ @connected = false
81
+ @lock = Mutex.new
82
+ end
83
+
84
+ def start
85
+ return if @connected
86
+
87
+ @environment = @parent._environment
88
+ @ws_manager = @parent._ensure_ws
89
+ @ws_manager.on("config_changed") { |data| handle_config_changed(data) }
90
+ @ws_manager.on("config_deleted") { |data| handle_config_deleted(data) }
91
+ @connected = true
92
+ end
93
+
94
+ def get(config_key, model_class = nil)
95
+ start unless @connected
96
+
97
+ snapshot = resolve(config_key)
98
+ return snapshot if model_class.nil?
99
+
100
+ model_class.new(snapshot)
101
+ end
102
+
103
+ def get_string(item_key, default: nil, config: nil)
104
+ typed_get(item_key, default, config) { |v| v.is_a?(String) ? v : v.to_s }
105
+ end
106
+
107
+ def get_number(item_key, default: nil, config: nil)
108
+ typed_get(item_key, default, config) do |v|
109
+ v.is_a?(Numeric) ? v : Float(v)
110
+ rescue StandardError
111
+ default
112
+ end
113
+ end
114
+
115
+ def get_boolean(item_key, default: nil, config: nil)
116
+ typed_get(item_key, default, config) { |v| !!v }
117
+ end
118
+
119
+ def get_json(item_key, default: nil, config: nil)
120
+ typed_get(item_key, default, config) { |v| v }
121
+ end
122
+
123
+ def live(config_key)
124
+ start unless @connected
125
+
126
+ LiveConfigProxy.new(self, config_key)
127
+ end
128
+
129
+ def on_change(config_key = nil, &block)
130
+ raise ArgumentError, "on_change requires a block" unless block
131
+
132
+ if config_key.nil?
133
+ @global_listeners << block
134
+ else
135
+ @key_listeners[config_key] << block
136
+ end
137
+ block
138
+ end
139
+
140
+ def refresh
141
+ @lock.synchronize do
142
+ @snapshots.clear
143
+ @raw_chains.clear
144
+ end
145
+ fire_change_listeners_all("manual")
146
+ end
147
+
148
+ def _resolve_now(config_key)
149
+ resolve(config_key)
150
+ end
151
+
152
+ def _close
153
+ # No durable resources; symmetry stub.
154
+ end
155
+
156
+ private
157
+
158
+ def typed_get(item_key, default, config_key)
159
+ snapshot = config_key ? resolve(config_key) : merged_snapshot
160
+ keys = item_key.to_s.split(".")
161
+ value = keys.reduce(snapshot) do |scope, k|
162
+ break default unless scope.is_a?(Hash)
163
+
164
+ scope[k]
165
+ end
166
+ return default if value.nil?
167
+
168
+ block_given? ? yield(value) : value
169
+ end
170
+
171
+ def merged_snapshot
172
+ @lock.synchronize do
173
+ @snapshots.values.reduce({}) { |acc, snap| Helpers.deep_merge(acc, snap) }
174
+ end
175
+ end
176
+
177
+ def resolve(config_key)
178
+ @lock.synchronize do
179
+ return @snapshots[config_key].dup if @snapshots.key?(config_key)
180
+ end
181
+
182
+ chain = fetch_chain(config_key)
183
+ snapshot = Helpers.resolve_chain(chain, @environment)
184
+ @lock.synchronize do
185
+ @raw_chains[config_key] = chain
186
+ @snapshots[config_key] = snapshot
187
+ end
188
+ snapshot.dup
189
+ end
190
+
191
+ def fetch_chain(config_key)
192
+ # Stub: in the absence of a generated client, the runtime returns an
193
+ # empty chain. ManagementClient wires this up properly once the
194
+ # generated layer is committed.
195
+ @parent._config_transport.fetch_chain(config_key)
196
+ rescue Smplkit::Error
197
+ raise
198
+ rescue StandardError => e
199
+ raise Smplkit::ConnectionError, "Failed to fetch config #{config_key.inspect}: #{e.message}"
200
+ end
201
+
202
+ def handle_config_changed(data)
203
+ key = data["key"] || data["id"]
204
+ return unless key
205
+
206
+ @lock.synchronize do
207
+ @snapshots.delete(key)
208
+ @raw_chains.delete(key)
209
+ end
210
+ fire_change_listeners(key, "websocket")
211
+ end
212
+
213
+ def handle_config_deleted(data)
214
+ key = data["key"] || data["id"]
215
+ return unless key
216
+
217
+ @lock.synchronize do
218
+ @snapshots.delete(key)
219
+ @raw_chains.delete(key)
220
+ end
221
+ fire_change_listeners(key, "websocket", deleted: true)
222
+ end
223
+
224
+ def fire_change_listeners(config_key, source, deleted: false)
225
+ event = ConfigChangeEvent.new(key: config_key, source: source, deleted: deleted)
226
+ (@global_listeners + @key_listeners[config_key]).each do |cb|
227
+ cb.call(event)
228
+ rescue StandardError => e
229
+ Smplkit.debug("config", "listener raised: #{e.class}: #{e.message}")
230
+ end
231
+ end
232
+
233
+ def fire_change_listeners_all(source)
234
+ (@snapshots.keys | @key_listeners.keys).each { |key| fire_change_listeners(key, source) }
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smplkit
4
+ module Config
5
+ module Helpers
6
+ module_function
7
+
8
+ # Translate a JSON:API resource Hash into a Config domain model.
9
+ def config_from_json(client, resource)
10
+ attrs = resource["attributes"] || {}
11
+ items = (attrs["items"] || {}).map do |name, item|
12
+ if item.is_a?(Hash) && item.key?("value")
13
+ ConfigItem.new(
14
+ name: name,
15
+ value: item["value"],
16
+ type: item["type"],
17
+ description: item["description"]
18
+ )
19
+ else
20
+ ConfigItem.new(name: name, value: item, type: ItemType::JSON)
21
+ end
22
+ end
23
+
24
+ environments = (attrs["environments"] || {}).each_with_object({}) do |(env, env_data), out|
25
+ env_values = env_data.is_a?(Hash) ? (env_data["values"] || {}) : {}
26
+ out[env] = ConfigEnvironment.new(values: env_values)
27
+ end
28
+
29
+ Config.new(
30
+ client,
31
+ id: resource["id"] || attrs["id"],
32
+ key: attrs["key"] || resource["id"],
33
+ name: attrs["name"],
34
+ description: attrs["description"],
35
+ parent_id: attrs["parent_id"],
36
+ items: items,
37
+ environments: environments,
38
+ created_at: attrs["created_at"],
39
+ updated_at: attrs["updated_at"]
40
+ )
41
+ end
42
+
43
+ def build_config_request_body(config)
44
+ items = {}
45
+ config.items.each do |item|
46
+ items[item.name] = {
47
+ "value" => item.value,
48
+ "type" => item.type,
49
+ "description" => item.description
50
+ }.compact
51
+ end
52
+
53
+ environments = config.environments.each_with_object({}) do |(env, env_obj), out|
54
+ out[env] = { "values" => env_obj.values_raw }
55
+ end
56
+
57
+ attributes = {
58
+ "key" => config.key,
59
+ "name" => config.name,
60
+ "description" => config.description,
61
+ "parent_id" => config.parent_id,
62
+ "items" => items,
63
+ "environments" => environments
64
+ }.compact
65
+ { "data" => { "type" => "config", "id" => config.key, "attributes" => attributes } }
66
+ end
67
+
68
+ # Deep-merge two Hashes, with +override+ winning. Mirrors the Python
69
+ # +deep_merge+ helper used by the resolver.
70
+ def deep_merge(base, override)
71
+ result = base.dup
72
+ override.each do |key, value|
73
+ result[key] = if result[key].is_a?(Hash) && value.is_a?(Hash)
74
+ deep_merge(result[key], value)
75
+ else
76
+ value
77
+ end
78
+ end
79
+ result
80
+ end
81
+
82
+ # Unwrap typed items +{ key => { value, type, desc } }+ to +{ key => raw }+.
83
+ def unwrap_items(items)
84
+ items.each_with_object({}) do |(k, v), out|
85
+ out[k] = v.is_a?(Hash) && v.key?("value") ? v["value"] : v
86
+ end
87
+ end
88
+
89
+ # Resolve the full configuration for an environment given a config chain.
90
+ #
91
+ # Walks from root (last element) to child (first element), accumulating
92
+ # values via deep merge so child configs override parent configs.
93
+ def resolve_chain(chain, environment)
94
+ accumulated = {}
95
+ chain.reverse_each do |config_data|
96
+ raw_items = config_data["items"] || config_data["values"] || {}
97
+ base_values = unwrap_items(raw_items)
98
+ env_data = (config_data["environments"] || {})[environment] || {}
99
+ env_raw = env_data.is_a?(Hash) ? (env_data["values"] || {}) : {}
100
+ env_values = unwrap_items(env_raw)
101
+ config_resolved = deep_merge(base_values, env_values)
102
+ accumulated = deep_merge(accumulated, config_resolved)
103
+ end
104
+ accumulated
105
+ end
106
+ end
107
+ end
108
+ end