tungsten-runtime 0.1.2

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.
@@ -0,0 +1,1147 @@
1
+ # frozen_string_literal: true
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ module Tungsten
5
+ module Runtime
6
+ # The engine behind every generated client. Each call runs one pipeline:
7
+ # pre-flight validation of the arguments (nothing is sent on failure),
8
+ # auth, idempotency keys, the confirmation rules of destructive and
9
+ # irreversible operations, sending with tier-aware retries, and the
10
+ # classification of the answer (or of a lost request) into Ok or Err
11
+ # with the diagnostic envelope. On top of it: previews with
12
+ # confirmation tokens, verification hooks, pagination, polling, macros
13
+ # and server-sent event streams. It is safe for concurrent use; nothing
14
+ # raises for an API or transport failure.
15
+ class ClientCore
16
+ # Same-origin redirects one read follows.
17
+ MAX_REDIRECTS = 5
18
+
19
+ # Generated clients require this capability when redirects are forbidden.
20
+ def self.require_redirect_policy_v1; end
21
+ DEFAULT_POLL_INTERVAL_MS = 1000
22
+ DEFAULT_VERIFY_BUDGET_MS = 30_000
23
+ RETRYABLE_CATEGORIES = %w[RATE_LIMITED UPSTREAM_UNAVAILABLE TRANSPORT_FAILED OUTCOME_UNKNOWN].freeze
24
+
25
+ # A request ready to send, with its redacted rendering.
26
+ Prepared = Struct.new(:op, :args, :http_method, :url, :display_url, :headers, :body, :key, :key_header, :auth_query,
27
+ :hidden_query, :secrets, :oauth, keyword_init: true)
28
+
29
+ attr_reader :descriptors, :base_url, :timeout_ms, :max_event_bytes, :max_collect_bytes, :max_collect_ms,
30
+ :max_reconnects, :reconnect_max_ms, :idle_timeout_ms
31
+
32
+ # @param descriptors [Descriptors] the SDK descriptor document
33
+ # @param options [ClientOptions, Hash, nil]
34
+ def initialize(descriptors, options = nil)
35
+ raise ConfigError, "the SDK descriptor document is missing" unless descriptors.is_a?(Descriptors)
36
+
37
+ o = options.is_a?(ClientOptions) ? options : ClientOptions.from(options)
38
+ @descriptors = descriptors
39
+ @api = descriptors.api
40
+ policies = [@api["redirects"], o.redirects]
41
+ unless policies.all? { |policy| policy.nil? || %w[same_origin_read none].include?(policy) }
42
+ raise ConfigError, "redirects must be same_origin_read or none"
43
+ end
44
+ @redirects_disabled = policies.include?("none")
45
+ base = o.base_url.to_s
46
+ base = (@api["servers"] || []).first.to_s if base.empty?
47
+ if !base.empty? && !valid_base_url?(base)
48
+ raise ConfigError, "the base URL is not a valid absolute http(s) URL without credentials"
49
+ end
50
+
51
+ @base_url = base
52
+ @auth = (o.auth || {}).to_h.transform_keys(&:to_s)
53
+ @headers = (o.headers || {}).to_h.transform_keys(&:to_s)
54
+ @timeout_ms = positive(o.timeout_ms) || DEFAULT_TIMEOUT_MS
55
+ @max_event_bytes = positive(o.max_event_bytes) || DEFAULT_MAX_EVENT_BYTES
56
+ @max_collect_bytes = positive(o.max_collect_bytes) || DEFAULT_MAX_COLLECT_BYTES
57
+ @max_collect_ms = positive(o.max_collect_ms) || DEFAULT_MAX_COLLECT_MS
58
+ @max_reconnects = o.max_reconnects.nil? ? DEFAULT_MAX_RECONNECTS : o.max_reconnects.to_i.clamp(0, 1000)
59
+ @reconnect_max_ms = positive(o.reconnect_max_ms) || DEFAULT_RECONNECT_MAX_MS
60
+ @idle_timeout_ms = positive(o.idle_timeout_ms) ? [o.idle_timeout_ms.to_i, 1].max : nil
61
+ @retries = RetryOptions.from(o.retries)
62
+ @store = o.idempotency_store || MemoryIdempotencyStore.new
63
+ @middleware = Array(o.middleware)
64
+ @validate_responses = %w[warn strict off].include?(o.validate_responses.to_s) ? o.validate_responses.to_s : "warn"
65
+ @on_diagnostic = o.on_diagnostic
66
+ @confirmation_key = o.confirmation_key.to_s.empty? ? SecureRandom.random_bytes(32) : o.confirmation_key.to_s.b
67
+ @now_fn = o.now
68
+ @random_fn = o.random
69
+ @token_store = o.token_store || MemoryTokenStore.new
70
+ @lock = Mutex.new
71
+ @key_lock = Mutex.new
72
+ @cc_lock = Mutex.new
73
+ @cc_tokens = {}
74
+ @used_tokens = {}
75
+ @oauth_gates = {}
76
+ @registry = descriptors.operations.dup
77
+ end
78
+
79
+ # The API descriptor (the document's `api`).
80
+ def api = @api
81
+
82
+ # A registered operation by id.
83
+ def operation(id) = @lock.synchronize { @registry[id] }
84
+
85
+ # Make operations resolvable by id (verification hooks, endpoint
86
+ # previews, macro steps).
87
+ def register(*ops)
88
+ @lock.synchronize { ops.each { |op| @registry[op.id] ||= op if op.is_a?(OperationDescriptor) && !op.id.empty? } }
89
+ end
90
+
91
+ # Call an operation (an id or a descriptor) with an arguments Hash
92
+ # keyed by the SDK's argument names.
93
+ #
94
+ # @return [Ok, Err]
95
+ def call(op, args = {}, opts = nil)
96
+ with_options(op, opts) { |d, o| call_with(d, args, o, nil, nil) }
97
+ end
98
+
99
+ # Render the request without sending it (or run the operation's header
100
+ # or endpoint preview). A mutating operation gets a confirmation token
101
+ # bound to these exact arguments for five minutes.
102
+ #
103
+ # @return [Ok, Err] `value` is a PreviewResult
104
+ def preview(op, args = {}, opts = nil)
105
+ with_options(op, opts) { |d, o| preview_with(d, args, o) }
106
+ end
107
+
108
+ # Call a read operation until `until_` holds or the budget runs out;
109
+ # `value` is {"body" => ..., "timed_out" => bool}.
110
+ def poll(op, args = {}, until_: nil, interval_ms: DEFAULT_POLL_INTERVAL_MS, budget_ms: DEFAULT_VERIFY_BUDGET_MS, opts: nil)
111
+ with_options(op, opts) { |d, o| poll_with(d, args, until_, interval_ms, budget_ms, o, nil) }
112
+ end
113
+
114
+ # Milliseconds since the epoch (the clock of ClientOptions `now`).
115
+ def now
116
+ v = @now_fn&.call
117
+ v.is_a?(Numeric) ? v.to_i : (Time.now.to_f * 1000).to_i
118
+ end
119
+
120
+ private
121
+
122
+ def positive(value) = value.is_a?(Numeric) && value.positive? ? value.to_i : nil
123
+
124
+ def valid_base_url?(base)
125
+ u = URI.parse(base)
126
+ %w[http https].include?(u.scheme) && !u.host.to_s.empty? && u.userinfo.nil?
127
+ rescue URI::Error
128
+ false
129
+ end
130
+
131
+ # The descriptor of an id or descriptor and the call options, or the
132
+ # Err that explains why there is none.
133
+ def with_options(op, opts)
134
+ d = op.is_a?(OperationDescriptor) ? op : operation(op.to_s)
135
+ unless d
136
+ id = op.to_s.empty? ? "<unknown operation>" : op.to_s
137
+ return Runtime.diag(id, "VALIDATION_FAILED").param("operation").expected("an operation id of this SDK")
138
+ .remedy("This SDK has no operation #{id}; check the id. Nothing was sent.").err
139
+ end
140
+ begin
141
+ o = CallOptions.from(opts)
142
+ rescue ArgumentError => e
143
+ return Runtime.diag(d.id, "VALIDATION_FAILED").param("opts").expected("call options")
144
+ .remedy("Pass the call options as a Hash of idempotency_key, confirm, timeout_ms, verify and headers (#{e.message}).").err
145
+ end
146
+ yield d, o
147
+ end
148
+
149
+ def random
150
+ v = @random_fn ? @random_fn.call : SecureRandom.random_number
151
+ v.is_a?(Numeric) && v.finite? ? v.to_f.clamp(0.0, 1.0) : 0.5
152
+ end
153
+
154
+ # The retry policy of one operation: the runtime defaults, then the
155
+ # API's defaults for the operation's tier, then ClientOptions
156
+ # `retries`, field by field.
157
+ def retry_options(op)
158
+ out = { max: 3, base_ms: 200.0, max_ms: 5000.0, jitter: "full", honor_retry_after: true }
159
+ layers = []
160
+ if (tiers = @api["retries"])
161
+ layers << tiers[op.mutation? ? "mutating" : "read_only"]
162
+ end
163
+ layers << @retries.to_h.transform_keys(&:to_s)
164
+ layers.compact.each do |l|
165
+ out[:max] = l["max"].to_i.clamp(0, 100) unless l["max"].nil?
166
+ out[:base_ms] = l["base_ms"].to_f unless l["base_ms"].nil?
167
+ out[:max_ms] = l["max_ms"].to_f unless l["max_ms"].nil?
168
+ out[:jitter] = l["jitter"].to_s unless l["jitter"].nil? || l["jitter"].to_s.empty?
169
+ out[:honor_retry_after] = l["honor_retry_after"] == true unless l["honor_retry_after"].nil?
170
+ end
171
+ out
172
+ end
173
+
174
+ # The per-attempt timeout (at least one millisecond).
175
+ def attempt_timeout(opts)
176
+ t = opts.timeout_ms.is_a?(Numeric) && opts.timeout_ms.positive? ? opts.timeout_ms.to_i : @timeout_ms
177
+ [t, 1].max
178
+ end
179
+
180
+ # Report a warning; a failing observer never changes the call.
181
+ def emit(diagnostic)
182
+ @on_diagnostic&.call(diagnostic)
183
+ rescue StandardError
184
+ nil
185
+ end
186
+
187
+ def hook(name, *args)
188
+ @middleware.each do |m|
189
+ m.public_send(name, *args) if m.respond_to?(name)
190
+ rescue StandardError
191
+ nil
192
+ end
193
+ end
194
+
195
+ # The pipeline shared by calls, pages, polls and macro steps.
196
+ def call_with(op, args, opts, url_override, claim)
197
+ prepared = prepare(op, args, opts, :call, url_override, claim)
198
+ return prepared if prepared.is_a?(Err)
199
+
200
+ result = send_request(prepared, opts)
201
+ if result.is_a?(Err) && result.error.http_status == 401 && prepared.oauth
202
+ result = resend_rejected(prepared, result, opts)
203
+ end
204
+ return result if result.is_a?(Err)
205
+
206
+ if opts.verify && op.agent["verify"]
207
+ return result.with(verification: verify(op, prepared.args, result.raw.nil? ? UNSET : result.raw, opts))
208
+ end
209
+ result
210
+ end
211
+
212
+ # Send a request whose stored OAuth2 token was rejected (401) once more
213
+ # with the refreshed token.
214
+ def resend_rejected(p, rejected, opts)
215
+ scheme = p.oauth
216
+ p.oauth = nil
217
+ current = p.headers.get("Authorization")
218
+ return rejected unless current&.start_with?("Bearer ")
219
+
220
+ token = refresh_after_rejection(scheme, current.delete_prefix("Bearer "))
221
+ return rejected unless token
222
+
223
+ p.headers.set("Authorization", "Bearer #{token}", true)
224
+ p.secrets << token
225
+ send_request(p, opts)
226
+ end
227
+
228
+ def validation(op, path, value, expected, remediation)
229
+ received = Util.envelope_value(UNSET.equal?(value) ? nil : Helpers.redact_below(op, path, value), Helpers.sensitive_arg?(op, path))
230
+ Runtime.diag(op.id, "VALIDATION_FAILED").param(Helpers.argument_path(op, path)).received(received)
231
+ .expected(expected).remedy(remediation).err
232
+ end
233
+
234
+ def validate_args(op, args, placeholders)
235
+ params = op.argument_params
236
+ params.each do |p|
237
+ next unless p.required && (!args.key?(p.name) || args[p.name].nil?)
238
+
239
+ return validation(op, [p.name], args.fetch(p.name, UNSET),
240
+ "a value for the required #{p.location} parameter #{p.wire}",
241
+ "Pass #{p.name}; #{op.id} cannot be called without it.")
242
+ end
243
+ body = op.body
244
+ if body&.required && body.arg && !args.key?(body.arg)
245
+ return validation(op, [body.arg], UNSET, "a request body", "Pass the request body as #{body.arg}.")
246
+ end
247
+ if op.request
248
+ issues =
249
+ begin
250
+ op.request.validate(args)
251
+ rescue StandardError
252
+ return Runtime.diag(op.id, "UNEXPECTED_RESPONSE").retrying("never")
253
+ .remedy("The SDK failed while building or sending the request (the request validator failed). This is a bug in the generated SDK or the runtime; report it. Nothing was sent.").err
254
+ end
255
+ return args if issues.empty?
256
+
257
+ issue =
258
+ if placeholders
259
+ issues.find do |i|
260
+ (1..i.path.length).none? { |n| Helpers.placeholder?(Util.get_path(args, i.path.first(n))) }
261
+ end
262
+ else
263
+ issues.first
264
+ end
265
+ return args if issue.nil?
266
+
267
+ shown = Helpers.argument_path(op, issue.path)
268
+ return validation(op, issue.path, Util.get_path(args, issue.path), issue.message,
269
+ "Fix #{shown} (#{issue.message}) and call again.")
270
+ end
271
+ return args if op.rpc && body.nil?
272
+
273
+ allowed = params.map(&:name)
274
+ allowed += body.arg ? [body.arg] : body.fields.map(&:first) if body
275
+ args.each_key do |k|
276
+ next if allowed.include?(k)
277
+
278
+ names = allowed.sort.first(20)
279
+ list = names.empty? ? "no arguments" : names.join(", ")
280
+ return validation(op, [k], args[k], "one of: #{list}", "Remove #{k}; #{op.id} does not accept it.")
281
+ end
282
+ args
283
+ end
284
+
285
+ def check_key(op, opts, purpose)
286
+ policy = op.agent["idempotency"]
287
+ note = policy["note"].to_s.empty? ? "" : " #{policy['note']}"
288
+ unless opts.key?
289
+ if purpose == :call && policy["policy"] == "caller_owned" && policy["persist_required"]
290
+ fresh = policy["format"].to_s.downcase.include?("uuid") ? "UUIDv4" : "unique key"
291
+ return Runtime.diag(op.id, "VALIDATION_FAILED").param("idempotency_key")
292
+ .expected(Keys.format_description(policy["format"]))
293
+ .remedy("Generate a random #{fresh}, persist it with the intent of this #{op.id} call, and pass it as idempotency_key. " \
294
+ "Reuse the exact same key on every retry; a new key can apply the effect twice.#{note}").err
295
+ end
296
+ return nil
297
+ end
298
+ return nil if policy["policy"] == "none" && op.params.none? { |p| p.role == "idempotency_key" }
299
+ return nil if %w[content_identity content_hash].include?(policy["policy"])
300
+
301
+ format = policy["policy"] == "caller_owned" ? policy["format"] : nil
302
+ expected = Keys.check_format(opts.idempotency_key, format)
303
+ return nil unless expected
304
+
305
+ Runtime.diag(op.id, "VALIDATION_FAILED").param("idempotency_key")
306
+ .received(Util.envelope_value(opts.idempotency_key, false)).expected(expected)
307
+ .remedy("Pass idempotency_key as #{expected}. Generate it once, persist it with the intent of this call, and reuse it on every retry.#{note}").err
308
+ end
309
+
310
+ # The confirmation rule of a tier: destructive accepts `confirm: true`
311
+ # (unless refused) or a token, irreversible only a token issued for
312
+ # the subject and these exact arguments.
313
+ def confirmed(id, subject, preview_call, safety, args, opts)
314
+ return nil unless %w[destructive irreversible].include?(safety)
315
+
316
+ allow_yes = !opts.refuse_confirm_yes
317
+ how = "call #{preview_call} with the same arguments and pass its confirmation_token as confirm"
318
+ required = lambda do |remediation|
319
+ Runtime.diag(id, "CONFIRMATION_REQUIRED").param("confirm").expected("a confirmation_token from preview()")
320
+ .remedy(remediation).err
321
+ end
322
+ if opts.confirm_yes?
323
+ return nil if safety == "destructive" && allow_yes
324
+
325
+ return required.call("#{id} is #{safety}, so confirm: true is not accepted: #{how}.")
326
+ end
327
+ if opts.token
328
+ case Keys.check_token(@confirmation_key, opts.token, subject, args, now)
329
+ when :valid then return nil
330
+ when :expired
331
+ return required.call("The confirmation token expired (tokens last #{Keys::TTL_MS / 60_000} minutes): #{how}.")
332
+ else
333
+ return required.call("The confirmation token was not issued by this client for #{id} with exactly these arguments: #{how}.")
334
+ end
335
+ end
336
+ or_yes = safety == "destructive" && allow_yes ? " (or pass confirm: true)" : ""
337
+ required.call("#{id} is #{safety}: #{how}#{or_yes}.")
338
+ end
339
+
340
+ # Spend a valid token on the call about to be sent: its first use binds
341
+ # it to that call's replay protection (`bind`, nil for none), and a
342
+ # later use is accepted only as a retry under the same protection.
343
+ def claim_token(id, token, bind, preview_call)
344
+ t = now
345
+ @lock.synchronize do
346
+ @used_tokens.delete_if { |_, u| u[:expiry] <= t }
347
+ if (previous = @used_tokens[token])
348
+ return nil if previous[:bind] && bind && previous[:bind] == bind
349
+
350
+ why =
351
+ if previous[:bind].nil? then "This call has no idempotency key, so a repeat can apply the effect twice"
352
+ elsif bind.nil? then "It was used with an idempotency key; only a retry with that same key may reuse it"
353
+ else "It was used with another idempotency key; only a retry with that same key may reuse it"
354
+ end
355
+ return Runtime.diag(id, "CONFIRMATION_REQUIRED").param("confirm").expected("a confirmation_token from preview()")
356
+ .remedy("This confirmation token was already used for one #{id} call. #{why}. Check whether that call took effect; " \
357
+ "to send again, call #{preview_call} and confirm with its new token.").err
358
+ end
359
+ parsed = Keys.token_expiry(token)
360
+ @used_tokens[token] = { bind: bind, expiry: parsed ? parsed[0] : t + Keys::TTL_MS }
361
+ end
362
+ nil
363
+ end
364
+
365
+ # Arguments as a JSON object, or the Err of a value that is not one.
366
+ def normalize_args(op, raw)
367
+ value =
368
+ begin
369
+ Json.from_ruby(raw.nil? ? {} : raw)
370
+ rescue ArgumentError
371
+ UNSET
372
+ end
373
+ return value if value.is_a?(Hash)
374
+
375
+ names = op.argument_params.first(3).map { |p| "#{p.name}: ..." }
376
+ Runtime.diag(op.id, "VALIDATION_FAILED").param("args").received(Util.envelope_value(value, false))
377
+ .expected("an object of named arguments")
378
+ .remedy("Pass the arguments of #{op.id} as one object, e.g. {#{names.join(', ')}}.").err
379
+ end
380
+
381
+ def descriptor_err(op)
382
+ problem = Helpers.descriptor_problem(op)
383
+ return nil unless problem
384
+
385
+ id = op.id.empty? ? "<unknown operation>" : op.id
386
+ Runtime.diag(id, "VALIDATION_FAILED").param("operation").expected("a valid operation descriptor")
387
+ .remedy("The operation descriptor is invalid: #{problem}. Regenerate the SDK or fix the descriptor; nothing was sent.").err
388
+ end
389
+
390
+ # Validate, authenticate and render a request. `purpose` is :call,
391
+ # :preview, :server_preview or :macro_preview.
392
+ def prepare(op, raw_args, opts, purpose, url_override, claim)
393
+ if (err = descriptor_err(op))
394
+ return err
395
+ end
396
+
397
+ register(op)
398
+ args = normalize_args(op, raw_args)
399
+ return args if args.is_a?(Err)
400
+ if Util.nests_deeper?(args, Util::MAX_ARG_DEPTH)
401
+ return validation(op, [], "<deeply nested value>", "a JSON value nested at most #{Util::MAX_ARG_DEPTH} levels",
402
+ "Pass arguments without such deep nesting.")
403
+ end
404
+
405
+ args = validate_args(op, args, purpose == :macro_preview)
406
+ return args if args.is_a?(Err)
407
+
408
+ err = check_key(op, opts, purpose)
409
+ return err if err
410
+
411
+ if purpose == :call && claim.nil?
412
+ err = confirmed(op.id, op.id, "preview(...)", op.agent["safety"], args, opts)
413
+ return err if err
414
+ end
415
+ method = op.http_method
416
+ auth = resolve_auth(op, method)
417
+ return Runtime.diag(op.id, "AUTH_FAILED").remedy(auth.failed).err if auth.failed
418
+
419
+ secrets = auth.secrets
420
+ Helpers.sensitive_request_paths(op).each do |path|
421
+ found = []
422
+ Helpers.values_at(args, Util.split_path(path), found)
423
+ found.each { |v| secrets << v }
424
+ end
425
+ urls = build_url(op, args, auth.plan, url_override)
426
+ return urls if urls.is_a?(Err)
427
+
428
+ full_url, display_url = urls
429
+ headers = HeaderBag.new
430
+ build_headers(op, args, opts, auth.plan, headers)
431
+ param_names = op.argument_params.map(&:name)
432
+ body_paths = Helpers.sensitive_body_paths(op)
433
+ value = UNSET
434
+ value = Serialize.body_value(op, args, param_names) if !safe_method?(method) || method == "OPTIONS"
435
+ redact = lambda do |display|
436
+ next REDACTED if body_paths.include?("")
437
+
438
+ Util.redact_sensitive_keys(Util.redact_paths(display, body_paths))
439
+ end
440
+ encoded = Serialize.encode_body(op.body, value, redact)
441
+ if encoded.is_a?(Serialize::SerializationError)
442
+ path = op.body&.arg && encoded.parameter == "body" ? [op.body.arg] : []
443
+ return validation(op, path, encoded.value, encoded.expected, "Pass #{encoded.parameter} as #{encoded.expected}.")
444
+ end
445
+ headers.set("Content-Type", encoded.content_type, false) if encoded.content_type
446
+ header = Keys.header(op)
447
+ key = idempotency_key(op, args, opts, purpose, encoded)
448
+ return key if key.is_a?(Err)
449
+
450
+ policy = op.agent["idempotency"]["policy"]
451
+ if key
452
+ headers.set(header, key, true)
453
+ secrets << key
454
+ elsif purpose != :call && %w[auto caller_owned].include?(policy)
455
+ headers.set(header, "<set at call time>", false)
456
+ end
457
+ auth.plan.headers.each { |name, v, secret| headers.set(name, v, secret) }
458
+ if purpose == :server_preview && op.agent["preview"]["mode"] == "header"
459
+ headers.set(op.agent["preview"]["header"], op.agent["preview"]["value"], false)
460
+ end
461
+ headers.entries.each do |e|
462
+ next if Serialize.valid_header_name?(e.name) && Serialize.valid_header_value?(e.value)
463
+
464
+ if auth.plan.headers.any? { |name, _, _| name.casecmp?(e.name) }
465
+ return Runtime.diag(op.id, "AUTH_FAILED")
466
+ .remedy("The credential for header #{e.name} contains characters not allowed in an HTTP header; check ClientOptions.auth.").err
467
+ end
468
+ p = op.params.find { |x| x.wire.casecmp?(e.name) }
469
+ return validation(op, p ? [p.name] : [], e.secret ? REDACTED : e.value,
470
+ "a header value of visible ASCII characters (no line breaks)",
471
+ "Header #{e.name} cannot carry this value; remove line breaks and non-Latin-1 characters.")
472
+ end
473
+ if purpose == :call
474
+ if claim
475
+ err = claim.claim(self)
476
+ return err if err
477
+ elsif opts.token && %w[destructive irreversible].include?(op.agent["safety"])
478
+ bind = key || (policy == "content_identity" ? "content-identity" : nil)
479
+ err = claim_token(op.id, opts.token, bind, "preview(...)")
480
+ return err if err
481
+ end
482
+ end
483
+ headers.secrets.each { |s| secrets << s }
484
+ hidden = auth.plan.query.map(&:first)
485
+ op.argument_params.each do |p|
486
+ next unless p.location == "query" && args.key?(p.name)
487
+ next if request_display_value(op, p, args[p.name]) == args[p.name]
488
+
489
+ hidden.concat(Serialize.query_param(p, args[p.name]).map { |part| query_pairs(part).first&.first }.compact)
490
+ end
491
+ Prepared.new(op: op, args: args, http_method: method, url: full_url, display_url: display_url, headers: headers,
492
+ body: encoded, key: key, key_header: header, auth_query: auth.plan.query, hidden_query: hidden,
493
+ secrets: secrets, oauth: auth.oauth)
494
+ end
495
+
496
+ def bad_base(op)
497
+ Runtime.diag(op.id, "TRANSPORT_FAILED").retrying("never")
498
+ .remedy("The base URL is not a valid absolute http(s) URL without credentials: fix ClientOptions.base_url. Nothing was sent.").err
499
+ end
500
+
501
+ def dot_segment?(segment)
502
+ rest = segment.downcase
503
+ count = 0
504
+ while count < 3
505
+ if rest.start_with?("%2e") then rest = rest[3..]
506
+ elsif rest.start_with?(".") then rest = rest[1..]
507
+ else break
508
+ end
509
+ count += 1
510
+ end
511
+ segment.empty? || (rest.empty? && count.between?(1, 2))
512
+ end
513
+
514
+ def request_display_value(op, param, value)
515
+ return REDACTED if Helpers.sensitive_arg?(op, [param.name]) || Util.looks_sensitive?(param.wire)
516
+
517
+ Util.redact_sensitive_keys(Helpers.redact_below(op, [param.name], value))
518
+ end
519
+
520
+ # Redirects and pagination can supply paths other than the template.
521
+ # Hide the path conservatively when it may carry sensitive arguments.
522
+ def display_override_url(op, args, url)
523
+ uri = URI.parse(url)
524
+ hidden = []
525
+ op.argument_params.each do |p|
526
+ next unless args.key?(p.name)
527
+ next if request_display_value(op, p, args[p.name]) == args[p.name]
528
+
529
+ if p.location == "path"
530
+ uri.path = "/#{Serialize.encode_component(REDACTED)}"
531
+ elsif p.location == "query"
532
+ hidden.concat(Serialize.query_param(p, args[p.name]).flat_map { |part| query_pairs(part).map(&:first) })
533
+ end
534
+ end
535
+ with_query(uri.to_s, [], hidden, replace: true)
536
+ rescue URI::Error
537
+ url # The caller rejects invalid URLs before sending.
538
+ end
539
+
540
+ # [url, display url], or an Err.
541
+ def build_url(op, args, plan, url_override)
542
+ if @base_url.empty? && url_override.nil?
543
+ return Runtime.diag(op.id, "TRANSPORT_FAILED").retrying("never")
544
+ .remedy("No base URL is configured: set ClientOptions.base_url. Nothing was sent.").err
545
+ end
546
+ params = op.argument_params
547
+ if url_override
548
+ full = url_override
549
+ display = display_override_url(op, args, url_override)
550
+ unless plan.query.empty?
551
+ begin
552
+ full = with_query(full, plan.query, [], replace: false)
553
+ display = with_query(display, plan.query.map { |k, _| [k, REDACTED] }, [], replace: true)
554
+ rescue URI::Error
555
+ return bad_base(op)
556
+ end
557
+ end
558
+ else
559
+ unknown = nil
560
+ missing = nil
561
+ displayed_path = {}
562
+ built = op.path.gsub(/\{([^{}]+)\}/) do
563
+ inner = Regexp.last_match(1)
564
+ p = params.find { |x| x.location == "path" && x.wire == inner } || params.find { |x| x.location == "path" && x.name == inner }
565
+ if p.nil?
566
+ unknown ||= inner
567
+ "{#{inner}}"
568
+ elsif !args.key?(p.name) || args[p.name].nil?
569
+ missing ||= p
570
+ "{#{inner}}"
571
+ else
572
+ displayed_path[inner] = Serialize.path_param(p, request_display_value(op, p, args[p.name]))
573
+ Serialize.path_param(p, args[p.name])
574
+ end
575
+ end
576
+ if unknown
577
+ return Runtime.diag(op.id, "VALIDATION_FAILED").param("operation").expected("a valid operation descriptor")
578
+ .remedy("The path template has a placeholder {#{unknown}} with no path parameter; regenerate the SDK.").err
579
+ end
580
+ return validation(op, [missing.name], UNSET, "a value for path parameter #{missing.wire}", "Pass #{missing.name}.") if missing
581
+
582
+ template = op.path.split("/", -1)
583
+ segments = built.split("/", -1)
584
+ if template.length == segments.length
585
+ segments.each_with_index do |segment, i|
586
+ tmpl = template[i]
587
+ next unless tmpl.include?("{") && dot_segment?(segment)
588
+
589
+ wire = tmpl[/\{([^}]*)\}/, 1]
590
+ p = params.find { |x| x.location == "path" && (x.wire == wire || x.name == wire) }
591
+ return validation(op, p ? [p.name] : [], p ? args[p.name] : segment,
592
+ "a non-empty path segment other than . and ..",
593
+ "Pass #{p ? p.name : 'the path parameter'} as the identifier of one resource; \"#{segment}\" would change the request path.")
594
+ end
595
+ end
596
+ parts = []
597
+ shown = []
598
+ params.each do |p|
599
+ next unless p.location == "query" && args.key?(p.name)
600
+
601
+ serialized = Serialize.query_param(p, args[p.name])
602
+ shown.concat(Serialize.query_param(p, request_display_value(op, p, args[p.name])))
603
+ parts.concat(serialized)
604
+ end
605
+ plan.query.each do |k, v|
606
+ parts << "#{Serialize.encode_component(k)}=#{Serialize.encode_component(v)}"
607
+ shown << "#{Serialize.encode_component(k)}=#{REDACTED}"
608
+ end
609
+ root = @base_url.sub(%r{/+\z}, "")
610
+ join = ->(list) { list.empty? ? "" : (built.include?("?") ? "&" : "?") + list.join("&") }
611
+ full = root + built + join.call(parts)
612
+ displayed = op.path.gsub(/\{([^{}]+)\}/) { displayed_path.fetch(Regexp.last_match(1)) }
613
+ display = root + displayed + join.call(shown)
614
+ end
615
+ return bad_base(op) unless valid_base_url?(full)
616
+
617
+ [full, display]
618
+ end
619
+
620
+ def query_pairs(raw)
621
+ return [] if raw.nil? || raw.empty?
622
+
623
+ raw.split("&").reject(&:empty?).map do |part|
624
+ k, v = part.split("=", 2)
625
+ [URI.decode_www_form_component(k.to_s), URI.decode_www_form_component(v.to_s)]
626
+ rescue ArgumentError
627
+ [k.to_s, v.to_s]
628
+ end
629
+ end
630
+
631
+ # A URL with the pairs set (URLSearchParams.set when `replace`, else
632
+ # only when absent) and the `hidden` parameters shown redacted.
633
+ def with_query(url, pairs, hidden, replace:)
634
+ u = URI.parse(url)
635
+ list = query_pairs(u.query)
636
+ pairs.each do |k, v|
637
+ index = list.index { |name, _| name == k }
638
+ if index.nil?
639
+ list << [k, v]
640
+ elsif replace
641
+ list[index] = [k, v]
642
+ list = list.each_with_index.reject { |(name, _), i| name == k && i != index }.map(&:first)
643
+ end
644
+ end
645
+ hidden.each do |name|
646
+ list = list.map { |k, v| k == name ? [k, REDACTED] : [k, v] }
647
+ end
648
+ u.query = list.empty? ? nil : list.map { |k, v| "#{Serialize.encode_form_component(k)}=#{Serialize.encode_form_component(v)}" }.join("&")
649
+ u.to_s
650
+ end
651
+
652
+ def build_headers(op, args, opts, plan, headers)
653
+ accept = []
654
+ op.responses.each do |r|
655
+ accept << r.media_type if r.kind == "success" && r.media_type.is_a?(String) && !r.media_type.empty? && !accept.include?(r.media_type)
656
+ end
657
+ headers.set("Accept", accept.join(", "), false) unless accept.empty?
658
+ api_name = @api["name"].to_s.empty? ? "api" : @api["name"]
659
+ api_version = @api["version"].to_s.empty? ? "0" : @api["version"]
660
+ headers.set("X-Tungsten-Runtime", "tungsten-rb/#{VERSION} #{api_name}-sdk/#{api_version}", false)
661
+ headers.set("X-Tungsten-Operation", op.id, false)
662
+ tungsten = @api["tungsten_version"].to_s.empty? ? VERSION : @api["tungsten_version"]
663
+ headers.set("User-Agent", "#{api_name}-sdk/#{api_version} tungsten/#{tungsten} (ruby)", false)
664
+ op.params.each do |p|
665
+ headers.set(p.wire, p.constant.to_s, false) if p.role == "constant" && p.location == "header" && p.constant
666
+ end
667
+ [@headers, (opts.headers || {}).to_h.transform_keys(&:to_s)].each do |extra|
668
+ extra.keys.sort.each { |n| headers.set(n, extra[n].to_s, Util.looks_sensitive?(n)) }
669
+ end
670
+ cookies = []
671
+ existing = headers.get("Cookie")
672
+ cookies << existing if existing
673
+ op.argument_params.each do |p|
674
+ next unless args.key?(p.name) && !args[p.name].nil?
675
+
676
+ case p.location
677
+ when "header"
678
+ secret = request_display_value(op, p, args[p.name]) != args[p.name]
679
+ headers.set(p.wire, Serialize.header_param(p, args[p.name]), secret)
680
+ when "cookie" then cookies << Serialize.cookie_param(p, args[p.name])
681
+ end
682
+ end
683
+ plan.cookies.each { |k, v| cookies << "#{k}=#{v}" }
684
+ # Cookies are session material: the whole header is always redacted.
685
+ headers.set("Cookie", cookies.join("; "), true) unless cookies.empty?
686
+ end
687
+
688
+ # The key to send (nil for none), or an Err.
689
+ def idempotency_key(op, args, opts, purpose, encoded)
690
+ supplied = opts.key? ? opts.idempotency_key : nil
691
+ case op.agent["idempotency"]["policy"]
692
+ when "content_identity" then nil
693
+ when "content_hash" then encoded.hashed ? Util.sha256_hex(encoded.hashed) : nil
694
+ when "caller_owned" then supplied
695
+ when "auto"
696
+ return supplied if supplied
697
+ return nil unless purpose == :call
698
+
699
+ logical = Util.sha256_hex(Json.canonical(args))
700
+ @key_lock.synchronize do
701
+ existing = @store.get(op.id, logical)
702
+ return existing if existing.is_a?(String) && !existing.empty?
703
+
704
+ key = Util.uuid_v4
705
+ @store.put(op.id, logical, key)
706
+ return key if @store.get(op.id, logical) == key
707
+
708
+ Runtime.diag(op.id, "TRANSPORT_FAILED").retrying("never")
709
+ .remedy("The idempotency store failed (the key could not be stored), so the call was not sent rather than risk a second key " \
710
+ "for the same intent. Fix or replace ClientOptions.idempotency_store.").err
711
+ end
712
+ else
713
+ op.params.any? { |p| p.role == "idempotency_key" } ? supplied : nil
714
+ end
715
+ rescue StandardError
716
+ Runtime.diag(op.id, "TRANSPORT_FAILED").retrying("never")
717
+ .remedy("The idempotency store failed (it raised an error), so the call was not sent rather than risk a second key " \
718
+ "for the same intent. Fix or replace ClientOptions.idempotency_store.").err
719
+ end
720
+
721
+ # ------------------------------------------------------------ sending
722
+
723
+ def request_context(p, attempt)
724
+ RequestContext.new(operation: p.op.id, attempt: attempt, http_method: p.http_method, url: p.display_url,
725
+ headers: p.headers.to_h(true), body: p.body.empty ? UNSET : p.body.display)
726
+ end
727
+
728
+ def send_request(p, opts)
729
+ out = send_with(p, opts, false)
730
+ return out unless out.is_a?(Transport::Outcome)
731
+
732
+ out.stream.close
733
+ Runtime.diag(p.op.id, "UNEXPECTED_RESPONSE").retrying("never")
734
+ .remedy("The SDK received an event stream it did not ask for. This is a bug in the runtime; report it. If the call may have been sent, check its effect before repeating it.").err
735
+ end
736
+
737
+ def redirect?(status) = [301, 302, 303, 307, 308].include?(status)
738
+
739
+ def default_port(scheme) = { "http" => 80, "https" => 443 }[scheme.to_s.downcase]
740
+
741
+ def same_origin?(a, b)
742
+ a.scheme.to_s.casecmp?(b.scheme.to_s) && a.host.to_s.casecmp?(b.host.to_s) &&
743
+ (a.port || default_port(a.scheme)) == (b.port || default_port(b.scheme))
744
+ end
745
+
746
+ # [url, shown url] of a read's redirect that stays on the request's
747
+ # origin, with the auth query re-applied; nil otherwise.
748
+ def redirect_target(p, from, location)
749
+ return nil if location.nil? || location.empty?
750
+
751
+ base = URI.parse(from)
752
+ target = base.merge(location)
753
+ return nil unless same_origin?(base, target) && target.userinfo.nil?
754
+
755
+ sent = with_query(target.to_s, p.auth_query, [], replace: false)
756
+ display = display_override_url(p.op, p.args, sent)
757
+ [sent, with_query(display, [], p.hidden_query, replace: true)]
758
+ rescue URI::Error
759
+ nil
760
+ end
761
+
762
+ # Send with retries. With `stream`, a 2xx text/event-stream answer is
763
+ # returned unread as [:stream, meta, body]; otherwise Ok or Err.
764
+ def send_with(p, opts, stream)
765
+ op = p.op
766
+ retries = retry_options(op)
767
+ mutation = op.mutation?
768
+ protected_ = Classify.replay_protection?(op, !p.key.nil?)
769
+ check = mutation && !protected_ ? outcome_check(op, p.args) : nil
770
+ timeout = attempt_timeout(opts)
771
+ attempts = 0
772
+ loop do
773
+ attempts += 1
774
+ rctx = request_context(p, attempts)
775
+ hook(:on_request, rctx)
776
+ target = p.url
777
+ method = p.http_method
778
+ body = p.body
779
+ headers = p.headers.entries.map { |e| [e.name, e.value] }
780
+ send_once = lambda do
781
+ Transport.attempt(url: target, method: method, headers: headers, body: body, timeout_ms: timeout, stream: stream,
782
+ idle_ms: stream ? @idle_timeout_ms : nil)
783
+ end
784
+ outcome = send_once.call
785
+ unless mutation || @redirects_disabled
786
+ MAX_REDIRECTS.times do
787
+ break unless outcome.kind == :response && redirect?(outcome.status)
788
+
789
+ next_url = redirect_target(p, target, outcome.headers["location"])
790
+ break unless next_url
791
+
792
+ target = next_url[0]
793
+ if ![307, 308].include?(outcome.status) && !%w[GET HEAD].include?(method)
794
+ method = "GET"
795
+ body = Serialize::EMPTY
796
+ headers = headers.reject { |name, _| name.casecmp?("content-type") }
797
+ end
798
+ rctx = rctx.with(url: next_url[1], http_method: method)
799
+ outcome = send_once.call
800
+ end
801
+ end
802
+ ctx = Classify::CallContext.new(api: @api, op: op, key: p.key, key_header: p.key_header, attempts: attempts, check: check)
803
+ result = classify_response(ctx, p, outcome, rctx, timeout)
804
+ return result unless result.is_a?(Err)
805
+
806
+ d = Util.scrub_diagnostic(result.error, p.secrets)
807
+ done = Err.new(error: d, partial: result.partial)
808
+ return done if attempts > retries[:max] || !RETRYABLE_CATEGORIES.include?(d.category)
809
+ return done if %w[never after_remediation].include?(d.retryable) || (mutation && !protected_)
810
+
811
+ if d.retry_after_ms && retries[:honor_retry_after]
812
+ return done if d.retry_after_ms > retries[:max_ms]
813
+
814
+ delay = d.retry_after_ms.to_f
815
+ else
816
+ ceiling = [retries[:max_ms], retries[:base_ms] * (2**(attempts - 1))].min
817
+ r = random
818
+ delay =
819
+ case retries[:jitter]
820
+ when "none" then ceiling
821
+ when "equal" then (ceiling / 2) + (r * ceiling / 2)
822
+ else r * ceiling
823
+ end
824
+ end
825
+ hook(:on_retry, rctx, d)
826
+ sleep([delay, 0].max / 1000.0)
827
+ end
828
+ end
829
+
830
+ def classify_response(ctx, p, outcome, rctx, timeout)
831
+ op = p.op
832
+ mutation = op.mutation?
833
+ attempts = ctx.attempts
834
+ case outcome.kind
835
+ when :not_sent
836
+ return Runtime.diag(op.id, "TRANSPORT_FAILED")
837
+ .remedy("The request could not be delivered (#{outcome.detail}), so the server did not receive it. Check base_url and network access, then call again.")
838
+ .attempts(attempts).err
839
+ when :lost
840
+ cause = "The connection failed before a response arrived (#{outcome.detail})."
841
+ return Err.new(error: Classify.outcome_unknown(ctx, cause)) if mutation
842
+
843
+ return Runtime.diag(op.id, "TRANSPORT_FAILED").remedy("#{cause} This read has no side effects; call again.").attempts(attempts).err
844
+ when :timeout
845
+ return Err.new(error: Classify.outcome_unknown(ctx, "No response arrived within #{timeout} ms.")) if mutation
846
+
847
+ return Runtime.diag(op.id, "UPSTREAM_UNAVAILABLE")
848
+ .remedy("No response arrived within #{timeout} ms. This read has no side effects; call again later or with a larger timeout_ms.")
849
+ .attempts(attempts).err
850
+ when :streaming
851
+ hook(:on_response, rctx, ResponseContext.new(status: outcome.status, headers: outcome.headers))
852
+ meta = ResponseMeta.new(status: outcome.status, headers: outcome.headers, request_id: Classify.request_id(outcome.headers), attempts: attempts)
853
+ return [:stream, meta, outcome.stream]
854
+ end
855
+ status = outcome.status
856
+ headers = outcome.headers
857
+ hook(:on_response, rctx, ResponseContext.new(status: status, headers: headers))
858
+ rid = Classify.request_id(headers)
859
+ retry_after = Transport.parse_retry_after(headers["retry-after"], now)
860
+ declared = Classify.match_response(op.responses, status)
861
+ # A status the descriptor declares (exactly or by class) is classified as declared. With
862
+ # redirects disabled, a 3xx it does not declare is a refused redirect, never a success.
863
+ refused_redirect = @redirects_disabled && status.between?(300, 399) && (declared.nil? || declared.status == "default")
864
+ success = !refused_redirect && (status.between?(200, 299) || (declared&.kind == "success" && status.between?(100, 399)))
865
+ verify_op = op.agent["verify"]
866
+ hint = verify_op ? "Call #{verify_op['operation']} to read the current state." : nil
867
+ if success && outcome.body.nil?
868
+ failure = outcome.body_failure == "timeout" ? "timeout" : "broken"
869
+ unless mutation
870
+ return Runtime.diag(op.id, "UPSTREAM_UNAVAILABLE").status(status).request_id(rid).retrying("after_delay")
871
+ .remedy("The response body was cut off (#{failure}). This read has no side effects; call again.").attempts(attempts).err
872
+ end
873
+ return Runtime.diag(op.id, "UNEXPECTED_RESPONSE").status(status).request_id(rid).retrying("never")
874
+ .remedy("The server accepted #{op.id} (HTTP #{status}) but the response body was cut off (#{failure}). The call took effect; do not repeat it.")
875
+ .next_action(hint).attempts(attempts).err
876
+ end
877
+ data = outcome.body || "".b
878
+ declared_media = declared&.media_type.to_s
879
+ unless success
880
+ if status.between?(300, 399)
881
+ to = headers["location"] ? " to #{Json.take_units(headers['location'], 200)}" : ""
882
+ remediation =
883
+ if mutation
884
+ "The server answered with a redirect#{to}. Redirects are never followed on mutations; check base_url (scheme, host, trailing slash). " \
885
+ "Whether the call took effect is unknown only if the server applied it before redirecting."
886
+ elsif @redirects_disabled
887
+ "The server answered with a redirect#{to}. Redirects are disabled; check redirects and base_url."
888
+ else
889
+ "The server answered with a redirect#{to} that leaves the API's origin (or too many redirects). It is not followed, so credentials never leave the API's host; check base_url."
890
+ end
891
+ return Runtime.diag(op.id, "UNEXPECTED_RESPONSE").status(status).request_id(rid).remedy(remediation)
892
+ .next_action(mutation ? hint : nil).attempts(attempts).err
893
+ end
894
+ decoded = Classify.decode_body(data, headers, declared_media)
895
+ return Err.new(error: Classify.classify_error(ctx, status, headers, decoded, retry_after))
896
+ end
897
+ decoded = Classify.decode_body(data, headers, declared_media)
898
+ mode = @validate_responses
899
+ after_effect = mutation ? " The call took effect; do not repeat it." : ""
900
+ problem = nil
901
+ if decoded.invalid_json
902
+ expected = decoded.jsonl ? "a JSON Lines body" : "a JSON body"
903
+ remediation =
904
+ if decoded.jsonl
905
+ "The success response announced JSON Lines but line #{decoded.bad_line} did not parse.#{after_effect}"
906
+ else
907
+ "The success response announced JSON but did not parse.#{after_effect}"
908
+ end
909
+ problem = Runtime.diag(op.id, "UNEXPECTED_RESPONSE").status(status).request_id(rid).param("response")
910
+ .expected(expected).remedy(remediation).attempts(attempts).build
911
+ elsif mode != "off" && op.response && decoded.present?
912
+ issues = judge(op.response, decoded.value)
913
+ unless issues.empty?
914
+ issue = issues.first
915
+ path_text = Runtime.issue_path(issue.path)
916
+ kept = mutation && mode == "strict" ? " The decoded body is in the result's partial; store any value shown only once from it before anything else." : ""
917
+ redacted = Util.redact_paths(decoded.value, op.agent["sensitive_response_fields"])
918
+ received = Util.get_path(redacted, issue.path)
919
+ sensitive = issue.path.any? { |s| s.is_a?(String) && Util.looks_sensitive?(s) }
920
+ problem = Runtime.diag(op.id, "UNEXPECTED_RESPONSE").status(status).request_id(rid).param("response#{path_text}")
921
+ .received(Util.envelope_value(received, sensitive)).expected(issue.message)
922
+ .remedy("The success response does not match the API description at response#{path_text} (#{issue.message}).#{after_effect}#{kept}")
923
+ .attempts(attempts).build
924
+ end
925
+ end
926
+ if problem
927
+ scrubbed = Util.scrub_diagnostic(problem, p.secrets)
928
+ if mode == "strict"
929
+ # The effect of a mutation happened: its body is never dropped,
930
+ # since it can hold the only copy of a value.
931
+ return Err.new(error: scrubbed, partial: decoded.value) if mutation && !decoded.invalid_json && decoded.present?
932
+
933
+ return Err.new(error: scrubbed)
934
+ end
935
+ emit(scrubbed)
936
+ end
937
+ value = decoded.present? ? decoded.value : nil
938
+ Ok.new(value: value, raw: value, meta: ResponseMeta.new(status: status, headers: headers, request_id: rid, attempts: attempts))
939
+ end
940
+
941
+ # A validator that only judges (responses, page items, events): a
942
+ # failure is an issue at the root.
943
+ def judge(validator, value)
944
+ validator.validate(value)
945
+ rescue StandardError
946
+ [Issue.new(path: [], message: "the response schema failed (the validator raised an error)")]
947
+ end
948
+
949
+ # How to find out whether a mutation without replay protection took
950
+ # effect after its answer was lost.
951
+ def outcome_check(op, args)
952
+ sent_args = Helpers.sent_arguments(op, args)
953
+ hook_ = op.agent["verify"]
954
+ if hook_ && hook_["operation"].to_s != "" && Classify.verify_callable?(hook_)
955
+ scope = { "args" => Helpers.with_wire_names(op, args) }
956
+ evaluated = Expr.evaluate(hook_["args"] || {}, scope)
957
+ call = hook_["operation"] + Util.with_arguments(evaluated)
958
+ lost = []
959
+ expect = hook_["expect"].is_a?(Hash) ? resolve_lost(hook_["expect"], scope, lost) : {}
960
+ fields = expect.is_a?(Hash) ? expect.keys : []
961
+ shows =
962
+ if fields.empty? then "whether it shows #{Classify.change_of(op)}"
963
+ elsif !lost.empty?
964
+ "whether #{fields.join(' and ')} holds what #{op.id} creates, matching the arguments you sent#{sent_args} (its #{lost.uniq.join(', ')} was in the lost response)"
965
+ else "whether #{Expr.describe(expect)}"
966
+ end
967
+ return Classify::OutcomeCheck.new(call: call, shows: shows)
968
+ end
969
+ read = resource_read(op, args)
970
+ return nil unless read
971
+
972
+ Classify::OutcomeCheck.new(call: read[0] + Util.with_arguments(read[1]), shows: "whether it shows #{Classify.change_of(op)}")
973
+ end
974
+
975
+ def root_segment?(s) = s.empty? || s == "api" || s.match?(/\A[vV]\d+(\.\d+)*\z/)
976
+
977
+ # A registered read of the resource op changes: a GET whose path is the
978
+ # longest prefix of op's path (never the API root) whose required
979
+ # parameters are path parameters op was called with.
980
+ def resource_read(op, args)
981
+ return nil if op.rpc
982
+
983
+ known = {}
984
+ op.params.each { |p| known[p.wire] = args[p.name] if p.location == "path" && !args[p.name].nil? }
985
+ segments = op.path.split("/", -1)
986
+ reads = @lock.synchronize { @registry.keys.sort.map { |id| @registry[id] } }
987
+ .select { |r| r.id != op.id && r.http_method == "GET" && !r.mutation? && r.rpc.nil? }
988
+ segments.length.downto(1) do |n|
989
+ prefix = segments.first(n)
990
+ break if prefix.all? { |s| root_segment?(s) }
991
+
992
+ path = prefix.join("/")
993
+ reads.each do |read|
994
+ next unless read.path == path
995
+
996
+ params = read.argument_params
997
+ next unless params.all? { |p| !p.required || (p.location == "path" && known.key?(p.wire)) }
998
+
999
+ read_args = {}
1000
+ params.each { |p| read_args[p.wire] = known[p.wire] if p.location == "path" && known.key?(p.wire) }
1001
+ return [read.id, read_args]
1002
+ end
1003
+ end
1004
+ nil
1005
+ end
1006
+
1007
+ def resolve_lost(node, scope, lost, depth = 0)
1008
+ return nil if depth > 32
1009
+
1010
+ case node
1011
+ when String
1012
+ if node.start_with?("$response")
1013
+ rest = node.delete_prefix("$response").delete_prefix(".")
1014
+ lost << (rest.empty? ? "body" : rest)
1015
+ return "<#{node[1..]}>"
1016
+ end
1017
+ return node unless node.start_with?("$")
1018
+
1019
+ v = Expr.resolve_ref(node, scope)
1020
+ UNSET.equal?(v) ? "<#{node[1..]}>" : v
1021
+ when Array then node.map { |n| resolve_lost(n, scope, lost, depth + 1) }
1022
+ when Hash then node.transform_values { |n| resolve_lost(n, scope, lost, depth + 1) }
1023
+ else node
1024
+ end
1025
+ end
1026
+
1027
+ # ------------------------------------------------- previews and polls
1028
+
1029
+ def preview_with(op, raw_args, opts)
1030
+ p = prepare(op, raw_args, opts, :preview, nil, nil)
1031
+ return p if p.is_a?(Err)
1032
+
1033
+ conf = op.agent["confirmation"]
1034
+ effects = []
1035
+ effects << Helpers.interpolate(conf["message"], p.args, op) if conf && !conf["message"].to_s.empty?
1036
+ effects << op.agent["remediation_note"] unless op.agent["remediation_note"].to_s.empty?
1037
+ if conf && !(conf["summary_fields"] || []).empty?
1038
+ fields = conf["summary_fields"].map { |f| "#{f}={#{f}}" }
1039
+ effects << Helpers.interpolate("Arguments: #{fields.join(', ')}.", p.args, op)
1040
+ end
1041
+ effects << op.summary unless op.summary.empty?
1042
+ mutating = op.mutation?
1043
+ result = PreviewResult.new(
1044
+ operation: op.id, safety: op.agent["safety"],
1045
+ request: RenderedRequest.new(http_method: p.http_method, url: p.display_url, headers: p.headers.to_h(true), body: p.body.display),
1046
+ effects: effects, confirmation_token: mutating ? Keys.issue_token(@confirmation_key, op.id, p.args, now) : nil,
1047
+ expires_in_ms: mutating ? Keys::TTL_MS : nil, server_preview: UNSET, steps: nil
1048
+ )
1049
+ case op.agent["preview"]["mode"]
1050
+ when "header"
1051
+ dry = prepare(op, raw_args, opts, :server_preview, nil, nil)
1052
+ return dry if dry.is_a?(Err)
1053
+
1054
+ answer = send_request(dry, opts)
1055
+ return answer if answer.is_a?(Err)
1056
+
1057
+ shown = answer.raw.nil? ? UNSET : Util.redact_paths(answer.raw, op.agent["sensitive_response_fields"])
1058
+ Ok.new(value: result.with(server_preview: shown), meta: answer.meta)
1059
+ when "endpoint"
1060
+ name = op.agent["preview"]["operation"]
1061
+ target = operation(name)
1062
+ unless target
1063
+ return Runtime.diag(op.id, "VALIDATION_FAILED").param("operation").expected("the preview operation #{name} registered with the client")
1064
+ .remedy("Register #{name} (ClientCore#register) so preview() can call it; nothing was sent.").err
1065
+ end
1066
+ answer = call_with(target, raw_args, opts.with(confirm: nil, verify: false), nil, nil)
1067
+ return answer if answer.is_a?(Err)
1068
+
1069
+ fields = op.agent["sensitive_response_fields"] | target.agent["sensitive_response_fields"]
1070
+ shown = answer.raw.nil? ? UNSET : Util.redact_paths(answer.raw, fields)
1071
+ Ok.new(value: result.with(server_preview: shown), meta: answer.meta)
1072
+ else
1073
+ Ok.new(value: result, meta: ResponseMeta.local)
1074
+ end
1075
+ end
1076
+
1077
+ def monotonic_ms = Transport.monotonic_ms
1078
+
1079
+ # Poll until `until_` holds or the budget runs out: Ok whose value is
1080
+ # {"body" => last body (UNSET without one), "timed_out" => bool}.
1081
+ def poll_with(op, args, until_, interval_ms, budget_ms, opts, claim)
1082
+ started = monotonic_ms
1083
+ once = opts.with(verify: false)
1084
+ loop do
1085
+ response = call_with(op, args, once, nil, claim)
1086
+ return response if response.is_a?(Err)
1087
+
1088
+ body = response.raw.nil? ? UNSET : response.raw
1089
+ done = Expr.predicate?(until_, body)
1090
+ out_of_time = !done && (monotonic_ms - started) + interval_ms > budget_ms
1091
+ if done || out_of_time
1092
+ return Ok.new(value: { "body" => body, "timed_out" => out_of_time }, raw: body, meta: response.meta)
1093
+ end
1094
+
1095
+ sleep(interval_ms / 1000.0)
1096
+ end
1097
+ end
1098
+
1099
+ def verify(op, args, body, opts)
1100
+ hook_ = op.agent["verify"]
1101
+ target = hook_ && operation(hook_["operation"])
1102
+ return Verification.unchecked unless target
1103
+
1104
+ # The hook is written against the API: argument keys and $args
1105
+ # references use wire names.
1106
+ scope = { "args" => Helpers.with_wire_names(op, args) }
1107
+ scope["response"] = body unless UNSET.equal?(body)
1108
+ evaluated = Expr.evaluate(hook_["args"] || {}, scope)
1109
+ return Verification.unchecked unless evaluated.is_a?(Hash)
1110
+
1111
+ mapped = evaluated.to_h { |k, v| [target.arg_name(k), v] }
1112
+ resolve = ->(predicate) { predicate.is_a?(Hash) && !predicate.empty? ? resolve_refs(predicate, scope) : nil }
1113
+ terminal = resolve.call(hook_["terminal"])
1114
+ expect = resolve.call(hook_["expect"]) || {}
1115
+ interval = hook_["poll_interval_ms"] || DEFAULT_POLL_INTERVAL_MS
1116
+ budget = hook_["poll_budget_ms"] || (terminal ? DEFAULT_VERIFY_BUDGET_MS : 0)
1117
+ rest = opts.with(confirm: nil, idempotency_key: nil, verify: false)
1118
+ polled = poll_with(target, mapped, terminal || expect, interval, budget, rest, nil)
1119
+ return Verification.unchecked(polled.error) if polled.is_a?(Err)
1120
+
1121
+ last = polled.value["body"]
1122
+ Verification.new(checked: true, passed: Expr.predicate?(expect, last),
1123
+ observed: UNSET.equal?(last) ? UNSET : Util.redact_paths(last, target.agent["sensitive_response_fields"]),
1124
+ timed_out: budget != 0 && polled.value["timed_out"], error: nil)
1125
+ end
1126
+
1127
+ UNRESOLVED = { "$tungsten_unresolved" => true }.freeze
1128
+
1129
+ # References resolved; one that does not resolve equals nothing a
1130
+ # response can hold.
1131
+ def resolve_refs(node, scope, depth = 0)
1132
+ return UNRESOLVED.dup if depth > 64
1133
+
1134
+ case node
1135
+ when String
1136
+ return node unless node.start_with?("$")
1137
+
1138
+ v = Expr.resolve_ref(node, scope)
1139
+ UNSET.equal?(v) || v.nil? ? UNRESOLVED.dup : v
1140
+ when Array then node.map { |n| resolve_refs(n, scope, depth + 1) }
1141
+ when Hash then node.transform_values { |n| resolve_refs(n, scope, depth + 1) }
1142
+ else node
1143
+ end
1144
+ end
1145
+ end
1146
+ end
1147
+ end