tungsten-runtime 0.1.4 → 0.1.6
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 +4 -4
- data/README.md +191 -9
- data/lib/tungsten/runtime/auth.rb +35 -2
- data/lib/tungsten/runtime/client_core.rb +195 -20
- data/lib/tungsten/runtime/descriptors.rb +12 -3
- data/lib/tungsten/runtime/json.rb +10 -0
- data/lib/tungsten/runtime/macros.rb +1 -1
- data/lib/tungsten/runtime/oauth.rb +133 -36
- data/lib/tungsten/runtime/oauth_inputs.rb +136 -0
- data/lib/tungsten/runtime/oauth_sessions.rb +521 -0
- data/lib/tungsten/runtime/options.rb +35 -11
- data/lib/tungsten/runtime/pages.rb +3 -1
- data/lib/tungsten/runtime/schema.rb +49 -19
- data/lib/tungsten/runtime/serialize.rb +43 -8
- data/lib/tungsten/runtime/stream.rb +3 -3
- data/lib/tungsten/runtime/version.rb +1 -1
- data/lib/tungsten/runtime.rb +2 -0
- metadata +3 -1
|
@@ -18,6 +18,10 @@ module Tungsten
|
|
|
18
18
|
|
|
19
19
|
# Generated clients require this capability when redirects are forbidden.
|
|
20
20
|
def self.require_redirect_policy_v1; end
|
|
21
|
+
|
|
22
|
+
# Generated clients require this capability when an operation carries
|
|
23
|
+
# an imported-operation policy.
|
|
24
|
+
def self.require_import_policy_v1; end
|
|
21
25
|
DEFAULT_POLL_INTERVAL_MS = 1000
|
|
22
26
|
DEFAULT_VERIFY_BUDGET_MS = 30_000
|
|
23
27
|
RETRYABLE_CATEGORIES = %w[RATE_LIMITED UPSTREAM_UNAVAILABLE TRANSPORT_FAILED OUTCOME_UNKNOWN].freeze
|
|
@@ -29,6 +33,10 @@ module Tungsten
|
|
|
29
33
|
attr_reader :descriptors, :base_url, :timeout_ms, :max_event_bytes, :max_collect_bytes, :max_collect_ms,
|
|
30
34
|
:max_reconnects, :reconnect_max_ms, :idle_timeout_ms
|
|
31
35
|
|
|
36
|
+
# Default Object inspection would include configured credentials/keys.
|
|
37
|
+
def inspect = "#<Tungsten::Runtime::ClientCore #{REDACTED}>"
|
|
38
|
+
alias_method :to_s, :inspect
|
|
39
|
+
|
|
32
40
|
# @param descriptors [Descriptors] the SDK descriptor document
|
|
33
41
|
# @param options [ClientOptions, Hash, nil]
|
|
34
42
|
def initialize(descriptors, options = nil)
|
|
@@ -49,11 +57,14 @@ module Tungsten
|
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
@base_url = base
|
|
60
|
+
@allow_insecure_http = o.allow_insecure_http == true
|
|
52
61
|
@auth = (o.auth || {}).to_h.transform_keys(&:to_s)
|
|
53
62
|
@headers = (o.headers || {}).to_h.transform_keys(&:to_s)
|
|
54
63
|
@timeout_ms = positive(o.timeout_ms) || DEFAULT_TIMEOUT_MS
|
|
64
|
+
@deadline_ms = positive(o.deadline_ms)
|
|
55
65
|
@max_event_bytes = positive(o.max_event_bytes) || DEFAULT_MAX_EVENT_BYTES
|
|
56
|
-
@max_response_bytes = o.max_response_bytes
|
|
66
|
+
@max_response_bytes = o.max_response_bytes.nil? ? 4 * 1024 * 1024 : o.max_response_bytes
|
|
67
|
+
@max_oauth_token_response_bytes = o.max_oauth_token_response_bytes.nil? ? 64 * 1024 : o.max_oauth_token_response_bytes
|
|
57
68
|
@max_collect_bytes = positive(o.max_collect_bytes) || DEFAULT_MAX_COLLECT_BYTES
|
|
58
69
|
@max_collect_ms = positive(o.max_collect_ms) || DEFAULT_MAX_COLLECT_MS
|
|
59
70
|
@max_reconnects = o.max_reconnects.nil? ? DEFAULT_MAX_RECONNECTS : o.max_reconnects.to_i.clamp(0, 1000)
|
|
@@ -64,7 +75,12 @@ module Tungsten
|
|
|
64
75
|
@middleware = Array(o.middleware)
|
|
65
76
|
@validate_responses = %w[warn strict off].include?(o.validate_responses.to_s) ? o.validate_responses.to_s : "warn"
|
|
66
77
|
@on_diagnostic = o.on_diagnostic
|
|
67
|
-
|
|
78
|
+
key = o.confirmation_key
|
|
79
|
+
copied_key = String.new(key, encoding: Encoding::BINARY) if key.is_a?(String)
|
|
80
|
+
if !key.nil? && (copied_key.nil? || copied_key.bytesize < 32)
|
|
81
|
+
raise ConfigError, "confirmation_key must contain at least 32 bytes; omit it to generate a random key"
|
|
82
|
+
end
|
|
83
|
+
@confirmation_key = copied_key || SecureRandom.random_bytes(32)
|
|
68
84
|
@now_fn = o.now
|
|
69
85
|
@random_fn = o.random
|
|
70
86
|
@token_store = o.token_store || MemoryTokenStore.new
|
|
@@ -75,6 +91,7 @@ module Tungsten
|
|
|
75
91
|
@used_tokens = {}
|
|
76
92
|
@oauth_gates = {}
|
|
77
93
|
@registry = descriptors.operations.dup
|
|
94
|
+
@oauth_sessions = OAuthSessions.new(self)
|
|
78
95
|
end
|
|
79
96
|
|
|
80
97
|
# The API descriptor (the document's `api`).
|
|
@@ -144,7 +161,51 @@ module Tungsten
|
|
|
144
161
|
return Runtime.diag(d.id, "VALIDATION_FAILED").param("opts").expected("call options")
|
|
145
162
|
.remedy("Pass the call options as a Hash of idempotency_key, confirm, timeout_ms, verify and headers (#{e.message}).").err
|
|
146
163
|
end
|
|
147
|
-
yield d, o
|
|
164
|
+
yield d, with_call_deadline(o)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# `until_deadline` gave up waiting.
|
|
168
|
+
DEADLINE_EXPIRED = Object.new.freeze
|
|
169
|
+
|
|
170
|
+
# The options with the absolute deadline of one logical call, fixed
|
|
171
|
+
# when a public entry point starts it. Nested work (pages, polls,
|
|
172
|
+
# verification, macro steps) inherits it and takes the earlier one, so
|
|
173
|
+
# it is never restarted or extended.
|
|
174
|
+
def with_call_deadline(opts)
|
|
175
|
+
ms = opts.deadline_ms.is_a?(Numeric) && opts.deadline_ms.positive? ? opts.deadline_ms : @deadline_ms
|
|
176
|
+
return opts unless ms
|
|
177
|
+
|
|
178
|
+
at = monotonic_ms + ms
|
|
179
|
+
opts.with(call_deadline: opts.call_deadline ? [opts.call_deadline, at].min : at)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Milliseconds left before the call's deadline, nil without one.
|
|
183
|
+
def deadline_left(opts) = opts.call_deadline && (opts.call_deadline - monotonic_ms)
|
|
184
|
+
|
|
185
|
+
def deadline_passed?(opts)
|
|
186
|
+
left = deadline_left(opts)
|
|
187
|
+
!left.nil? && left <= 0
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def deadline_expired(op, during)
|
|
191
|
+
Runtime.diag(op.id, "TRANSPORT_FAILED").code("DEADLINE_EXCEEDED").retrying("never")
|
|
192
|
+
.remedy("The operation deadline (deadline_ms) expired #{during}, so the request was not sent. Call again with a larger deadline_ms.").err
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The block's value, waited for only until the call's deadline
|
|
196
|
+
# (DEADLINE_EXPIRED after it). The work runs on its own thread and is
|
|
197
|
+
# not cancelled: a shared token refresh or store write keeps running
|
|
198
|
+
# for its other waiters, bounded by its own timeout.
|
|
199
|
+
def until_deadline(opts, &work)
|
|
200
|
+
left = deadline_left(opts)
|
|
201
|
+
return work.call if left.nil?
|
|
202
|
+
return DEADLINE_EXPIRED if left <= 0
|
|
203
|
+
|
|
204
|
+
worker = Thread.new do
|
|
205
|
+
Thread.current.report_on_exception = false
|
|
206
|
+
work.call
|
|
207
|
+
end
|
|
208
|
+
worker.join(left / 1000.0) ? worker.value : DEADLINE_EXPIRED
|
|
148
209
|
end
|
|
149
210
|
|
|
150
211
|
def random
|
|
@@ -231,8 +292,8 @@ module Tungsten
|
|
|
231
292
|
current = p.headers.get("Authorization")
|
|
232
293
|
return rejected unless current&.start_with?("Bearer ")
|
|
233
294
|
|
|
234
|
-
token = refresh_after_rejection(scheme, current.delete_prefix("Bearer "))
|
|
235
|
-
return rejected
|
|
295
|
+
token = until_deadline(opts) { refresh_after_rejection(scheme, current.delete_prefix("Bearer ")) }
|
|
296
|
+
return rejected if DEADLINE_EXPIRED.equal?(token) || !token
|
|
236
297
|
|
|
237
298
|
p.headers.set("Authorization", "Bearer #{token}", true)
|
|
238
299
|
p.secrets << token
|
|
@@ -261,7 +322,10 @@ module Tungsten
|
|
|
261
322
|
if op.request
|
|
262
323
|
issues =
|
|
263
324
|
begin
|
|
264
|
-
|
|
325
|
+
notes = []
|
|
326
|
+
op.request.validate(args, notes).tap do
|
|
327
|
+
report_unchecked(op.id, "VALIDATION_FAILED", notes) { |path| Helpers.argument_path(op, path) }
|
|
328
|
+
end
|
|
265
329
|
rescue StandardError
|
|
266
330
|
return Runtime.diag(op.id, "UNEXPECTED_RESPONSE").retrying("never")
|
|
267
331
|
.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
|
|
@@ -401,12 +465,26 @@ module Tungsten
|
|
|
401
465
|
.remedy("The operation descriptor is invalid: #{problem}. Regenerate the SDK or fix the descriptor; nothing was sent.").err
|
|
402
466
|
end
|
|
403
467
|
|
|
468
|
+
def response_limit(op, opts)
|
|
469
|
+
cap = opts.max_response_bytes.nil? ? @max_response_bytes : opts.max_response_bytes
|
|
470
|
+
unless cap.is_a?(Integer) && cap.between?(1, 9_007_199_254_740_991)
|
|
471
|
+
return Runtime.diag(op.id, "VALIDATION_FAILED").param("maxResponseBytes")
|
|
472
|
+
.remedy("maxResponseBytes must be a positive safe integer; nothing was sent.").err
|
|
473
|
+
end
|
|
474
|
+
cap
|
|
475
|
+
end
|
|
476
|
+
|
|
404
477
|
# Validate, authenticate and render a request. `purpose` is :call,
|
|
405
478
|
# :preview, :server_preview or :macro_preview.
|
|
406
479
|
def prepare(op, raw_args, opts, purpose, url_override, claim)
|
|
407
480
|
if (err = descriptor_err(op))
|
|
408
481
|
return err
|
|
409
482
|
end
|
|
483
|
+
limit = response_limit(op, opts)
|
|
484
|
+
return limit if limit.is_a?(Err)
|
|
485
|
+
if (err = import_scope_err(op, url_override || @base_url))
|
|
486
|
+
return err
|
|
487
|
+
end
|
|
410
488
|
|
|
411
489
|
register(op)
|
|
412
490
|
args = normalize_args(op, raw_args)
|
|
@@ -427,7 +505,10 @@ module Tungsten
|
|
|
427
505
|
return err if err
|
|
428
506
|
end
|
|
429
507
|
method = op.http_method
|
|
430
|
-
|
|
508
|
+
return deadline_expired(op, "before the request could be sent") if deadline_passed?(opts)
|
|
509
|
+
|
|
510
|
+
auth = until_deadline(opts) { resolve_auth(op, method, insecure_http?(url_override || @base_url) ? INSECURE_HTTP_AUTH : nil) }
|
|
511
|
+
return deadline_expired(op, "while authenticating") if DEADLINE_EXPIRED.equal?(auth)
|
|
431
512
|
return Runtime.diag(op.id, "AUTH_FAILED").remedy(auth.failed).err if auth.failed
|
|
432
513
|
|
|
433
514
|
secrets = auth.secrets
|
|
@@ -442,10 +523,14 @@ module Tungsten
|
|
|
442
523
|
full_url, display_url = urls
|
|
443
524
|
headers = HeaderBag.new
|
|
444
525
|
build_headers(op, args, opts, auth.plan, headers)
|
|
526
|
+
if insecure_http?(full_url) && (!auth.plan.headers.empty? || !auth.plan.cookies.empty? || !auth.plan.query.empty? ||
|
|
527
|
+
headers.entries.any? { |h| h.secret || credential_header?(h.name) })
|
|
528
|
+
return Runtime.diag(op.id, "AUTH_FAILED").remedy(INSECURE_HTTP_AUTH).err
|
|
529
|
+
end
|
|
445
530
|
param_names = op.argument_params.map(&:name)
|
|
446
531
|
body_paths = Helpers.sensitive_body_paths(op)
|
|
447
532
|
value = UNSET
|
|
448
|
-
value = Serialize.body_value(op, args, param_names) if !safe_method?(method) || method
|
|
533
|
+
value = Serialize.body_value(op, args, param_names) if !safe_method?(method) || %w[OPTIONS QUERY].include?(method)
|
|
449
534
|
redact = lambda do |display|
|
|
450
535
|
next REDACTED if body_paths.include?("")
|
|
451
536
|
|
|
@@ -458,7 +543,8 @@ module Tungsten
|
|
|
458
543
|
end
|
|
459
544
|
headers.set("Content-Type", encoded.content_type, false) if encoded.content_type
|
|
460
545
|
header = Keys.header(op)
|
|
461
|
-
key = idempotency_key(op, args, opts, purpose, encoded)
|
|
546
|
+
key = until_deadline(opts) { idempotency_key(op, args, opts, purpose, encoded) }
|
|
547
|
+
return deadline_expired(op, "while reserving an idempotency key") if DEADLINE_EXPIRED.equal?(key)
|
|
462
548
|
return key if key.is_a?(Err)
|
|
463
549
|
|
|
464
550
|
policy = op.agent["idempotency"]["policy"]
|
|
@@ -472,6 +558,12 @@ module Tungsten
|
|
|
472
558
|
if purpose == :server_preview && op.agent["preview"]["mode"] == "header"
|
|
473
559
|
headers.set(op.agent["preview"]["header"], op.agent["preview"]["value"], false)
|
|
474
560
|
end
|
|
561
|
+
if insecure_http?(full_url) && headers.entries.any? { |h|
|
|
562
|
+
preserved = key && h.name.casecmp?(header) && h.value == key
|
|
563
|
+
(h.secret && !preserved) || credential_header?(h.name, preserved)
|
|
564
|
+
}
|
|
565
|
+
return Runtime.diag(op.id, "AUTH_FAILED").remedy(INSECURE_HTTP_AUTH).err
|
|
566
|
+
end
|
|
475
567
|
headers.entries.each do |e|
|
|
476
568
|
next if Serialize.valid_header_name?(e.name) && Serialize.valid_header_value?(e.value)
|
|
477
569
|
|
|
@@ -627,10 +719,39 @@ module Tungsten
|
|
|
627
719
|
display = root + displayed + join.call(shown)
|
|
628
720
|
end
|
|
629
721
|
return bad_base(op) unless valid_base_url?(full)
|
|
722
|
+
if (err = import_scope_err(op, full))
|
|
723
|
+
return err
|
|
724
|
+
end
|
|
630
725
|
|
|
631
726
|
[full, display]
|
|
632
727
|
end
|
|
633
728
|
|
|
729
|
+
# The origin (scheme, host and port) of an absolute http(s) URL without
|
|
730
|
+
# credentials, or nil.
|
|
731
|
+
def origin_of(url)
|
|
732
|
+
return nil unless url.is_a?(String) && valid_base_url?(url)
|
|
733
|
+
|
|
734
|
+
u = URI.parse(url)
|
|
735
|
+
"#{u.scheme.downcase}://#{u.host.downcase}:#{u.port}"
|
|
736
|
+
rescue URI::Error
|
|
737
|
+
nil
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
# Refuses a reviewed imported operation whose target lies outside the
|
|
741
|
+
# origins its review covers. A malformed or missing scope fails closed.
|
|
742
|
+
def import_scope_err(op, target)
|
|
743
|
+
policy = op.agent["import_policy"]
|
|
744
|
+
return nil unless policy.is_a?(Hash) && policy["reviewed"] == true
|
|
745
|
+
|
|
746
|
+
actual = origin_of(target)
|
|
747
|
+
origins = policy["allowed_origins"]
|
|
748
|
+
allowed = !actual.nil? && origins.is_a?(Array) && origins.any? { |o| origin_of(o) == actual }
|
|
749
|
+
return nil if allowed
|
|
750
|
+
|
|
751
|
+
Runtime.diag(op.id, "VALIDATION_FAILED").param("base_url").retrying("never")
|
|
752
|
+
.remedy("The target origin is outside this operation's saved review policy. Select a reviewed server or review a separate policy for this environment. Nothing was sent.").err
|
|
753
|
+
end
|
|
754
|
+
|
|
634
755
|
def query_pairs(raw)
|
|
635
756
|
return [] if raw.nil? || raw.empty?
|
|
636
757
|
|
|
@@ -782,11 +903,8 @@ module Tungsten
|
|
|
782
903
|
# returned unread as [:stream, meta, body]; otherwise Ok or Err.
|
|
783
904
|
def send_with(p, opts, stream)
|
|
784
905
|
op = p.op
|
|
785
|
-
cap =
|
|
786
|
-
|
|
787
|
-
return Runtime.diag(op.id, "VALIDATION_FAILED").param("maxResponseBytes")
|
|
788
|
-
.remedy("maxResponseBytes must be a positive safe integer; nothing was sent.").err
|
|
789
|
-
end
|
|
906
|
+
cap = response_limit(op, opts)
|
|
907
|
+
return cap if cap.is_a?(Err)
|
|
790
908
|
retries = retry_options(op)
|
|
791
909
|
mutation = op.mutation?
|
|
792
910
|
protected_ = Classify.replay_protection?(op, !p.key.nil?)
|
|
@@ -795,6 +913,12 @@ module Tungsten
|
|
|
795
913
|
attempts = 0
|
|
796
914
|
loop do
|
|
797
915
|
attempts += 1
|
|
916
|
+
return deadline_expired(op, "before the request could be sent") if deadline_passed?(opts)
|
|
917
|
+
|
|
918
|
+
# Each attempt's timeout is cut to the time left before the call's deadline.
|
|
919
|
+
left = deadline_left(opts)
|
|
920
|
+
by_deadline = !left.nil? && left < timeout
|
|
921
|
+
attempt_ms = by_deadline ? [left.to_i, 1].max : timeout
|
|
798
922
|
rctx = request_context(p, attempts)
|
|
799
923
|
hook(:on_request, rctx)
|
|
800
924
|
target = p.url
|
|
@@ -802,8 +926,9 @@ module Tungsten
|
|
|
802
926
|
body = p.body
|
|
803
927
|
headers = p.headers.entries.map { |e| [e.name, e.value] }
|
|
804
928
|
send_once = lambda do
|
|
805
|
-
|
|
806
|
-
|
|
929
|
+
# An open stream keeps the idle bound of an uncut attempt; the deadline only bounds connecting.
|
|
930
|
+
Transport.attempt(url: target, method: method, headers: headers, body: body, timeout_ms: attempt_ms, stream: stream,
|
|
931
|
+
idle_ms: stream ? @idle_timeout_ms || timeout : nil, max_response_bytes: cap)
|
|
807
932
|
end
|
|
808
933
|
outcome = send_once.call
|
|
809
934
|
unless mutation || @redirects_disabled
|
|
@@ -825,7 +950,12 @@ module Tungsten
|
|
|
825
950
|
end
|
|
826
951
|
p.final_url = target
|
|
827
952
|
ctx = Classify::CallContext.new(api: @api, op: op, key: p.key, key_header: p.key_header, attempts: attempts, check: check)
|
|
828
|
-
result =
|
|
953
|
+
result =
|
|
954
|
+
if by_deadline && (outcome.kind == :timeout || (outcome.kind == :response && outcome.body_failure == "timeout" && !mutation))
|
|
955
|
+
deadline_timeout(ctx, outcome.kind == :response ? outcome.status : nil)
|
|
956
|
+
else
|
|
957
|
+
classify_response(ctx, p, outcome, rctx, timeout)
|
|
958
|
+
end
|
|
829
959
|
return result unless result.is_a?(Err)
|
|
830
960
|
|
|
831
961
|
d = Util.scrub_diagnostic(result.error, p.secrets)
|
|
@@ -847,11 +977,25 @@ module Tungsten
|
|
|
847
977
|
else r * ceiling
|
|
848
978
|
end
|
|
849
979
|
end
|
|
980
|
+
# A retry that could not start before the deadline is not waited for.
|
|
981
|
+
left = deadline_left(opts)
|
|
982
|
+
return done if left && left <= delay
|
|
983
|
+
|
|
850
984
|
hook(:on_retry, rctx, d)
|
|
851
985
|
sleep([delay, 0].max / 1000.0)
|
|
852
986
|
end
|
|
853
987
|
end
|
|
854
988
|
|
|
989
|
+
# The call's deadline ran out before a response (or a read's body) arrived.
|
|
990
|
+
def deadline_timeout(ctx, status)
|
|
991
|
+
cause = "The operation deadline expired before a response arrived."
|
|
992
|
+
return Err.new(error: Classify.outcome_unknown(ctx, cause)) if ctx.op.mutation?
|
|
993
|
+
|
|
994
|
+
Runtime.diag(ctx.op.id, "UPSTREAM_UNAVAILABLE").code("DEADLINE_EXCEEDED").status(status).retrying("never")
|
|
995
|
+
.remedy("#{cause} This read has no side effects; call again with a larger deadline_ms.")
|
|
996
|
+
.attempts(ctx.attempts).err
|
|
997
|
+
end
|
|
998
|
+
|
|
855
999
|
def classify_response(ctx, p, outcome, rctx, timeout)
|
|
856
1000
|
op = p.op
|
|
857
1001
|
mutation = op.mutation?
|
|
@@ -940,7 +1084,7 @@ module Tungsten
|
|
|
940
1084
|
problem = Runtime.diag(op.id, "UNEXPECTED_RESPONSE").status(status).request_id(rid).param("response")
|
|
941
1085
|
.expected(expected).remedy(remediation).attempts(attempts).build
|
|
942
1086
|
elsif mode != "off" && op.response && decoded.present?
|
|
943
|
-
issues = judge(op.response, decoded.value)
|
|
1087
|
+
issues = judge(op.response, decoded.value, op.id, "response")
|
|
944
1088
|
unless issues.empty?
|
|
945
1089
|
issue = issues.first
|
|
946
1090
|
path_text = Runtime.issue_path(issue.path)
|
|
@@ -971,12 +1115,39 @@ module Tungsten
|
|
|
971
1115
|
|
|
972
1116
|
# A validator that only judges (responses, page items, events): a
|
|
973
1117
|
# failure is an issue at the root.
|
|
974
|
-
|
|
975
|
-
|
|
1118
|
+
#
|
|
1119
|
+
# The patterns the validator could not check are reported once through
|
|
1120
|
+
# the observer (`PATTERN_UNCHECKED`), under `root` (for example
|
|
1121
|
+
# "response"); `seen` deduplicates across several calls.
|
|
1122
|
+
def judge(validator, value, operation, root, seen = nil)
|
|
1123
|
+
notes = []
|
|
1124
|
+
issues = validator.validate(value, notes)
|
|
1125
|
+
report_unchecked(operation, "UNEXPECTED_RESPONSE", notes, seen) { |path| "#{root}#{Runtime.issue_path(path)}" }
|
|
1126
|
+
issues
|
|
976
1127
|
rescue StandardError
|
|
977
1128
|
[Issue.new(path: [], message: "the response schema failed (the validator raised an error)")]
|
|
978
1129
|
end
|
|
979
1130
|
|
|
1131
|
+
# Emits PATTERN_UNCHECKED for each schema pattern the runtime could not
|
|
1132
|
+
# check: the value was accepted without it. The diagnostic names the
|
|
1133
|
+
# path, the pattern and the reason, never the value.
|
|
1134
|
+
def report_unchecked(operation, category, notes, seen = nil)
|
|
1135
|
+
return if notes.empty? || @on_diagnostic.nil?
|
|
1136
|
+
|
|
1137
|
+
seen ||= {}
|
|
1138
|
+
notes.each do |n|
|
|
1139
|
+
param = yield(n.path)
|
|
1140
|
+
key = [param, n.pattern]
|
|
1141
|
+
next if seen.key?(key) || seen.length >= 16
|
|
1142
|
+
|
|
1143
|
+
seen[key] = true
|
|
1144
|
+
emit(Runtime.diag(operation, category).code("PATTERN_UNCHECKED").retrying("never").param(param)
|
|
1145
|
+
.expected("a value matching the pattern /#{n.pattern}/")
|
|
1146
|
+
.remedy("The pattern /#{n.pattern}/ at #{param} was not checked because #{n.reason}; the value was accepted without it. The server still validates it.")
|
|
1147
|
+
.build)
|
|
1148
|
+
end
|
|
1149
|
+
end
|
|
1150
|
+
|
|
980
1151
|
# How to find out whether a mutation without replay protection took
|
|
981
1152
|
# effect after its answer was lost.
|
|
982
1153
|
def outcome_check(op, args)
|
|
@@ -1123,6 +1294,10 @@ module Tungsten
|
|
|
1123
1294
|
return Ok.new(value: { "body" => body, "timed_out" => out_of_time }, raw: body, meta: response.meta)
|
|
1124
1295
|
end
|
|
1125
1296
|
|
|
1297
|
+
# The next poll could not be sent before the call's deadline, so it is not waited for.
|
|
1298
|
+
left = deadline_left(opts)
|
|
1299
|
+
return deadline_expired(op, "before the next poll") if left && left <= interval_ms
|
|
1300
|
+
|
|
1126
1301
|
sleep(interval_ms / 1000.0)
|
|
1127
1302
|
end
|
|
1128
1303
|
end
|
|
@@ -13,7 +13,11 @@ module Tungsten
|
|
|
13
13
|
|
|
14
14
|
# The request body: with `arg` set the whole body is that argument;
|
|
15
15
|
# otherwise a JSON object of the merged `fields` ([name, wire] pairs).
|
|
16
|
-
|
|
16
|
+
# `parts` maps a multipart field's wire name to the media type its parts
|
|
17
|
+
# are sent with (OpenAPI `encoding`); a file part's own media type wins.
|
|
18
|
+
BodyDescriptor = Data.define(:media_type, :encoding, :required, :fields, :arg, :parts) do
|
|
19
|
+
def initialize(parts: {}, **rest) = super(parts: parts, **rest)
|
|
20
|
+
end
|
|
17
21
|
|
|
18
22
|
# A declared response: `status` an Integer, `"4XX"` or `"default"`.
|
|
19
23
|
ResponseDescriptor = Data.define(:status, :kind, :media_type)
|
|
@@ -53,7 +57,7 @@ module Tungsten
|
|
|
53
57
|
@response = doc["response"] ? Schema.new(doc["response"], defs) : nil
|
|
54
58
|
@page_item = doc["page_item"] ? Schema.new(doc["page_item"], defs) : nil
|
|
55
59
|
s = doc["stream"]
|
|
56
|
-
@stream = s ? { "event" => Schema.new(s["event"], defs), "done" => s["done"], "flag" => s["flag"] } : nil
|
|
60
|
+
@stream = s ? { "event" => Schema.new(s["event"], defs), "done" => s["done"], "flag" => s["flag"], "text" => s["text"] == true } : nil
|
|
57
61
|
@summary = doc["summary"].to_s
|
|
58
62
|
end
|
|
59
63
|
|
|
@@ -90,8 +94,13 @@ module Tungsten
|
|
|
90
94
|
arg = (shape["name"] || shape["arg"]).to_s
|
|
91
95
|
@key_names[shape["arg"].to_s] = arg
|
|
92
96
|
end
|
|
97
|
+
parts = {}
|
|
98
|
+
(b["parts"] || []).each do |part|
|
|
99
|
+
type = part["content_type"]
|
|
100
|
+
parts[part["name"].to_s] ||= type.to_s.dup.freeze if type
|
|
101
|
+
end
|
|
93
102
|
BodyDescriptor.new(media_type: b["media_type"].to_s, encoding: b["encoding"] || "json",
|
|
94
|
-
required: b["required"] == true, fields: fields, arg: arg)
|
|
103
|
+
required: b["required"] == true, fields: fields, arg: arg, parts: parts.freeze)
|
|
95
104
|
end
|
|
96
105
|
|
|
97
106
|
def pagination_of(p)
|
|
@@ -303,6 +303,16 @@ module Tungsten
|
|
|
303
303
|
end
|
|
304
304
|
|
|
305
305
|
# The length of a text in UTF-16 code units, the unit JavaScript counts.
|
|
306
|
+
# The Unicode code points of a text: a surrogate pair is one, a lone
|
|
307
|
+
# surrogate (three bytes in a broken string) is one, and every other
|
|
308
|
+
# invalid byte is one.
|
|
309
|
+
def code_point_length(text)
|
|
310
|
+
return text.length if text.encoding == Encoding::UTF_8 && text.valid_encoding?
|
|
311
|
+
|
|
312
|
+
bytes = text.b.gsub(/\xED[\xA0-\xBF][\x80-\xBF]/n, "\xEF\xBF\xBD".b)
|
|
313
|
+
bytes.force_encoding(Encoding::UTF_8).scrub("\uFFFD").length
|
|
314
|
+
end
|
|
315
|
+
|
|
306
316
|
def utf16_length(text)
|
|
307
317
|
text.each_char.sum { |c| c.ord >= 0x10000 ? 2 : 1 }
|
|
308
318
|
end
|
|
@@ -58,7 +58,7 @@ module Tungsten
|
|
|
58
58
|
return Runtime.diag(m.name, "VALIDATION_FAILED").param("opts").expected("call options")
|
|
59
59
|
.remedy("Pass the call options as a Hash of idempotency_key, confirm, timeout_ms, verify and headers (#{e.message}).").err
|
|
60
60
|
end
|
|
61
|
-
yield m, o
|
|
61
|
+
yield m, with_call_deadline(o)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def uses_key?(op) = %w[caller_owned auto].include?(op.agent["idempotency"]["policy"])
|