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
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
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
|
+
[](https://rubygems.org/gems/smplkit)
|
|
4
|
+
[](https://github.com/smplkit/ruby-sdk/actions/workflows/ci-cd.yml)
|
|
5
|
+
[](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
|