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.
- checksums.yaml +7 -0
- data/LICENSE +55 -0
- data/README.md +23 -0
- data/lib/tungsten/runtime/auth.rb +258 -0
- data/lib/tungsten/runtime/classify.rb +248 -0
- data/lib/tungsten/runtime/client_core.rb +1147 -0
- data/lib/tungsten/runtime/descriptors.rb +173 -0
- data/lib/tungsten/runtime/dispatcher.rb +83 -0
- data/lib/tungsten/runtime/envelope.rb +230 -0
- data/lib/tungsten/runtime/expr.rb +186 -0
- data/lib/tungsten/runtime/helpers.rb +185 -0
- data/lib/tungsten/runtime/idempotency.rb +110 -0
- data/lib/tungsten/runtime/json.rb +346 -0
- data/lib/tungsten/runtime/macros.rb +343 -0
- data/lib/tungsten/runtime/oauth.rb +329 -0
- data/lib/tungsten/runtime/options.rb +142 -0
- data/lib/tungsten/runtime/pages.rb +173 -0
- data/lib/tungsten/runtime/schema.rb +320 -0
- data/lib/tungsten/runtime/serialize.rb +353 -0
- data/lib/tungsten/runtime/sse.rb +148 -0
- data/lib/tungsten/runtime/stream.rb +500 -0
- data/lib/tungsten/runtime/transport.rb +386 -0
- data/lib/tungsten/runtime/typed.rb +107 -0
- data/lib/tungsten/runtime/util.rb +254 -0
- data/lib/tungsten/runtime/version.rb +9 -0
- data/lib/tungsten/runtime.rb +49 -0
- metadata +82 -0
|
@@ -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
|