amritk-scalar-test 0.1.1
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 +201 -0
- data/README.md +125 -0
- data/SECURITY.md +8 -0
- data/SKILL.md +53 -0
- data/lib/amritk-scalar-test/client.rb +21 -0
- data/lib/amritk-scalar-test/errors.rb +89 -0
- data/lib/amritk-scalar-test/file_part.rb +14 -0
- data/lib/amritk-scalar-test/internal/cursor_page.rb +9 -0
- data/lib/amritk-scalar-test/models.rb +41 -0
- data/lib/amritk-scalar-test/request_options.rb +22 -0
- data/lib/amritk-scalar-test/resources/ping.rb +71 -0
- data/lib/amritk-scalar-test/runtime.rb +893 -0
- data/lib/amritk-scalar-test/version.rb +6 -0
- data/lib/amritk-scalar-test/webhooks.rb +111 -0
- data/lib/amritk-scalar-test.rb +8 -0
- data/tests/smoke-test.rb +69 -0
- metadata +59 -0
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Code generated by Scalar SDK Generator. DO NOT EDIT.
|
|
4
|
+
require "base64"
|
|
5
|
+
require "json"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "openssl"
|
|
8
|
+
require "securerandom"
|
|
9
|
+
require "socket"
|
|
10
|
+
require "time"
|
|
11
|
+
require "uri"
|
|
12
|
+
|
|
13
|
+
module ScalarRubyTest
|
|
14
|
+
module Runtime
|
|
15
|
+
RETRYABLE_STATUS = [408, 409, 429, 500, 502, 503, 504].freeze
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def compact_hash(hash)
|
|
19
|
+
hash.each_with_object({}) { |(key, value), result| result[key] = value unless value.nil? }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def serialize(value)
|
|
23
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
|
24
|
+
return value.to_h if value.respond_to?(:to_h)
|
|
25
|
+
return value.map { |item| serialize(item) } if value.is_a?(Array)
|
|
26
|
+
return value.transform_values { |item| serialize(item) } if value.is_a?(Hash)
|
|
27
|
+
value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def deserialize(value, descriptor)
|
|
31
|
+
return value if value.nil? || descriptor.nil?
|
|
32
|
+
return value.map { |item| deserialize(item, descriptor.first) } if descriptor.is_a?(Array) && value.is_a?(Array)
|
|
33
|
+
return value unless descriptor.is_a?(String)
|
|
34
|
+
klass = Models.const_get(descriptor, false)
|
|
35
|
+
klass.respond_to?(:from_h) ? klass.from_h(value) : value
|
|
36
|
+
rescue NameError
|
|
37
|
+
value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class Page
|
|
41
|
+
include Enumerable
|
|
42
|
+
|
|
43
|
+
attr_reader :items, :response, :next_cursor
|
|
44
|
+
|
|
45
|
+
def initialize(items:, response:, next_cursor: nil, fetch_next: nil)
|
|
46
|
+
@items = items
|
|
47
|
+
@response = response
|
|
48
|
+
@next_cursor = next_cursor
|
|
49
|
+
@fetch_next = fetch_next
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def each(&block)
|
|
53
|
+
return enum_for(:each) unless block
|
|
54
|
+
@items.each(&block)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def each_page
|
|
58
|
+
return enum_for(:each_page) unless block_given?
|
|
59
|
+
page = self
|
|
60
|
+
loop do
|
|
61
|
+
yield page
|
|
62
|
+
break unless page.has_next_page?
|
|
63
|
+
page = page.next_page
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def auto_paging_each
|
|
68
|
+
return enum_for(:auto_paging_each) unless block_given?
|
|
69
|
+
each_page { |page| page.each { |item| yield item } }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def has_next_page?
|
|
73
|
+
!@next_cursor.nil? && !@fetch_next.nil?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def next_page
|
|
77
|
+
raise RuntimeError, "No next page available" unless has_next_page?
|
|
78
|
+
@fetch_next.call(@next_cursor)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class RawResponse
|
|
83
|
+
attr_reader :data, :status, :headers, :body
|
|
84
|
+
|
|
85
|
+
def initialize(data:, status:, headers:, body:)
|
|
86
|
+
@data = data
|
|
87
|
+
@status = status
|
|
88
|
+
@headers = headers
|
|
89
|
+
@body = body
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def map
|
|
93
|
+
self.class.new(data: yield(@data), status: @status, headers: @headers, body: @body)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class ResultResponse
|
|
98
|
+
attr_reader :data, :error, :status, :headers, :body
|
|
99
|
+
|
|
100
|
+
def initialize(data: nil, error: nil, status:, headers:, body:)
|
|
101
|
+
@data = data
|
|
102
|
+
@error = error
|
|
103
|
+
@status = status
|
|
104
|
+
@headers = headers
|
|
105
|
+
@body = body
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class WebSocketMessageError < StandardError; end
|
|
110
|
+
|
|
111
|
+
class WebSocketConnection
|
|
112
|
+
attr_reader :socket, :request
|
|
113
|
+
|
|
114
|
+
def initialize(socket, request)
|
|
115
|
+
@socket = socket
|
|
116
|
+
@request = request
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def send(message)
|
|
120
|
+
send_text(@request.serialize_send(message))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def send_text(payload)
|
|
124
|
+
data = payload.to_s.b
|
|
125
|
+
mask = SecureRandom.random_bytes(4)
|
|
126
|
+
header = [0x81]
|
|
127
|
+
if data.bytesize < 126
|
|
128
|
+
header << (0x80 | data.bytesize)
|
|
129
|
+
elsif data.bytesize < 65_536
|
|
130
|
+
header << (0x80 | 126)
|
|
131
|
+
header.concat([data.bytesize].pack("n").bytes)
|
|
132
|
+
else
|
|
133
|
+
header << (0x80 | 127)
|
|
134
|
+
header.concat([data.bytesize].pack("Q>").bytes)
|
|
135
|
+
end
|
|
136
|
+
masked = data.bytes.each_with_index.map { |byte, index| (byte ^ mask.getbyte(index % 4)).chr }.join
|
|
137
|
+
@socket.write(header.pack("C*") + mask + masked)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def receive
|
|
141
|
+
text = receive_text
|
|
142
|
+
text.nil? ? nil : @request.deserialize_receive(text)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def receive_text
|
|
146
|
+
header = read_exact(2).bytes
|
|
147
|
+
opcode = header[0] & 0x0f
|
|
148
|
+
length_byte = header[1]
|
|
149
|
+
masked = (length_byte & 0x80) != 0
|
|
150
|
+
length = length_byte & 0x7f
|
|
151
|
+
length = read_exact(2).unpack1("n") if length == 126
|
|
152
|
+
length = read_exact(8).unpack1("Q>") if length == 127
|
|
153
|
+
mask = masked ? read_exact(4).bytes : nil
|
|
154
|
+
payload = read_exact(length)
|
|
155
|
+
payload = payload.bytes.each_with_index.map { |byte, index| (byte ^ mask[index % 4]).chr }.join if mask
|
|
156
|
+
return nil if opcode == 0x8
|
|
157
|
+
raise WebSocketMessageError, "unsupported websocket frame opcode #{opcode}" unless opcode == 0x1
|
|
158
|
+
payload.force_encoding("UTF-8")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def close
|
|
162
|
+
@socket.close unless @socket.closed?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def closed?
|
|
166
|
+
@socket.closed?
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def read_exact(size)
|
|
172
|
+
data = @socket.read(size)
|
|
173
|
+
raise WebSocketMessageError, "unexpected websocket EOF" if data.nil? || data.bytesize != size
|
|
174
|
+
data
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
class WebSocketRequest
|
|
179
|
+
attr_reader :url, :headers, :send_descriptor, :receive_descriptor, :event_name_separator, :reconnect_options, :heartbeat_options
|
|
180
|
+
|
|
181
|
+
def initialize(url:, headers: {}, send_descriptor: nil, receive_descriptor: nil, event_name_separator: nil, reconnect_options: nil, heartbeat_options: nil)
|
|
182
|
+
@url = url
|
|
183
|
+
@headers = headers
|
|
184
|
+
@send_descriptor = send_descriptor
|
|
185
|
+
@receive_descriptor = receive_descriptor
|
|
186
|
+
@event_name_separator = event_name_separator
|
|
187
|
+
@reconnect_options = reconnect_options
|
|
188
|
+
@heartbeat_options = heartbeat_options
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def auth_headers
|
|
192
|
+
@headers.dup
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def with_headers(extra = {})
|
|
196
|
+
self.class.new(url: @url, headers: @headers.merge(extra || {}), send_descriptor: @send_descriptor, receive_descriptor: @receive_descriptor, event_name_separator: @event_name_separator, reconnect_options: @reconnect_options, heartbeat_options: @heartbeat_options)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def with_reconnect(**options)
|
|
200
|
+
self.class.new(url: @url, headers: @headers, send_descriptor: @send_descriptor, receive_descriptor: @receive_descriptor, event_name_separator: @event_name_separator, reconnect_options: options, heartbeat_options: @heartbeat_options)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def with_heartbeat(interval:, message:)
|
|
204
|
+
self.class.new(url: @url, headers: @headers, send_descriptor: @send_descriptor, receive_descriptor: @receive_descriptor, event_name_separator: @event_name_separator, reconnect_options: @reconnect_options, heartbeat_options: { interval: interval, message: message })
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def connect
|
|
208
|
+
uri = URI(@url)
|
|
209
|
+
raise WebSocketMessageError, "only ws:// and wss:// URLs are supported by the stdlib connector" unless ["ws", "wss"].include?(uri.scheme)
|
|
210
|
+
socket = TCPSocket.new(uri.host, uri.port || (uri.scheme == "wss" ? 443 : 80))
|
|
211
|
+
if uri.scheme == "wss"
|
|
212
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
213
|
+
socket = OpenSSL::SSL::SSLSocket.new(socket, context)
|
|
214
|
+
socket.hostname = uri.host if socket.respond_to?(:hostname=)
|
|
215
|
+
socket.sync_close = true
|
|
216
|
+
socket.connect
|
|
217
|
+
end
|
|
218
|
+
key = SecureRandom.base64(16)
|
|
219
|
+
path = uri.path.empty? ? "/" : uri.path
|
|
220
|
+
path = "#{path}?#{uri.query}" if uri.query
|
|
221
|
+
headers = { "Host" => uri.host, "Upgrade" => "websocket", "Connection" => "Upgrade", "Sec-WebSocket-Key" => key, "Sec-WebSocket-Version" => "13" }.merge(auth_headers)
|
|
222
|
+
socket.write("GET #{path} HTTP/1.1\r\n")
|
|
223
|
+
headers.each { |name, value| socket.write("#{name}: #{value}\r\n") }
|
|
224
|
+
socket.write("\r\n")
|
|
225
|
+
status = socket.gets
|
|
226
|
+
raise WebSocketMessageError, "WebSocket upgrade failed: #{status}" unless status&.include?(" 101 ")
|
|
227
|
+
WebSocketConnection.new(socket, self)
|
|
228
|
+
rescue StandardError
|
|
229
|
+
socket&.close
|
|
230
|
+
raise
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def serialize_send(message)
|
|
234
|
+
payload = Runtime.serialize(message)
|
|
235
|
+
return payload if payload.is_a?(String) && @send_descriptor.nil?
|
|
236
|
+
JSON.generate(payload)
|
|
237
|
+
rescue JSON::GeneratorError => e
|
|
238
|
+
raise WebSocketMessageError, "failed to serialize websocket payload: #{e.message}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def deserialize_receive(payload)
|
|
242
|
+
parsed = parse_receive_payload(payload)
|
|
243
|
+
Runtime.deserialize(parsed, @receive_descriptor)
|
|
244
|
+
rescue JSON::ParserError => e
|
|
245
|
+
raise WebSocketMessageError, "failed to parse websocket payload: #{e.message}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def normalize_event_name(name)
|
|
249
|
+
return name if name.nil? || @event_name_separator.nil? || @event_name_separator.empty?
|
|
250
|
+
name.to_s.tr(@event_name_separator, ".")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
private
|
|
254
|
+
|
|
255
|
+
def parse_receive_payload(payload)
|
|
256
|
+
return payload if payload.nil? || payload.is_a?(Hash) || payload.is_a?(Array)
|
|
257
|
+
return payload unless payload.respond_to?(:to_str)
|
|
258
|
+
text = payload.to_str
|
|
259
|
+
return text if @receive_descriptor.nil?
|
|
260
|
+
JSON.parse(text)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
class Stream
|
|
265
|
+
include Enumerable
|
|
266
|
+
|
|
267
|
+
def initialize(source:, descriptor:, format: :sse, handlers: [])
|
|
268
|
+
@source = source
|
|
269
|
+
@descriptor = descriptor
|
|
270
|
+
@format = format
|
|
271
|
+
@handlers = handlers
|
|
272
|
+
@closed = false
|
|
273
|
+
@retry_after = nil
|
|
274
|
+
@last_event_id = nil
|
|
275
|
+
@last_event_name = nil
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def each(&block)
|
|
279
|
+
return enum_for(:each) unless block
|
|
280
|
+
return if @closed
|
|
281
|
+
each_event do |data|
|
|
282
|
+
break if @closed
|
|
283
|
+
next if stream_action(data) == :skip
|
|
284
|
+
block.call(Runtime.deserialize(Runtime.parse_json(data), @descriptor))
|
|
285
|
+
end
|
|
286
|
+
ensure
|
|
287
|
+
close
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def close
|
|
291
|
+
@closed = true
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def closed?
|
|
295
|
+
@closed
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
attr_reader :retry_after, :last_event_id, :last_event_name
|
|
299
|
+
|
|
300
|
+
private
|
|
301
|
+
|
|
302
|
+
def each_event
|
|
303
|
+
return enum_for(:each_event) unless block_given?
|
|
304
|
+
return each_json_line { |line| yield line } if @format == :jsonl
|
|
305
|
+
|
|
306
|
+
buffer = +""
|
|
307
|
+
event = []
|
|
308
|
+
each_raw_event do |chunk|
|
|
309
|
+
buffer << chunk.to_s
|
|
310
|
+
while (line_end = buffer.index("\n"))
|
|
311
|
+
data = consume_sse_line(buffer.slice!(0, line_end + 1), event)
|
|
312
|
+
yield data unless data.nil? || data == "[DONE]" || @closed
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
data = flush_sse_event(event)
|
|
316
|
+
yield data unless data.nil? || data == "[DONE]" || @closed
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def each_raw_event
|
|
320
|
+
@source.call do |chunk|
|
|
321
|
+
yield chunk unless @closed
|
|
322
|
+
!@closed
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def each_json_line
|
|
327
|
+
buffer = +""
|
|
328
|
+
each_raw_event do |chunk|
|
|
329
|
+
buffer << chunk.to_s
|
|
330
|
+
while (line_end = buffer.index("\n"))
|
|
331
|
+
line = buffer.slice!(0, line_end + 1).strip
|
|
332
|
+
yield line unless line.empty? || @closed
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
line = buffer.strip
|
|
336
|
+
yield line unless line.empty? || @closed
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def consume_sse_line(raw_line, event)
|
|
340
|
+
line = raw_line.delete_suffix("\n").delete_suffix("\r")
|
|
341
|
+
return flush_sse_event(event) if line.empty?
|
|
342
|
+
return nil if line.start_with?(":")
|
|
343
|
+
if line.start_with?("event:")
|
|
344
|
+
@last_event_name = line.delete_prefix("event:").strip
|
|
345
|
+
return nil
|
|
346
|
+
end
|
|
347
|
+
if line.start_with?("id:")
|
|
348
|
+
@last_event_id = line.delete_prefix("id:").strip
|
|
349
|
+
return nil
|
|
350
|
+
end
|
|
351
|
+
if line.start_with?("retry:")
|
|
352
|
+
@retry_after = line.delete_prefix("retry:").strip.to_i
|
|
353
|
+
return nil
|
|
354
|
+
end
|
|
355
|
+
return nil unless line.start_with?("data:")
|
|
356
|
+
|
|
357
|
+
data = line.delete_prefix("data:")
|
|
358
|
+
event << data.delete_prefix(" ")
|
|
359
|
+
nil
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def flush_sse_event(event)
|
|
363
|
+
return nil if event.empty?
|
|
364
|
+
data = event.join("\n")
|
|
365
|
+
event.clear
|
|
366
|
+
data
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def stream_action(data)
|
|
370
|
+
handler = @handlers.find { |entry| handler_matches?(entry, data) }
|
|
371
|
+
return :yield if handler.nil?
|
|
372
|
+
handle = handler["handle"] || handler[:handle]
|
|
373
|
+
return :skip if ["continue", "ignore"].include?(handle)
|
|
374
|
+
raise WebSocketMessageError, data if handle == "error"
|
|
375
|
+
:yield
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def handler_matches?(handler, data)
|
|
379
|
+
kind = handler["kind"] || handler[:kind]
|
|
380
|
+
return false if kind == "event" && !event_type_matches?(handler)
|
|
381
|
+
prefix = handler["dataStartsWith"] || handler[:dataStartsWith]
|
|
382
|
+
return false if prefix && !data.to_s.start_with?(prefix.to_s)
|
|
383
|
+
true
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def event_type_matches?(handler)
|
|
387
|
+
expected = handler["eventType"] || handler[:eventType]
|
|
388
|
+
return @last_event_name.nil? if expected.nil?
|
|
389
|
+
return expected.include?(@last_event_name) if expected.is_a?(Array)
|
|
390
|
+
expected.to_s == @last_event_name.to_s
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def extra_keys(data, known)
|
|
395
|
+
data.reject { |key, _value| known.include?(key) }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def parse_json(text)
|
|
399
|
+
return nil if text.nil? || text.empty?
|
|
400
|
+
JSON.parse(text)
|
|
401
|
+
rescue JSON::ParserError
|
|
402
|
+
text
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def value_at(value, path)
|
|
406
|
+
path.reduce(value) do |current, key|
|
|
407
|
+
break nil if current.nil?
|
|
408
|
+
current.is_a?(Hash) ? current[key] || current[key.to_s] : nil
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def header_value(headers, name)
|
|
413
|
+
return nil unless headers.is_a?(Hash)
|
|
414
|
+
value = headers[name] || headers[name.to_s.downcase] || headers[name.to_s]
|
|
415
|
+
value.is_a?(Array) ? value.first : value
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def link_header_url(headers, name, rel)
|
|
419
|
+
header = header_value(headers, name)
|
|
420
|
+
return nil if header.nil?
|
|
421
|
+
header.to_s.split(",").each do |part|
|
|
422
|
+
url = part[/<([^>]+)>/, 1]
|
|
423
|
+
rel_value = part[/rel="?([^";,]+)"?/, 1]
|
|
424
|
+
return url if url && rel_value == rel
|
|
425
|
+
end
|
|
426
|
+
nil
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def extract_error_message(body)
|
|
430
|
+
return nil unless body.is_a?(Hash)
|
|
431
|
+
candidate = body["message"] || body["error"] || body["detail"]
|
|
432
|
+
return candidate if candidate.is_a?(String)
|
|
433
|
+
candidate["message"] if candidate.is_a?(Hash) && candidate["message"].is_a?(String)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def request_options_hash(options)
|
|
437
|
+
return {} if options.nil?
|
|
438
|
+
value = options.respond_to?(:to_h) ? options.to_h : options
|
|
439
|
+
value.is_a?(Hash) ? value : {}
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def request_option_hash(options, *keys)
|
|
443
|
+
keys.each do |key|
|
|
444
|
+
value = request_option_value(options, key)
|
|
445
|
+
return value if value.is_a?(Hash)
|
|
446
|
+
end
|
|
447
|
+
{}
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def request_option_value(options, key)
|
|
451
|
+
value = request_options_hash(options)
|
|
452
|
+
value[key] || value[key.to_s]
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def safe_header_value(value)
|
|
456
|
+
text = value.to_s
|
|
457
|
+
raise ArgumentError, "header values cannot contain CR or LF" if text.match?(/[\r\n]/)
|
|
458
|
+
text
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def safe_header_name(value)
|
|
462
|
+
text = value.to_s
|
|
463
|
+
raise ArgumentError, "header names cannot be empty" if text.empty?
|
|
464
|
+
raise ArgumentError, "invalid header name #{text.inspect}" unless text.match?(/\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/)
|
|
465
|
+
text
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def safe_cookie_pair(name, value)
|
|
469
|
+
"#{URI.encode_www_form_component(name.to_s)}=#{URI.encode_www_form_component(safe_header_value(value))}"
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def body_encoding_for_content_type(content_type)
|
|
473
|
+
value = content_type.to_s.downcase
|
|
474
|
+
return :multipart if value.include?("multipart/")
|
|
475
|
+
return :form if value.include?("x-www-form-urlencoded")
|
|
476
|
+
return :json if value.include?("json")
|
|
477
|
+
:binary
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
class APIClient
|
|
482
|
+
def initialize(base_url:, max_retries: 2, timeout: 60.0, initial_retry_delay: 0.5, max_retry_delay: 8.0, idempotency_header: "Idempotency-Key", logger: nil, default_headers: {}, default_query: {})
|
|
483
|
+
@base_url = base_url.to_s.delete_suffix("/")
|
|
484
|
+
@max_retries = max_retries
|
|
485
|
+
@timeout = timeout
|
|
486
|
+
@initial_retry_delay = initial_retry_delay
|
|
487
|
+
@max_retry_delay = max_retry_delay
|
|
488
|
+
@idempotency_header = idempotency_header
|
|
489
|
+
@logger = logger
|
|
490
|
+
@default_headers = default_headers
|
|
491
|
+
@default_query = default_query
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def request(
|
|
495
|
+
method:, path:,
|
|
496
|
+
path_params: {}, path_settings: {},
|
|
497
|
+
query: {}, query_settings: {},
|
|
498
|
+
cookies: {}, cookie_settings: {},
|
|
499
|
+
body: nil, body_type: :json, content_type: nil,
|
|
500
|
+
headers: nil, request_options: nil
|
|
501
|
+
)
|
|
502
|
+
options = normalize_options(request_options)
|
|
503
|
+
merged_query = @default_query.merge(auth_query).merge(option_hash(options, :query, :extra_query)).merge(query || {})
|
|
504
|
+
# Link-header pagination passes the exact next-page URL via `url:`; use it
|
|
505
|
+
# directly instead of rebuilding base_url + path + query.
|
|
506
|
+
exact_url = option_value(options, :url)
|
|
507
|
+
uri = exact_url ? URI.parse(exact_url.to_s) : build_uri(
|
|
508
|
+
option_value(options, :base_url) || @base_url,
|
|
509
|
+
path,
|
|
510
|
+
path_params,
|
|
511
|
+
path_settings,
|
|
512
|
+
merged_query,
|
|
513
|
+
query_settings
|
|
514
|
+
)
|
|
515
|
+
cookie_header = build_cookie_header(cookies || {}, cookie_settings || {})
|
|
516
|
+
headers = (headers || {}).merge("Cookie" => [headers && headers["Cookie"], cookie_header].compact.reject(&:empty?).join("; ")) unless cookie_header.empty?
|
|
517
|
+
request = build_request(method, uri, body, body_type, content_type, headers || {}, options)
|
|
518
|
+
response = execute_with_retry(uri, request, option_value(options, :max_retries) || @max_retries, option_value(options, :timeout) || @timeout)
|
|
519
|
+
parsed = parse_response_body(response)
|
|
520
|
+
raise APIError.from_response(response, parsed) unless response.is_a?(Net::HTTPSuccess)
|
|
521
|
+
parsed
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def request_raw(
|
|
525
|
+
method:, path:,
|
|
526
|
+
path_params: {}, path_settings: {},
|
|
527
|
+
query: {}, query_settings: {},
|
|
528
|
+
cookies: {}, cookie_settings: {},
|
|
529
|
+
body: nil, body_type: :json, content_type: nil,
|
|
530
|
+
headers: nil, request_options: nil
|
|
531
|
+
)
|
|
532
|
+
options = normalize_options(request_options)
|
|
533
|
+
merged_query = @default_query.merge(auth_query).merge(option_hash(options, :query, :extra_query)).merge(query || {})
|
|
534
|
+
exact_url = option_value(options, :url)
|
|
535
|
+
uri = exact_url ? URI.parse(exact_url.to_s) : build_uri(
|
|
536
|
+
option_value(options, :base_url) || @base_url,
|
|
537
|
+
path,
|
|
538
|
+
path_params,
|
|
539
|
+
path_settings,
|
|
540
|
+
merged_query,
|
|
541
|
+
query_settings
|
|
542
|
+
)
|
|
543
|
+
cookie_header = build_cookie_header(cookies || {}, cookie_settings || {})
|
|
544
|
+
headers = (headers || {}).merge("Cookie" => [headers && headers["Cookie"], cookie_header].compact.reject(&:empty?).join("; ")) unless cookie_header.empty?
|
|
545
|
+
request = build_request(method, uri, body, body_type, content_type, headers || {}, options)
|
|
546
|
+
response = execute_with_retry(uri, request, option_value(options, :max_retries) || @max_retries, option_value(options, :timeout) || @timeout)
|
|
547
|
+
parsed = parse_response_body(response)
|
|
548
|
+
raise APIError.from_response(response, parsed) unless response.is_a?(Net::HTTPSuccess)
|
|
549
|
+
Runtime::RawResponse.new(data: parsed, status: response.code.to_i, headers: response.to_hash, body: response.body)
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def request_stream(
|
|
553
|
+
method:, path:,
|
|
554
|
+
path_params: {}, path_settings: {},
|
|
555
|
+
query: {}, query_settings: {},
|
|
556
|
+
cookies: {}, cookie_settings: {},
|
|
557
|
+
body: nil, body_type: :json, content_type: nil,
|
|
558
|
+
headers: nil, stream_format: :sse, descriptor: nil, handlers: [], request_options: nil
|
|
559
|
+
)
|
|
560
|
+
options = normalize_options(request_options)
|
|
561
|
+
merged_query = @default_query.merge(auth_query).merge(option_hash(options, :query, :extra_query)).merge(query || {})
|
|
562
|
+
uri = build_uri(
|
|
563
|
+
option_value(options, :base_url) || @base_url,
|
|
564
|
+
path,
|
|
565
|
+
path_params,
|
|
566
|
+
path_settings,
|
|
567
|
+
merged_query,
|
|
568
|
+
query_settings
|
|
569
|
+
)
|
|
570
|
+
cookie_header = build_cookie_header(cookies || {}, cookie_settings || {})
|
|
571
|
+
headers = (headers || {}).merge("Cookie" => [headers && headers["Cookie"], cookie_header].compact.reject(&:empty?).join("; ")) unless cookie_header.empty?
|
|
572
|
+
request = build_request(method, uri, body, body_type, content_type, headers || {}, options)
|
|
573
|
+
source = proc { |&chunk_handler| execute_stream_with_retry(uri, request, option_value(options, :max_retries) || @max_retries, option_value(options, :timeout) || @timeout, &chunk_handler) }
|
|
574
|
+
Runtime::Stream.new(source: source, descriptor: descriptor, format: stream_format, handlers: handlers)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def websocket_request(
|
|
578
|
+
method: "GET", path:,
|
|
579
|
+
path_params: {}, path_settings: {},
|
|
580
|
+
query: {}, query_settings: {},
|
|
581
|
+
cookies: {}, cookie_settings: {},
|
|
582
|
+
body: nil, body_type: :json, content_type: nil,
|
|
583
|
+
headers: nil, send_descriptor: nil, receive_descriptor: nil, event_name_separator: nil, request_options: nil
|
|
584
|
+
)
|
|
585
|
+
options = normalize_options(request_options)
|
|
586
|
+
merged_query = @default_query.merge(auth_query).merge(option_hash(options, :query, :extra_query)).merge(query || {})
|
|
587
|
+
uri = build_uri(
|
|
588
|
+
option_value(options, :base_url) || @base_url,
|
|
589
|
+
path,
|
|
590
|
+
path_params,
|
|
591
|
+
path_settings,
|
|
592
|
+
merged_query,
|
|
593
|
+
query_settings
|
|
594
|
+
)
|
|
595
|
+
uri.scheme = uri.scheme == "https" ? "wss" : "ws"
|
|
596
|
+
request_headers = sanitize_headers(@default_headers.merge(auth_headers_for(uri, options)).merge(headers || {}))
|
|
597
|
+
Runtime::WebSocketRequest.new(url: uri.to_s, headers: request_headers, send_descriptor: send_descriptor, receive_descriptor: receive_descriptor, event_name_separator: event_name_separator)
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
def auth_headers
|
|
601
|
+
{}
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def auth_query
|
|
605
|
+
{}
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
private
|
|
609
|
+
|
|
610
|
+
def normalize_options(options)
|
|
611
|
+
Runtime.request_options_hash(options)
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def option_value(options, *keys)
|
|
615
|
+
keys.each do |key|
|
|
616
|
+
value = Runtime.request_option_value(options, key)
|
|
617
|
+
return value unless value.nil?
|
|
618
|
+
end
|
|
619
|
+
nil
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def option_hash(options, *keys)
|
|
623
|
+
Runtime.request_option_hash(options, *keys)
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def sanitize_headers(headers)
|
|
627
|
+
headers.each_with_object({}) do |(key, value), out|
|
|
628
|
+
next if value.nil?
|
|
629
|
+
out[Runtime.safe_header_name(key)] = Runtime.safe_header_value(value)
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def auth_headers_for(uri, options)
|
|
634
|
+
# Both `base_url:` and `url:` overrides suppress auth when the target
|
|
635
|
+
# crosses origins, unless the caller explicitly allows it.
|
|
636
|
+
override = option_value(options, :base_url) || option_value(options, :url)
|
|
637
|
+
return auth_headers if override.nil? || option_value(options, :allow_auth_to_base_url)
|
|
638
|
+
same_origin?(URI.parse(@base_url), uri) ? auth_headers : {}
|
|
639
|
+
rescue URI::InvalidURIError
|
|
640
|
+
{}
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def same_origin?(left, right)
|
|
644
|
+
left.scheme == right.scheme && left.host == right.host && left.port == right.port
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def log_debug(message)
|
|
648
|
+
return if @logger.nil?
|
|
649
|
+
@logger.respond_to?(:call) ? @logger.call(message) : @logger.puts(message)
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def build_uri(base_url, path, path_params, path_settings, query, query_settings)
|
|
653
|
+
resolved = path.dup
|
|
654
|
+
path_params.each { |key, value| resolved = resolved.gsub("{#{key}}", serialize_path_param(key, value, path_settings[key] || {})) }
|
|
655
|
+
uri = URI.parse(base_url.to_s.delete_suffix("/") + resolved)
|
|
656
|
+
pairs = URI.decode_www_form(uri.query.to_s)
|
|
657
|
+
query.each { |key, value| append_query(pairs, key, value, query_settings[key] || {}) }
|
|
658
|
+
uri.query = pairs.empty? ? nil : URI.encode_www_form(pairs)
|
|
659
|
+
uri
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def append_query(pairs, key, raw_value, setting = {})
|
|
663
|
+
value = parameter_value(raw_value, setting)
|
|
664
|
+
return if value.nil?
|
|
665
|
+
style = setting[:style] || "form"
|
|
666
|
+
explode = setting.key?(:explode) ? setting[:explode] : true
|
|
667
|
+
if value.is_a?(Array)
|
|
668
|
+
return value.each { |item| append_query(pairs, key, item, setting.merge(explode: false)) } if ["form", "spaceDelimited", "pipeDelimited"].include?(style) && explode
|
|
669
|
+
sep = style == "spaceDelimited" ? " " : style == "pipeDelimited" ? "|" : ","
|
|
670
|
+
pairs << [key, value.map { |item| parameter_value(item, setting).to_s }.join(sep)]
|
|
671
|
+
elsif value.is_a?(Hash)
|
|
672
|
+
entries = value.map { |prop, prop_value| [prop, parameter_value(prop_value, setting)] }.reject { |_, prop_value| prop_value.nil? }
|
|
673
|
+
return entries.each { |prop, prop_value| pairs << ["#{key}[#{prop}]", prop_value.to_s] } if style == "deepObject"
|
|
674
|
+
return entries.each { |prop, prop_value| pairs << [prop.to_s, prop_value.to_s] } if style == "form" && explode
|
|
675
|
+
pairs << [key, style == "form" ? entries.flat_map { |prop, prop_value| [prop, prop_value] }.join(",") : JSON.generate(value)]
|
|
676
|
+
else
|
|
677
|
+
pairs << [key, value.to_s]
|
|
678
|
+
end
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
def parameter_value(value, setting)
|
|
682
|
+
return setting[:default] if value.nil? && setting.key?(:default)
|
|
683
|
+
return "" if value.nil? && (setting[:nullable] || setting[:required] || setting[:allow_empty_value])
|
|
684
|
+
value
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
def encode_part(value, allow_reserved: false)
|
|
688
|
+
encoded = URI.encode_www_form_component(value.to_s)
|
|
689
|
+
return encoded unless allow_reserved
|
|
690
|
+
encoded.gsub(/%3A/i, ":").gsub(/%2F/i, "/").gsub(/%3F/i, "?").gsub(/%23/i, "#").gsub(/%5B/i, "[").gsub(/%5D/i, "]").gsub(/%40/i, "@").gsub(/%21/i, "!").gsub(/%24/i, "$").gsub(/%26/i, "&").gsub(/%27/i, "'").gsub(/%28/i, "(").gsub(/%29/i, ")").gsub(/%2A/i, "*").gsub(/%2B/i, "+").gsub(/%2C/i, ",").gsub(/%3B/i, ";").gsub(/%3D/i, "=")
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def serialize_path_param(key, raw_value, setting = {})
|
|
694
|
+
value = parameter_value(raw_value, setting) || ""
|
|
695
|
+
style = setting[:style] || "simple"
|
|
696
|
+
explode = setting.key?(:explode) ? setting[:explode] : false
|
|
697
|
+
# `allow_reserved` keeps RFC 6570 reserved characters (notably `/`) so a path-like value such as a
|
|
698
|
+
# file path stays nested under the route instead of collapsing to a single `%2F`-encoded segment.
|
|
699
|
+
allow_reserved = setting.key?(:allow_reserved) ? setting[:allow_reserved] : false
|
|
700
|
+
return label_path_value(value, explode, allow_reserved: allow_reserved) if style == "label"
|
|
701
|
+
if style == "matrix"
|
|
702
|
+
return value.map { |item| ";#{encode_part(key)}=#{encode_part(parameter_value(item, setting) || "", allow_reserved: allow_reserved)}" }.join if value.is_a?(Array) && explode
|
|
703
|
+
return value.map { |prop, prop_value| ";#{encode_part(prop)}=#{encode_part(parameter_value(prop_value, setting) || "", allow_reserved: allow_reserved)}" }.join if value.is_a?(Hash) && explode
|
|
704
|
+
return ";#{encode_part(key)}=#{simple_path_value(value, false, allow_reserved: allow_reserved)}"
|
|
705
|
+
end
|
|
706
|
+
simple_path_value(value, explode, allow_reserved: allow_reserved)
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
def label_path_value(value, explode, allow_reserved: false)
|
|
710
|
+
return "." + value.map { |item| encode_part(parameter_value(item, {}) || "", allow_reserved: allow_reserved) }.join(".") if value.is_a?(Array) && explode
|
|
711
|
+
return "." + value.map { |prop, prop_value| "#{encode_part(prop)}=#{encode_part(parameter_value(prop_value, {}) || "", allow_reserved: allow_reserved)}" }.join(".") if value.is_a?(Hash) && explode
|
|
712
|
+
"." + simple_path_value(value, false, allow_reserved: allow_reserved)
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def simple_path_value(value, explode, allow_reserved: false)
|
|
716
|
+
return value.map { |item| encode_part(parameter_value(item, {}) || "", allow_reserved: allow_reserved) }.join(",") if value.is_a?(Array)
|
|
717
|
+
return value.map { |prop, prop_value| explode ? "#{encode_part(prop)}=#{encode_part(parameter_value(prop_value, {}) || "", allow_reserved: allow_reserved)}" : [encode_part(prop), encode_part(parameter_value(prop_value, {}) || "", allow_reserved: allow_reserved)] }.flatten.join(",") if value.is_a?(Hash)
|
|
718
|
+
encode_part(value, allow_reserved: allow_reserved)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def build_cookie_header(cookies, cookie_settings)
|
|
722
|
+
parts = []
|
|
723
|
+
cookies.each do |key, raw_value|
|
|
724
|
+
setting = cookie_settings[key] || {}
|
|
725
|
+
value = parameter_value(raw_value, setting)
|
|
726
|
+
next if value.nil?
|
|
727
|
+
explode = setting.key?(:explode) ? setting[:explode] : true
|
|
728
|
+
if value.is_a?(Array) && explode
|
|
729
|
+
value.each { |item| parts << "#{encode_part(key)}=#{encode_part(parameter_value(item, setting) || "")}" }
|
|
730
|
+
elsif value.is_a?(Hash) && explode
|
|
731
|
+
value.each { |prop, prop_value| parts << "#{encode_part(prop)}=#{encode_part(parameter_value(prop_value, setting) || "")}" }
|
|
732
|
+
else
|
|
733
|
+
serialized = value.is_a?(Array) ? value.map { |item| parameter_value(item, setting).to_s }.join(",") : value.is_a?(Hash) ? value.flat_map { |prop, prop_value| [prop, parameter_value(prop_value, setting)] }.join(",") : value.to_s
|
|
734
|
+
parts << "#{encode_part(key)}=#{encode_part(serialized)}"
|
|
735
|
+
end
|
|
736
|
+
end
|
|
737
|
+
parts.join("; ")
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def build_request(method, uri, body, body_type, content_type, extra_headers, options = {})
|
|
741
|
+
request = request_class(method).new(uri)
|
|
742
|
+
headers = { "Accept" => "application/json" }.merge(@default_headers).merge(auth_headers_for(uri, options)).merge(option_hash(options, :extra_headers)).merge(extra_headers)
|
|
743
|
+
if write_method?(method) && !@idempotency_header.nil? && !@idempotency_header.empty?
|
|
744
|
+
idempotency_key = option_value(options, :idempotency_key)
|
|
745
|
+
headers[@idempotency_header] = idempotency_key.to_s unless idempotency_key.nil? || idempotency_key.to_s.empty?
|
|
746
|
+
end
|
|
747
|
+
encoded_body, encoded_content_type = encode_body(body, body_type, content_type, options)
|
|
748
|
+
headers["Content-Type"] ||= encoded_content_type unless encoded_content_type.nil?
|
|
749
|
+
sanitize_headers(headers).each { |key, value| request[key] = value }
|
|
750
|
+
request.body = encoded_body unless encoded_body.nil?
|
|
751
|
+
request
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def write_method?(method)
|
|
755
|
+
!["GET", "HEAD", "OPTIONS"].include?(method.to_s.upcase)
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
def request_class(method)
|
|
759
|
+
{ "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post, "PUT" => Net::HTTP::Put, "PATCH" => Net::HTTP::Patch, "DELETE" => Net::HTTP::Delete, "HEAD" => Net::HTTP::Head, "OPTIONS" => Net::HTTP::Options }.fetch(method.to_s.upcase)
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def encode_body(body, body_type, content_type, options = {})
|
|
763
|
+
return [nil, nil] if body.nil?
|
|
764
|
+
value = Runtime.serialize(body)
|
|
765
|
+
extra_body = option_hash(options, :extra_body)
|
|
766
|
+
value = value.merge(extra_body) if value.is_a?(Hash) && !extra_body.empty?
|
|
767
|
+
return [URI.encode_www_form(value || {}), "application/x-www-form-urlencoded"] if body_type == :form
|
|
768
|
+
return encode_multipart(value || {}, content_type) if body_type == :multipart
|
|
769
|
+
return [value, content_type || "application/octet-stream"] if body_type == :binary
|
|
770
|
+
[JSON.generate(value), content_type || "application/json"]
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def encode_multipart(value, content_type)
|
|
774
|
+
boundary = "scalar-sdk-generator-#{object_id}-#{rand(1_000_000)}"
|
|
775
|
+
chunks = []
|
|
776
|
+
value.each do |key, part|
|
|
777
|
+
Array(part).each do |item|
|
|
778
|
+
filename = multipart_filename(item)
|
|
779
|
+
part_content_type = multipart_content_type(item)
|
|
780
|
+
chunks << "--#{boundary}\r\n"
|
|
781
|
+
disposition = "Content-Disposition: form-data; name=\"#{multipart_disposition_param(key)}\""
|
|
782
|
+
disposition += "; filename=\"#{multipart_disposition_param(filename)}\"" unless filename.nil?
|
|
783
|
+
chunks << "#{disposition}\r\n"
|
|
784
|
+
chunks << "Content-Type: #{multipart_header_value(part_content_type)}\r\n" unless part_content_type.nil?
|
|
785
|
+
chunks << "\r\n"
|
|
786
|
+
chunks << multipart_value(item)
|
|
787
|
+
chunks << "\r\n"
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
chunks << "--#{boundary}--\r\n"
|
|
791
|
+
[chunks.join, content_type || "multipart/form-data; boundary=#{boundary}"]
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def multipart_disposition_param(value)
|
|
795
|
+
value.to_s.gsub(/["\\\r\n]/, "_")
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def multipart_header_value(value)
|
|
799
|
+
value.to_s.gsub(/[\r\n]/, "")
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def multipart_filename(value)
|
|
803
|
+
return File.basename(value.filename.to_s) if value.respond_to?(:filename) && value.filename
|
|
804
|
+
return File.basename(value.path) if value.respond_to?(:path) && value.path
|
|
805
|
+
nil
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
def multipart_content_type(value)
|
|
809
|
+
return value.content_type if value.respond_to?(:content_type) && value.content_type
|
|
810
|
+
return "application/octet-stream" unless multipart_filename(value).nil?
|
|
811
|
+
nil
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
def multipart_value(value)
|
|
815
|
+
source = value.respond_to?(:io) ? value.io : value
|
|
816
|
+
return source.read if source.respond_to?(:read)
|
|
817
|
+
source.to_s
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
def parse_response_body(response)
|
|
821
|
+
content_type = response["Content-Type"].to_s.downcase
|
|
822
|
+
body = response.body
|
|
823
|
+
return body unless content_type.empty? || content_type.include?("json") || content_type.start_with?("text/") || content_type.include?("xml")
|
|
824
|
+
Runtime.parse_json(body)
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
def execute_with_retry(uri, request, retries_left, timeout)
|
|
828
|
+
response = nil
|
|
829
|
+
log_debug("#{request.method} #{uri}")
|
|
830
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", read_timeout: timeout, open_timeout: timeout) { |http| response = http.request(request) }
|
|
831
|
+
log_debug("#{response.code} #{request.method} #{uri}")
|
|
832
|
+
if Runtime::RETRYABLE_STATUS.include?(response.code.to_i) && retries_left.positive?
|
|
833
|
+
sleep retry_delay(retries_left, response)
|
|
834
|
+
return execute_with_retry(uri, request, retries_left - 1, timeout)
|
|
835
|
+
end
|
|
836
|
+
response
|
|
837
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
838
|
+
raise APIConnectionTimeoutError.new(e.message) unless retries_left.positive?
|
|
839
|
+
sleep retry_delay(retries_left)
|
|
840
|
+
execute_with_retry(uri, request, retries_left - 1, timeout)
|
|
841
|
+
rescue StandardError => e
|
|
842
|
+
raise APIConnectionError.new(e.message) unless retries_left.positive?
|
|
843
|
+
sleep retry_delay(retries_left)
|
|
844
|
+
execute_with_retry(uri, request, retries_left - 1, timeout)
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
def execute_stream_with_retry(uri, request, retries_left, timeout, &chunk_handler)
|
|
848
|
+
log_debug("#{request.method} #{uri} stream")
|
|
849
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", read_timeout: timeout, open_timeout: timeout) do |http|
|
|
850
|
+
http.request(request) do |response|
|
|
851
|
+
log_debug("#{response.code} #{request.method} #{uri} stream")
|
|
852
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
853
|
+
error_body = +""
|
|
854
|
+
response.read_body { |chunk| error_body << chunk.to_s }
|
|
855
|
+
if Runtime::RETRYABLE_STATUS.include?(response.code.to_i) && retries_left.positive?
|
|
856
|
+
sleep retry_delay(retries_left, response)
|
|
857
|
+
return execute_stream_with_retry(uri, request, retries_left - 1, timeout, &chunk_handler)
|
|
858
|
+
end
|
|
859
|
+
raise APIError.from_response(response, Runtime.parse_json(error_body))
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
response.read_body do |chunk|
|
|
863
|
+
break if chunk_handler.call(chunk) == false
|
|
864
|
+
end
|
|
865
|
+
end
|
|
866
|
+
end
|
|
867
|
+
rescue APIError
|
|
868
|
+
raise
|
|
869
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
870
|
+
raise APIConnectionTimeoutError.new(e.message) unless retries_left.positive?
|
|
871
|
+
sleep retry_delay(retries_left)
|
|
872
|
+
execute_stream_with_retry(uri, request, retries_left - 1, timeout, &chunk_handler)
|
|
873
|
+
rescue StandardError => e
|
|
874
|
+
raise APIConnectionError.new(e.message) unless retries_left.positive?
|
|
875
|
+
sleep retry_delay(retries_left)
|
|
876
|
+
execute_stream_with_retry(uri, request, retries_left - 1, timeout, &chunk_handler)
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
def retry_delay(retries_left, response = nil)
|
|
880
|
+
retry_after = response&.[]("Retry-After")
|
|
881
|
+
unless retry_after.nil? || retry_after.empty?
|
|
882
|
+
seconds = Float(retry_after, exception: false)
|
|
883
|
+
return seconds if !seconds.nil? && seconds >= 0
|
|
884
|
+
begin
|
|
885
|
+
return [Time.httpdate(retry_after) - Time.now, 0].max
|
|
886
|
+
rescue ArgumentError
|
|
887
|
+
# Fall through to exponential delay.
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
[@initial_retry_delay * (2**(@max_retries - retries_left)), @max_retry_delay].min + rand * 0.25
|
|
891
|
+
end
|
|
892
|
+
end
|
|
893
|
+
end
|