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,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