smplkit 3.0.95 → 3.0.96

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.
@@ -18,6 +18,7 @@ module Smplkit
18
18
  log_group_id: attrs["log_group_id"],
19
19
  managed: attrs.fetch("managed", true),
20
20
  description: attrs["description"],
21
+ environments: attrs["environments"],
21
22
  created_at: attrs["created_at"],
22
23
  updated_at: attrs["updated_at"]
23
24
  )
@@ -2,6 +2,55 @@
2
2
 
3
3
  module Smplkit
4
4
  module Logging
5
+ # Per-environment configuration on a logger or log group.
6
+ #
7
+ # Lives at +logger.environments[env_name]+ (a +Hash{String => LoggerEnvironment}+).
8
+ # Frozen — mutate the override via +logger.set_level(level, environment: "...")+
9
+ # or remove it via +logger.clear_level(environment: "...")+.
10
+ #
11
+ # Attributes:
12
+ # - level: Per-environment level override (+nil+ means no override).
13
+ LoggerEnvironment = Struct.new(:level, keyword_init: true) do
14
+ def initialize(level: nil)
15
+ super
16
+ freeze
17
+ end
18
+ end
19
+
20
+ # Coerce a dict input into +Hash{String => LoggerEnvironment}+.
21
+ #
22
+ # Accepts both pre-built +LoggerEnvironment+ instances and the wire-shaped
23
+ # +{env_id => {"level" => "ERROR"}}+ dicts.
24
+ def self.convert_environments(value)
25
+ return {} if value.nil? || value.empty?
26
+
27
+ value.each_with_object({}) do |(env_id, env_data), out|
28
+ out[env_id] = build_logger_environment(env_data)
29
+ end
30
+ end
31
+
32
+ def self.build_logger_environment(env_data)
33
+ return env_data if env_data.is_a?(LoggerEnvironment)
34
+ return LoggerEnvironment.new unless env_data.is_a?(Hash)
35
+
36
+ level_str = env_data["level"] || env_data[:level]
37
+ return LoggerEnvironment.new if level_str.nil?
38
+
39
+ begin
40
+ LoggerEnvironment.new(level: LogLevel.coerce(level_str))
41
+ rescue ArgumentError
42
+ LoggerEnvironment.new
43
+ end
44
+ end
45
+
46
+ # Convert a typed environments dict to the wire-shaped dict for sending.
47
+ # Entries with +level=nil+ are skipped (no override to send).
48
+ def self.environments_to_wire(environments)
49
+ environments.each_with_object({}) do |(env_id, env), out|
50
+ out[env_id] = { "level" => env.level.to_s } unless env.level.nil?
51
+ end
52
+ end
53
+
5
54
  # A logger resource managed by the smplkit Logging service.
6
55
  #
7
56
  # Attributes:
@@ -12,13 +61,15 @@ module Smplkit
12
61
  # - service, environment: provenance
13
62
  # - log_group_id: parent log group, if any
14
63
  # - managed: whether the SDK should apply server-driven level changes
64
+ # - environments: per-environment level overrides, keyed by environment
15
65
  class SmplLogger
16
66
  attr_accessor :id, :name, :resolved_level, :level, :service, :environment,
17
67
  :log_group_id, :managed, :created_at, :updated_at, :description
18
68
 
19
69
  def initialize(client = nil, name:, resolved_level:, id: nil, level: nil,
20
70
  service: nil, environment: nil, log_group_id: nil,
21
- managed: true, description: nil, created_at: nil, updated_at: nil)
71
+ managed: true, description: nil, environments: nil,
72
+ created_at: nil, updated_at: nil)
22
73
  @client = client
23
74
  @id = id
24
75
  @name = name
@@ -29,12 +80,51 @@ module Smplkit
29
80
  @log_group_id = log_group_id
30
81
  @managed = managed
31
82
  @description = description
83
+ @environments = Logging.convert_environments(environments)
32
84
  @created_at = created_at
33
85
  @updated_at = updated_at
34
86
  end
35
87
 
36
88
  def managed? = !!@managed
37
89
 
90
+ # Read-only view of per-environment level overrides. Mutate via
91
+ # +set_level+ / +clear_level+ (with +environment: "..."+).
92
+ def environments
93
+ @environments.dup
94
+ end
95
+
96
+ # Set the log level.
97
+ #
98
+ # With +environment: nil+ (the default), sets the base log level used when
99
+ # no environment-specific override applies. With +environment: "..."+,
100
+ # sets the per-environment override. Changes are local until +save+.
101
+ def set_level(level, environment: nil)
102
+ if environment.nil?
103
+ @level = level
104
+ else
105
+ @environments[environment] = LoggerEnvironment.new(level: level)
106
+ end
107
+ end
108
+
109
+ # Remove a log level.
110
+ #
111
+ # With +environment: nil+ (the default), removes the base log level (the
112
+ # logger then inherits from its group / ancestor / system default). With
113
+ # +environment: "..."+, removes the per-environment override only. Changes
114
+ # are local until +save+.
115
+ def clear_level(environment: nil)
116
+ if environment.nil?
117
+ @level = nil
118
+ else
119
+ @environments.delete(environment)
120
+ end
121
+ end
122
+
123
+ # Remove all per-environment level overrides.
124
+ def clear_all_environment_levels
125
+ @environments = {}
126
+ end
127
+
38
128
  def save
39
129
  raise "SmplLogger was constructed without a client; cannot save" if @client.nil?
40
130
 
@@ -61,6 +151,7 @@ module Smplkit
61
151
  @log_group_id = other.log_group_id
62
152
  @managed = other.managed
63
153
  @description = other.description
154
+ @environments = other.environments
64
155
  @created_at = other.created_at
65
156
  @updated_at = other.updated_at
66
157
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Smplkit
4
4
  module Logging
5
- # Describes a logger to register via +Smplkit::ManagementClient#loggers#register+.
5
+ # Describes a logger to register via +client.logging.loggers.register+.
6
6
  #
7
7
  # Used both for buffered runtime discovery (called by +Smplkit::Client+ as
8
8
  # adapters discover loggers) and for explicit registration from setup
@@ -0,0 +1,472 @@
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
+ def self.split_context_id(id_or_type, key)
30
+ return [id_or_type, key] unless key.nil?
31
+
32
+ unless id_or_type.include?(":")
33
+ raise ArgumentError,
34
+ "context id must be 'type:key' (got #{id_or_type.inspect}); " \
35
+ "alternatively pass type and key as separate args"
36
+ end
37
+
38
+ id_or_type.split(":", 2)
39
+ end
40
+
41
+ # Build a standalone app transport from resolved config.
42
+ #
43
+ # +base_url+/+api_key+ are used directly when supplied (the path the
44
+ # top-level client takes after it has already resolved them); otherwise the
45
+ # management config resolver fills in whatever is missing (+~/.smplkit+ /
46
+ # env vars / defaults).
47
+ def self.app_transport(api_key:, base_url:, profile:, base_domain:, scheme:, debug:, extra_headers:)
48
+ cfg = ConfigResolution.resolve_management_config(
49
+ profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme, debug: debug
50
+ )
51
+ resolved_key = api_key.nil? ? cfg.api_key : api_key
52
+ merged = {}
53
+ merged.merge!(cfg.extra_headers || {})
54
+ merged.merge!(extra_headers || {})
55
+ tcfg = ConfigResolution::ResolvedManagementConfig.new(
56
+ api_key: resolved_key, base_domain: cfg.base_domain, scheme: cfg.scheme,
57
+ debug: cfg.debug, extra_headers: merged
58
+ )
59
+ Transport.build_api_client(SmplkitGeneratedClient::App, "app", tcfg, base_url: base_url)
60
+ end
61
+
62
+ # -----------------------------------------------------------------------
63
+ # Environments
64
+ # -----------------------------------------------------------------------
65
+
66
+ # Sync environment CRUD (+client.platform.environments+).
67
+ class EnvironmentsClient
68
+ def initialize(app_http)
69
+ @api = SmplkitGeneratedClient::App::EnvironmentsApi.new(app_http)
70
+ end
71
+
72
+ # Return an unsaved +Environment+. Call +.save+ to persist.
73
+ def new(id, name:, color: nil, classification: EnvironmentClassification::STANDARD)
74
+ Environment.new(self, id: id, name: name, color: color, classification: classification)
75
+ end
76
+
77
+ def list(page_number: nil, page_size: nil)
78
+ opts = {}
79
+ opts[:page_number] = page_number unless page_number.nil?
80
+ opts[:page_size] = page_size unless page_size.nil?
81
+ response = ApiSupport::ErrorMapping.call { @api.list_environments(opts) }
82
+ (response.data || []).map { |r| from_resource(ApiSupport::ResourceShim.from_model(r)) }
83
+ end
84
+
85
+ def get(id)
86
+ response = ApiSupport::ErrorMapping.call { @api.get_environment(id) }
87
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
88
+ end
89
+
90
+ def delete(id)
91
+ ApiSupport::ErrorMapping.call { @api.delete_environment(id) }
92
+ nil
93
+ end
94
+
95
+ def _create(env)
96
+ response = ApiSupport::ErrorMapping.call { @api.create_environment(body_for(env)) }
97
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
98
+ end
99
+
100
+ def _update(env)
101
+ raise "cannot update an Environment with no id" if env.id.nil?
102
+
103
+ response = ApiSupport::ErrorMapping.call { @api.update_environment(env.id, body_for(env)) }
104
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
105
+ end
106
+
107
+ private
108
+
109
+ def body_for(env)
110
+ SmplkitGeneratedClient::App::EnvironmentRequest.new(
111
+ data: SmplkitGeneratedClient::App::EnvironmentResource.new(
112
+ type: "environment",
113
+ id: env.id,
114
+ attributes: SmplkitGeneratedClient::App::Environment.new(
115
+ name: env.name,
116
+ color: env.color&.hex,
117
+ classification: env.classification
118
+ )
119
+ )
120
+ )
121
+ end
122
+
123
+ def from_resource(resource)
124
+ attrs = resource["attributes"] || {}
125
+ classification =
126
+ attrs["classification"] == "AD_HOC" ? EnvironmentClassification::AD_HOC : EnvironmentClassification::STANDARD
127
+ Environment.new(
128
+ self,
129
+ id: resource["id"], name: attrs["name"], color: attrs["color"],
130
+ classification: classification,
131
+ created_at: attrs["created_at"], updated_at: attrs["updated_at"]
132
+ )
133
+ end
134
+ end
135
+
136
+ # -----------------------------------------------------------------------
137
+ # Services
138
+ # -----------------------------------------------------------------------
139
+
140
+ # Sync service CRUD (+client.platform.services+).
141
+ class ServicesClient
142
+ def initialize(app_http)
143
+ @api = SmplkitGeneratedClient::App::ServicesApi.new(app_http)
144
+ end
145
+
146
+ # Return an unsaved +Service+. Call +.save+ to persist.
147
+ def new(id, name:)
148
+ Service.new(self, id: id, name: name)
149
+ end
150
+
151
+ def list(page_number: nil, page_size: nil)
152
+ opts = {}
153
+ opts[:page_number] = page_number unless page_number.nil?
154
+ opts[:page_size] = page_size unless page_size.nil?
155
+ response = ApiSupport::ErrorMapping.call { @api.list_services(opts) }
156
+ (response.data || []).map { |r| from_resource(ApiSupport::ResourceShim.from_model(r)) }
157
+ end
158
+
159
+ def get(id)
160
+ response = ApiSupport::ErrorMapping.call { @api.get_service(id) }
161
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
162
+ end
163
+
164
+ def delete(id)
165
+ ApiSupport::ErrorMapping.call { @api.delete_service(id) }
166
+ nil
167
+ end
168
+
169
+ def _create(svc)
170
+ response = ApiSupport::ErrorMapping.call { @api.create_service(create_body_for(svc)) }
171
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
172
+ end
173
+
174
+ def _update(svc)
175
+ raise "cannot update a Service with no id" if svc.id.nil?
176
+
177
+ response = ApiSupport::ErrorMapping.call { @api.update_service(svc.id, body_for(svc)) }
178
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
179
+ end
180
+
181
+ private
182
+
183
+ def body_for(svc)
184
+ SmplkitGeneratedClient::App::ServiceRequest.new(
185
+ data: SmplkitGeneratedClient::App::ServiceResource.new(
186
+ type: "service",
187
+ id: svc.id,
188
+ attributes: SmplkitGeneratedClient::App::Service.new(name: svc.name)
189
+ )
190
+ )
191
+ end
192
+
193
+ def create_body_for(svc)
194
+ SmplkitGeneratedClient::App::ServiceCreateRequest.new(
195
+ data: SmplkitGeneratedClient::App::ServiceCreateResource.new(
196
+ type: "service",
197
+ id: svc.id,
198
+ attributes: SmplkitGeneratedClient::App::Service.new(name: svc.name)
199
+ )
200
+ )
201
+ end
202
+
203
+ def from_resource(resource)
204
+ attrs = resource["attributes"] || {}
205
+ Service.new(
206
+ self,
207
+ id: resource["id"], name: attrs["name"],
208
+ created_at: attrs["created_at"], updated_at: attrs["updated_at"]
209
+ )
210
+ end
211
+ end
212
+
213
+ # -----------------------------------------------------------------------
214
+ # Context Types
215
+ # -----------------------------------------------------------------------
216
+
217
+ # Sync context-type CRUD (+client.platform.context_types+).
218
+ class ContextTypesClient
219
+ def initialize(app_http)
220
+ @api = SmplkitGeneratedClient::App::ContextTypesApi.new(app_http)
221
+ end
222
+
223
+ def new(id, name: nil, attributes: nil)
224
+ ContextType.new(self, id: id, name: name || id, attributes: attributes || {})
225
+ end
226
+
227
+ def list(page_number: nil, page_size: nil)
228
+ opts = {}
229
+ opts[:page_number] = page_number unless page_number.nil?
230
+ opts[:page_size] = page_size unless page_size.nil?
231
+ response = ApiSupport::ErrorMapping.call { @api.list_context_types(opts) }
232
+ (response.data || []).map { |r| from_resource(ApiSupport::ResourceShim.from_model(r)) }
233
+ end
234
+
235
+ def get(id)
236
+ response = ApiSupport::ErrorMapping.call { @api.get_context_type(id) }
237
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
238
+ end
239
+
240
+ def delete(id)
241
+ ApiSupport::ErrorMapping.call { @api.delete_context_type(id) }
242
+ nil
243
+ end
244
+
245
+ def _create(ct)
246
+ response = ApiSupport::ErrorMapping.call { @api.create_context_type(body_for(ct)) }
247
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
248
+ end
249
+
250
+ def _update(ct)
251
+ raise "cannot update a ContextType with no id" if ct.id.nil?
252
+
253
+ response = ApiSupport::ErrorMapping.call { @api.update_context_type(ct.id, body_for(ct)) }
254
+ from_resource(ApiSupport::ResourceShim.from_model(response.data))
255
+ end
256
+
257
+ private
258
+
259
+ def body_for(ct)
260
+ SmplkitGeneratedClient::App::ContextTypeRequest.new(
261
+ data: SmplkitGeneratedClient::App::ContextTypeResource.new(
262
+ type: "context_type",
263
+ id: ct.id,
264
+ attributes: SmplkitGeneratedClient::App::ContextType.new(name: ct.name, attributes: ct.attributes)
265
+ )
266
+ )
267
+ end
268
+
269
+ def from_resource(resource)
270
+ attrs = resource["attributes"] || {}
271
+ raw_meta = attrs["attributes"]
272
+ attribute_metadata = raw_meta.is_a?(Hash) ? raw_meta : {}
273
+ ContextType.new(
274
+ self,
275
+ id: resource["id"], name: attrs["name"], attributes: attribute_metadata,
276
+ created_at: attrs["created_at"], updated_at: attrs["updated_at"]
277
+ )
278
+ end
279
+ end
280
+
281
+ # -----------------------------------------------------------------------
282
+ # Contexts
283
+ # -----------------------------------------------------------------------
284
+
285
+ # Sync context registration + read/delete (+client.platform.contexts+).
286
+ class ContextsClient
287
+ def initialize(app_http, buffer)
288
+ @api = SmplkitGeneratedClient::App::ContextsApi.new(app_http)
289
+ @buffer = buffer
290
+ end
291
+
292
+ # Buffer contexts for registration; optionally flush immediately.
293
+ def register(items, flush: false)
294
+ batch = items.is_a?(Array) ? items : [items]
295
+ @buffer.observe(batch)
296
+ if flush
297
+ self.flush
298
+ return
299
+ end
300
+ return unless @buffer.pending_count >= CONTEXT_BATCH_FLUSH_SIZE
301
+
302
+ Thread.new { threshold_flush }
303
+ end
304
+
305
+ # Send any pending observations to the server.
306
+ def flush
307
+ batch = @buffer.drain
308
+ return if batch.empty?
309
+
310
+ body = build_bulk_register_body(batch)
311
+ ApiSupport::ErrorMapping.call { @api.bulk_register_contexts(body) }
312
+ end
313
+
314
+ # Number of observations queued and awaiting flush.
315
+ def pending_count
316
+ @buffer.pending_count
317
+ end
318
+
319
+ # List all contexts of a given type.
320
+ def list(type, page_number: nil, page_size: nil)
321
+ opts = { filter_context_type: type }
322
+ opts[:page_number] = page_number unless page_number.nil?
323
+ opts[:page_size] = page_size unless page_size.nil?
324
+ response = ApiSupport::ErrorMapping.call { @api.list_contexts(opts) }
325
+ (response.data || []).map { |r| context_from_resource(ApiSupport::ResourceShim.from_model(r)) }
326
+ end
327
+
328
+ def get(id_or_type, key = nil)
329
+ ctx_type, ctx_key = Platform.split_context_id(id_or_type, key)
330
+ response = ApiSupport::ErrorMapping.call { @api.get_context("#{ctx_type}:#{ctx_key}") }
331
+ context_from_resource(ApiSupport::ResourceShim.from_model(response.data))
332
+ end
333
+
334
+ def delete(id_or_type, key = nil)
335
+ ctx_type, ctx_key = Platform.split_context_id(id_or_type, key)
336
+ ApiSupport::ErrorMapping.call { @api.delete_context("#{ctx_type}:#{ctx_key}") }
337
+ nil
338
+ end
339
+
340
+ def _save_context(ctx)
341
+ body = ctx_to_resource(ctx)
342
+ response = ApiSupport::ErrorMapping.call { @api.update_context(ctx.id, body) }
343
+ context_from_resource(ApiSupport::ResourceShim.from_model(response.data))
344
+ end
345
+
346
+ private
347
+
348
+ def threshold_flush
349
+ flush
350
+ rescue StandardError => e
351
+ Smplkit.debug("registration", "context registration flush failed: #{e.class}: #{e.message}")
352
+ end
353
+
354
+ def build_bulk_register_body(items)
355
+ bulk = items.map do |item|
356
+ SmplkitGeneratedClient::App::ContextBulkItem.new(
357
+ type: item["type"], key: item["key"], attributes: item["attributes"] || {}
358
+ )
359
+ end
360
+ SmplkitGeneratedClient::App::ContextBulkRegister.new(contexts: bulk)
361
+ end
362
+
363
+ def ctx_to_resource(ctx)
364
+ SmplkitGeneratedClient::App::ContextResponse.new(
365
+ data: SmplkitGeneratedClient::App::ContextResource.new(
366
+ type: "context",
367
+ id: ctx.id,
368
+ attributes: SmplkitGeneratedClient::App::Context.new(
369
+ name: ctx.name, context_type: ctx.type, attributes: ctx.attributes
370
+ )
371
+ )
372
+ )
373
+ end
374
+
375
+ def context_from_resource(resource)
376
+ attrs = resource["attributes"] || {}
377
+ Smplkit::Context.new(
378
+ attrs["context_type"] || attrs["type"] || resource["id"].to_s.split(":").first,
379
+ attrs["key"] || resource["id"].to_s.split(":", 2).last,
380
+ attrs["attributes"] || {},
381
+ name: attrs["name"],
382
+ created_at: attrs["created_at"],
383
+ updated_at: attrs["updated_at"]
384
+ )._bind_client(self)
385
+ end
386
+ end
387
+
388
+ # -----------------------------------------------------------------------
389
+ # PlatformClient (client.platform)
390
+ # -----------------------------------------------------------------------
391
+
392
+ # The Smpl Platform client (sync).
393
+ #
394
+ # Groups the account-wide CRUD resources that aren't owned by a single
395
+ # product, reachable as +client.platform+ (+Smplkit::Client+) or
396
+ # constructed directly:
397
+ #
398
+ # platform = Smplkit::PlatformClient.new(api_key: "sk_...")
399
+ # prod = platform.environments.new("production", name: "Production")
400
+ # prod.save
401
+ # platform.services.list.each { |svc| ... }
402
+ #
403
+ # Sub-clients: +environments+, +services+, +contexts+, +context_types+.
404
+ # Pure CRUD — no +install+ required.
405
+ #
406
+ # @param api_key [String, nil] API key. When omitted, resolved from
407
+ # +SMPLKIT_API_KEY+ or +~/.smplkit+.
408
+ # @param base_url [String, nil] Full app-service base URL. Usually resolved
409
+ # from +base_domain+/+scheme+; supplied directly by the top-level clients
410
+ # which have already computed it.
411
+ # @param profile [String, nil] Named +~/.smplkit+ profile section.
412
+ # @param base_domain [String, nil] Base domain for API requests (default
413
+ # +"smplkit.com"+).
414
+ # @param scheme [String, nil] URL scheme (default +"https"+).
415
+ # @param debug [Boolean, nil] Enable SDK debug logging.
416
+ # @param extra_headers [Hash, nil] Extra headers attached to every request.
417
+ # @param app_transport Internal — a pre-built app transport supplied by a
418
+ # top-level client so the platform surface shares one connection pool.
419
+ # Not for direct use.
420
+ # @param context_buffer Internal — the shared context-registration buffer.
421
+ # Not for direct use.
422
+ class PlatformClient
423
+ attr_reader :environments, :services, :contexts, :context_types
424
+
425
+ def initialize(api_key: nil, base_url: nil, profile: nil, base_domain: nil,
426
+ scheme: nil, debug: nil, extra_headers: nil,
427
+ app_transport: nil, context_buffer: nil)
428
+ if app_transport.nil?
429
+ @app_http = Platform.app_transport(
430
+ api_key: api_key, base_url: base_url, profile: profile,
431
+ base_domain: base_domain, scheme: scheme, debug: debug, extra_headers: extra_headers
432
+ )
433
+ @owns_transport = true
434
+ else
435
+ @app_http = app_transport
436
+ @owns_transport = false
437
+ end
438
+
439
+ buffer = context_buffer || ContextRegistrationBuffer.new
440
+ @context_buffer = buffer
441
+
442
+ @environments = EnvironmentsClient.new(@app_http)
443
+ @services = ServicesClient.new(@app_http)
444
+ @contexts = ContextsClient.new(@app_http, buffer)
445
+ @context_types = ContextTypesClient.new(@app_http)
446
+ end
447
+
448
+ # Close the app transport — only when this client owns it.
449
+ #
450
+ # A wired client borrows the parent's app transport and closes nothing.
451
+ def close
452
+ return unless @owns_transport
453
+
454
+ # The generated ApiClient owns Faraday connections that release on GC;
455
+ # there is no explicit shutdown to call.
456
+ nil
457
+ end
458
+
459
+ # Construct, yield to the block, and close on exit.
460
+ def self.open(**kwargs)
461
+ client = new(**kwargs)
462
+ begin
463
+ yield client
464
+ ensure
465
+ client.close
466
+ end
467
+ end
468
+ end
469
+ end
470
+
471
+ PlatformClient = Platform::PlatformClient
472
+ end