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,386 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "timeout"
|
|
7
|
+
|
|
8
|
+
module Tungsten
|
|
9
|
+
module Runtime
|
|
10
|
+
# One HTTP attempt over Net::HTTP: HTTP/1.1, a new connection per
|
|
11
|
+
# attempt, no redirects (the client core follows same-origin redirects
|
|
12
|
+
# of reads itself), no automatic retries (`max_retries = 0`), no
|
|
13
|
+
# compression (`Accept-Encoding` is not sent), an explicit `Accept` and
|
|
14
|
+
# `User-Agent`, `Content-Length: 0` on a bodiless POST, PUT or PATCH,
|
|
15
|
+
# and an absolute deadline per attempt, including all response headers:
|
|
16
|
+
# the open, read and write timeouts also use the time left on a
|
|
17
|
+
# monotonic clock. An event stream's body is handed back
|
|
18
|
+
# unread; each read of it waits at most the idle timeout (else the
|
|
19
|
+
# attempt timeout), and closing it closes the connection.
|
|
20
|
+
module Transport
|
|
21
|
+
# Internal control flow must escape broad StandardError handlers in
|
|
22
|
+
# Net::HTTP and the call pipeline. Only the deadline owner catches it.
|
|
23
|
+
class DeadlineExceeded < Exception; end # rubocop:disable Lint/InheritException
|
|
24
|
+
# What one attempt gave. `kind` is :response, :streaming, :not_sent
|
|
25
|
+
# (the request never left: DNS, connect, TLS), :lost (the connection
|
|
26
|
+
# failed after the request was written) or :timeout. A :response has
|
|
27
|
+
# `status`, `headers` (lower-cased names) and `body` (a binary String,
|
|
28
|
+
# nil with `body_failure` "timeout" or "broken" when reading it
|
|
29
|
+
# failed); a :streaming one has `stream`, an EventBody.
|
|
30
|
+
Outcome = Data.define(:kind, :status, :headers, :body, :body_failure, :stream, :detail) do
|
|
31
|
+
def initialize(kind:, status: 0, headers: {}, body: nil, body_failure: nil, stream: nil, detail: nil) = super
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The unread body of an event stream.
|
|
35
|
+
class EventBody
|
|
36
|
+
def initialize(http, chunks, idle_s)
|
|
37
|
+
@http = http
|
|
38
|
+
@chunks = chunks
|
|
39
|
+
@idle_s = idle_s
|
|
40
|
+
@closed = false
|
|
41
|
+
@lock = Mutex.new
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The next chunk: [:chunk, bytes], [:end], [:timeout] or [:lost].
|
|
45
|
+
def read
|
|
46
|
+
return [:lost] if closed?
|
|
47
|
+
|
|
48
|
+
@http.read_timeout = @idle_s
|
|
49
|
+
item = @chunks.next
|
|
50
|
+
item.first == :chunk ? item : [:end]
|
|
51
|
+
rescue StopIteration
|
|
52
|
+
[:end]
|
|
53
|
+
rescue Net::ReadTimeout, IO::TimeoutError
|
|
54
|
+
[:timeout]
|
|
55
|
+
rescue StandardError
|
|
56
|
+
[:lost]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def closed? = @lock.synchronize { @closed }
|
|
60
|
+
|
|
61
|
+
# Close the connection; safe from any thread, more than once.
|
|
62
|
+
def close
|
|
63
|
+
first = @lock.synchronize do
|
|
64
|
+
was = @closed
|
|
65
|
+
@closed = true
|
|
66
|
+
!was
|
|
67
|
+
end
|
|
68
|
+
return unless first
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
@http.finish if @http.started?
|
|
72
|
+
rescue StandardError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
ERRNO_CODES = %w[ECONNREFUSED ECONNRESET ECONNABORTED ENOTCONN EPIPE ETIMEDOUT EADDRNOTAVAIL EHOSTUNREACH
|
|
79
|
+
ENETUNREACH].freeze
|
|
80
|
+
|
|
81
|
+
module_function
|
|
82
|
+
|
|
83
|
+
def monotonic_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
84
|
+
|
|
85
|
+
# Short, secret-free codes of a transport error.
|
|
86
|
+
def error_codes(error)
|
|
87
|
+
codes = []
|
|
88
|
+
name = error.class.name.to_s.split("::").last
|
|
89
|
+
codes << name if error.is_a?(SystemCallError) && ERRNO_CODES.include?(name)
|
|
90
|
+
codes << "ENOTFOUND" if error.is_a?(SocketError) && !error.is_a?(SystemCallError)
|
|
91
|
+
if defined?(OpenSSL::SSL::SSLError) && error.is_a?(OpenSSL::SSL::SSLError)
|
|
92
|
+
codes << (error.message.include?("certificate verify failed") ? "CERT_VERIFY_FAILED" : "TLS_ERROR")
|
|
93
|
+
end
|
|
94
|
+
codes << "CONNECTION_CLOSED" if error.is_a?(EOFError) || error.message.to_s.include?("end of file")
|
|
95
|
+
codes.uniq
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def detail(error, sent)
|
|
99
|
+
codes = error_codes(error)
|
|
100
|
+
return codes.join(", ") unless codes.empty?
|
|
101
|
+
|
|
102
|
+
sent ? "network error" : "connect error"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Send once. Never raises: every outcome is an Outcome.
|
|
106
|
+
#
|
|
107
|
+
# @param url [String]
|
|
108
|
+
# @param method [String]
|
|
109
|
+
# @param headers [Array<Array(String, String)>] in order
|
|
110
|
+
# @param body [Serialize::Encoded, nil]
|
|
111
|
+
# @param timeout_ms [Integer] the attempt's deadline
|
|
112
|
+
# @param stream [Boolean] hand a 2xx text/event-stream answer back unread
|
|
113
|
+
# @param idle_ms [Integer, nil] the silence a stream read waits for (default: timeout_ms)
|
|
114
|
+
def attempt(url:, method:, headers:, body:, timeout_ms:, stream: false, idle_ms: nil)
|
|
115
|
+
http = nil
|
|
116
|
+
handed_off = false
|
|
117
|
+
Timeout.timeout(timeout_ms / 1000.0, DeadlineExceeded) do
|
|
118
|
+
uri = URI.parse(url)
|
|
119
|
+
deadline = monotonic_ms + timeout_ms
|
|
120
|
+
left = -> { [(deadline - monotonic_ms) / 1000.0, 0.001].max }
|
|
121
|
+
http = Net::HTTP.new(uri.hostname, uri.port)
|
|
122
|
+
http.use_ssl = uri.scheme == "https"
|
|
123
|
+
http.max_retries = 0
|
|
124
|
+
http.open_timeout = left.call
|
|
125
|
+
http.read_timeout = left.call
|
|
126
|
+
http.write_timeout = left.call
|
|
127
|
+
http.keep_alive_timeout = 0
|
|
128
|
+
begin
|
|
129
|
+
http.start
|
|
130
|
+
rescue Net::OpenTimeout, IO::TimeoutError, DeadlineExceeded
|
|
131
|
+
return Outcome.new(kind: :timeout)
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
return Outcome.new(kind: :not_sent, detail: detail(e, false))
|
|
134
|
+
end
|
|
135
|
+
request = build(uri, method, headers, body)
|
|
136
|
+
return Outcome.new(kind: :not_sent, detail: request) if request.is_a?(String)
|
|
137
|
+
|
|
138
|
+
result = exchange(http, request, stream, left, idle_ms ? idle_ms / 1000.0 : nil, timeout_ms / 1000.0)
|
|
139
|
+
handed_off = result.kind == :streaming
|
|
140
|
+
result
|
|
141
|
+
end
|
|
142
|
+
rescue DeadlineExceeded
|
|
143
|
+
handed_off = false
|
|
144
|
+
Outcome.new(kind: :timeout)
|
|
145
|
+
rescue URI::Error, ArgumentError
|
|
146
|
+
Outcome.new(kind: :not_sent, detail: "INVALID_REQUEST")
|
|
147
|
+
ensure
|
|
148
|
+
close(http) if http && (!handed_off || $!)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def build(uri, method, headers, body)
|
|
152
|
+
has_body = body && !body.empty
|
|
153
|
+
# Without a body the request permits none: Net::HTTP would send an
|
|
154
|
+
# empty one with a form Content-Type otherwise.
|
|
155
|
+
request = Net::HTTPGenericRequest.new(method, has_body ? true : false, method != "HEAD", uri.request_uri, nil)
|
|
156
|
+
headers.each { |name, value| request[name] = Serialize.header_bytes(value) }
|
|
157
|
+
if has_body
|
|
158
|
+
if body.parts
|
|
159
|
+
data, type = Serialize.multipart_body(body.parts)
|
|
160
|
+
request["Content-Type"] = type
|
|
161
|
+
request.body = data
|
|
162
|
+
else
|
|
163
|
+
request.body = body.bytes
|
|
164
|
+
end
|
|
165
|
+
elsif %w[POST PUT PATCH].include?(method)
|
|
166
|
+
request["Content-Length"] = "0"
|
|
167
|
+
end
|
|
168
|
+
request.delete("accept-encoding")
|
|
169
|
+
request
|
|
170
|
+
rescue ArgumentError
|
|
171
|
+
"INVALID_REQUEST"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def response_headers(response)
|
|
175
|
+
out = {}
|
|
176
|
+
response.each_name do |name|
|
|
177
|
+
values = response.get_fields(name) || []
|
|
178
|
+
out[name.downcase] = name.casecmp?("set-cookie") ? values.last.to_s : values.join(", ")
|
|
179
|
+
end
|
|
180
|
+
out
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def event_stream?(headers)
|
|
184
|
+
headers.fetch("content-type", "").split(";").first.to_s.strip.casecmp?("text/event-stream")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def exchange(http, request, stream, left, idle_s, timeout_s)
|
|
188
|
+
chunks = Enumerator.new do |y|
|
|
189
|
+
http.request(request) do |response|
|
|
190
|
+
y << [:response, response]
|
|
191
|
+
response.read_body { |chunk| y << [:chunk, chunk] }
|
|
192
|
+
end
|
|
193
|
+
y << [:end]
|
|
194
|
+
end
|
|
195
|
+
begin
|
|
196
|
+
first = chunks.next
|
|
197
|
+
rescue Net::ReadTimeout, Net::WriteTimeout, IO::TimeoutError, DeadlineExceeded
|
|
198
|
+
close(http)
|
|
199
|
+
return Outcome.new(kind: :timeout)
|
|
200
|
+
rescue StandardError => e
|
|
201
|
+
close(http)
|
|
202
|
+
return Outcome.new(kind: :lost, detail: detail(e, true))
|
|
203
|
+
end
|
|
204
|
+
response = first[1]
|
|
205
|
+
status = response.code.to_i
|
|
206
|
+
headers = response_headers(response)
|
|
207
|
+
if stream && status.between?(200, 299) && event_stream?(headers)
|
|
208
|
+
return Outcome.new(kind: :streaming, status: status, headers: headers,
|
|
209
|
+
stream: EventBody.new(http, chunks, idle_s || timeout_s))
|
|
210
|
+
end
|
|
211
|
+
body = +"".b
|
|
212
|
+
failure = nil
|
|
213
|
+
loop do
|
|
214
|
+
remaining = left.call
|
|
215
|
+
if remaining <= 0.001
|
|
216
|
+
failure = "timeout"
|
|
217
|
+
break
|
|
218
|
+
end
|
|
219
|
+
http.read_timeout = remaining
|
|
220
|
+
item = chunks.next
|
|
221
|
+
break unless item.first == :chunk
|
|
222
|
+
|
|
223
|
+
body << item[1].b
|
|
224
|
+
rescue StopIteration
|
|
225
|
+
break
|
|
226
|
+
rescue Net::ReadTimeout, IO::TimeoutError, DeadlineExceeded
|
|
227
|
+
failure = "timeout"
|
|
228
|
+
break
|
|
229
|
+
rescue StandardError
|
|
230
|
+
failure = "broken"
|
|
231
|
+
break
|
|
232
|
+
end
|
|
233
|
+
close(http)
|
|
234
|
+
Outcome.new(kind: :response, status: status, headers: headers, body: failure ? nil : body, body_failure: failure)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def close(http)
|
|
238
|
+
http.finish if http.started?
|
|
239
|
+
rescue StandardError
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# ------------------------------------------------------ retry-after
|
|
244
|
+
|
|
245
|
+
MONTHS = %w[jan feb mar apr may jun jul aug sep oct nov dec].freeze
|
|
246
|
+
MONTHS_FULL = %w[january february march april may june july august september october november december].freeze
|
|
247
|
+
|
|
248
|
+
def days_from_civil(year, month, day)
|
|
249
|
+
y = month <= 2 ? year - 1 : year
|
|
250
|
+
era = (y >= 0 ? y : y - 399) / 400
|
|
251
|
+
yoe = y - (era * 400)
|
|
252
|
+
mp = (month + 9) % 12
|
|
253
|
+
doy = (((153 * mp) + 2) / 5) + day - 1
|
|
254
|
+
doe = (yoe * 365) + (yoe / 4) - (yoe / 100) + doy
|
|
255
|
+
(era * 146_097) + doe - 719_468
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def month_number(token)
|
|
259
|
+
lower = token.downcase
|
|
260
|
+
return nil if lower.length < 3
|
|
261
|
+
|
|
262
|
+
i = MONTHS.index(lower[0, 3])
|
|
263
|
+
return nil unless i && (lower.length == 3 || lower == MONTHS_FULL[i] || lower == "sept")
|
|
264
|
+
|
|
265
|
+
i + 1
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def digits?(text) = text.match?(/\A\d+\z/)
|
|
269
|
+
|
|
270
|
+
def parse_clock(token)
|
|
271
|
+
parts = token.split(":", -1)
|
|
272
|
+
return nil unless parts.length.between?(2, 3) && digits?(parts[0]) && digits?(parts[1])
|
|
273
|
+
|
|
274
|
+
hour = parts[0].to_i
|
|
275
|
+
minute = parts[1].to_i
|
|
276
|
+
return nil unless hour < 24 && minute < 60
|
|
277
|
+
|
|
278
|
+
second = 0.0
|
|
279
|
+
if parts.length == 3
|
|
280
|
+
second = Float(parts[2], exception: false)
|
|
281
|
+
return nil if second.nil? || second.negative? || second >= 61
|
|
282
|
+
end
|
|
283
|
+
((((hour * 3600) + (minute * 60)) * 1000) + ((second * 1000) + 0.5).floor)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def parse_zone(token)
|
|
287
|
+
return 0 if %w[GMT UTC UT Z].include?(token.upcase)
|
|
288
|
+
|
|
289
|
+
m = token.match(/\A([+-])(\d\d):?(\d\d)\z/)
|
|
290
|
+
return nil unless m
|
|
291
|
+
|
|
292
|
+
(m[1] == "-" ? -1 : 1) * ((m[2].to_i * 60) + m[3].to_i) * 60_000
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def parse_iso_date(text)
|
|
296
|
+
date, rest = text.split(/[T ]/, 2)
|
|
297
|
+
fields = date.split("-", -1)
|
|
298
|
+
return nil unless fields.length == 3 && fields.all? { |f| digits?(f) }
|
|
299
|
+
|
|
300
|
+
year, month, day = fields.map(&:to_i)
|
|
301
|
+
return nil unless month.between?(1, 12) && day.between?(1, 31) && year.between?(0, 9999)
|
|
302
|
+
|
|
303
|
+
clock = 0
|
|
304
|
+
zone = 0
|
|
305
|
+
if rest
|
|
306
|
+
split = rest.index(/[Zz+-]/) || rest.length
|
|
307
|
+
clock = parse_clock(rest[0, split])
|
|
308
|
+
return nil unless clock
|
|
309
|
+
|
|
310
|
+
if split < rest.length
|
|
311
|
+
zone = parse_zone(rest[split..])
|
|
312
|
+
return nil unless zone
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
(days_from_civil(year, month, day) * 86_400_000) + clock - zone
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# An HTTP date (IMF-fixdate, RFC 850, asctime) or an ISO 8601 timestamp
|
|
319
|
+
# as epoch milliseconds, or nil.
|
|
320
|
+
def parse_http_date(text)
|
|
321
|
+
text = text.strip
|
|
322
|
+
return parse_iso_date(text) if text.length >= 8 && text[0].match?(/\d/) && text[4] == "-"
|
|
323
|
+
|
|
324
|
+
day = month = year = nil
|
|
325
|
+
clock = 0
|
|
326
|
+
zone = 0
|
|
327
|
+
text.split(/[ ,]+/).reject(&:empty?).each do |token|
|
|
328
|
+
if token.include?(":")
|
|
329
|
+
clock = parse_clock(token)
|
|
330
|
+
return nil unless clock
|
|
331
|
+
elsif token.include?("-") && token[0].match?(/\d/)
|
|
332
|
+
parts = token.split("-")
|
|
333
|
+
return nil if parts.length < 3 || !digits?(parts[0]) || !digits?(parts[2])
|
|
334
|
+
|
|
335
|
+
month = month_number(parts[1])
|
|
336
|
+
return nil unless month
|
|
337
|
+
|
|
338
|
+
day = parts[0].to_i
|
|
339
|
+
year = parts[2].to_i
|
|
340
|
+
year += 2000 if year < 50
|
|
341
|
+
year += 1900 if year.between?(50, 99)
|
|
342
|
+
elsif digits?(token)
|
|
343
|
+
if day.nil? && token.length <= 2
|
|
344
|
+
day = token.to_i
|
|
345
|
+
else
|
|
346
|
+
year = token.to_i
|
|
347
|
+
end
|
|
348
|
+
elsif (m = month_number(token))
|
|
349
|
+
month = m
|
|
350
|
+
elsif (z = parse_zone(token))
|
|
351
|
+
zone = z
|
|
352
|
+
elsif !token.match?(/\A[A-Za-z.]+\z/)
|
|
353
|
+
return nil
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
return nil unless day && month && year && day.between?(1, 31) && year.between?(0, 9999)
|
|
357
|
+
|
|
358
|
+
(days_from_civil(year, month, day) * 86_400_000) + clock - zone
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Retry-After in milliseconds (delta seconds or an HTTP date), or nil.
|
|
362
|
+
def parse_retry_after(value, now_ms)
|
|
363
|
+
return nil if value.nil?
|
|
364
|
+
|
|
365
|
+
trimmed = value.strip
|
|
366
|
+
return [trimmed.to_i * 1000, 2**62].min if digits?(trimmed)
|
|
367
|
+
|
|
368
|
+
date = parse_http_date(trimmed)
|
|
369
|
+
date && [date - now_ms, 0].max
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# The next page URL of a Link header (rel="next"), or nil.
|
|
373
|
+
def next_link(header)
|
|
374
|
+
return nil if header.nil? || header.empty?
|
|
375
|
+
|
|
376
|
+
parts = header.split(/,(?=\s*<)/)
|
|
377
|
+
parts.each do |part|
|
|
378
|
+
m = part.match(/\A\s*<([^>]*)>(.*)\z/m)
|
|
379
|
+
next unless m
|
|
380
|
+
return m[1] if m[2].match?(/;\s*rel\s*=\s*"?([^";]*\s)?next(\s[^";]*)?"?\s*(;|\z)/i)
|
|
381
|
+
end
|
|
382
|
+
nil
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
module Tungsten
|
|
5
|
+
module Runtime
|
|
6
|
+
# The behaviour of the value objects of generated SDKs (`Data.define`
|
|
7
|
+
# classes that include it and define `wire_names` and `from_json`):
|
|
8
|
+
# `to_h` writes the members by wire name, without the UNSET ones, with
|
|
9
|
+
# nested models, arrays and maps converted and byte strings as base64.
|
|
10
|
+
module Model
|
|
11
|
+
def to_h
|
|
12
|
+
out = {}
|
|
13
|
+
self.class.wire_names.each do |member, wire|
|
|
14
|
+
value = public_send(member)
|
|
15
|
+
out[wire] = Models.dump(value) unless UNSET.equal?(value)
|
|
16
|
+
end
|
|
17
|
+
out
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_json(*) = Json.text(to_h)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The helpers generated models decode with. Each keeps a value it
|
|
24
|
+
# cannot decode (nil, UNSET, another shape) as it is.
|
|
25
|
+
module Models
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Every item of an array, decoded by the block.
|
|
29
|
+
def array(value)
|
|
30
|
+
value.is_a?(Array) ? value.map { |item| yield item } : value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Every member value of an object, decoded by the block.
|
|
34
|
+
def map(value)
|
|
35
|
+
value.is_a?(Hash) ? value.transform_values { |item| yield item } : value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A base64 string as a binary String.
|
|
39
|
+
def bytes(value)
|
|
40
|
+
return value unless value.is_a?(String)
|
|
41
|
+
|
|
42
|
+
Base64.strict_decode64(value).force_encoding(Encoding::BINARY)
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
value
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The index of the variant of the union named `type_id` the value is
|
|
48
|
+
# (by its tag, else the first variant that accepts it), or nil.
|
|
49
|
+
def variant(descriptors, type_id, value)
|
|
50
|
+
schema = descriptors.schema_of(type_id)
|
|
51
|
+
return nil if UNSET.equal?(schema)
|
|
52
|
+
|
|
53
|
+
union = descriptors.defs[type_id]
|
|
54
|
+
return nil unless union.is_a?(Hash) && union["kind"] == "union"
|
|
55
|
+
|
|
56
|
+
variants = union["variants"] || []
|
|
57
|
+
if (tag = union["tag"])
|
|
58
|
+
return nil unless value.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
index = (union["tags"] || []).index(value[tag])
|
|
61
|
+
return index && index < variants.length ? index : nil
|
|
62
|
+
end
|
|
63
|
+
variants.index { |v| Schema.new(v, descriptors.defs).accepts?(value) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A typed value back to JSON: models by wire name, arrays and maps
|
|
67
|
+
# converted, byte strings as base64.
|
|
68
|
+
def dump(value)
|
|
69
|
+
case value
|
|
70
|
+
when Model then value.to_h
|
|
71
|
+
when Array then value.reject { |v| UNSET.equal?(v) }.map { |v| dump(v) }
|
|
72
|
+
when Hash then value.reject { |_, v| UNSET.equal?(v) }.transform_values { |v| dump(v) }
|
|
73
|
+
when String then value.encoding == Encoding::BINARY ? Base64.strict_encode64(value) : value
|
|
74
|
+
else value
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Typed results over the dynamic ones of the core.
|
|
80
|
+
module Typed
|
|
81
|
+
module_function
|
|
82
|
+
|
|
83
|
+
# The Ok of a call with its value decoded by the block (`raw` keeps
|
|
84
|
+
# the wire value); an Err as it is.
|
|
85
|
+
def result(result)
|
|
86
|
+
return result unless result.is_a?(Ok)
|
|
87
|
+
return result if result.raw.nil?
|
|
88
|
+
|
|
89
|
+
result.with(value: yield(result.raw))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The pages of an enumeration with their items decoded by the block.
|
|
93
|
+
def pages(enum)
|
|
94
|
+
Enumerator.new do |y|
|
|
95
|
+
enum.each do |page|
|
|
96
|
+
if page.is_a?(Ok)
|
|
97
|
+
items = page.value.items.map { |item| yield item }
|
|
98
|
+
y << page.with(value: page.value.with(items: items), raw: page.value)
|
|
99
|
+
else
|
|
100
|
+
y << page
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|