apollo-deploy-signal-sdk 1.0.5
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/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +79 -0
- data/docs/README.md +30 -0
- data/docs/domains/api-keys.md +92 -0
- data/docs/domains/contact-properties.md +122 -0
- data/docs/domains/contacts.md +394 -0
- data/docs/domains/emails.md +150 -0
- data/docs/domains/metrics.md +131 -0
- data/docs/domains/projects.md +127 -0
- data/docs/domains/segments.md +116 -0
- data/docs/domains/sending-domains.md +160 -0
- data/docs/domains/suppressions.md +120 -0
- data/docs/domains/topics.md +141 -0
- data/docs/domains/webhooks.md +200 -0
- data/docs/types.md +1225 -0
- data/lib/apollo-deploy-signal-sdk.rb +3 -0
- data/lib/apollo_deploy_signal_sdk/client.rb +111 -0
- data/lib/apollo_deploy_signal_sdk/errors.rb +150 -0
- data/lib/apollo_deploy_signal_sdk/resources/api-keys.rb +86 -0
- data/lib/apollo_deploy_signal_sdk/resources/contact-properties.rb +108 -0
- data/lib/apollo_deploy_signal_sdk/resources/contacts.rb +356 -0
- data/lib/apollo_deploy_signal_sdk/resources/emails.rb +128 -0
- data/lib/apollo_deploy_signal_sdk/resources/metrics.rb +122 -0
- data/lib/apollo_deploy_signal_sdk/resources/projects.rb +114 -0
- data/lib/apollo_deploy_signal_sdk/resources/segments.rb +105 -0
- data/lib/apollo_deploy_signal_sdk/resources/sending-domains.rb +144 -0
- data/lib/apollo_deploy_signal_sdk/resources/suppressions.rb +104 -0
- data/lib/apollo_deploy_signal_sdk/resources/topics.rb +126 -0
- data/lib/apollo_deploy_signal_sdk/resources/webhooks.rb +184 -0
- data/lib/apollo_deploy_signal_sdk/transport.rb +618 -0
- data/lib/apollo_deploy_signal_sdk/types.rb +4917 -0
- data/lib/apollo_deploy_signal_sdk/version.rb +5 -0
- data/lib/apollo_deploy_signal_sdk.rb +47 -0
- metadata +135 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
require "uri"
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
|
|
10
|
+
module ApolloDeploySignalSdk
|
|
11
|
+
class SSEEvent
|
|
12
|
+
attr_reader :type, :data, :id, :retry_milliseconds
|
|
13
|
+
|
|
14
|
+
def initialize(type:, data:, id: nil, retry_milliseconds: nil)
|
|
15
|
+
@type = type
|
|
16
|
+
@data = data
|
|
17
|
+
@id = id
|
|
18
|
+
@retry_milliseconds = retry_milliseconds
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# HTTP transport layer built on Faraday.
|
|
23
|
+
class Transport
|
|
24
|
+
RETRYABLE_METHODS = %i[get head options put delete].freeze
|
|
25
|
+
RETRYABLE_STATUSES = [408, 425, 429, 500, 502, 503, 504].freeze
|
|
26
|
+
MAX_RETRIES = 8
|
|
27
|
+
MAX_RETRY_DELAY = 30.0
|
|
28
|
+
MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024
|
|
29
|
+
MAX_SSE_LINE_BYTES = 1024 * 1024
|
|
30
|
+
|
|
31
|
+
COOKIE_SAFE_CHARACTERS = "!#$%&'()*+-./:<=>?@[]^_`{|}~".freeze
|
|
32
|
+
|
|
33
|
+
class SSEParser
|
|
34
|
+
def initialize(output)
|
|
35
|
+
@output = output
|
|
36
|
+
@buffer = +""
|
|
37
|
+
@event_type = "message"
|
|
38
|
+
@data_lines = []
|
|
39
|
+
@event_id = nil
|
|
40
|
+
@retry_milliseconds = nil
|
|
41
|
+
@total_bytes = 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def write(chunk)
|
|
45
|
+
@total_bytes += chunk.to_s.bytesize
|
|
46
|
+
raise_response_limit if @total_bytes > MAX_RESPONSE_BODY_BYTES
|
|
47
|
+
|
|
48
|
+
@buffer << chunk.to_s
|
|
49
|
+
consume_lines
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def finish
|
|
53
|
+
unless @buffer.empty?
|
|
54
|
+
line = @buffer.end_with?("\r") ? @buffer.byteslice(0, @buffer.bytesize - 1) : @buffer
|
|
55
|
+
process_line(line)
|
|
56
|
+
end
|
|
57
|
+
finish_event
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def consume_lines
|
|
63
|
+
loop do
|
|
64
|
+
index = @buffer.index(/[\r\n]/)
|
|
65
|
+
break if index.nil?
|
|
66
|
+
|
|
67
|
+
byte = @buffer.getbyte(index)
|
|
68
|
+
break if byte == 13 && index == @buffer.bytesize - 1
|
|
69
|
+
|
|
70
|
+
delimiter_length = byte == 13 && @buffer.getbyte(index + 1) == 10 ? 2 : 1
|
|
71
|
+
line = @buffer.byteslice(0, index)
|
|
72
|
+
@buffer = @buffer.byteslice(index + delimiter_length, @buffer.bytesize) || +""
|
|
73
|
+
process_line(line)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
raise_line_limit if @buffer.bytesize > MAX_SSE_LINE_BYTES
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def process_line(line)
|
|
80
|
+
raise_line_limit if line.bytesize > MAX_SSE_LINE_BYTES
|
|
81
|
+
if line.empty?
|
|
82
|
+
finish_event
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
return if line.start_with?(":")
|
|
86
|
+
|
|
87
|
+
separator = line.index(":")
|
|
88
|
+
if separator
|
|
89
|
+
field = line.byteslice(0, separator)
|
|
90
|
+
value = line.byteslice(separator + 1, line.bytesize) || ""
|
|
91
|
+
value = value.byteslice(1, value.bytesize) if value.start_with?(" ")
|
|
92
|
+
else
|
|
93
|
+
field = line
|
|
94
|
+
value = ""
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
case field
|
|
98
|
+
when "event"
|
|
99
|
+
@event_type = value
|
|
100
|
+
when "data"
|
|
101
|
+
@data_lines << value
|
|
102
|
+
when "id"
|
|
103
|
+
@event_id = value unless value.include?("\0")
|
|
104
|
+
when "retry"
|
|
105
|
+
@retry_milliseconds = Integer(value, 10) if value.match?(/\A\d+\z/)
|
|
106
|
+
end
|
|
107
|
+
rescue ArgumentError
|
|
108
|
+
# Invalid retry fields are ignored by the SSE protocol.
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def finish_event
|
|
112
|
+
return reset_event if @data_lines.empty?
|
|
113
|
+
|
|
114
|
+
raw_data = @data_lines.join("\n")
|
|
115
|
+
data = begin
|
|
116
|
+
JSON.parse(raw_data)
|
|
117
|
+
rescue JSON::ParserError
|
|
118
|
+
raw_data
|
|
119
|
+
end
|
|
120
|
+
@output << SSEEvent.new(
|
|
121
|
+
type: @event_type,
|
|
122
|
+
data: data,
|
|
123
|
+
id: @event_id,
|
|
124
|
+
retry_milliseconds: @retry_milliseconds,
|
|
125
|
+
)
|
|
126
|
+
reset_event
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def reset_event
|
|
130
|
+
@event_type = "message"
|
|
131
|
+
@data_lines = []
|
|
132
|
+
@event_id = nil
|
|
133
|
+
@retry_milliseconds = nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def raise_response_limit
|
|
137
|
+
raise SDKError.new(
|
|
138
|
+
"Response body exceeded the configured safety limit",
|
|
139
|
+
0,
|
|
140
|
+
"response_body_too_large",
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def raise_line_limit
|
|
145
|
+
raise SDKError.new(
|
|
146
|
+
"SSE line exceeded the configured safety limit",
|
|
147
|
+
0,
|
|
148
|
+
"sse_line_too_large",
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
attr_reader :config
|
|
154
|
+
attr_reader :connection
|
|
155
|
+
|
|
156
|
+
def initialize(config)
|
|
157
|
+
@config = config
|
|
158
|
+
base_uri = URI.parse(config.base_url)
|
|
159
|
+
unless base_uri.is_a?(URI::HTTP) && base_uri.host
|
|
160
|
+
raise ArgumentError, "base_url must be an absolute HTTP(S) URL"
|
|
161
|
+
end
|
|
162
|
+
@base_query_pairs = URI.decode_www_form(base_uri.query.to_s)
|
|
163
|
+
base_uri.query = nil
|
|
164
|
+
base_uri.fragment = nil
|
|
165
|
+
base_path = base_uri.path.to_s.sub(%r{/+\z}, "")
|
|
166
|
+
base_uri.path = base_path.empty? ? "/" : "#{base_path}/"
|
|
167
|
+
@connection = Faraday.new(url: base_uri.to_s) do |faraday|
|
|
168
|
+
faraday.request :json
|
|
169
|
+
faraday.response :raise_error
|
|
170
|
+
faraday.options.timeout = config.timeout
|
|
171
|
+
faraday.options.open_timeout = [config.timeout / 3.0, 2.0].max
|
|
172
|
+
faraday.headers["Accept"] = "application/json"
|
|
173
|
+
faraday.headers["User-Agent"] = "apollo-deploy-signal-sdk-ruby-sdk/1.0.5"
|
|
174
|
+
config.default_headers.each { |key, value| faraday.headers[key] = value }
|
|
175
|
+
faraday.adapter Faraday.default_adapter
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def request(method:, path:, path_params: {}, query: nil, body: nil, headers: {}, content_type: nil, timeout_ms: nil)
|
|
180
|
+
url = append_query(build_url(path, path_params), build_query(query))
|
|
181
|
+
method = method.to_sym
|
|
182
|
+
max_retries = [[(@config.retries[:attempts] || 3).to_i, 0].max, MAX_RETRIES].min
|
|
183
|
+
attempt = 0
|
|
184
|
+
|
|
185
|
+
begin
|
|
186
|
+
request_headers = build_headers(headers, content_type)
|
|
187
|
+
prepared_body, request_headers = prepare_request_body(
|
|
188
|
+
body,
|
|
189
|
+
content_type,
|
|
190
|
+
request_headers,
|
|
191
|
+
)
|
|
192
|
+
response = @connection.run_request(
|
|
193
|
+
method,
|
|
194
|
+
url,
|
|
195
|
+
prepared_body,
|
|
196
|
+
request_headers,
|
|
197
|
+
) do |request|
|
|
198
|
+
apply_timeout(request, timeout_ms)
|
|
199
|
+
end
|
|
200
|
+
parse_response(response)
|
|
201
|
+
rescue Faraday::Error => error
|
|
202
|
+
mapped = map_error(error, method: method, path: path)
|
|
203
|
+
unless retryable_request?(method, headers) && mapped.retryable? && attempt < max_retries
|
|
204
|
+
raise mapped
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
sleep(retry_delay(error, attempt))
|
|
208
|
+
attempt += 1
|
|
209
|
+
retry
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def stream(method:, path:, path_params: {}, query: nil, body: nil, headers: {}, content_type: nil, timeout_ms: nil)
|
|
214
|
+
Enumerator.new do |output|
|
|
215
|
+
url = append_query(build_url(path, path_params), build_query(query))
|
|
216
|
+
parser = SSEParser.new(output)
|
|
217
|
+
stream_headers = build_headers(headers, content_type)
|
|
218
|
+
set_header(stream_headers, "Accept", "text/event-stream")
|
|
219
|
+
prepared_body, stream_headers = prepare_request_body(
|
|
220
|
+
body,
|
|
221
|
+
content_type,
|
|
222
|
+
stream_headers,
|
|
223
|
+
)
|
|
224
|
+
@connection.run_request(
|
|
225
|
+
method.to_sym,
|
|
226
|
+
url,
|
|
227
|
+
prepared_body,
|
|
228
|
+
stream_headers
|
|
229
|
+
) do |request|
|
|
230
|
+
apply_timeout(request, timeout_ms)
|
|
231
|
+
request.options.on_data = proc { |chunk, _bytes| parser.write(chunk) }
|
|
232
|
+
end
|
|
233
|
+
parser.finish
|
|
234
|
+
rescue Faraday::Error => error
|
|
235
|
+
raise map_error(error, method: method, path: path)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
private
|
|
240
|
+
|
|
241
|
+
def apply_timeout(request, timeout_ms)
|
|
242
|
+
seconds = timeout_ms.nil? ? @config.timeout.to_f : timeout_ms.to_f / 1000.0
|
|
243
|
+
if seconds <= 0
|
|
244
|
+
raise SDKError.new("timeout_ms must be greater than zero", 0, "invalid_timeout")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
request.options.timeout = seconds
|
|
248
|
+
request.options.open_timeout = [seconds / 3.0, 2.0].max
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def prepare_request_body(body, content_type, headers)
|
|
252
|
+
return [nil, headers] if body.nil?
|
|
253
|
+
|
|
254
|
+
media_type = content_type.to_s.split(";", 2).first.to_s.strip.downcase
|
|
255
|
+
if media_type.empty? || media_type == "application/json" || media_type.end_with?("+json")
|
|
256
|
+
normalized_body = body.respond_to?(:to_h) ? body.to_h : body
|
|
257
|
+
return [normalized_body, headers]
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
if media_type == "multipart/form-data"
|
|
261
|
+
boundary = "tesseract-#{SecureRandom.hex(16)}"
|
|
262
|
+
multipart_headers = headers.dup
|
|
263
|
+
set_header(
|
|
264
|
+
multipart_headers,
|
|
265
|
+
"Content-Type",
|
|
266
|
+
"multipart/form-data; boundary=#{boundary}",
|
|
267
|
+
)
|
|
268
|
+
return [encode_multipart_body(body, boundary), multipart_headers]
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
[read_raw_body(body), headers]
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def read_raw_body(body)
|
|
275
|
+
return body if body.is_a?(String)
|
|
276
|
+
return body.to_str if body.respond_to?(:to_str)
|
|
277
|
+
|
|
278
|
+
if body.respond_to?(:read)
|
|
279
|
+
contents = body.read
|
|
280
|
+
return contents.to_s if contents.nil? || contents.is_a?(String)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
return body.to_s if body.is_a?(Numeric) || body == true || body == false
|
|
284
|
+
|
|
285
|
+
raise SDKError.new(
|
|
286
|
+
"Raw request bodies must be strings or readable streams",
|
|
287
|
+
0,
|
|
288
|
+
"invalid_request_body",
|
|
289
|
+
)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def encode_multipart_body(body, boundary)
|
|
293
|
+
fields = if body.is_a?(Hash)
|
|
294
|
+
body
|
|
295
|
+
elsif body.respond_to?(:to_h)
|
|
296
|
+
body.to_h
|
|
297
|
+
end
|
|
298
|
+
unless fields.is_a?(Hash)
|
|
299
|
+
raise SDKError.new(
|
|
300
|
+
"Multipart request bodies must be hashes or model objects",
|
|
301
|
+
0,
|
|
302
|
+
"invalid_request_body",
|
|
303
|
+
)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
encoded = String.new(encoding: Encoding::BINARY)
|
|
307
|
+
fields.each do |name, value|
|
|
308
|
+
append_multipart_fields(encoded, boundary, name, value)
|
|
309
|
+
end
|
|
310
|
+
encoded << "--#{boundary}--\r\n".b
|
|
311
|
+
encoded
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def append_multipart_fields(buffer, boundary, name, value)
|
|
315
|
+
return if value.nil?
|
|
316
|
+
|
|
317
|
+
if !value.is_a?(Hash) && !value.is_a?(Array) && value.respond_to?(:to_h) && !value.respond_to?(:read)
|
|
318
|
+
value = value.to_h
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
descriptor = multipart_descriptor(value)
|
|
322
|
+
if descriptor
|
|
323
|
+
append_multipart_part(
|
|
324
|
+
buffer,
|
|
325
|
+
boundary,
|
|
326
|
+
descriptor[:name] || name,
|
|
327
|
+
descriptor[:contents],
|
|
328
|
+
descriptor[:filename],
|
|
329
|
+
descriptor[:content_type],
|
|
330
|
+
)
|
|
331
|
+
elsif value.is_a?(Array)
|
|
332
|
+
value.each { |item| append_multipart_fields(buffer, boundary, name, item) }
|
|
333
|
+
elsif value.is_a?(Hash)
|
|
334
|
+
value.each do |nested_name, nested_value|
|
|
335
|
+
append_multipart_fields(
|
|
336
|
+
buffer,
|
|
337
|
+
boundary,
|
|
338
|
+
"#{name}[#{nested_name}]",
|
|
339
|
+
nested_value,
|
|
340
|
+
)
|
|
341
|
+
end
|
|
342
|
+
else
|
|
343
|
+
append_multipart_part(buffer, boundary, name, value, nil, nil)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def multipart_descriptor(value)
|
|
348
|
+
return nil unless value.is_a?(Hash)
|
|
349
|
+
return nil unless value.key?(:contents) || value.key?("contents")
|
|
350
|
+
|
|
351
|
+
keys = value.keys.map(&:to_s)
|
|
352
|
+
allowed = %w[contents name filename content_type contentType]
|
|
353
|
+
return nil unless (keys - allowed).empty?
|
|
354
|
+
|
|
355
|
+
{
|
|
356
|
+
name: value[:name] || value["name"],
|
|
357
|
+
contents: value.key?(:contents) ? value[:contents] : value["contents"],
|
|
358
|
+
filename: value[:filename] || value["filename"],
|
|
359
|
+
content_type: value[:content_type] || value["content_type"] || value["contentType"],
|
|
360
|
+
}
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def append_multipart_part(buffer, boundary, name, value, filename, content_type)
|
|
364
|
+
contents = value
|
|
365
|
+
if value.respond_to?(:read)
|
|
366
|
+
contents = value.read.to_s
|
|
367
|
+
filename ||= multipart_filename(value)
|
|
368
|
+
content_type ||= "application/octet-stream"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
unless contents.is_a?(String) || contents.respond_to?(:to_str)
|
|
372
|
+
contents = contents.to_s
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
disposition = "Content-Disposition: form-data; name=\"#{multipart_quote(name)}\""
|
|
376
|
+
disposition += "; filename=\"#{multipart_quote(filename)}\"" if filename
|
|
377
|
+
|
|
378
|
+
buffer << "--#{boundary}\r\n".b
|
|
379
|
+
buffer << disposition.b << "\r\n".b
|
|
380
|
+
buffer << "Content-Type: #{content_type}\r\n".b if content_type
|
|
381
|
+
buffer << "\r\n".b
|
|
382
|
+
buffer << contents.to_str.b
|
|
383
|
+
buffer << "\r\n".b
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def multipart_filename(value)
|
|
387
|
+
if value.respond_to?(:original_filename) && value.original_filename
|
|
388
|
+
return value.original_filename.to_s
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
return nil unless value.respond_to?(:path)
|
|
392
|
+
|
|
393
|
+
path = value.path
|
|
394
|
+
path.nil? || path.to_s.empty? ? nil : File.basename(path.to_s)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def multipart_quote(value)
|
|
398
|
+
value.to_s
|
|
399
|
+
.gsub(/[\r\n]/, "")
|
|
400
|
+
.gsub(/["\\]/) { |character| "\\#{character}" }
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def build_url(path, path_params)
|
|
404
|
+
expanded = if path_params.empty?
|
|
405
|
+
path
|
|
406
|
+
else
|
|
407
|
+
path.gsub(/(?:\$\{(\w+)\}|:(\w+))/) do |match|
|
|
408
|
+
key = Regexp.last_match(1) || Regexp.last_match(2)
|
|
409
|
+
value = if path_params.key?(key.to_sym)
|
|
410
|
+
path_params[key.to_sym]
|
|
411
|
+
elsif path_params.key?(key)
|
|
412
|
+
path_params[key]
|
|
413
|
+
end
|
|
414
|
+
value.nil? ? match : URI.encode_www_form_component(wire_value(value)).gsub("+", "%20")
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
expanded.sub(%r{\A/+}, "")
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def build_headers(extra_headers = {}, content_type = nil)
|
|
421
|
+
headers = {}
|
|
422
|
+
headers["Content-Type"] = content_type if content_type
|
|
423
|
+
headers.merge!(extra_headers)
|
|
424
|
+
claimed_security_headers = {}
|
|
425
|
+
headers
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def build_query(query)
|
|
429
|
+
request_pairs = []
|
|
430
|
+
(query || {}).each do |key, value|
|
|
431
|
+
next if value.nil?
|
|
432
|
+
|
|
433
|
+
values = value.is_a?(Array) ? value : [value]
|
|
434
|
+
values.each do |item|
|
|
435
|
+
request_pairs << [key.to_s, wire_value(item)] unless item.nil?
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
request_names = request_pairs.map(&:first).to_h { |name| [name, true] }
|
|
440
|
+
pairs = @base_query_pairs.reject { |key, _| request_names.key?(key) }
|
|
441
|
+
pairs.concat(request_pairs)
|
|
442
|
+
pairs
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def append_query(path, pairs)
|
|
446
|
+
query = URI.encode_www_form(pairs)
|
|
447
|
+
query.empty? ? path : "#{path}?#{query}"
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def wire_value(value)
|
|
451
|
+
return "true" if value == true
|
|
452
|
+
return "false" if value == false
|
|
453
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
|
454
|
+
|
|
455
|
+
value.to_s
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def set_header(headers, name, value)
|
|
459
|
+
headers.delete_if { |key, _| key.to_s.casecmp(name).zero? }
|
|
460
|
+
headers[name] = value
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def set_security_header(headers, claimed_headers, name, value)
|
|
464
|
+
normalized = name.to_s.downcase
|
|
465
|
+
if claimed_headers.key?(normalized)
|
|
466
|
+
raise ArgumentError, "Multiple configured security schemes target the #{name} header"
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
claimed_headers[normalized] = true
|
|
470
|
+
set_header(headers, name, value)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def credential_configured?(value)
|
|
474
|
+
!value.nil? && !value.to_s.empty?
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def set_security_cookie(headers, name, value)
|
|
478
|
+
cookie_key = headers.keys.find { |key| key.to_s.casecmp("Cookie").zero? }
|
|
479
|
+
pairs = cookie_key ? headers.delete(cookie_key).to_s.split(";") : []
|
|
480
|
+
pairs.reject! { |pair| pair.partition("=").first.strip == name }
|
|
481
|
+
pairs << "#{name}=#{self.class.encode_cookie_component(value)}"
|
|
482
|
+
headers["Cookie"] = pairs.map(&:strip).reject(&:empty?).join("; ")
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def self.encode_cookie_component(value)
|
|
486
|
+
value.to_s.bytes.map do |byte|
|
|
487
|
+
character = byte.chr
|
|
488
|
+
if (byte >= 0x30 && byte <= 0x39) ||
|
|
489
|
+
(byte >= 0x41 && byte <= 0x5A) ||
|
|
490
|
+
(byte >= 0x61 && byte <= 0x7A) ||
|
|
491
|
+
COOKIE_SAFE_CHARACTERS.include?(character)
|
|
492
|
+
character
|
|
493
|
+
else
|
|
494
|
+
format("%%%02X", byte)
|
|
495
|
+
end
|
|
496
|
+
end.join
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def parse_response(response)
|
|
500
|
+
body = response.body
|
|
501
|
+
ensure_response_body_size!(body, response.status)
|
|
502
|
+
return nil if body.nil? || (body.is_a?(String) && body.strip.empty?)
|
|
503
|
+
return body unless body.is_a?(String)
|
|
504
|
+
|
|
505
|
+
JSON.parse(body)
|
|
506
|
+
rescue JSON::ParserError
|
|
507
|
+
body
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def ensure_response_body_size!(body, status = 0, request_id: nil, method: nil, path: nil)
|
|
511
|
+
return body unless body.is_a?(String) && body.bytesize > MAX_RESPONSE_BODY_BYTES
|
|
512
|
+
|
|
513
|
+
raise SDKError.new(
|
|
514
|
+
"Response body exceeded the configured safety limit",
|
|
515
|
+
status.to_i,
|
|
516
|
+
"response_body_too_large",
|
|
517
|
+
request_id: request_id,
|
|
518
|
+
method: method,
|
|
519
|
+
path: path,
|
|
520
|
+
)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def retryable_request?(method, headers)
|
|
524
|
+
return true if @config.retry_unsafe_requests
|
|
525
|
+
return true if RETRYABLE_METHODS.include?(method)
|
|
526
|
+
|
|
527
|
+
@config.default_headers.merge(headers).any? do |key, value|
|
|
528
|
+
["Idempotency-Key", "X-Idempotency-Key"].any? { |name| key.to_s.casecmp(name).zero? } &&
|
|
529
|
+
!value.to_s.empty?
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def retry_delay(error, attempt)
|
|
534
|
+
retry_after = retry_after_seconds(error)
|
|
535
|
+
return retry_after unless retry_after.nil?
|
|
536
|
+
|
|
537
|
+
base = [(@config.retries[:backoff] || 0.4).to_f, 0.0].max
|
|
538
|
+
max_backoff = retry_max_backoff
|
|
539
|
+
delay = [base * (2**attempt), max_backoff].min
|
|
540
|
+
@config.retries.fetch(:jitter, true) ? rand * delay : delay
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def retry_max_backoff
|
|
544
|
+
[[(@config.retries[:max_backoff] || MAX_RETRY_DELAY).to_f, 0.0].max, MAX_RETRY_DELAY].min
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def retry_after_seconds(error)
|
|
548
|
+
headers = error.response&.[](:headers) || {}
|
|
549
|
+
value = headers["retry-after"] || headers["Retry-After"]
|
|
550
|
+
return nil if value.nil? || value.to_s.empty?
|
|
551
|
+
|
|
552
|
+
[[Float(value), 0.0].max, retry_max_backoff].min
|
|
553
|
+
rescue ArgumentError, TypeError
|
|
554
|
+
begin
|
|
555
|
+
[
|
|
556
|
+
[Time.httpdate(value.to_s) - Time.now, 0.0].max,
|
|
557
|
+
retry_max_backoff
|
|
558
|
+
].min
|
|
559
|
+
rescue ArgumentError
|
|
560
|
+
nil
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def map_error(error, method:, path:)
|
|
565
|
+
status = error.response&.[](:status).to_i
|
|
566
|
+
headers = error.response&.[](:headers) || {}
|
|
567
|
+
request_id = headers["x-request-id"] || headers["X-Request-ID"] || headers["request-id"]
|
|
568
|
+
|
|
569
|
+
case error
|
|
570
|
+
when Faraday::TimeoutError
|
|
571
|
+
SDKError.new("Request timed out", 0, "timeout", method: method, path: path)
|
|
572
|
+
when Faraday::ConnectionFailed
|
|
573
|
+
SDKError.new("Connection failed: #{error.message}", 0, "network_error", method: method, path: path)
|
|
574
|
+
else
|
|
575
|
+
body = error.response&.[](:body)
|
|
576
|
+
ensure_response_body_size!(
|
|
577
|
+
body,
|
|
578
|
+
status,
|
|
579
|
+
request_id: request_id,
|
|
580
|
+
method: method,
|
|
581
|
+
path: path,
|
|
582
|
+
)
|
|
583
|
+
envelope = parse_error_body(body)
|
|
584
|
+
message = envelope["message"] || envelope.dig("error", "message") || error.message
|
|
585
|
+
code = envelope["code"] || envelope.dig("error", "code")
|
|
586
|
+
code ||= envelope["error"] if envelope["error"].is_a?(String)
|
|
587
|
+
SDKError.new(
|
|
588
|
+
message,
|
|
589
|
+
status,
|
|
590
|
+
code || default_error_code(status),
|
|
591
|
+
request_id: envelope["request_id"] || request_id,
|
|
592
|
+
method: method,
|
|
593
|
+
path: path,
|
|
594
|
+
details: envelope["details"]
|
|
595
|
+
)
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def parse_error_body(body)
|
|
600
|
+
return body if body.is_a?(Hash)
|
|
601
|
+
return {} unless body.is_a?(String) && !body.empty?
|
|
602
|
+
|
|
603
|
+
parsed = JSON.parse(body)
|
|
604
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
605
|
+
rescue JSON::ParserError
|
|
606
|
+
{}
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def default_error_code(status)
|
|
610
|
+
return "network_error" if status.zero?
|
|
611
|
+
return "rate_limit_exceeded" if status == 429
|
|
612
|
+
return "request_timeout" if status == 408
|
|
613
|
+
return "internal_server_error" if status >= 500
|
|
614
|
+
|
|
615
|
+
"http_error"
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
end
|