api_adaptor 0.0.2 → 1.0.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 +4 -4
- data/.rubocop.yml +7 -8
- data/.yardopts +10 -0
- data/CHANGELOG.md +42 -1
- data/CLAUDE.md +423 -0
- data/Gemfile.lock +68 -39
- data/README.md +211 -24
- data/Rakefile +7 -1
- data/fixtures/v1/integration/foo.json +3 -0
- data/lib/api_adaptor/base.rb +139 -2
- data/lib/api_adaptor/exceptions.rb +73 -4
- data/lib/api_adaptor/headers.rb +32 -0
- data/lib/api_adaptor/json_client.rb +337 -43
- data/lib/api_adaptor/list_response.rb +63 -21
- data/lib/api_adaptor/null_logger.rb +53 -39
- data/lib/api_adaptor/response.rb +108 -12
- data/lib/api_adaptor/variables.rb +37 -0
- data/lib/api_adaptor/version.rb +1 -1
- data/lib/api_adaptor.rb +31 -1
- metadata +117 -7
|
@@ -9,11 +9,68 @@ require_relative "response"
|
|
|
9
9
|
require "rest-client"
|
|
10
10
|
|
|
11
11
|
module ApiAdaptor
|
|
12
|
+
# HTTP client for JSON APIs with comprehensive redirect handling and authentication support.
|
|
13
|
+
#
|
|
14
|
+
# JSONClient provides a low-level interface for making HTTP requests to JSON APIs. It handles
|
|
15
|
+
# automatic JSON parsing, configurable redirect following, authentication (bearer token and basic auth),
|
|
16
|
+
# timeout management, and comprehensive error handling.
|
|
17
|
+
#
|
|
18
|
+
# @example Basic usage with bearer token
|
|
19
|
+
# client = JSONClient.new(bearer_token: "abc123")
|
|
20
|
+
# response = client.get_json("https://api.example.com/users")
|
|
21
|
+
# users = response["data"]
|
|
22
|
+
#
|
|
23
|
+
# @example Custom timeout and redirect configuration
|
|
24
|
+
# client = JSONClient.new(
|
|
25
|
+
# timeout: 10,
|
|
26
|
+
# max_redirects: 5,
|
|
27
|
+
# follow_non_get_redirects: true
|
|
28
|
+
# )
|
|
29
|
+
#
|
|
30
|
+
# @example With basic authentication
|
|
31
|
+
# client = JSONClient.new(
|
|
32
|
+
# basic_auth: { user: "username", password: "password" }
|
|
33
|
+
# )
|
|
34
|
+
#
|
|
35
|
+
# @example Disable cross-origin redirects for security
|
|
36
|
+
# client = JSONClient.new(
|
|
37
|
+
# allow_cross_origin_redirects: false
|
|
38
|
+
# )
|
|
39
|
+
#
|
|
40
|
+
# @see Base for a higher-level API client framework
|
|
12
41
|
class JsonClient
|
|
13
42
|
include ApiAdaptor::ExceptionHandling
|
|
14
43
|
|
|
44
|
+
# @return [Logger] Logger instance for request/response logging
|
|
45
|
+
# @return [Hash] Client configuration options
|
|
15
46
|
attr_accessor :logger, :options
|
|
16
47
|
|
|
48
|
+
# Initializes a new JSON HTTP client
|
|
49
|
+
#
|
|
50
|
+
# @param options [Hash] Configuration options
|
|
51
|
+
# @option options [String] :bearer_token Bearer token for Authorization header
|
|
52
|
+
# @option options [Hash] :basic_auth Basic authentication credentials with :user and :password keys
|
|
53
|
+
# @option options [Integer] :timeout Request timeout in seconds (default: 4)
|
|
54
|
+
# @option options [Integer] :max_redirects Maximum number of redirects to follow (default: 3)
|
|
55
|
+
# @option options [Boolean] :allow_cross_origin_redirects Allow redirects to different origins (default: true)
|
|
56
|
+
# @option options [Boolean] :forward_auth_on_cross_origin_redirects Forward auth headers on cross-origin redirects (default: false, security risk if enabled)
|
|
57
|
+
# @option options [Boolean] :follow_non_get_redirects Follow redirects for POST/PUT/PATCH/DELETE (default: false, only 307/308 supported)
|
|
58
|
+
# @option options [Logger] :logger Custom logger instance (default: NullLogger)
|
|
59
|
+
#
|
|
60
|
+
# @raise [RuntimeError] If disable_timeout or negative timeout is provided
|
|
61
|
+
#
|
|
62
|
+
# @example Default configuration
|
|
63
|
+
# client = JSONClient.new
|
|
64
|
+
# # timeout: 4s, max_redirects: 3, only GET/HEAD follow redirects
|
|
65
|
+
#
|
|
66
|
+
# @example Full configuration
|
|
67
|
+
# client = JSONClient.new(
|
|
68
|
+
# bearer_token: "secret",
|
|
69
|
+
# timeout: 10,
|
|
70
|
+
# max_redirects: 5,
|
|
71
|
+
# allow_cross_origin_redirects: false,
|
|
72
|
+
# logger: Logger.new($stdout)
|
|
73
|
+
# )
|
|
17
74
|
def initialize(options = {})
|
|
18
75
|
raise "It is no longer possible to disable the timeout." if options[:disable_timeout] || options[:timeout].to_i.negative?
|
|
19
76
|
|
|
@@ -21,6 +78,9 @@ module ApiAdaptor
|
|
|
21
78
|
@options = options
|
|
22
79
|
end
|
|
23
80
|
|
|
81
|
+
# Returns default HTTP headers for all requests
|
|
82
|
+
#
|
|
83
|
+
# @return [Hash] Default headers including Accept and User-Agent
|
|
24
84
|
def self.default_request_headers
|
|
25
85
|
{
|
|
26
86
|
"Accept" => "application/json",
|
|
@@ -28,51 +88,161 @@ module ApiAdaptor
|
|
|
28
88
|
}
|
|
29
89
|
end
|
|
30
90
|
|
|
91
|
+
# Returns default headers for requests with JSON body
|
|
92
|
+
#
|
|
93
|
+
# @return [Hash] Default headers plus Content-Type: application/json
|
|
31
94
|
def self.default_request_with_json_body_headers
|
|
32
95
|
default_request_headers.merge(json_body_headers)
|
|
33
96
|
end
|
|
34
97
|
|
|
98
|
+
# Returns Content-Type header for JSON requests
|
|
99
|
+
#
|
|
100
|
+
# @return [Hash] Content-Type header
|
|
35
101
|
def self.json_body_headers
|
|
36
102
|
{
|
|
37
103
|
"Content-Type" => "application/json"
|
|
38
104
|
}
|
|
39
105
|
end
|
|
40
106
|
|
|
107
|
+
# Default request timeout in seconds
|
|
41
108
|
DEFAULT_TIMEOUT_IN_SECONDS = 4
|
|
42
109
|
|
|
110
|
+
# Default maximum number of redirects to follow
|
|
111
|
+
DEFAULT_MAX_REDIRECTS = 3
|
|
112
|
+
|
|
113
|
+
# Performs a GET request and returns the raw response
|
|
114
|
+
#
|
|
115
|
+
# @param url [String] The URL to request
|
|
116
|
+
#
|
|
117
|
+
# @return [RestClient::Response] Raw HTTP response
|
|
118
|
+
#
|
|
119
|
+
# @raise [HTTPClientError, HTTPServerError] On HTTP errors
|
|
120
|
+
# @raise [TimedOutException] On timeout
|
|
121
|
+
# @raise [EndpointNotFound] On connection refused
|
|
122
|
+
# @raise [InvalidUrl] On invalid URI
|
|
123
|
+
# @raise [TooManyRedirects] When max_redirects is exceeded
|
|
124
|
+
# @raise [RedirectLocationMissing] When redirect lacks Location header
|
|
43
125
|
def get_raw!(url)
|
|
44
126
|
do_raw_request(:get, url)
|
|
45
127
|
end
|
|
46
128
|
|
|
129
|
+
# Performs a GET request and returns the raw response (alias for get_raw!)
|
|
130
|
+
#
|
|
131
|
+
# @param url [String] The URL to request
|
|
132
|
+
#
|
|
133
|
+
# @return [RestClient::Response] Raw HTTP response
|
|
134
|
+
#
|
|
135
|
+
# @see #get_raw!
|
|
47
136
|
def get_raw(url)
|
|
48
137
|
get_raw!(url)
|
|
49
138
|
end
|
|
50
139
|
|
|
140
|
+
# Performs a GET request and parses the JSON response
|
|
141
|
+
#
|
|
142
|
+
# @param url [String] The URL to request
|
|
143
|
+
# @param additional_headers [Hash] Additional HTTP headers to include
|
|
144
|
+
# @yield [response] Optional block to create custom response object
|
|
145
|
+
# @yieldparam response [RestClient::Response] The raw HTTP response
|
|
146
|
+
# @yieldreturn [Object] Custom response object
|
|
147
|
+
#
|
|
148
|
+
# @return [Response, Object] Response object or custom object from block
|
|
149
|
+
#
|
|
150
|
+
# @raise [HTTPClientError, HTTPServerError] On HTTP errors
|
|
151
|
+
# @raise [TimedOutException] On timeout
|
|
152
|
+
#
|
|
153
|
+
# @example Basic usage
|
|
154
|
+
# response = client.get_json("https://api.example.com/users")
|
|
155
|
+
# users = response["data"]
|
|
156
|
+
#
|
|
157
|
+
# @example With custom response class
|
|
158
|
+
# users = client.get_json("https://api.example.com/users") do |r|
|
|
159
|
+
# UserListResponse.new(r)
|
|
160
|
+
# end
|
|
51
161
|
def get_json(url, additional_headers = {}, &create_response)
|
|
52
162
|
do_json_request(:get, url, nil, additional_headers, &create_response)
|
|
53
163
|
end
|
|
54
164
|
|
|
165
|
+
# Performs a POST request with JSON body
|
|
166
|
+
#
|
|
167
|
+
# @param url [String] The URL to request
|
|
168
|
+
# @param params [Hash] Data to send as JSON in request body (default: {})
|
|
169
|
+
# @param additional_headers [Hash] Additional HTTP headers to include
|
|
170
|
+
#
|
|
171
|
+
# @return [Response] Response object with parsed JSON
|
|
172
|
+
#
|
|
173
|
+
# @raise [HTTPClientError, HTTPServerError] On HTTP errors
|
|
174
|
+
#
|
|
175
|
+
# @example
|
|
176
|
+
# response = client.post_json("https://api.example.com/users", {
|
|
177
|
+
# name: "Alice",
|
|
178
|
+
# email: "alice@example.com"
|
|
179
|
+
# })
|
|
55
180
|
def post_json(url, params = {}, additional_headers = {})
|
|
56
181
|
do_json_request(:post, url, params, additional_headers)
|
|
57
182
|
end
|
|
58
183
|
|
|
184
|
+
# Performs a PUT request with JSON body
|
|
185
|
+
#
|
|
186
|
+
# @param url [String] The URL to request
|
|
187
|
+
# @param params [Hash] Data to send as JSON in request body
|
|
188
|
+
# @param additional_headers [Hash] Additional HTTP headers to include
|
|
189
|
+
#
|
|
190
|
+
# @return [Response] Response object with parsed JSON
|
|
191
|
+
#
|
|
192
|
+
# @raise [HTTPClientError, HTTPServerError] On HTTP errors
|
|
59
193
|
def put_json(url, params, additional_headers = {})
|
|
60
194
|
do_json_request(:put, url, params, additional_headers)
|
|
61
195
|
end
|
|
62
196
|
|
|
197
|
+
# Performs a PATCH request with JSON body
|
|
198
|
+
#
|
|
199
|
+
# @param url [String] The URL to request
|
|
200
|
+
# @param params [Hash] Data to send as JSON in request body
|
|
201
|
+
# @param additional_headers [Hash] Additional HTTP headers to include
|
|
202
|
+
#
|
|
203
|
+
# @return [Response] Response object with parsed JSON
|
|
204
|
+
#
|
|
205
|
+
# @raise [HTTPClientError, HTTPServerError] On HTTP errors
|
|
63
206
|
def patch_json(url, params, additional_headers = {})
|
|
64
207
|
do_json_request(:patch, url, params, additional_headers)
|
|
65
208
|
end
|
|
66
209
|
|
|
210
|
+
# Performs a DELETE request with optional JSON body
|
|
211
|
+
#
|
|
212
|
+
# @param url [String] The URL to request
|
|
213
|
+
# @param params [Hash] Optional data to send as JSON in request body (default: {})
|
|
214
|
+
# @param additional_headers [Hash] Additional HTTP headers to include
|
|
215
|
+
#
|
|
216
|
+
# @return [Response] Response object with parsed JSON
|
|
217
|
+
#
|
|
218
|
+
# @raise [HTTPClientError, HTTPServerError] On HTTP errors
|
|
67
219
|
def delete_json(url, params = {}, additional_headers = {})
|
|
68
220
|
do_json_request(:delete, url, params, additional_headers)
|
|
69
221
|
end
|
|
70
222
|
|
|
223
|
+
# Performs a POST request with multipart/form-data
|
|
224
|
+
#
|
|
225
|
+
# @param url [String] The URL to request
|
|
226
|
+
# @param params [Hash] Multipart form data (may include file uploads)
|
|
227
|
+
#
|
|
228
|
+
# @return [Response] Response object
|
|
229
|
+
#
|
|
230
|
+
# @example Uploading a file
|
|
231
|
+
# client.post_multipart("https://api.example.com/upload", {
|
|
232
|
+
# file: File.open("image.jpg", "rb"),
|
|
233
|
+
# description: "Profile photo"
|
|
234
|
+
# })
|
|
71
235
|
def post_multipart(url, params)
|
|
72
236
|
r = do_raw_request(:post, url, params.merge(multipart: true))
|
|
73
237
|
Response.new(r)
|
|
74
238
|
end
|
|
75
239
|
|
|
240
|
+
# Performs a PUT request with multipart/form-data
|
|
241
|
+
#
|
|
242
|
+
# @param url [String] The URL to request
|
|
243
|
+
# @param params [Hash] Multipart form data (may include file uploads)
|
|
244
|
+
#
|
|
245
|
+
# @return [Response] Response object
|
|
76
246
|
def put_multipart(url, params)
|
|
77
247
|
r = do_raw_request(:put, url, params.merge(multipart: true))
|
|
78
248
|
Response.new(r)
|
|
@@ -141,9 +311,9 @@ module ApiAdaptor
|
|
|
141
311
|
def with_headers(method_params, default_headers, additional_headers)
|
|
142
312
|
method_params.merge(
|
|
143
313
|
headers: default_headers
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
314
|
+
.merge(method_params[:headers] || {})
|
|
315
|
+
.merge(ApiAdaptor::Headers.headers)
|
|
316
|
+
.merge(additional_headers)
|
|
147
317
|
)
|
|
148
318
|
end
|
|
149
319
|
|
|
@@ -154,49 +324,173 @@ module ApiAdaptor
|
|
|
154
324
|
)
|
|
155
325
|
end
|
|
156
326
|
|
|
327
|
+
def max_redirects
|
|
328
|
+
value = options.fetch(:max_redirects, DEFAULT_MAX_REDIRECTS)
|
|
329
|
+
value = value.to_i
|
|
330
|
+
value.negative? ? 0 : value
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def follow_non_get_redirects?
|
|
334
|
+
options.fetch(:follow_non_get_redirects, false)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def allow_cross_origin_redirects?
|
|
338
|
+
options.fetch(:allow_cross_origin_redirects, true)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def forward_auth_on_cross_origin_redirects?
|
|
342
|
+
options.fetch(:forward_auth_on_cross_origin_redirects, false)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def redirect_status_code?(code)
|
|
346
|
+
code.to_i >= 300 && code.to_i <= 399
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def follow_redirect_code?(method, code)
|
|
350
|
+
code = code.to_i
|
|
351
|
+
return false unless redirect_status_code?(code)
|
|
352
|
+
return false if code == 304
|
|
353
|
+
return false if [305, 306].include?(code)
|
|
354
|
+
|
|
355
|
+
if %i[get head].include?(method)
|
|
356
|
+
[301, 302, 303, 307, 308].include?(code)
|
|
357
|
+
else
|
|
358
|
+
return true if follow_non_get_redirects? && [307, 308].include?(code)
|
|
359
|
+
|
|
360
|
+
false
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def response_location(response)
|
|
365
|
+
return nil unless response
|
|
366
|
+
|
|
367
|
+
headers = response.headers || {}
|
|
368
|
+
headers[:location] || headers["location"] || headers["Location"]
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def resolve_location(current_url, location)
|
|
372
|
+
URI.join(current_url, location.to_s).to_s
|
|
373
|
+
rescue URI::Error
|
|
374
|
+
location.to_s
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def origin_for(url)
|
|
378
|
+
uri = URI.parse(url)
|
|
379
|
+
[uri.scheme, uri.host, uri.port]
|
|
380
|
+
end
|
|
381
|
+
|
|
157
382
|
def do_request(method, url, params = nil, additional_headers = {})
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
383
|
+
current_method = method
|
|
384
|
+
current_url = url
|
|
385
|
+
current_params = params
|
|
386
|
+
redirects_followed = 0
|
|
161
387
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
388
|
+
initial_origin = begin
|
|
389
|
+
origin_for(url)
|
|
390
|
+
rescue URI::InvalidURIError => e
|
|
391
|
+
raise ApiAdaptor::InvalidUrl, e.message
|
|
392
|
+
end
|
|
166
393
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
394
|
+
loop do
|
|
395
|
+
loggable = { request_uri: current_url, start_time: Time.now.to_f }
|
|
396
|
+
start_logging = loggable.merge(action: "start")
|
|
397
|
+
logger.debug start_logging.to_json
|
|
398
|
+
|
|
399
|
+
method_params = {
|
|
400
|
+
method: current_method,
|
|
401
|
+
url: current_url,
|
|
402
|
+
max_redirects: 0
|
|
403
|
+
}
|
|
404
|
+
method_params[:payload] = current_params
|
|
405
|
+
method_params = with_timeout(method_params)
|
|
406
|
+
method_params = with_headers(method_params, self.class.default_request_headers, additional_headers)
|
|
407
|
+
|
|
408
|
+
begin
|
|
409
|
+
current_origin = origin_for(current_url)
|
|
410
|
+
cross_origin = current_origin != initial_origin
|
|
411
|
+
include_auth = !cross_origin || forward_auth_on_cross_origin_redirects?
|
|
412
|
+
method_params = with_auth_options(method_params) if include_auth
|
|
413
|
+
unless include_auth
|
|
414
|
+
if method_params[:headers]
|
|
415
|
+
method_params[:headers].delete("Authorization")
|
|
416
|
+
method_params[:headers].delete("Proxy-Authorization")
|
|
417
|
+
end
|
|
418
|
+
method_params.delete(:user)
|
|
419
|
+
method_params.delete(:password)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
method_params = with_ssl_options(method_params) if URI.parse(current_url).is_a? URI::HTTPS
|
|
423
|
+
return ::RestClient::Request.execute(method_params)
|
|
424
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
425
|
+
if e.is_a?(RestClient::Exceptions::Timeout)
|
|
426
|
+
logger.error loggable.merge(status: "timeout", error_message: e.message, error_class: e.class.name,
|
|
427
|
+
end_time: Time.now.to_f).to_json
|
|
428
|
+
raise ApiAdaptor::TimedOutException, e.message
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
status_code = (e.http_code || e.response&.code).to_i
|
|
432
|
+
|
|
433
|
+
raise ApiAdaptor::TimedOutException, e.message if status_code == 408
|
|
434
|
+
|
|
435
|
+
if follow_redirect_code?(current_method.to_sym, status_code)
|
|
436
|
+
location = response_location(e.response)
|
|
437
|
+
raise ApiAdaptor::RedirectLocationMissing, "Redirect response missing Location header for #{current_url}" if location.to_s.strip.empty?
|
|
438
|
+
|
|
439
|
+
next_url = resolve_location(current_url, location)
|
|
440
|
+
begin
|
|
441
|
+
next_origin = origin_for(next_url)
|
|
442
|
+
rescue URI::InvalidURIError => e
|
|
443
|
+
logger.error loggable.merge(status: "invalid_uri", error_message: e.message, error_class: e.class.name,
|
|
444
|
+
end_time: Time.now.to_f).to_json
|
|
445
|
+
raise ApiAdaptor::InvalidUrl, e.message
|
|
446
|
+
end
|
|
447
|
+
if next_origin != initial_origin && !allow_cross_origin_redirects?
|
|
448
|
+
loggable.merge!(status: status_code, end_time: Time.now.to_f, body: e.http_body)
|
|
449
|
+
logger.warn loggable.to_json
|
|
450
|
+
raise
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
raise ApiAdaptor::TooManyRedirects, "Too many redirects (max #{max_redirects}) while requesting #{url}" if redirects_followed >= max_redirects
|
|
454
|
+
|
|
455
|
+
redirects_followed += 1
|
|
456
|
+
current_url = next_url
|
|
457
|
+
|
|
458
|
+
next
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
loggable.merge!(status: status_code, end_time: Time.now.to_f, body: e.http_body)
|
|
462
|
+
logger.warn loggable.to_json
|
|
463
|
+
raise
|
|
464
|
+
rescue Errno::ECONNREFUSED => e
|
|
465
|
+
logger.error loggable.merge(status: "refused", error_message: e.message, error_class: e.class.name,
|
|
466
|
+
end_time: Time.now.to_f).to_json
|
|
467
|
+
raise ApiAdaptor::EndpointNotFound, "Could not connect to #{current_url}"
|
|
468
|
+
rescue Timeout::Error => e
|
|
469
|
+
logger.error loggable.merge(status: "timeout", error_message: e.message, error_class: e.class.name,
|
|
470
|
+
end_time: Time.now.to_f).to_json
|
|
471
|
+
raise ApiAdaptor::TimedOutException, e.message
|
|
472
|
+
rescue RestClient::Exceptions::Timeout => e
|
|
473
|
+
logger.error loggable.merge(status: "timeout", error_message: e.message, error_class: e.class.name,
|
|
474
|
+
end_time: Time.now.to_f).to_json
|
|
475
|
+
raise ApiAdaptor::TimedOutException, e.message
|
|
476
|
+
rescue URI::InvalidURIError => e
|
|
477
|
+
logger.error loggable.merge(status: "invalid_uri", error_message: e.message, error_class: e.class.name,
|
|
478
|
+
end_time: Time.now.to_f).to_json
|
|
479
|
+
raise ApiAdaptor::InvalidUrl, e.message
|
|
480
|
+
rescue RestClient::Exception => e
|
|
481
|
+
loggable.merge!(status: e.http_code, end_time: Time.now.to_f, body: e.http_body)
|
|
482
|
+
logger.warn loggable.to_json
|
|
483
|
+
raise
|
|
484
|
+
rescue Errno::ECONNRESET => e
|
|
485
|
+
logger.error loggable.merge(status: "connection_reset", error_message: e.message, error_class: e.class.name,
|
|
486
|
+
end_time: Time.now.to_f).to_json
|
|
487
|
+
raise ApiAdaptor::TimedOutException, e.message
|
|
488
|
+
rescue SocketError => e
|
|
489
|
+
logger.error loggable.merge(status: "socket_error", error_message: e.message, error_class: e.class.name,
|
|
490
|
+
end_time: Time.now.to_f).to_json
|
|
491
|
+
raise ApiAdaptor::SocketErrorException, e.message
|
|
492
|
+
end
|
|
493
|
+
end
|
|
200
494
|
end
|
|
201
495
|
end
|
|
202
496
|
end
|
|
@@ -5,31 +5,66 @@ require "api_adaptor/response"
|
|
|
5
5
|
require "link_header"
|
|
6
6
|
|
|
7
7
|
module ApiAdaptor
|
|
8
|
-
# Response
|
|
8
|
+
# Response wrapper for paginated API results.
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# ListResponse handles paginated responses using Link headers (RFC 5988) for navigation.
|
|
11
|
+
# It expects responses to have a "results" array and provides methods to navigate through pages.
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# response = client.get_list("/posts?page=1")
|
|
15
|
+
# response.results # => Array of items on current page
|
|
16
|
+
# response.next_page? # => true
|
|
17
|
+
# response.next_page # => ListResponse for page 2
|
|
18
|
+
#
|
|
19
|
+
# @example Iterating over current page
|
|
20
|
+
# response.each do |item|
|
|
21
|
+
# puts item["title"]
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# @example Fetching all pages
|
|
25
|
+
# response.with_subsequent_pages.each do |item|
|
|
26
|
+
# puts item["title"] # Automatically fetches additional pages
|
|
27
|
+
# end
|
|
13
28
|
class ListResponse < Response
|
|
14
|
-
#
|
|
15
|
-
#
|
|
29
|
+
# Initializes a new ListResponse with API client reference for pagination
|
|
30
|
+
#
|
|
31
|
+
# @param response [RestClient::Response] The raw HTTP response
|
|
32
|
+
# @param api_client [Base] API client instance for fetching additional pages
|
|
33
|
+
# @param options [Hash] Configuration options (see Response#initialize)
|
|
16
34
|
def initialize(response, api_client, options = {})
|
|
17
35
|
super(response, options)
|
|
18
36
|
@api_client = api_client
|
|
19
37
|
end
|
|
20
38
|
|
|
21
|
-
#
|
|
22
|
-
# over the
|
|
39
|
+
# @!method each(&block)
|
|
40
|
+
# Iterate over results on the current page only
|
|
41
|
+
# @yield [Hash] Each result item
|
|
42
|
+
# @see #with_subsequent_pages for iterating across all pages
|
|
43
|
+
#
|
|
44
|
+
# @!method to_ary
|
|
45
|
+
# Convert results to array
|
|
46
|
+
# @return [Array] Array of result items on current page
|
|
23
47
|
def_delegators :results, :each, :to_ary
|
|
24
48
|
|
|
49
|
+
# Returns the array of results from the current page
|
|
50
|
+
#
|
|
51
|
+
# @return [Array<Hash>] Array of result items
|
|
25
52
|
def results
|
|
26
53
|
to_hash["results"]
|
|
27
54
|
end
|
|
28
55
|
|
|
56
|
+
# Checks if there is a next page available
|
|
57
|
+
#
|
|
58
|
+
# @return [Boolean] true if next page exists
|
|
29
59
|
def next_page?
|
|
30
60
|
!page_link("next").nil?
|
|
31
61
|
end
|
|
32
62
|
|
|
63
|
+
# Fetches the next page of results
|
|
64
|
+
#
|
|
65
|
+
# Results are memoized to avoid refetching the same page multiple times.
|
|
66
|
+
#
|
|
67
|
+
# @return [ListResponse, nil] Next page response or nil if no next page
|
|
33
68
|
def next_page
|
|
34
69
|
# This shouldn't be a performance problem, since the cache will generally
|
|
35
70
|
# avoid us making multiple requests for the same page, but we shouldn't
|
|
@@ -38,33 +73,40 @@ module ApiAdaptor
|
|
|
38
73
|
@next_page ||= (@api_client.get_list page_link("next").href if next_page?)
|
|
39
74
|
end
|
|
40
75
|
|
|
76
|
+
# Checks if there is a previous page available
|
|
77
|
+
#
|
|
78
|
+
# @return [Boolean] true if previous page exists
|
|
41
79
|
def previous_page?
|
|
42
80
|
!page_link("previous").nil?
|
|
43
81
|
end
|
|
44
82
|
|
|
83
|
+
# Fetches the previous page of results
|
|
84
|
+
#
|
|
85
|
+
# Results are memoized to avoid refetching the same page multiple times.
|
|
86
|
+
#
|
|
87
|
+
# @return [ListResponse, nil] Previous page response or nil if no previous page
|
|
45
88
|
def previous_page
|
|
46
89
|
# See the note in `next_page` for why this is memoised
|
|
47
90
|
@previous_page ||= (@api_client.get_list(page_link("previous").href) if previous_page?)
|
|
48
91
|
end
|
|
49
92
|
|
|
50
|
-
#
|
|
51
|
-
# or #results which only iterate over the current page.
|
|
93
|
+
# Returns an enumerator that transparently fetches and iterates over all pages
|
|
52
94
|
#
|
|
53
|
-
#
|
|
95
|
+
# Pages are fetched on demand as you iterate. If you call a method like #count,
|
|
96
|
+
# all pages will be fetched immediately. Results are memoized to avoid duplicate requests.
|
|
54
97
|
#
|
|
55
|
-
#
|
|
56
|
-
# ...
|
|
57
|
-
# end
|
|
98
|
+
# @return [Enumerator] Enumerator for all results across all pages
|
|
58
99
|
#
|
|
59
|
-
#
|
|
100
|
+
# @example Iterate over all pages
|
|
101
|
+
# response.with_subsequent_pages.each do |item|
|
|
102
|
+
# puts item["title"]
|
|
103
|
+
# end
|
|
60
104
|
#
|
|
61
|
-
#
|
|
105
|
+
# @example Count all items across all pages
|
|
106
|
+
# total_count = response.with_subsequent_pages.count
|
|
62
107
|
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
# invoke a method such as #count, this method will fetch all pages at that
|
|
66
|
-
# point. Note that the responses are stored so subsequent pages will not be
|
|
67
|
-
# loaded multiple times.
|
|
108
|
+
# @example Convert all pages to array
|
|
109
|
+
# all_items = response.with_subsequent_pages.to_a
|
|
68
110
|
def with_subsequent_pages
|
|
69
111
|
Enumerator.new do |yielder|
|
|
70
112
|
each { |i| yielder << i }
|