smplkit 3.0.95 → 3.0.97

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/lib/smplkit/account/client.rb +128 -0
  3. data/lib/smplkit/account/models.rb +71 -0
  4. data/lib/smplkit/api_support.rb +91 -0
  5. data/lib/smplkit/audit/buffer.rb +3 -1
  6. data/lib/smplkit/audit/categories.rb +21 -10
  7. data/lib/smplkit/audit/client.rb +18 -9
  8. data/lib/smplkit/audit/event_types.rb +26 -10
  9. data/lib/smplkit/audit/events.rb +93 -17
  10. data/lib/smplkit/{management/audit.rb → audit/forwarders.rb} +93 -85
  11. data/lib/smplkit/audit/models.rb +86 -32
  12. data/lib/smplkit/audit/resource_types.rb +21 -9
  13. data/lib/smplkit/buffers.rb +250 -0
  14. data/lib/smplkit/client.rb +161 -70
  15. data/lib/smplkit/config/client.rb +874 -186
  16. data/lib/smplkit/config/helpers.rb +44 -6
  17. data/lib/smplkit/config/models.rb +114 -7
  18. data/lib/smplkit/config_resolution.rb +17 -9
  19. data/lib/smplkit/errors.rb +14 -3
  20. data/lib/smplkit/flags/client.rb +602 -116
  21. data/lib/smplkit/flags/models.rb +110 -8
  22. data/lib/smplkit/flags/types.rb +8 -9
  23. data/lib/smplkit/jobs/client.rb +306 -0
  24. data/lib/smplkit/jobs/models.rb +47 -18
  25. data/lib/smplkit/logging/client.rb +755 -191
  26. data/lib/smplkit/logging/helpers.rb +5 -1
  27. data/lib/smplkit/logging/levels.rb +3 -1
  28. data/lib/smplkit/logging/models.rb +163 -6
  29. data/lib/smplkit/logging/normalize.rb +3 -1
  30. data/lib/smplkit/logging/resolution.rb +4 -4
  31. data/lib/smplkit/logging/sources.rb +1 -1
  32. data/lib/smplkit/platform/client.rb +597 -0
  33. data/lib/smplkit/platform/models.rb +282 -0
  34. data/lib/smplkit/{management → platform}/types.rb +21 -4
  35. data/lib/smplkit/transport.rb +103 -0
  36. data/lib/smplkit/ws.rb +1 -1
  37. data/lib/smplkit.rb +18 -6
  38. metadata +11 -7
  39. data/lib/smplkit/management/buffer.rb +0 -198
  40. data/lib/smplkit/management/client.rb +0 -1074
  41. data/lib/smplkit/management/jobs.rb +0 -226
  42. data/lib/smplkit/management/models.rb +0 -178
@@ -0,0 +1,597 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The Smpl Platform client — cross-cutting CRUD on +client.platform+.
4
+ #
5
+ # +PlatformClient+ groups the account-wide configuration resources that aren't
6
+ # owned by a single product, mirroring the product UI's Platform area:
7
+ #
8
+ # - +platform.environments+ — environment CRUD
9
+ # - +platform.services+ — service CRUD
10
+ # - +platform.contexts+ — evaluation-context registration + read/delete
11
+ # - +platform.context_types+ — context-type CRUD
12
+ #
13
+ # All four are pure CRUD — no +install+ gate. Every sub-client speaks to the
14
+ # app service, so the client needs exactly one app transport (plus the
15
+ # context-registration buffer that +contexts+ drains).
16
+ #
17
+ # The client supports two construction shapes:
18
+ #
19
+ # * *Wired* into +Smplkit::Client+ — borrows the parent's app transport and an
20
+ # externally-supplied context buffer. This is the common path;
21
+ # +client.flags+ borrows +client.platform.contexts+ as its evaluation-context
22
+ # registration seam.
23
+ # * *Standalone* — +PlatformClient.new(api_key: ..., base_url: ..., ...)+ builds
24
+ # and owns its own app transport and buffer. +close+ tears down only the
25
+ # owned transport.
26
+ module Smplkit
27
+ module Platform
28
+ # Resolve the two-arg or composite-id form to +[type, key]+.
29
+ #
30
+ # @api private
31
+ def self.split_context_id(id_or_type, key)
32
+ return [id_or_type, key] unless key.nil?
33
+
34
+ unless id_or_type.include?(":")
35
+ raise ArgumentError,
36
+ "context id must be 'type:key' (got #{id_or_type.inspect}); " \
37
+ "alternatively pass type and key as separate args"
38
+ end
39
+
40
+ id_or_type.split(":", 2)
41
+ end
42
+
43
+ # Build a standalone app transport from resolved config.
44
+ #
45
+ # +base_url+/+api_key+ are used directly when supplied (the path the
46
+ # top-level client takes after it has already resolved them); otherwise the
47
+ # config resolver fills in whatever is missing (+~/.smplkit+ / env vars /
48
+ # defaults).
49
+ #
50
+ # @api private
51
+ def self.app_transport(api_key:, base_url:, profile:, base_domain:, scheme:, debug:, extra_headers:)
52
+ cfg = ConfigResolution.resolve_client_config(
53
+ profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme, debug: debug
54
+ )
55
+ resolved_key = api_key.nil? ? cfg.api_key : api_key
56
+ merged = {}
57
+ merged.merge!(cfg.extra_headers || {})
58
+ merged.merge!(extra_headers || {})
59
+ tcfg = ConfigResolution::ResolvedClientConfig.new(
60
+ api_key: resolved_key, base_domain: cfg.base_domain, scheme: cfg.scheme,
61
+ debug: cfg.debug, extra_headers: merged
62
+ )
63
+ Transport.build_api_client(SmplkitGeneratedClient::App, "app", tcfg, base_url: base_url)
64
+ end
65
+
66
+ # -----------------------------------------------------------------------
67
+ # Environments
68
+ # -----------------------------------------------------------------------
69
+
70
+ # Sync environment CRUD (+client.platform.environments+).
71
+ class EnvironmentsClient
72
+ def initialize(app_http)
73
+ @api = SmplkitGeneratedClient::App::EnvironmentsApi.new(app_http)
74
+ end
75
+
76
+ # Build an unsaved +Environment+; call +.save+ to persist it.
77
+ #
78
+ # @param id [String] Stable, human-readable identifier for the
79
+ # environment (for example +"production"+).
80
+ # @param name [String] Display name shown in the Console.
81
+ # @param color [Color, String, nil] Accent color for the environment, as
82
+ # a +Color+ or a CSS hex string. Defaults to no color.
83
+ # @param classification [String] Whether the environment participates in
84
+ # the standard environment ordering. Defaults to
85
+ # +EnvironmentClassification::STANDARD+.
86
+ # @return [Environment] An unsaved environment bound to this client.
87
+ def new(id, name:, color: nil, classification: EnvironmentClassification::STANDARD)
88
+ Environment.new(self, id: id, name: name, color: color, classification: classification)
89
+ end
90
+
91
+ # List environments in the account.
92
+ #
93
+ # @param page_number [Integer, nil] 1-based page to fetch. Defaults to
94
+ # the first page.
95
+ # @param page_size [Integer, nil] Maximum number of environments per
96
+ # page. Defaults to the server's page size.
97
+ # @return [Array<Environment>] The environments on the requested page.
98
+ def list(page_number: nil, page_size: nil)
99
+ opts = {}
100
+ opts[:page_number] = page_number unless page_number.nil?
101
+ opts[:page_size] = page_size unless page_size.nil?
102
+ response = ApiSupport::ErrorMapping.call { @api.list_environments(opts) }
103
+ (response.data || []).map { |r| from_resource(ApiSupport::ResourceShim.from_model(r)) }
104
+ end
105
+
106
+ # Fetch a single environment by id.
107
+ #
108
+ # @param id [String] Identifier of the environment to fetch.
109
+ # @return [Environment] The matching environment.
110
+ # @raise [Smplkit::NotFoundError] If no environment with that id exists.
111
+ def get(id)
112
+ response = ApiSupport::ErrorMapping.call { @api.get_environment(id) }
113
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
114
+ end
115
+
116
+ # Delete an environment by id.
117
+ #
118
+ # @param id [String] Identifier of the environment to delete.
119
+ # @return [void]
120
+ def delete(id)
121
+ ApiSupport::ErrorMapping.call { @api.delete_environment(id) }
122
+ nil
123
+ end
124
+
125
+ # @api private
126
+ def _create(env)
127
+ response = ApiSupport::ErrorMapping.call { @api.create_environment(body_for(env)) }
128
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
129
+ end
130
+
131
+ # @api private
132
+ def _update(env)
133
+ raise "cannot update an Environment with no id" if env.id.nil?
134
+
135
+ response = ApiSupport::ErrorMapping.call { @api.update_environment(env.id, body_for(env)) }
136
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
137
+ end
138
+
139
+ private
140
+
141
+ def body_for(env)
142
+ SmplkitGeneratedClient::App::EnvironmentRequest.new(
143
+ data: SmplkitGeneratedClient::App::EnvironmentResource.new(
144
+ type: "environment",
145
+ id: env.id,
146
+ attributes: SmplkitGeneratedClient::App::Environment.new(
147
+ name: env.name,
148
+ color: env.color&.hex,
149
+ classification: env.classification
150
+ )
151
+ )
152
+ )
153
+ end
154
+
155
+ def from_resource(resource)
156
+ attrs = resource["attributes"] || {}
157
+ classification =
158
+ attrs["classification"] == "AD_HOC" ? EnvironmentClassification::AD_HOC : EnvironmentClassification::STANDARD
159
+ Environment.new(
160
+ self,
161
+ id: resource["id"], name: attrs["name"], color: attrs["color"],
162
+ classification: classification,
163
+ created_at: attrs["created_at"], updated_at: attrs["updated_at"]
164
+ )
165
+ end
166
+ end
167
+
168
+ # -----------------------------------------------------------------------
169
+ # Services
170
+ # -----------------------------------------------------------------------
171
+
172
+ # Sync service CRUD (+client.platform.services+).
173
+ class ServicesClient
174
+ def initialize(app_http)
175
+ @api = SmplkitGeneratedClient::App::ServicesApi.new(app_http)
176
+ end
177
+
178
+ # Build an unsaved +Service+; call +.save+ to persist it.
179
+ #
180
+ # @param id [String] Stable, human-readable identifier for the service.
181
+ # @param name [String] Display name shown in the Console.
182
+ # @return [Service] An unsaved service bound to this client.
183
+ def new(id, name:)
184
+ Service.new(self, id: id, name: name)
185
+ end
186
+
187
+ # List services in the account.
188
+ #
189
+ # @param page_number [Integer, nil] 1-based page to fetch. Defaults to
190
+ # the first page.
191
+ # @param page_size [Integer, nil] Maximum number of services per page.
192
+ # Defaults to the server's page size.
193
+ # @return [Array<Service>] The services on the requested page.
194
+ def list(page_number: nil, page_size: nil)
195
+ opts = {}
196
+ opts[:page_number] = page_number unless page_number.nil?
197
+ opts[:page_size] = page_size unless page_size.nil?
198
+ response = ApiSupport::ErrorMapping.call { @api.list_services(opts) }
199
+ (response.data || []).map { |r| from_resource(ApiSupport::ResourceShim.from_model(r)) }
200
+ end
201
+
202
+ # Fetch a single service by id.
203
+ #
204
+ # @param id [String] Identifier of the service to fetch.
205
+ # @return [Service] The matching service.
206
+ # @raise [Smplkit::NotFoundError] If no service with that id exists.
207
+ def get(id)
208
+ response = ApiSupport::ErrorMapping.call { @api.get_service(id) }
209
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
210
+ end
211
+
212
+ # Delete a service by id.
213
+ #
214
+ # @param id [String] Identifier of the service to delete.
215
+ # @return [void]
216
+ def delete(id)
217
+ ApiSupport::ErrorMapping.call { @api.delete_service(id) }
218
+ nil
219
+ end
220
+
221
+ # @api private
222
+ def _create(svc)
223
+ response = ApiSupport::ErrorMapping.call { @api.create_service(create_body_for(svc)) }
224
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
225
+ end
226
+
227
+ # @api private
228
+ def _update(svc)
229
+ raise "cannot update a Service with no id" if svc.id.nil?
230
+
231
+ response = ApiSupport::ErrorMapping.call { @api.update_service(svc.id, body_for(svc)) }
232
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
233
+ end
234
+
235
+ private
236
+
237
+ def body_for(svc)
238
+ SmplkitGeneratedClient::App::ServiceRequest.new(
239
+ data: SmplkitGeneratedClient::App::ServiceResource.new(
240
+ type: "service",
241
+ id: svc.id,
242
+ attributes: SmplkitGeneratedClient::App::Service.new(name: svc.name)
243
+ )
244
+ )
245
+ end
246
+
247
+ def create_body_for(svc)
248
+ SmplkitGeneratedClient::App::ServiceCreateRequest.new(
249
+ data: SmplkitGeneratedClient::App::ServiceCreateResource.new(
250
+ type: "service",
251
+ id: svc.id,
252
+ attributes: SmplkitGeneratedClient::App::Service.new(name: svc.name)
253
+ )
254
+ )
255
+ end
256
+
257
+ def from_resource(resource)
258
+ attrs = resource["attributes"] || {}
259
+ Service.new(
260
+ self,
261
+ id: resource["id"], name: attrs["name"],
262
+ created_at: attrs["created_at"], updated_at: attrs["updated_at"]
263
+ )
264
+ end
265
+ end
266
+
267
+ # -----------------------------------------------------------------------
268
+ # Context Types
269
+ # -----------------------------------------------------------------------
270
+
271
+ # Sync context-type CRUD (+client.platform.context_types+).
272
+ class ContextTypesClient
273
+ def initialize(app_http)
274
+ @api = SmplkitGeneratedClient::App::ContextTypesApi.new(app_http)
275
+ end
276
+
277
+ # Build an unsaved +ContextType+; call +.save+ to persist it.
278
+ #
279
+ # @param id [String] Stable, human-readable identifier for the context
280
+ # type (for example +"user"+).
281
+ # @param name [String, nil] Display name shown in the Console. Defaults
282
+ # to +id+ when omitted.
283
+ # @param attributes [Hash, nil] Known-attribute slots, keyed by attribute
284
+ # name, with a metadata dict per slot. Defaults to no declared
285
+ # attributes.
286
+ # @return [ContextType] An unsaved context type bound to this client.
287
+ def new(id, name: nil, attributes: nil)
288
+ ContextType.new(self, id: id, name: name || id, attributes: attributes || {})
289
+ end
290
+
291
+ # List context types in the account.
292
+ #
293
+ # @param page_number [Integer, nil] 1-based page to fetch. Defaults to
294
+ # the first page.
295
+ # @param page_size [Integer, nil] Maximum number of context types per
296
+ # page. Defaults to the server's page size.
297
+ # @return [Array<ContextType>] The context types on the requested page.
298
+ def list(page_number: nil, page_size: nil)
299
+ opts = {}
300
+ opts[:page_number] = page_number unless page_number.nil?
301
+ opts[:page_size] = page_size unless page_size.nil?
302
+ response = ApiSupport::ErrorMapping.call { @api.list_context_types(opts) }
303
+ (response.data || []).map { |r| from_resource(ApiSupport::ResourceShim.from_model(r)) }
304
+ end
305
+
306
+ # Fetch a single context type by id.
307
+ #
308
+ # @param id [String] Identifier of the context type to fetch.
309
+ # @return [ContextType] The matching context type.
310
+ # @raise [Smplkit::NotFoundError] If no context type with that id exists.
311
+ def get(id)
312
+ response = ApiSupport::ErrorMapping.call { @api.get_context_type(id) }
313
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
314
+ end
315
+
316
+ # Delete a context type by id.
317
+ #
318
+ # @param id [String] Identifier of the context type to delete.
319
+ # @return [void]
320
+ def delete(id)
321
+ ApiSupport::ErrorMapping.call { @api.delete_context_type(id) }
322
+ nil
323
+ end
324
+
325
+ # @api private
326
+ def _create(ct)
327
+ response = ApiSupport::ErrorMapping.call { @api.create_context_type(body_for(ct)) }
328
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
329
+ end
330
+
331
+ # @api private
332
+ def _update(ct)
333
+ raise "cannot update a ContextType with no id" if ct.id.nil?
334
+
335
+ response = ApiSupport::ErrorMapping.call { @api.update_context_type(ct.id, body_for(ct)) }
336
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
337
+ end
338
+
339
+ private
340
+
341
+ def body_for(ct)
342
+ SmplkitGeneratedClient::App::ContextTypeRequest.new(
343
+ data: SmplkitGeneratedClient::App::ContextTypeResource.new(
344
+ type: "context_type",
345
+ id: ct.id,
346
+ attributes: SmplkitGeneratedClient::App::ContextType.new(name: ct.name, attributes: ct.attributes)
347
+ )
348
+ )
349
+ end
350
+
351
+ def from_resource(resource)
352
+ attrs = resource["attributes"] || {}
353
+ raw_meta = attrs["attributes"]
354
+ attribute_metadata = raw_meta.is_a?(Hash) ? raw_meta : {}
355
+ ContextType.new(
356
+ self,
357
+ id: resource["id"], name: attrs["name"], attributes: attribute_metadata,
358
+ created_at: attrs["created_at"], updated_at: attrs["updated_at"]
359
+ )
360
+ end
361
+ end
362
+
363
+ # -----------------------------------------------------------------------
364
+ # Contexts
365
+ # -----------------------------------------------------------------------
366
+
367
+ # Sync context registration + read/delete (+client.platform.contexts+).
368
+ class ContextsClient
369
+ def initialize(app_http, buffer)
370
+ @api = SmplkitGeneratedClient::App::ContextsApi.new(app_http)
371
+ @buffer = buffer
372
+ end
373
+
374
+ # Buffer one or more contexts for registration.
375
+ #
376
+ # Buffered contexts are sent in batches: a background flush kicks in once
377
+ # enough have accumulated, and any remainder is sent on the next explicit
378
+ # flush. Pass +flush: true+ to send everything buffered right away.
379
+ #
380
+ # @param items [Context, Array<Context>] A single context or a list of
381
+ # contexts to register.
382
+ # @param flush [Boolean] When +true+, send all buffered contexts
383
+ # immediately rather than waiting for the batch threshold. Defaults to
384
+ # +false+.
385
+ # @return [void]
386
+ def register(items, flush: false)
387
+ batch = items.is_a?(Array) ? items : [items]
388
+ @buffer.observe(batch)
389
+ if flush
390
+ self.flush
391
+ return
392
+ end
393
+ return unless @buffer.pending_count >= CONTEXT_BATCH_FLUSH_SIZE
394
+
395
+ Thread.new { threshold_flush }
396
+ end
397
+
398
+ # Send any pending observations to the server.
399
+ #
400
+ # @return [void]
401
+ def flush
402
+ batch = @buffer.drain
403
+ return if batch.empty?
404
+
405
+ body = build_bulk_register_body(batch)
406
+ ApiSupport::ErrorMapping.call { @api.bulk_register_contexts(body) }
407
+ end
408
+
409
+ # Number of observations queued and awaiting flush.
410
+ #
411
+ # @return [Integer] The count of observations pending flush.
412
+ def pending_count
413
+ @buffer.pending_count
414
+ end
415
+
416
+ # List all contexts of a given type.
417
+ #
418
+ # @param type [String] Context type to list (for example +"user"+).
419
+ # @param page_number [Integer, nil] 1-based page to fetch. Defaults to
420
+ # the first page.
421
+ # @param page_size [Integer, nil] Maximum number of contexts per page.
422
+ # Defaults to the server's page size.
423
+ # @return [Array<Context>] The contexts of the given type on the
424
+ # requested page.
425
+ def list(type, page_number: nil, page_size: nil)
426
+ opts = { filter_context_type: type }
427
+ opts[:page_number] = page_number unless page_number.nil?
428
+ opts[:page_size] = page_size unless page_size.nil?
429
+ response = ApiSupport::ErrorMapping.call { @api.list_contexts(opts) }
430
+ (response.data || []).map { |r| context_from_resource(ApiSupport::ResourceShim.from_model(r)) }
431
+ end
432
+
433
+ # Fetch a single context, identified by composite id or by type and key.
434
+ #
435
+ # @param id_or_type [String] Either the composite context id +"type:key"+
436
+ # (when +key+ is omitted) or just the context type (when +key+ is
437
+ # supplied).
438
+ # @param key [String, nil] The context key. Provide it to use the
439
+ # two-argument form; omit it when +id_or_type+ already carries the
440
+ # composite id.
441
+ # @return [Context] The matching context.
442
+ # @raise [Smplkit::NotFoundError] If no context with that id exists.
443
+ def get(id_or_type, key = nil)
444
+ ctx_type, ctx_key = Platform.split_context_id(id_or_type, key)
445
+ response = ApiSupport::ErrorMapping.call { @api.get_context("#{ctx_type}:#{ctx_key}") }
446
+ context_from_resource(ApiSupport::ResourceShim.from_model(response.data))
447
+ end
448
+
449
+ # Delete a single context, identified by composite id or by type and key.
450
+ #
451
+ # @param id_or_type [String] Either the composite context id +"type:key"+
452
+ # (when +key+ is omitted) or just the context type (when +key+ is
453
+ # supplied).
454
+ # @param key [String, nil] The context key. Provide it to use the
455
+ # two-argument form; omit it when +id_or_type+ already carries the
456
+ # composite id.
457
+ # @return [void]
458
+ def delete(id_or_type, key = nil)
459
+ ctx_type, ctx_key = Platform.split_context_id(id_or_type, key)
460
+ ApiSupport::ErrorMapping.call { @api.delete_context("#{ctx_type}:#{ctx_key}") }
461
+ nil
462
+ end
463
+
464
+ # @api private
465
+ def _save_context(ctx)
466
+ body = ctx_to_resource(ctx)
467
+ response = ApiSupport::ErrorMapping.call { @api.update_context(ctx.id, body) }
468
+ context_from_resource(ApiSupport::ResourceShim.from_model(response.data))
469
+ end
470
+
471
+ private
472
+
473
+ def threshold_flush
474
+ flush
475
+ rescue StandardError => e
476
+ Smplkit.debug("registration", "context registration flush failed: #{e.class}: #{e.message}")
477
+ end
478
+
479
+ def build_bulk_register_body(items)
480
+ bulk = items.map do |item|
481
+ SmplkitGeneratedClient::App::ContextBulkItem.new(
482
+ type: item["type"], key: item["key"], attributes: item["attributes"] || {}
483
+ )
484
+ end
485
+ SmplkitGeneratedClient::App::ContextBulkRegister.new(contexts: bulk)
486
+ end
487
+
488
+ def ctx_to_resource(ctx)
489
+ SmplkitGeneratedClient::App::ContextResponse.new(
490
+ data: SmplkitGeneratedClient::App::ContextResource.new(
491
+ type: "context",
492
+ id: ctx.id,
493
+ attributes: SmplkitGeneratedClient::App::Context.new(
494
+ name: ctx.name, context_type: ctx.type, attributes: ctx.attributes
495
+ )
496
+ )
497
+ )
498
+ end
499
+
500
+ def context_from_resource(resource)
501
+ attrs = resource["attributes"] || {}
502
+ Smplkit::Context.new(
503
+ attrs["context_type"] || attrs["type"] || resource["id"].to_s.split(":").first,
504
+ attrs["key"] || resource["id"].to_s.split(":", 2).last,
505
+ attrs["attributes"] || {},
506
+ name: attrs["name"],
507
+ created_at: attrs["created_at"],
508
+ updated_at: attrs["updated_at"]
509
+ )._bind_client(self)
510
+ end
511
+ end
512
+
513
+ # -----------------------------------------------------------------------
514
+ # PlatformClient (client.platform)
515
+ # -----------------------------------------------------------------------
516
+
517
+ # The Smpl Platform client (sync).
518
+ #
519
+ # Groups the account-wide CRUD resources that aren't owned by a single
520
+ # product, reachable as +client.platform+ (+Smplkit::Client+) or
521
+ # constructed directly:
522
+ #
523
+ # platform = Smplkit::PlatformClient.new(api_key: "sk_...")
524
+ # prod = platform.environments.new("production", name: "Production")
525
+ # prod.save
526
+ # platform.services.list.each { |svc| ... }
527
+ #
528
+ # Sub-clients: +environments+, +services+, +contexts+, +context_types+.
529
+ # Pure CRUD — no +install+ required.
530
+ #
531
+ # @param api_key [String, nil] API key. When omitted, resolved from
532
+ # +SMPLKIT_API_KEY+ or +~/.smplkit+.
533
+ # @param base_url [String, nil] Full app-service base URL. Usually resolved
534
+ # from +base_domain+/+scheme+; supplied directly by the top-level clients
535
+ # which have already computed it.
536
+ # @param profile [String, nil] Named +~/.smplkit+ profile section.
537
+ # @param base_domain [String, nil] Base domain for API requests (default
538
+ # +"smplkit.com"+).
539
+ # @param scheme [String, nil] URL scheme (default +"https"+).
540
+ # @param debug [Boolean, nil] Enable SDK debug logging.
541
+ # @param extra_headers [Hash, nil] Extra headers attached to every request.
542
+ # @param app_transport Internal — a pre-built app transport supplied by a
543
+ # top-level client so the platform surface shares one connection pool.
544
+ # Not for direct use.
545
+ # @param context_buffer Internal — the shared context-registration buffer.
546
+ # Not for direct use.
547
+ class PlatformClient
548
+ attr_reader :environments, :services, :contexts, :context_types
549
+
550
+ def initialize(api_key: nil, base_url: nil, profile: nil, base_domain: nil,
551
+ scheme: nil, debug: nil, extra_headers: nil,
552
+ app_transport: nil, context_buffer: nil)
553
+ if app_transport.nil?
554
+ @app_http = Platform.app_transport(
555
+ api_key: api_key, base_url: base_url, profile: profile,
556
+ base_domain: base_domain, scheme: scheme, debug: debug, extra_headers: extra_headers
557
+ )
558
+ @owns_transport = true
559
+ else
560
+ @app_http = app_transport
561
+ @owns_transport = false
562
+ end
563
+
564
+ buffer = context_buffer || ContextRegistrationBuffer.new
565
+ @context_buffer = buffer
566
+
567
+ @environments = EnvironmentsClient.new(@app_http)
568
+ @services = ServicesClient.new(@app_http)
569
+ @contexts = ContextsClient.new(@app_http, buffer)
570
+ @context_types = ContextTypesClient.new(@app_http)
571
+ end
572
+
573
+ # Close the app transport — only when this client owns it.
574
+ #
575
+ # A wired client borrows the parent's app transport and closes nothing.
576
+ def close
577
+ return unless @owns_transport
578
+
579
+ # The generated ApiClient owns Faraday connections that release on GC;
580
+ # there is no explicit shutdown to call.
581
+ nil
582
+ end
583
+
584
+ # Construct, yield to the block, and close on exit.
585
+ def self.open(**kwargs)
586
+ client = new(**kwargs)
587
+ begin
588
+ yield client
589
+ ensure
590
+ client.close
591
+ end
592
+ end
593
+ end
594
+ end
595
+
596
+ PlatformClient = Platform::PlatformClient
597
+ end