fsdk-2237-ruby-e2e 0.1.0
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/README.md +107 -0
- data/lib/petstore/client.rb +45 -0
- data/lib/petstore/config.rb +54 -0
- data/lib/petstore/environment.rb +7 -0
- data/lib/petstore/error.rb +40 -0
- data/lib/petstore/errors/api_error.rb +9 -0
- data/lib/petstore/errors/client_error.rb +43 -0
- data/lib/petstore/errors/connection_error.rb +11 -0
- data/lib/petstore/errors/redirect_error.rb +9 -0
- data/lib/petstore/errors/response_error.rb +92 -0
- data/lib/petstore/errors/server_error.rb +25 -0
- data/lib/petstore/errors/timeout_error.rb +9 -0
- data/lib/petstore/http/auth.rb +19 -0
- data/lib/petstore/http/oauth_token_manager.rb +91 -0
- data/lib/petstore/internal/errors/constraint_error.rb +10 -0
- data/lib/petstore/internal/errors/type_error.rb +10 -0
- data/lib/petstore/internal/form/request.rb +110 -0
- data/lib/petstore/internal/http/base_request.rb +64 -0
- data/lib/petstore/internal/http/event_stream.rb +106 -0
- data/lib/petstore/internal/http/raw_client.rb +426 -0
- data/lib/petstore/internal/http/response_body.rb +35 -0
- data/lib/petstore/internal/iterators/cursor_item_iterator.rb +28 -0
- data/lib/petstore/internal/iterators/cursor_page_iterator.rb +72 -0
- data/lib/petstore/internal/iterators/item_iterator.rb +72 -0
- data/lib/petstore/internal/iterators/offset_item_iterator.rb +30 -0
- data/lib/petstore/internal/iterators/offset_page_iterator.rb +110 -0
- data/lib/petstore/internal/json/request.rb +61 -0
- data/lib/petstore/internal/json/serializable.rb +25 -0
- data/lib/petstore/internal/multipart/multipart_encoder.rb +141 -0
- data/lib/petstore/internal/multipart/multipart_form_data.rb +78 -0
- data/lib/petstore/internal/multipart/multipart_form_data_part.rb +51 -0
- data/lib/petstore/internal/multipart/multipart_request.rb +49 -0
- data/lib/petstore/internal/raw/request.rb +88 -0
- data/lib/petstore/internal/types/array.rb +47 -0
- data/lib/petstore/internal/types/boolean.rb +34 -0
- data/lib/petstore/internal/types/enum.rb +56 -0
- data/lib/petstore/internal/types/hash.rb +36 -0
- data/lib/petstore/internal/types/model/field.rb +38 -0
- data/lib/petstore/internal/types/model.rb +246 -0
- data/lib/petstore/internal/types/type.rb +35 -0
- data/lib/petstore/internal/types/union.rb +193 -0
- data/lib/petstore/internal/types/unknown.rb +15 -0
- data/lib/petstore/internal/types/utils.rb +137 -0
- data/lib/petstore/models/open_model.rb +30 -0
- data/lib/petstore/models/serializable.rb +63 -0
- data/lib/petstore/models/union_type.rb +12 -0
- data/lib/petstore/pet/client.rb +196 -0
- data/lib/petstore/pet/types/delete_pet_request.rb +13 -0
- data/lib/petstore/pet/types/find_pets_by_status_request.rb +11 -0
- data/lib/petstore/pet/types/find_pets_by_status_request_status.rb +24 -0
- data/lib/petstore/pet/types/find_pets_by_tags_request.rb +11 -0
- data/lib/petstore/pet/types/get_pet_by_id_request.rb +11 -0
- data/lib/petstore/pet/types/pet.rb +25 -0
- data/lib/petstore/pet/types/update_pet_with_form_request.rb +15 -0
- data/lib/petstore/serializers/form_serializer.rb +41 -0
- data/lib/petstore/serializers/json_serializer.rb +41 -0
- data/lib/petstore/serializers/xml_serializer.rb +56 -0
- data/lib/petstore/store/client.rb +89 -0
- data/lib/petstore/store/types/delete_order_request.rb +11 -0
- data/lib/petstore/store/types/get_order_by_id_request.rb +11 -0
- data/lib/petstore/store/types/order.rb +25 -0
- data/lib/petstore/types/api_response.rb +20 -0
- data/lib/petstore/types/category.rb +19 -0
- data/lib/petstore/types/order_status.rb +22 -0
- data/lib/petstore/types/pet_status.rb +22 -0
- data/lib/petstore/types/tag.rb +19 -0
- data/lib/petstore/user/client.rb +149 -0
- data/lib/petstore/user/types/delete_user_request.rb +11 -0
- data/lib/petstore/user/types/get_user_by_name_request.rb +11 -0
- data/lib/petstore/user/types/login_user_request.rb +13 -0
- data/lib/petstore/user/types/user.rb +27 -0
- data/lib/petstore/validator.rb +42 -0
- data/lib/petstore/version.rb +5 -0
- data/lib/petstore.rb +78 -0
- metadata +236 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Required here rather than inherited from elsewhere in the load order: this file is the only one
|
|
4
|
+
# in the generated runtime that talks to Net::HTTP, and TIMEOUT_ERRORS names its exception classes
|
|
5
|
+
# at class-definition time.
|
|
6
|
+
require 'net/http'
|
|
7
|
+
require 'openssl'
|
|
8
|
+
require 'uri'
|
|
9
|
+
|
|
10
|
+
module Petstore
|
|
11
|
+
module Internal
|
|
12
|
+
module Http
|
|
13
|
+
# @api private
|
|
14
|
+
class RawClient
|
|
15
|
+
# The retry policy this SDK was generated with. Every value is overridable per client via
|
|
16
|
+
# `retry_config:`, and the keys are the ones the rest of the generated SDKs already use
|
|
17
|
+
# (the legacy Ruby `HTTP::Connection` and the Python `RetryHandler`), so there is one shape
|
|
18
|
+
# to learn rather than a Ruby-only third one.
|
|
19
|
+
DEFAULT_RETRY_CONFIG = {
|
|
20
|
+
# Retried status codes. `retry_all_server_errors` covers the open-ended 5xx range that
|
|
21
|
+
# cannot be enumerated.
|
|
22
|
+
status_codes_to_retry: [408, 429].freeze,
|
|
23
|
+
retry_all_server_errors: true,
|
|
24
|
+
# HTTP methods allowed to retry at all. A method absent here is never retried; a method
|
|
25
|
+
# present here is retried only if replaying it is safe (see `#retryable_method?`).
|
|
26
|
+
http_methods_to_retry: %w[GET POST PUT DELETE PATCH HEAD OPTIONS].freeze,
|
|
27
|
+
# Exponential backoff, in milliseconds: delay_ms * (backoff_factor ** attempt), capped at
|
|
28
|
+
# max_delay_ms, plus up to jitter_ms of random jitter.
|
|
29
|
+
delay_ms: 150,
|
|
30
|
+
max_delay_ms: 5_000,
|
|
31
|
+
backoff_factor: 2,
|
|
32
|
+
jitter_ms: 50,
|
|
33
|
+
# Ceiling for a server-directed wait parsed from Retry-After. Separate from max_delay_ms
|
|
34
|
+
# because a server asking for a long wait is legitimate; this only guards against a
|
|
35
|
+
# pathological value.
|
|
36
|
+
max_retry_after_delay_ms: 60_000,
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# Methods RFC 9110 defines as idempotent: replaying one cannot cause a second effect, so a
|
|
40
|
+
# retry is safe. POST and PATCH are absent deliberately — see `#retryable_method?`.
|
|
41
|
+
IDEMPOTENT_METHODS = %w[GET HEAD PUT DELETE OPTIONS TRACE].freeze
|
|
42
|
+
|
|
43
|
+
# Request headers that carry a caller-supplied idempotency key. A server that honours one
|
|
44
|
+
# deduplicates a replay, which is what makes retrying a non-idempotent method safe.
|
|
45
|
+
IDEMPOTENCY_KEY_HEADERS = %w[Idempotency-Key].freeze
|
|
46
|
+
|
|
47
|
+
# Net::HTTP's three timeout exceptions, all reachable because `#send` sets open_timeout,
|
|
48
|
+
# read_timeout AND write_timeout. Deliberately not widened to their shared parent
|
|
49
|
+
# `Timeout::Error`, which would also swallow an unrelated `Timeout.timeout` block in caller
|
|
50
|
+
# code.
|
|
51
|
+
# `IO::TimeoutError` is the fourth and is NOT a Net::HTTP class: since Ruby 3.2 an expired
|
|
52
|
+
# connect deadline surfaces from the IO layer, and it descends from IOError rather than
|
|
53
|
+
# Timeout::Error, so none of the three above catch it and CONNECTION_ERRORS excludes bare
|
|
54
|
+
# IOError. Without it a timed-out connect escaped unwrapped on ruby 3.3 -- the version the
|
|
55
|
+
# gemspec floors at and the e2e image runs -- while raising Net::OpenTimeout on 4.x, so it
|
|
56
|
+
# looked correct locally. No `defined?` guard: the gemspec requires >= 3.3.
|
|
57
|
+
TIMEOUT_ERRORS = [::Net::OpenTimeout, ::Net::ReadTimeout, ::Net::WriteTimeout, ::IO::TimeoutError].freeze
|
|
58
|
+
|
|
59
|
+
# Failures that mean the request never reached the server. `SocketError` covers DNS
|
|
60
|
+
# resolution (`Socket::ResolutionError` derives from it); `EOFError` is the peer closing
|
|
61
|
+
# mid-exchange; `OpenSSL::SSL::SSLError` is a handshake or certificate failure, which is
|
|
62
|
+
# still a connection that could not be established. Bare `IOError` is deliberately absent:
|
|
63
|
+
# it is broad enough to catch an unrelated failure from a caller-supplied IO body.
|
|
64
|
+
CONNECTION_ERRORS = [
|
|
65
|
+
::SocketError,
|
|
66
|
+
::EOFError,
|
|
67
|
+
::Errno::ECONNREFUSED,
|
|
68
|
+
::Errno::ECONNRESET,
|
|
69
|
+
::Errno::ECONNABORTED,
|
|
70
|
+
::Errno::EHOSTUNREACH,
|
|
71
|
+
::Errno::ENETUNREACH,
|
|
72
|
+
::Errno::EPIPE,
|
|
73
|
+
::Errno::ETIMEDOUT,
|
|
74
|
+
::OpenSSL::SSL::SSLError,
|
|
75
|
+
].freeze
|
|
76
|
+
|
|
77
|
+
# Both sets, in the order `#wrap_transport_error` classifies them. TIMEOUT_ERRORS is
|
|
78
|
+
# checked first because Net::WriteTimeout is an IOError descendant and would otherwise be
|
|
79
|
+
# ambiguous.
|
|
80
|
+
TRANSPORT_ERRORS = (TIMEOUT_ERRORS + CONNECTION_ERRORS).freeze
|
|
81
|
+
|
|
82
|
+
# Open/read/write timeout in seconds when neither the client nor the call names one. Held as
|
|
83
|
+
# a constant rather than a keyword default so the root client can offer a `timeout:` of its
|
|
84
|
+
# own without restating the number.
|
|
85
|
+
DEFAULT_TIMEOUT = 60.0
|
|
86
|
+
|
|
87
|
+
# @return [String] The base URL for requests
|
|
88
|
+
attr_reader :base_url
|
|
89
|
+
|
|
90
|
+
# @param base_url [String] The base url for the request.
|
|
91
|
+
# @param max_retries [Integer] The number of times to retry a failed request, defaults to 2.
|
|
92
|
+
# @param timeout [Float, nil] The timeout for the request; DEFAULT_TIMEOUT when nil.
|
|
93
|
+
# @param headers [Hash] The headers for the request.
|
|
94
|
+
# @param token_provider [Proc, nil] Optional callable returning the Authorization header
|
|
95
|
+
# value; invoked on every request so dynamic (OAuth/refresh) credentials stay current.
|
|
96
|
+
# @param retry_config [Hash] Overrides for any key of DEFAULT_RETRY_CONFIG.
|
|
97
|
+
def initialize(base_url:, max_retries: 2, timeout: nil, headers: {}, token_provider: nil, retry_config: {})
|
|
98
|
+
@base_url = base_url
|
|
99
|
+
@max_retries = max_retries
|
|
100
|
+
@timeout = timeout || DEFAULT_TIMEOUT
|
|
101
|
+
@token_provider = token_provider
|
|
102
|
+
@retry_config = DEFAULT_RETRY_CONFIG.merge(retry_config.to_h.transform_keys(&:to_sym))
|
|
103
|
+
@default_headers = {
|
|
104
|
+
'User-Agent': 'postman-codegen/3.3.0 petstore/0.1.0 (ruby)',
|
|
105
|
+
'X-Fern-Language': 'Ruby',
|
|
106
|
+
'X-Fern-SDK-Name': 'petstore',
|
|
107
|
+
'X-Fern-SDK-Version': '0.1.0',
|
|
108
|
+
}.merge(headers)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @param request [::Petstore::Internal::Http::BaseRequest] The HTTP request.
|
|
112
|
+
# @return [Net::HTTPResponse] The HTTP response.
|
|
113
|
+
# @raise [::Petstore::Errors::TimeoutError] The request timed out and no retry remained.
|
|
114
|
+
# @raise [::Petstore::Errors::ConnectionError] The request never reached the server.
|
|
115
|
+
def send(request)
|
|
116
|
+
url = build_url(request)
|
|
117
|
+
timeout = resolve_timeout(request)
|
|
118
|
+
attempt = 0
|
|
119
|
+
response = nil
|
|
120
|
+
|
|
121
|
+
loop do
|
|
122
|
+
# Refresh the dynamic auth header (OAuth/refresh) before each attempt, if configured.
|
|
123
|
+
@default_headers.merge!(resolve_auth_headers)
|
|
124
|
+
|
|
125
|
+
# Encoded ONCE per attempt and then reused for the retry decision. `encode_headers` is
|
|
126
|
+
# not a pure getter — `Multipart::Request` reads its body's content type off the
|
|
127
|
+
# encoder — so asking the request again from inside `should_retry?` re-ran that work on
|
|
128
|
+
# every attempt for no gain.
|
|
129
|
+
headers = request.encode_headers(protected_keys: protected_header_keys)
|
|
130
|
+
|
|
131
|
+
http_request = build_http_request(
|
|
132
|
+
url:,
|
|
133
|
+
method: request.method,
|
|
134
|
+
headers:,
|
|
135
|
+
body: request.encode_body,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
begin
|
|
139
|
+
conn = connect(url)
|
|
140
|
+
conn.open_timeout = timeout
|
|
141
|
+
conn.read_timeout = timeout
|
|
142
|
+
conn.write_timeout = timeout
|
|
143
|
+
conn.continue_timeout = timeout
|
|
144
|
+
|
|
145
|
+
response = conn.request(http_request)
|
|
146
|
+
rescue *TRANSPORT_ERRORS => e
|
|
147
|
+
raise wrap_transport_error(e) unless retry_transport_error?(request, attempt, headers)
|
|
148
|
+
|
|
149
|
+
sleep(retry_delay(nil, attempt))
|
|
150
|
+
attempt += 1
|
|
151
|
+
next
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
break unless should_retry?(request, response, attempt, headers)
|
|
155
|
+
|
|
156
|
+
delay = retry_delay(response, attempt)
|
|
157
|
+
sleep(delay)
|
|
158
|
+
attempt += 1
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
response
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The read/open/write timeout for this request, in seconds: `request_options[:timeout]`
|
|
165
|
+
# when the caller supplied one for this call, else the client's own.
|
|
166
|
+
# @param request [::Petstore::Internal::Http::BaseRequest] The HTTP request.
|
|
167
|
+
# @return [Float, Integer, nil] The timeout in seconds.
|
|
168
|
+
def resolve_timeout(request)
|
|
169
|
+
options = request.request_options
|
|
170
|
+
per_request = options&.dig(:timeout) || options&.dig('timeout')
|
|
171
|
+
per_request || @timeout
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Turns a transport-level exception into this SDK's own error type, so a caller can rescue
|
|
175
|
+
# a timeout or a failed connection without importing Net::HTTP's or Socket's exception
|
|
176
|
+
# classes. Ruby records the original on `#cause`, so nothing is lost. No status code is
|
|
177
|
+
# invented: neither failure produced a response, so there is none to report.
|
|
178
|
+
# @param error [StandardError] The transport exception.
|
|
179
|
+
# @return [::Petstore::Errors::ApiError] The SDK-owned error to raise.
|
|
180
|
+
def wrap_transport_error(error)
|
|
181
|
+
return ::Petstore::Errors::TimeoutError.new(error.message) if TIMEOUT_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
182
|
+
|
|
183
|
+
::Petstore::Errors::ConnectionError.new(error.message)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Whether a transport failure may be replayed. A timeout leaves the request's fate unknown —
|
|
187
|
+
# the server may have applied it and only failed to answer in time — so this reuses the same
|
|
188
|
+
# idempotency gate as a status-code retry rather than a looser one. A connection failure is
|
|
189
|
+
# held to the same gate: a reset or a broken pipe can happen after the server accepted the
|
|
190
|
+
# request, so it is no safer to replay than a timeout.
|
|
191
|
+
# @param request [::Petstore::Internal::Http::BaseRequest] The HTTP request.
|
|
192
|
+
# @param attempt [Integer] The current retry attempt (0-indexed).
|
|
193
|
+
# @param headers [Hash] The headers already encoded for this attempt by `#send`.
|
|
194
|
+
# @return [Boolean] Whether the request should be retried.
|
|
195
|
+
def retry_transport_error?(request, attempt, headers)
|
|
196
|
+
return false if attempt >= @max_retries
|
|
197
|
+
|
|
198
|
+
retryable_method?(request, headers)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Determines if a request should be retried, from the response status AND the request
|
|
202
|
+
# method. The method half is not optional: a retry re-sends the request, so retrying a
|
|
203
|
+
# non-idempotent write can duplicate it — a second charge, a second row — and a 503 is
|
|
204
|
+
# exactly the case where the server may well have applied the first attempt before failing
|
|
205
|
+
# to answer.
|
|
206
|
+
# @param request [::Petstore::Internal::Http::BaseRequest] The HTTP request.
|
|
207
|
+
# @param response [Net::HTTPResponse] The HTTP response.
|
|
208
|
+
# @param attempt [Integer] The current retry attempt (0-indexed).
|
|
209
|
+
# @param headers [Hash] The headers already encoded for this attempt by `#send`.
|
|
210
|
+
# @return [Boolean] Whether the request should be retried.
|
|
211
|
+
def should_retry?(request, response, attempt, headers)
|
|
212
|
+
return false if attempt >= @max_retries
|
|
213
|
+
return false unless retryable_method?(request, headers)
|
|
214
|
+
|
|
215
|
+
retryable_status?(response.code.to_i)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# @param status [Integer] The response status code.
|
|
219
|
+
# @return [Boolean] Whether the status code is retryable.
|
|
220
|
+
def retryable_status?(status)
|
|
221
|
+
return true if @retry_config[:retry_all_server_errors] && status >= 500
|
|
222
|
+
|
|
223
|
+
@retry_config[:status_codes_to_retry].include?(status)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Whether this request may be replayed.
|
|
227
|
+
#
|
|
228
|
+
# Two gates. The configured method list decides whether the method is eligible at all.
|
|
229
|
+
# Eligibility alone is not enough for a non-idempotent method, though: replaying a POST or
|
|
230
|
+
# a PATCH can duplicate its effect, so one is retried only when the caller supplied an
|
|
231
|
+
# idempotency key for the server to deduplicate on.
|
|
232
|
+
# @param request [::Petstore::Internal::Http::BaseRequest] The HTTP request.
|
|
233
|
+
# @param headers [Hash] The headers already encoded for this attempt by `#send`.
|
|
234
|
+
# @return [Boolean] Whether the request is safe to replay.
|
|
235
|
+
def retryable_method?(request, headers)
|
|
236
|
+
method = request.method.to_s.upcase
|
|
237
|
+
return false unless @retry_config[:http_methods_to_retry].include?(method)
|
|
238
|
+
return true if IDEMPOTENT_METHODS.include?(method)
|
|
239
|
+
|
|
240
|
+
idempotency_key?(headers)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Reads the headers `#send` already encoded rather than asking the request to encode them
|
|
244
|
+
# again: these are the headers actually going on the wire, and re-encoding per retry
|
|
245
|
+
# decision repeated work for an answer that cannot differ.
|
|
246
|
+
# @param headers [Hash] The encoded HTTP request headers.
|
|
247
|
+
# @return [Boolean] Whether the request carries a non-empty idempotency key header.
|
|
248
|
+
def idempotency_key?(headers)
|
|
249
|
+
wanted = IDEMPOTENCY_KEY_HEADERS.map(&:downcase)
|
|
250
|
+
headers.any? { |name, value| wanted.include?(name.to_s.downcase) && !value.to_s.empty? }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# The default-header names a per-request header may not override.
|
|
254
|
+
# @return [Array<Symbol>] The protected header names.
|
|
255
|
+
def protected_header_keys
|
|
256
|
+
@default_headers.keys
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# The dynamic auth headers for this attempt, re-resolved per attempt so a refreshed OAuth
|
|
260
|
+
# token reaches the retry. Empty when the client was built without a token provider.
|
|
261
|
+
# @return [Hash] The auth headers.
|
|
262
|
+
def resolve_auth_headers
|
|
263
|
+
return {} unless @token_provider
|
|
264
|
+
|
|
265
|
+
{ Authorization: @token_provider.call }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Calculates the delay before the next retry attempt using exponential backoff with jitter.
|
|
269
|
+
# Respects Retry-After header if present.
|
|
270
|
+
# @param response [Net::HTTPResponse, nil] The HTTP response, or nil after a transport failure.
|
|
271
|
+
# @param attempt [Integer] The current retry attempt (0-indexed).
|
|
272
|
+
# @return [Float] The delay in seconds before the next retry.
|
|
273
|
+
def retry_delay(response, attempt)
|
|
274
|
+
# Check for Retry-After header (can be seconds or HTTP date). Nil when the attempt ended
|
|
275
|
+
# in a transport failure rather than a response, which has no headers to read.
|
|
276
|
+
retry_after = response['Retry-After'] if response
|
|
277
|
+
if retry_after
|
|
278
|
+
delay = parse_retry_after(retry_after)
|
|
279
|
+
return [delay, @retry_config[:max_retry_after_delay_ms] / 1000.0].min if delay&.positive?
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Exponential backoff: delay_ms * (backoff_factor ** attempt), capped, plus jitter.
|
|
283
|
+
base_ms = @retry_config[:delay_ms] * (@retry_config[:backoff_factor]**attempt)
|
|
284
|
+
capped_ms = [base_ms, @retry_config[:max_delay_ms]].min
|
|
285
|
+
add_jitter(capped_ms) / 1000.0
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Spreads retries so concurrent clients do not resynchronise on the same backoff schedule.
|
|
289
|
+
# `to_i` because `jitter_ms` is overridable per client and only an Integer range gives
|
|
290
|
+
# `rand` a whole number of milliseconds to pick from — a Float bound answers with a Float,
|
|
291
|
+
# and a String bound (`"50"`) has no ordering against 0 at all.
|
|
292
|
+
# @param delay_ms [Numeric] The un-jittered delay in milliseconds.
|
|
293
|
+
# @return [Numeric] The delay in milliseconds with jitter added.
|
|
294
|
+
def add_jitter(delay_ms)
|
|
295
|
+
delay_ms + rand(0..@retry_config[:jitter_ms].to_i)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Parses the Retry-After header value.
|
|
299
|
+
# @param value [String] The Retry-After header value (seconds or HTTP date).
|
|
300
|
+
# @return [Float, nil] The delay in seconds, or nil if parsing fails.
|
|
301
|
+
def parse_retry_after(value)
|
|
302
|
+
# Try parsing as integer (seconds)
|
|
303
|
+
seconds = Integer(value, exception: false)
|
|
304
|
+
return seconds.to_f if seconds
|
|
305
|
+
|
|
306
|
+
# Try parsing as HTTP date
|
|
307
|
+
begin
|
|
308
|
+
retry_time = Time.httpdate(value)
|
|
309
|
+
delay = retry_time - Time.now
|
|
310
|
+
delay.positive? ? delay : nil
|
|
311
|
+
rescue ArgumentError
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
LOCALHOST_HOSTS = %w[localhost 127.0.0.1 [::1]].freeze
|
|
317
|
+
|
|
318
|
+
# @param request [::Petstore::Internal::Http::BaseRequest] The HTTP request.
|
|
319
|
+
# @return [URI::Generic] The URL.
|
|
320
|
+
def build_url(request)
|
|
321
|
+
encoded_query = request.encode_query
|
|
322
|
+
|
|
323
|
+
# If the path is already an absolute URL, use it directly
|
|
324
|
+
if request.path.start_with?('http://', 'https://')
|
|
325
|
+
url = request.path
|
|
326
|
+
url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any?
|
|
327
|
+
parsed = URI.parse(url)
|
|
328
|
+
validate_https!(parsed)
|
|
329
|
+
return parsed
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
path = request.path.start_with?('/') ? request.path[1..] : request.path
|
|
333
|
+
base = request.base_url || @base_url
|
|
334
|
+
url = "#{base.chomp('/')}/#{path}"
|
|
335
|
+
url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any?
|
|
336
|
+
parsed = URI.parse(url)
|
|
337
|
+
validate_https!(parsed)
|
|
338
|
+
parsed
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Raises if the URL uses http:// for a non-localhost host, which would
|
|
342
|
+
# send authentication credentials in plaintext.
|
|
343
|
+
# @param url [URI::Generic] The parsed URL.
|
|
344
|
+
def validate_https!(url)
|
|
345
|
+
return if url.scheme != 'http'
|
|
346
|
+
return if LOCALHOST_HOSTS.include?(url.host)
|
|
347
|
+
|
|
348
|
+
raise ArgumentError,
|
|
349
|
+
"Refusing to send request to non-HTTPS URL: #{url}. " \
|
|
350
|
+
'HTTP is only allowed for localhost. Use HTTPS or pass a localhost URL.'
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# @param url [URI::Generic] The url to the resource.
|
|
354
|
+
# @param method [String] The HTTP method to use.
|
|
355
|
+
# @param headers [Hash] The headers for the request.
|
|
356
|
+
# @param body [String, nil] The body for the request.
|
|
357
|
+
# @return [HTTP::Request] The HTTP request.
|
|
358
|
+
def build_http_request(url:, method:, headers: {}, body: nil)
|
|
359
|
+
request = Net::HTTPGenericRequest.new(
|
|
360
|
+
method,
|
|
361
|
+
!body.nil?,
|
|
362
|
+
method != 'HEAD',
|
|
363
|
+
url,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
request_headers = @default_headers.merge(headers)
|
|
367
|
+
request_headers.each { |name, value| request[name] = value }
|
|
368
|
+
request.body = body if body
|
|
369
|
+
|
|
370
|
+
# Net::HTTP silently turns OFF its transparent gzip/deflate decoding as soon as the request
|
|
371
|
+
# carries an explicit Accept-Encoding, handing back a compressed body. Re-assert it.
|
|
372
|
+
request.extend(DecodeContent) if request_headers.keys.any? { |name| name.to_s.casecmp('accept-encoding').zero? }
|
|
373
|
+
|
|
374
|
+
request
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Keeps Net::HTTP's transparent gzip/deflate response decoding enabled even when an
|
|
378
|
+
# Accept-Encoding header is set explicitly on the request.
|
|
379
|
+
# @api private
|
|
380
|
+
module DecodeContent
|
|
381
|
+
def decode_content # rubocop:disable Naming/PredicateMethod
|
|
382
|
+
true
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# @param query [Hash, Array<String>] The query for the request. An Array carries parts
|
|
387
|
+
# that +Serializers::Params+ has already style-serialized and percent-encoded (see
|
|
388
|
+
# +BaseRequest#encode_query+), so it is joined as-is — re-encoding it would escape the
|
|
389
|
+
# `=`, `&` and style delimiters it is built from.
|
|
390
|
+
# @return [String, nil] The encoded query.
|
|
391
|
+
def encode_query(query)
|
|
392
|
+
return query.empty? ? nil : query.join('&') if query.is_a?(::Array)
|
|
393
|
+
|
|
394
|
+
query.to_h.empty? ? nil : URI.encode_www_form(query)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# @param url [URI::Generic] The url to connect to.
|
|
398
|
+
# @return [Net::HTTP] The HTTP connection.
|
|
399
|
+
def connect(url)
|
|
400
|
+
is_https = (url.scheme == 'https')
|
|
401
|
+
|
|
402
|
+
port = if url.port
|
|
403
|
+
url.port
|
|
404
|
+
elsif is_https
|
|
405
|
+
Net::HTTP.https_default_port
|
|
406
|
+
else
|
|
407
|
+
Net::HTTP.http_default_port
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
http = Net::HTTP.new(url.host, port)
|
|
411
|
+
http.use_ssl = is_https
|
|
412
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER if is_https
|
|
413
|
+
# NOTE: We handle retries at the application level with HTTP status code awareness,
|
|
414
|
+
# so we set max_retries to 0 to disable Net::HTTP's built-in network-level retries.
|
|
415
|
+
http.max_retries = 0
|
|
416
|
+
http
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# @return [String]
|
|
420
|
+
def inspect
|
|
421
|
+
"#<#{self.class.name}:0x#{object_id.to_s(16)} @base_url=#{@base_url.inspect}>"
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petstore
|
|
4
|
+
module Internal
|
|
5
|
+
module Http
|
|
6
|
+
# Decoding a 2xx body. The service methods decide WHICH schema applies from the DECLARED
|
|
7
|
+
# status code and media type; this holds the one runtime question they cannot answer at
|
|
8
|
+
# generation time: whether there is a body at all.
|
|
9
|
+
#
|
|
10
|
+
# A status declaring several media types is dispatched on the declaration too, so the
|
|
11
|
+
# response's actual `Content-Type` is deliberately not consulted here. The reasoning is in
|
|
12
|
+
# `buildFernDeserializeExpression`: the alternative needs a union return type in a
|
|
13
|
+
# statically-typed sibling language, and trusting the declaration is what keeps a body the
|
|
14
|
+
# server mislabelled parseable.
|
|
15
|
+
#
|
|
16
|
+
# @api private
|
|
17
|
+
module ResponseBody
|
|
18
|
+
# Yields the body for decoding, or returns nil when there is nothing to decode.
|
|
19
|
+
#
|
|
20
|
+
# A 2xx with an empty body is not an error — a server is entitled to answer a declared 200
|
|
21
|
+
# with no content — but handing "" or nil to a JSON parse raises from inside the SDK
|
|
22
|
+
# (`TypeError: no implicit conversion of nil into String`, or `JSON::ParserError` on the
|
|
23
|
+
# empty string), which is the failure this guards.
|
|
24
|
+
# @param response [Net::HTTPResponse] The HTTP response.
|
|
25
|
+
# @return [Object, nil] The block's value, or nil for an absent body.
|
|
26
|
+
def self.decode(response)
|
|
27
|
+
body = response.body
|
|
28
|
+
return nil if body.nil? || body.empty?
|
|
29
|
+
|
|
30
|
+
yield body
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petstore
|
|
4
|
+
module Internal
|
|
5
|
+
class CursorItemIterator < ItemIterator
|
|
6
|
+
# Instantiates a CursorItemIterator, an Enumerable class which wraps calls to a cursor-based paginated API and yields individual items from it.
|
|
7
|
+
#
|
|
8
|
+
# @param initial_cursor [String] The initial cursor to use when iterating, if any.
|
|
9
|
+
# @param cursor_field [Symbol] The field in API responses to extract the next cursor from.
|
|
10
|
+
# @param item_field [Symbol] The field in API responses to extract the items to iterate over.
|
|
11
|
+
# @param block [Proc] A block which is responsible for receiving a cursor to use and returning the given page from the API.
|
|
12
|
+
# @return [::Petstore::Internal::CursorItemIterator]
|
|
13
|
+
def initialize(initial_cursor:, cursor_field:, item_field:, &)
|
|
14
|
+
super()
|
|
15
|
+
@item_field = item_field
|
|
16
|
+
@page_iterator = CursorPageIterator.new(initial_cursor:, cursor_field:, &)
|
|
17
|
+
@page = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns the CursorPageIterator mediating access to the underlying API.
|
|
21
|
+
#
|
|
22
|
+
# @return [::Petstore::Internal::CursorPageIterator]
|
|
23
|
+
def pages
|
|
24
|
+
@page_iterator
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petstore
|
|
4
|
+
module Internal
|
|
5
|
+
class CursorPageIterator
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
# The raw HTTP response from the most recent page response.
|
|
9
|
+
# @return [Net::HTTPResponse, nil]
|
|
10
|
+
attr_reader :http_response
|
|
11
|
+
|
|
12
|
+
# Instantiates a CursorPageIterator, an Enumerable class which wraps calls to a cursor-based paginated API and yields pages of items.
|
|
13
|
+
#
|
|
14
|
+
# @param initial_cursor [String] The initial cursor to use when iterating, if any.
|
|
15
|
+
# @param cursor_field [Symbol] The name of the field in API responses to extract the next cursor from.
|
|
16
|
+
# @param block [Proc] A block which is responsible for receiving a cursor to use and returning the given page from the API.
|
|
17
|
+
# The block should return a two-element array: [parsed_page, raw_http_response].
|
|
18
|
+
# @return [::Petstore::Internal::CursorPageIterator]
|
|
19
|
+
def initialize(initial_cursor:, cursor_field:, &block)
|
|
20
|
+
@need_initial_load = initial_cursor.nil?
|
|
21
|
+
@cursor = initial_cursor
|
|
22
|
+
@cursor_field = cursor_field
|
|
23
|
+
@get_next_page = block
|
|
24
|
+
@http_response = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Iterates over each page returned by the API.
|
|
28
|
+
#
|
|
29
|
+
# @yield [page] Each retrieved page.
|
|
30
|
+
# @return [NilClass]
|
|
31
|
+
def each
|
|
32
|
+
while (page = next_page)
|
|
33
|
+
yield(page)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether another page will be available from the API.
|
|
38
|
+
#
|
|
39
|
+
# @return [Boolean]
|
|
40
|
+
def next?
|
|
41
|
+
@need_initial_load || !@cursor.nil?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Retrieves the next page from the API.
|
|
45
|
+
#
|
|
46
|
+
# @return [Object, nil]
|
|
47
|
+
def next_page
|
|
48
|
+
return if !@need_initial_load && @cursor.nil?
|
|
49
|
+
|
|
50
|
+
@need_initial_load = false
|
|
51
|
+
result = @get_next_page.call(@cursor)
|
|
52
|
+
if result.is_a?(Array)
|
|
53
|
+
fetched_page, raw_response = result
|
|
54
|
+
@http_response = raw_response
|
|
55
|
+
else
|
|
56
|
+
fetched_page = result
|
|
57
|
+
end
|
|
58
|
+
@cursor = resolve_field(fetched_page, @cursor_field)
|
|
59
|
+
fetched_page
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Resolves a field accessor (Symbol) or nested accessor path (Array<Symbol>) against a page.
|
|
65
|
+
def resolve_field(page, field)
|
|
66
|
+
return nil if page.nil? || field.nil?
|
|
67
|
+
|
|
68
|
+
Array(field).reduce(page) { |value, accessor| value&.send(accessor) }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petstore
|
|
4
|
+
module Internal
|
|
5
|
+
class ItemIterator
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
# The raw HTTP response from the most recent page response.
|
|
9
|
+
# @return [Net::HTTPResponse, nil]
|
|
10
|
+
def http_response
|
|
11
|
+
@page_iterator&.http_response
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Iterates over each item returned by the API.
|
|
15
|
+
#
|
|
16
|
+
# @yield [item] Each retrieved item.
|
|
17
|
+
# @return [NilClass]
|
|
18
|
+
def each
|
|
19
|
+
while (item = next_element)
|
|
20
|
+
yield(item)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Whether another item will be available from the API.
|
|
25
|
+
#
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
def next?
|
|
28
|
+
load_next_page if @page.nil?
|
|
29
|
+
return false if @page.nil?
|
|
30
|
+
|
|
31
|
+
return true if any_items_in_cached_page?
|
|
32
|
+
|
|
33
|
+
load_next_page
|
|
34
|
+
any_items_in_cached_page?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Retrieves the next item from the API.
|
|
38
|
+
def next_element
|
|
39
|
+
item = next_item_from_cached_page
|
|
40
|
+
return item if item
|
|
41
|
+
|
|
42
|
+
load_next_page
|
|
43
|
+
next_item_from_cached_page
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def next_item_from_cached_page
|
|
49
|
+
return unless @page
|
|
50
|
+
|
|
51
|
+
resolve_field(@page, @item_field)&.shift
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def any_items_in_cached_page?
|
|
55
|
+
return false unless @page
|
|
56
|
+
|
|
57
|
+
!resolve_field(@page, @item_field).to_a.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def load_next_page
|
|
61
|
+
@page = @page_iterator.next_page
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Resolves a field accessor (Symbol) or nested accessor path (Array<Symbol>) against a page.
|
|
65
|
+
def resolve_field(page, field)
|
|
66
|
+
return nil if page.nil? || field.nil?
|
|
67
|
+
|
|
68
|
+
Array(field).reduce(page) { |value, accessor| value&.send(accessor) }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petstore
|
|
4
|
+
module Internal
|
|
5
|
+
class OffsetItemIterator < ItemIterator
|
|
6
|
+
# Instantiates an OffsetItemIterator, an Enumerable class which wraps calls to an offset-based paginated API and yields the individual items from it.
|
|
7
|
+
#
|
|
8
|
+
# @param initial_page [Integer] The initial page or offset to start from when iterating.
|
|
9
|
+
# @param item_field [Symbol] The name of the field in API responses to extract the items to iterate over.
|
|
10
|
+
# @param has_next_field [Symbol] The name of the field in API responses containing a boolean of whether another page exists.
|
|
11
|
+
# @param step [Boolean] If true, treats the page number as a true offset (i.e. increments the page number by the number of items returned from each call rather than just 1)
|
|
12
|
+
# @param block [Proc] A block which is responsible for receiving a page number to use and returning the given page from the API.
|
|
13
|
+
#
|
|
14
|
+
# @return [::Petstore::Internal::OffsetItemIterator]
|
|
15
|
+
def initialize(initial_page:, item_field:, has_next_field:, step:, &)
|
|
16
|
+
super()
|
|
17
|
+
@item_field = item_field
|
|
18
|
+
@page_iterator = OffsetPageIterator.new(initial_page:, item_field:, has_next_field:, step:, &)
|
|
19
|
+
@page = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Returns the OffsetPageIterator that is mediating access to the underlying API.
|
|
23
|
+
#
|
|
24
|
+
# @return [::Petstore::Internal::OffsetPageIterator]
|
|
25
|
+
def pages
|
|
26
|
+
@page_iterator
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|