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,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
module Tungsten
|
|
5
|
+
module Runtime
|
|
6
|
+
# One parameter: `name` is its key in the arguments object (the SDK's
|
|
7
|
+
# argument name), `wire` its name on the wire. Parameters with the roles
|
|
8
|
+
# idempotency_key, origin, auth and constant are not arguments.
|
|
9
|
+
ParamDescriptor = Data.define(:key, :name, :wire, :location, :required, :style, :explode, :role, :constant) do
|
|
10
|
+
# Whether the caller passes it among the arguments.
|
|
11
|
+
def argument? = %w[plain dry_run].include?(role)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The request body: with `arg` set the whole body is that argument;
|
|
15
|
+
# otherwise a JSON object of the merged `fields` ([name, wire] pairs).
|
|
16
|
+
BodyDescriptor = Data.define(:media_type, :encoding, :required, :fields, :arg)
|
|
17
|
+
|
|
18
|
+
# A declared response: `status` an Integer, `"4XX"` or `"default"`.
|
|
19
|
+
ResponseDescriptor = Data.define(:status, :kind, :media_type)
|
|
20
|
+
|
|
21
|
+
# What the runtime needs to call one operation, from the SDK descriptor
|
|
22
|
+
# document v1, with every argument named by its `name`.
|
|
23
|
+
class OperationDescriptor
|
|
24
|
+
attr_reader :id, :http_method, :path, :params, :body, :responses, :security, :pagination, :rpc,
|
|
25
|
+
:error_code_field, :gate, :agent, :request, :response, :page_item, :stream, :summary,
|
|
26
|
+
:key_names
|
|
27
|
+
|
|
28
|
+
# @param doc [Hash] one entry of the document's `operations`
|
|
29
|
+
# @param defs [Hash] the document's `defs`
|
|
30
|
+
def initialize(doc, defs)
|
|
31
|
+
@id = doc["id"].to_s
|
|
32
|
+
@http_method = doc["method"].to_s.upcase
|
|
33
|
+
@path = doc["path"].to_s
|
|
34
|
+
@params = (doc["params"] || []).map do |p|
|
|
35
|
+
ParamDescriptor.new(key: p["key"].to_s, name: (p["name"] || p["key"]).to_s, wire: p["wire"].to_s,
|
|
36
|
+
location: p["in"].to_s, required: p["required"] == true, style: p["style"] || "simple",
|
|
37
|
+
explode: p["explode"] == true, role: p["role"] || "plain", constant: p["constant"])
|
|
38
|
+
end
|
|
39
|
+
@key_names = {}
|
|
40
|
+
@params.each { |p| @key_names[p.key] = p.name }
|
|
41
|
+
@body = body_of(doc["body"])
|
|
42
|
+
@responses = (doc["responses"] || []).map do |r|
|
|
43
|
+
ResponseDescriptor.new(status: r["status"], kind: r["kind"].to_s, media_type: r["media_type"])
|
|
44
|
+
end
|
|
45
|
+
@security = doc["security"] || []
|
|
46
|
+
@pagination = pagination_of(doc["pagination"])
|
|
47
|
+
@rpc = doc["rpc"]
|
|
48
|
+
@error_code_field = doc["error_code_field"]
|
|
49
|
+
status = doc["status"] || {}
|
|
50
|
+
@gate = status["kind"] == "gated" ? { "env_var" => status["env_var"], "disabled_status" => status["disabled_status"] } : nil
|
|
51
|
+
@agent = agent_of(doc["agent"] || {})
|
|
52
|
+
@request = doc["request"] ? Schema.new(doc["request"], defs) : nil
|
|
53
|
+
@response = doc["response"] ? Schema.new(doc["response"], defs) : nil
|
|
54
|
+
@page_item = doc["page_item"] ? Schema.new(doc["page_item"], defs) : nil
|
|
55
|
+
s = doc["stream"]
|
|
56
|
+
@stream = s ? { "event" => Schema.new(s["event"], defs), "done" => s["done"], "flag" => s["flag"] } : nil
|
|
57
|
+
@summary = doc["summary"].to_s
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The parameters the caller supplies as arguments.
|
|
61
|
+
def argument_params = @params.select(&:argument?)
|
|
62
|
+
|
|
63
|
+
# Whether the operation can change state (it is not read-only).
|
|
64
|
+
def mutation? = @agent["safety"] != "read_only"
|
|
65
|
+
|
|
66
|
+
# The argument name of an arguments layout key, or the key itself.
|
|
67
|
+
def name_of_key(key) = @key_names.fetch(key, key)
|
|
68
|
+
|
|
69
|
+
# The argument name of a parameter given by name or wire name.
|
|
70
|
+
def arg_name(key)
|
|
71
|
+
p = @params.find { |x| x.name == key } || @params.find { |x| x.wire == key }
|
|
72
|
+
p ? p.name : key
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def body_of(b)
|
|
78
|
+
return nil unless b
|
|
79
|
+
|
|
80
|
+
shape = b["shape"] || {}
|
|
81
|
+
fields = []
|
|
82
|
+
arg = nil
|
|
83
|
+
if shape["kind"] == "merged"
|
|
84
|
+
(shape["fields"] || []).each do |f|
|
|
85
|
+
name = (f["name"] || f["key"]).to_s
|
|
86
|
+
fields << [name, f["wire"].to_s]
|
|
87
|
+
@key_names[f["key"].to_s] = name
|
|
88
|
+
end
|
|
89
|
+
else
|
|
90
|
+
arg = (shape["name"] || shape["arg"]).to_s
|
|
91
|
+
@key_names[shape["arg"].to_s] = arg
|
|
92
|
+
end
|
|
93
|
+
BodyDescriptor.new(media_type: b["media_type"].to_s, encoding: b["encoding"] || "json",
|
|
94
|
+
required: b["required"] == true, fields: fields, arg: arg)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def pagination_of(p)
|
|
98
|
+
return nil unless p
|
|
99
|
+
|
|
100
|
+
out = p.dup
|
|
101
|
+
%w[request_param page_size_param offset_param limit_param page_param size_param].each do |k|
|
|
102
|
+
out[k] = name_of_key(p[k]) if p[k]
|
|
103
|
+
end
|
|
104
|
+
out
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def agent_of(a)
|
|
108
|
+
out = a.dup
|
|
109
|
+
out["safety"] ||= "read_only"
|
|
110
|
+
out["idempotency"] = { "policy" => "none", "persist_required" => false }.merge(a["idempotency"] || {})
|
|
111
|
+
out["preview"] = a["preview"] || { "mode" => "local" }
|
|
112
|
+
out["remediation"] = a["remediation"] || {}
|
|
113
|
+
out["sensitive_response_fields"] = a["sensitive_response_fields"] || []
|
|
114
|
+
out["sensitive_request_fields"] = (a["sensitive_request_fields"] || []).map do |path|
|
|
115
|
+
head, rest = path.to_s.split(".", 2)
|
|
116
|
+
rest ? "#{name_of_key(head)}.#{rest}" : name_of_key(head)
|
|
117
|
+
end
|
|
118
|
+
out["shown_once"] = a["shown_once"] == true
|
|
119
|
+
out
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A macro in the canonical form, from the SDK descriptor document.
|
|
124
|
+
MacroDescriptor = Data.define(:name, :summary, :safety, :steps, :output, :input, :sensitive_response_fields,
|
|
125
|
+
:shown_once, :cluster) do
|
|
126
|
+
# The input fields: [key, name, required].
|
|
127
|
+
def input_fields = (input["fields"] || []).map { |f| [f["key"].to_s, (f["name"] || f["key"]).to_s, f["required"] == true] }
|
|
128
|
+
|
|
129
|
+
# The operation whose arguments the input takes.
|
|
130
|
+
def extends = input["extends"]
|
|
131
|
+
|
|
132
|
+
# The added fields as JSON Schemas.
|
|
133
|
+
def added = input["add"] || {}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The SDK descriptor document of a generated SDK
|
|
137
|
+
# (`tungsten-descriptors.json`, format `tungsten-sdk-descriptors/1`).
|
|
138
|
+
class Descriptors
|
|
139
|
+
FORMAT = "tungsten-sdk-descriptors/1"
|
|
140
|
+
|
|
141
|
+
attr_reader :api, :operations, :macros, :defs, :target
|
|
142
|
+
|
|
143
|
+
# Read the document at a path.
|
|
144
|
+
def self.load(path) = new(Json.parse(File.read(path, mode: "rb")))
|
|
145
|
+
|
|
146
|
+
# @param doc [Hash] the parsed document
|
|
147
|
+
def initialize(doc)
|
|
148
|
+
raise ArgumentError, "not an SDK descriptor document (format #{doc['format'].inspect})" unless doc.is_a?(Hash) && doc["format"] == FORMAT
|
|
149
|
+
|
|
150
|
+
@target = doc["target"]
|
|
151
|
+
@api = doc["api"] || {}
|
|
152
|
+
@defs = doc["defs"] || {}
|
|
153
|
+
@operations = (doc["operations"] || {}).transform_values { |o| OperationDescriptor.new(o, @defs) }
|
|
154
|
+
@macros = (doc["macros"] || {}).transform_values do |m|
|
|
155
|
+
MacroDescriptor.new(name: m["name"].to_s, summary: m["summary"].to_s, safety: m["safety"] || "read_only",
|
|
156
|
+
steps: m["steps"] || [], output: m["output"], input: m["input"] || {},
|
|
157
|
+
sensitive_response_fields: m["sensitive_response_fields"] || [],
|
|
158
|
+
shown_once: m["shown_once"] == true, cluster: m["cluster"])
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def operation(id) = @operations[id]
|
|
163
|
+
def macro(name) = @macros[name]
|
|
164
|
+
|
|
165
|
+
# A validator of the named type `id` (UNSET when the document does not
|
|
166
|
+
# define it).
|
|
167
|
+
def schema_of(id)
|
|
168
|
+
@schemas ||= {}
|
|
169
|
+
@schemas[id] ||= @defs.key?(id) ? Schema.new(@defs[id], @defs) : UNSET
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
module Tungsten
|
|
5
|
+
module Runtime
|
|
6
|
+
# The base of every generated `Dispatch`: calls by operation id or macro
|
|
7
|
+
# name with JSON arguments keyed by the SDK's argument names, for
|
|
8
|
+
# drivers that only know ids and JSON (the contract suite, tool
|
|
9
|
+
# servers). Arguments that fit a typed method (every required name
|
|
10
|
+
# given, no unknown name) run it; any others go to the core as they are,
|
|
11
|
+
# so pre-flight validation reports them. Results are the typed methods'
|
|
12
|
+
# (`raw` is the wire value).
|
|
13
|
+
class Dispatcher
|
|
14
|
+
# @param client [Object] the generated client
|
|
15
|
+
# @param operations [Hash] id => [required names, every name, call]
|
|
16
|
+
def initialize(client, operations:, previews:, pages:, streams:, macros:)
|
|
17
|
+
@client = client
|
|
18
|
+
@core = client.core
|
|
19
|
+
@operations = operations
|
|
20
|
+
@previews = previews
|
|
21
|
+
@pages = pages
|
|
22
|
+
@streams = streams
|
|
23
|
+
@macros = macros
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :core
|
|
27
|
+
|
|
28
|
+
# Every callable operation's id, in IR order.
|
|
29
|
+
def operations = @operations.keys
|
|
30
|
+
|
|
31
|
+
# Every macro's name, in IR order.
|
|
32
|
+
def macros = @macros.keys
|
|
33
|
+
|
|
34
|
+
# Call an operation.
|
|
35
|
+
# @return [Ok, Err]
|
|
36
|
+
def invoke(operation, args = {}, opts = nil)
|
|
37
|
+
run(@operations, operation, args, opts) { @core.call(operation, args, opts) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Preview an operation.
|
|
41
|
+
def preview_operation(operation, args = {}, opts = nil)
|
|
42
|
+
run(@previews, operation, args, opts) { @core.preview(operation, args, opts) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Every page of a paginated operation: an Enumerator of Ok and, last,
|
|
46
|
+
# Err.
|
|
47
|
+
def pages(operation, args = {}, opts = nil)
|
|
48
|
+
unless @core.operation(operation)&.pagination
|
|
49
|
+
return [Runtime.diag(operation, "VALIDATION_FAILED").param("operation").expected("a paginated operation")
|
|
50
|
+
.remedy("#{operation} is not paginated; use invoke instead. Nothing was sent.").err].each
|
|
51
|
+
end
|
|
52
|
+
run(@pages, operation, args, opts) { @core.pages(operation, args, opts) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The event stream of an operation.
|
|
56
|
+
# @return [EventStream]
|
|
57
|
+
def stream(operation, args = {}, opts = nil)
|
|
58
|
+
run(@streams, operation, args, opts) { @core.stream(operation, args, opts) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Run a macro.
|
|
62
|
+
def run_macro(name, input = {}, opts = nil)
|
|
63
|
+
run(@macros, name, input, opts) { @core.run_macro(name, input, opts) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Preview a macro.
|
|
67
|
+
def preview_macro(name, input = {}, opts = nil) = @core.preview_macro(name, input, opts)
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def run(table, name, args, opts)
|
|
72
|
+
entry = table[name]
|
|
73
|
+
return yield unless entry && args.is_a?(Hash)
|
|
74
|
+
|
|
75
|
+
required, every, call = entry
|
|
76
|
+
given = args.keys.map(&:to_s)
|
|
77
|
+
return yield unless (given - every).empty? && (required - given).empty?
|
|
78
|
+
|
|
79
|
+
call.call(@client, args.to_h { |k, v| [k.to_sym, v] }, opts)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
module Tungsten
|
|
5
|
+
module Runtime
|
|
6
|
+
# The closed set of error categories shared by every tungsten runtime.
|
|
7
|
+
CATEGORIES = %w[
|
|
8
|
+
VALIDATION_FAILED MALFORMED_REQUEST REQUEST_TOO_LARGE AUTH_FAILED NOT_FOUND CONFLICT
|
|
9
|
+
PRECONDITION_FAILED RATE_LIMITED UPSTREAM_UNAVAILABLE OUTCOME_UNKNOWN TRANSPORT_FAILED
|
|
10
|
+
CONFIRMATION_REQUIRED GATE_DISABLED UNEXPECTED_RESPONSE
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
# Whether and how a failed call may be repeated.
|
|
14
|
+
RETRYABLE = %w[never after_delay same_key_only after_remediation].freeze
|
|
15
|
+
|
|
16
|
+
# Written wherever a secret or sensitive value would appear.
|
|
17
|
+
REDACTED = "<redacted>"
|
|
18
|
+
|
|
19
|
+
# The error envelope (planning/06): `to_h` is exactly the fourteen
|
|
20
|
+
# members of specs/envelope.schema.json in order, every member present
|
|
21
|
+
# (nil when it has no value), identical across the runtimes.
|
|
22
|
+
Diagnostic = Data.define(
|
|
23
|
+
:category, :operation, :http_status, :code, :failed_parameter, :received_value, :expected,
|
|
24
|
+
:remediation, :retryable, :retry_after_ms, :next_action, :request_id, :attempts
|
|
25
|
+
) do
|
|
26
|
+
def to_h
|
|
27
|
+
{
|
|
28
|
+
"status" => "error",
|
|
29
|
+
"category" => category,
|
|
30
|
+
"operation" => operation,
|
|
31
|
+
"http_status" => http_status,
|
|
32
|
+
"code" => code,
|
|
33
|
+
"failed_parameter" => failed_parameter,
|
|
34
|
+
"received_value" => received_value,
|
|
35
|
+
"expected" => expected,
|
|
36
|
+
"remediation" => remediation,
|
|
37
|
+
"retryable" => retryable,
|
|
38
|
+
"retry_after_ms" => retry_after_ms,
|
|
39
|
+
"next_action" => next_action,
|
|
40
|
+
"request_id" => request_id,
|
|
41
|
+
"trace" => { "attempts" => attempts }
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def to_json(*) = Json.text(to_h)
|
|
46
|
+
|
|
47
|
+
# "<category> in <operation>: <remediation>".
|
|
48
|
+
def message = "#{category} in #{operation}: #{remediation}"
|
|
49
|
+
alias_method :to_s, :message
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The default `retryable` of a category.
|
|
53
|
+
def self.default_retryable(category)
|
|
54
|
+
case category
|
|
55
|
+
when "RATE_LIMITED", "UPSTREAM_UNAVAILABLE", "TRANSPORT_FAILED" then "after_delay"
|
|
56
|
+
when "OUTCOME_UNKNOWN" then "same_key_only"
|
|
57
|
+
else "never"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
GENERIC_REMEDIATION = {
|
|
62
|
+
"VALIDATION_FAILED" => "The server rejected the arguments. Fix the parameter the API error names and call again; the same request fails the same way.",
|
|
63
|
+
"MALFORMED_REQUEST" => "The server could not parse the request. Check the format of path and query parameters (for example canonical UUIDs); do not retry unchanged.",
|
|
64
|
+
"REQUEST_TOO_LARGE" => "The request body is larger than the server accepts. Send a smaller body; do not retry unchanged.",
|
|
65
|
+
"AUTH_FAILED" => "The credential is missing, expired, revoked or lacks permission. Do not retry; fix the credential configured in ClientOptions.auth.",
|
|
66
|
+
"NOT_FOUND" => "The resource does not exist or is not visible to this credential. Check the identifiers; do not retry unchanged.",
|
|
67
|
+
"CONFLICT" => "The request conflicts with the resource's current state. Read the current state before deciding what to do; do not retry unchanged.",
|
|
68
|
+
"PRECONDITION_FAILED" => "A precondition of this operation is not met (account, billing or resource state). Resolve it first; do not retry unchanged.",
|
|
69
|
+
"RATE_LIMITED" => "Rate limited. Wait retry_after_ms (or a few seconds when it is null) before calling again.",
|
|
70
|
+
"UPSTREAM_UNAVAILABLE" => "The service is temporarily unavailable. Wait, then call again.",
|
|
71
|
+
"OUTCOME_UNKNOWN" => "The server may or may not have applied this call; do not retry it blindly. Check whether it took effect before doing anything else.",
|
|
72
|
+
"TRANSPORT_FAILED" => "The request could not be delivered (DNS, TLS or connection failure), so the server did not receive it. Check base_url and network access, then call again.",
|
|
73
|
+
"CONFIRMATION_REQUIRED" => "This operation needs confirmation: call preview(...) and pass its confirmation_token.",
|
|
74
|
+
"GATE_DISABLED" => "This operation is disabled on this deployment. It is a deployment setting; do not retry.",
|
|
75
|
+
"UNEXPECTED_RESPONSE" => "The server's response did not match the API description. Do not retry blindly; report it."
|
|
76
|
+
}.freeze
|
|
77
|
+
|
|
78
|
+
# The remediation used when no manifest entry applies.
|
|
79
|
+
def self.generic_remediation(category) = GENERIC_REMEDIATION.fetch(category, "The call failed.")
|
|
80
|
+
|
|
81
|
+
# The category of an HTTP error status without a manifest entry.
|
|
82
|
+
def self.category_for_status(status, json_body)
|
|
83
|
+
case status
|
|
84
|
+
when 401, 403, 407 then "AUTH_FAILED"
|
|
85
|
+
when 404, 410 then "NOT_FOUND"
|
|
86
|
+
when 409 then "CONFLICT"
|
|
87
|
+
when 402, 412, 428 then "PRECONDITION_FAILED"
|
|
88
|
+
when 413 then "REQUEST_TOO_LARGE"
|
|
89
|
+
when 422 then "VALIDATION_FAILED"
|
|
90
|
+
when 429 then "RATE_LIMITED"
|
|
91
|
+
when 408, 502, 503, 504 then "UPSTREAM_UNAVAILABLE"
|
|
92
|
+
when 500..599 then "UPSTREAM_UNAVAILABLE"
|
|
93
|
+
when 400..499 then json_body ? "VALIDATION_FAILED" : "MALFORMED_REQUEST"
|
|
94
|
+
else "UNEXPECTED_RESPONSE"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Builds an envelope member by member; `build` fills the remediation
|
|
99
|
+
# and `retryable` a category implies when they were not set.
|
|
100
|
+
class Diag
|
|
101
|
+
def initialize(operation, category)
|
|
102
|
+
@fields = { operation: operation, category: category, http_status: nil, code: nil, failed_parameter: nil,
|
|
103
|
+
received_value: nil, expected: nil, remediation: nil, retryable: nil, retry_after_ms: nil,
|
|
104
|
+
next_action: nil, request_id: nil, attempts: 0 }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
%i[http_status code failed_parameter received_value expected remediation retry_after_ms next_action
|
|
108
|
+
request_id attempts].each do |name|
|
|
109
|
+
define_method(name) do |value|
|
|
110
|
+
@fields[name] = value
|
|
111
|
+
self
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def status(code) = http_status(code)
|
|
116
|
+
def param(path) = failed_parameter(path)
|
|
117
|
+
def received(value) = received_value(value)
|
|
118
|
+
def remedy(text) = remediation(text)
|
|
119
|
+
|
|
120
|
+
def retrying(value)
|
|
121
|
+
@fields[:retryable] = value
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# `retrying` unless the value is nil.
|
|
126
|
+
def retrying_opt(value) = value.nil? ? self : retrying(value)
|
|
127
|
+
|
|
128
|
+
def build
|
|
129
|
+
f = @fields.dup
|
|
130
|
+
f[:remediation] ||= Runtime.generic_remediation(f[:category])
|
|
131
|
+
f[:retryable] ||= Runtime.default_retryable(f[:category])
|
|
132
|
+
Diagnostic.new(**f)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def err = Err.new(error: build, partial: UNSET)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# A diagnostic builder.
|
|
139
|
+
def self.diag(operation, category) = Diag.new(operation, category)
|
|
140
|
+
|
|
141
|
+
# What the HTTP answer of a call said: the status, the headers (names
|
|
142
|
+
# lower-cased; repeated values joined with ", ", set-cookie keeps its
|
|
143
|
+
# last), the request id the server gave and the attempts made.
|
|
144
|
+
ResponseMeta = Data.define(:status, :headers, :request_id, :attempts) do
|
|
145
|
+
def self.local = new(status: 0, headers: {}, request_id: nil, attempts: 0)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The result of an operation's verification hook: `checked` false when
|
|
149
|
+
# it could not run (then `error` says why); `passed` when its
|
|
150
|
+
# expectation held; `observed` the read's body (sensitive fields
|
|
151
|
+
# redacted); `timed_out` when polling for its terminal state ran out of
|
|
152
|
+
# budget.
|
|
153
|
+
Verification = Data.define(:checked, :passed, :observed, :timed_out, :error) do
|
|
154
|
+
def self.unchecked(error = nil) = new(checked: false, passed: false, observed: UNSET, timed_out: false, error: error)
|
|
155
|
+
|
|
156
|
+
# The verification as the TypeScript runtime shapes it.
|
|
157
|
+
def to_h
|
|
158
|
+
out = { "checked" => checked, "passed" => passed, "observed" => UNSET.equal?(observed) ? nil : observed }
|
|
159
|
+
out["timed_out"] = true if timed_out
|
|
160
|
+
out["error"] = error.to_h if error
|
|
161
|
+
out
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A successful call. `value` is the typed value (a model, a page, ...),
|
|
166
|
+
# `raw` the decoded wire value it came from (nil for a success without a
|
|
167
|
+
# body), `meta` the HTTP answer, `verification` the verification
|
|
168
|
+
# result when the call asked for one.
|
|
169
|
+
Ok = Data.define(:value, :raw, :meta, :verification) do
|
|
170
|
+
def initialize(value:, meta:, raw: value, verification: nil) = super
|
|
171
|
+
def ok? = true
|
|
172
|
+
def error = nil
|
|
173
|
+
def partial = UNSET
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# A failed call: `error` is the envelope (a Diagnostic); `partial` what
|
|
177
|
+
# the failed call or macro already produced when its effect happened (a
|
|
178
|
+
# mutation's body that failed strict validation, a macro's completed
|
|
179
|
+
# step results by name), else UNSET. A partial can hold values the API
|
|
180
|
+
# shows only once: store them before acting on the error.
|
|
181
|
+
Err = Data.define(:error, :partial) do
|
|
182
|
+
def initialize(error:, partial: UNSET) = super
|
|
183
|
+
def ok? = false
|
|
184
|
+
def value = nil
|
|
185
|
+
def raw = nil
|
|
186
|
+
def meta = nil
|
|
187
|
+
def verification = nil
|
|
188
|
+
def partial? = !UNSET.equal?(partial)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# A request rendered without sending it; secrets show as <redacted>.
|
|
192
|
+
RenderedRequest = Data.define(:http_method, :url, :headers, :body) do
|
|
193
|
+
def to_h = { "method" => http_method, "url" => url, "headers" => headers, "body" => body }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# One step of a macro preview: `request` is nil when the step's
|
|
197
|
+
# arguments (or a bytes or multipart body) come from an earlier result.
|
|
198
|
+
MacroStepPreview = Data.define(:step, :kind, :operation, :as, :safety, :request, :effects) do
|
|
199
|
+
def to_h
|
|
200
|
+
{ "step" => step, "kind" => kind, "operation" => operation, "as" => as, "safety" => safety,
|
|
201
|
+
"request" => request&.to_h, "effects" => effects }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# What `preview` returns: the request (for a macro, its first step's),
|
|
206
|
+
# the side effects in words, and for a mutating operation a
|
|
207
|
+
# confirmation token bound to the exact arguments for `expires_in_ms`.
|
|
208
|
+
PreviewResult = Data.define(
|
|
209
|
+
:operation, :safety, :request, :effects, :confirmation_token, :expires_in_ms, :server_preview, :steps
|
|
210
|
+
) do
|
|
211
|
+
def to_h
|
|
212
|
+
out = { "operation" => operation, "safety" => safety, "request" => request.to_h, "effects" => effects,
|
|
213
|
+
"confirmation_token" => confirmation_token, "expires_in_ms" => expires_in_ms }
|
|
214
|
+
out["server_preview"] = server_preview unless UNSET.equal?(server_preview)
|
|
215
|
+
out["steps"] = steps.map(&:to_h) if steps
|
|
216
|
+
out
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# One page of a paginated operation: its items, the whole body and the
|
|
221
|
+
# cursor, offset, page number or URL of the next page (nil at the end).
|
|
222
|
+
Page = Data.define(:items, :body, :next_page)
|
|
223
|
+
|
|
224
|
+
# One event of a stream: its decoded `data` (`value`; `raw` is the JSON
|
|
225
|
+
# it came from), the event name (`message` when it has none), the last
|
|
226
|
+
# event id and retry value the stream has set so far, the answer of the
|
|
227
|
+
# connection it arrived on and the reconnects made up to it.
|
|
228
|
+
StreamEvent = Data.define(:value, :raw, :event, :id, :retry_ms, :meta, :reconnects)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
module Tungsten
|
|
5
|
+
module Runtime
|
|
6
|
+
# Predicates and expressions of verification hooks, polls and macros.
|
|
7
|
+
#
|
|
8
|
+
# Predicates (verify `expect` and `terminal`, poll `until`): field path
|
|
9
|
+
# to {"in": [...]} | {"equals": x} | {"contains": ...}; any other value is
|
|
10
|
+
# compared for equality. Every entry must hold.
|
|
11
|
+
#
|
|
12
|
+
# Expressions: JSON in which a string starting with "$" is a reference
|
|
13
|
+
# resolved against a scope ($input.a.b, $<name>.a, $response.x, $args.y),
|
|
14
|
+
# an object {"expr": "<ref> in [..]" | "<ref> == x" | "<ref> != x"} is a
|
|
15
|
+
# boolean, anything else a literal. A dry evaluation (macro previews)
|
|
16
|
+
# knows the input but not the earlier results: a reference to one is the
|
|
17
|
+
# placeholder "<from step NAME: path>".
|
|
18
|
+
module Expr
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Whether every entry of the predicate holds on the value (UNSET when
|
|
22
|
+
# absent). A nil predicate always holds; a malformed one never does.
|
|
23
|
+
def predicate?(predicate, value)
|
|
24
|
+
case predicate
|
|
25
|
+
when nil then true
|
|
26
|
+
when Hash then predicate.all? { |path, test| holds?(test, Util.get_path_str(value, path)) }
|
|
27
|
+
else false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def subset?(value, pattern)
|
|
32
|
+
case pattern
|
|
33
|
+
when Hash
|
|
34
|
+
value.is_a?(Hash) && pattern.all? { |k, v| value.key?(k) && subset?(value[k], v) }
|
|
35
|
+
when Array
|
|
36
|
+
value.is_a?(Array) && pattern.all? { |want| value.any? { |have| subset?(have, want) } }
|
|
37
|
+
else
|
|
38
|
+
!UNSET.equal?(value) && Json.same?(value, pattern)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def holds?(test, actual)
|
|
43
|
+
if test.is_a?(Hash) && test.length == 1
|
|
44
|
+
if test.key?("in")
|
|
45
|
+
list = test["in"]
|
|
46
|
+
return list.is_a?(Array) && list.any? { |c| Json.same_opt?(c, actual) }
|
|
47
|
+
end
|
|
48
|
+
return Json.same_opt?(test["equals"], actual) if test.key?("equals")
|
|
49
|
+
|
|
50
|
+
if test.key?("contains")
|
|
51
|
+
pattern = test["contains"]
|
|
52
|
+
if pattern.is_a?(String)
|
|
53
|
+
return actual.include?(pattern) if actual.is_a?(String)
|
|
54
|
+
return actual.any? { |i| i == pattern } if actual.is_a?(Array)
|
|
55
|
+
|
|
56
|
+
return false
|
|
57
|
+
end
|
|
58
|
+
return actual.any? { |item| subset?(item, pattern) } if actual.is_a?(Array) && !pattern.is_a?(Array)
|
|
59
|
+
|
|
60
|
+
return subset?(actual, pattern)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
Json.same_opt?(test, actual)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# One $name.path reference, or UNSET.
|
|
67
|
+
def resolve_ref(reference, scope)
|
|
68
|
+
segments = Util.split_path(reference.delete_prefix("$"))
|
|
69
|
+
return UNSET if segments.empty? || !scope.key?(segments.first)
|
|
70
|
+
|
|
71
|
+
Util.get_path(scope[segments.first], segments.drop(1))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# An expression's value; unknown references are UNSET (dropped from
|
|
75
|
+
# objects, nil in arrays); a malformed expr string is nil.
|
|
76
|
+
def evaluate(expr, scope, depth = 0)
|
|
77
|
+
return nil if depth > 64
|
|
78
|
+
|
|
79
|
+
case expr
|
|
80
|
+
when String then expr.start_with?("$") ? resolve_ref(expr, scope) : expr
|
|
81
|
+
when Array then expr.map { |item| evaluate(item, scope, depth + 1).then { |v| UNSET.equal?(v) ? nil : v } }
|
|
82
|
+
when Hash
|
|
83
|
+
return boolean(expr["expr"], scope) if expr.length == 1 && expr["expr"].is_a?(String)
|
|
84
|
+
|
|
85
|
+
expr.each_with_object({}) do |(k, v), out|
|
|
86
|
+
value = evaluate(v, scope, depth + 1)
|
|
87
|
+
out[k] = value unless UNSET.equal?(value)
|
|
88
|
+
end
|
|
89
|
+
else expr
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def placeholder(name, path) = path.empty? ? "<from step #{name}>" : "<from step #{name}: #{path}>"
|
|
94
|
+
|
|
95
|
+
# [name, path] of a reference to a pending binding, else nil.
|
|
96
|
+
def pending_ref(reference, pending)
|
|
97
|
+
return nil unless reference.is_a?(String) && reference.start_with?("$")
|
|
98
|
+
|
|
99
|
+
segs = Util.split_path(reference[1..])
|
|
100
|
+
return nil if segs.empty?
|
|
101
|
+
|
|
102
|
+
name = segs.first
|
|
103
|
+
return nil unless pending.include?(name) && reference.start_with?("$#{name}")
|
|
104
|
+
|
|
105
|
+
[name, reference[(name.length + 1)..].delete_prefix(".")]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A dry evaluation: references to the pending bindings become
|
|
109
|
+
# placeholders.
|
|
110
|
+
def evaluate_dry(expr, scope, pending, depth = 0)
|
|
111
|
+
return nil if depth > 64
|
|
112
|
+
|
|
113
|
+
case expr
|
|
114
|
+
when String
|
|
115
|
+
return expr unless expr.start_with?("$")
|
|
116
|
+
|
|
117
|
+
ref = pending_ref(expr, pending)
|
|
118
|
+
ref ? placeholder(*ref) : resolve_ref(expr, scope)
|
|
119
|
+
when Array then expr.map { |item| evaluate_dry(item, scope, pending, depth + 1).then { |v| UNSET.equal?(v) ? nil : v } }
|
|
120
|
+
when Hash
|
|
121
|
+
if expr.length == 1 && expr["expr"].is_a?(String)
|
|
122
|
+
source = expr["expr"].strip
|
|
123
|
+
head = source[/\A\$[^\s=!]+/]
|
|
124
|
+
ref = head && pending_ref(head, pending)
|
|
125
|
+
return placeholder(ref[0], source[(ref[0].length + 1)..].delete_prefix(".")) if ref
|
|
126
|
+
|
|
127
|
+
return boolean(source, scope)
|
|
128
|
+
end
|
|
129
|
+
expr.each_with_object({}) do |(k, v), out|
|
|
130
|
+
value = evaluate_dry(v, scope, pending, depth + 1)
|
|
131
|
+
out[k] = value unless UNSET.equal?(value)
|
|
132
|
+
end
|
|
133
|
+
else expr
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# A predicate in words: state in ["a", "b"], endpoints contains {...},
|
|
138
|
+
# a = 1, joined with "and".
|
|
139
|
+
def describe(predicate)
|
|
140
|
+
return "" unless predicate.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
predicate.map do |path, test|
|
|
143
|
+
if test.is_a?(Hash) && test.length == 1 && test["in"].is_a?(Array)
|
|
144
|
+
"#{path} in [#{test['in'].map { |v| Json.text(v) }.join(', ')}]"
|
|
145
|
+
elsif test.is_a?(Hash) && test.length == 1 && test.key?("equals")
|
|
146
|
+
"#{path} = #{Json.text(test['equals'])}"
|
|
147
|
+
elsif test.is_a?(Hash) && test.length == 1 && test.key?("contains")
|
|
148
|
+
"#{path} contains #{Json.text(test['contains'])}"
|
|
149
|
+
else
|
|
150
|
+
"#{path} = #{Json.text(test)}"
|
|
151
|
+
end
|
|
152
|
+
end.join(" and ")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def literal(text)
|
|
156
|
+
trimmed = text.strip
|
|
157
|
+
value = Json.try_parse(trimmed)
|
|
158
|
+
return value unless UNSET.equal?(value)
|
|
159
|
+
|
|
160
|
+
normalized = trimmed.gsub(/'((?:[^'\\]|\\.)*)'/) { Json.quote(Regexp.last_match(1).gsub("\\'", "'")) }
|
|
161
|
+
Json.try_parse(normalized)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
BOOLEAN = /\A\s*(\$[^\s=!]+)\s+(in|==|!=)\s+(.+)\z/m
|
|
165
|
+
|
|
166
|
+
# `<ref> in [..]`, `<ref> == x` or `<ref> != x`; nil when malformed.
|
|
167
|
+
def boolean(source, scope)
|
|
168
|
+
m = source.match(BOOLEAN)
|
|
169
|
+
return nil unless m
|
|
170
|
+
|
|
171
|
+
lit = literal(m[3])
|
|
172
|
+
return nil if UNSET.equal?(lit)
|
|
173
|
+
|
|
174
|
+
actual = resolve_ref(m[1], scope)
|
|
175
|
+
if m[2] == "in"
|
|
176
|
+
return nil unless lit.is_a?(Array)
|
|
177
|
+
|
|
178
|
+
return lit.any? { |v| Json.same_opt?(v, actual) }
|
|
179
|
+
end
|
|
180
|
+
actual = nil if UNSET.equal?(actual)
|
|
181
|
+
same = Json.same?(actual, lit)
|
|
182
|
+
m[2] == "==" ? same : !same
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|