tungsten-runtime 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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