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.
@@ -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
- .merge(method_params[:headers] || {})
145
- .merge(ApiAdaptor::Headers.headers)
146
- .merge(additional_headers)
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
- loggable = { request_uri: url, start_time: Time.now.to_f }
159
- start_logging = loggable.merge(action: "start")
160
- logger.debug start_logging.to_json
383
+ current_method = method
384
+ current_url = url
385
+ current_params = params
386
+ redirects_followed = 0
161
387
 
162
- method_params = {
163
- method: method,
164
- url: url
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
- method_params[:payload] = params
168
- method_params = with_timeout(method_params)
169
- method_params = with_headers(method_params, self.class.default_request_headers, additional_headers)
170
- method_params = with_auth_options(method_params)
171
- method_params = with_ssl_options(method_params) if URI.parse(url).is_a? URI::HTTPS
172
-
173
- ::RestClient::Request.execute(method_params)
174
- rescue Errno::ECONNREFUSED => e
175
- logger.error loggable.merge(status: "refused", error_message: e.message, error_class: e.class.name,
176
- end_time: Time.now.to_f).to_json
177
- raise ApiAdaptor::EndpointNotFound, "Could not connect to #{url}"
178
- rescue RestClient::Exceptions::Timeout => e
179
- logger.error loggable.merge(status: "timeout", error_message: e.message, error_class: e.class.name,
180
- end_time: Time.now.to_f).to_json
181
- raise ApiAdaptor::TimedOutException, e.message
182
- rescue URI::InvalidURIError => e
183
- logger.error loggable.merge(status: "invalid_uri", error_message: e.message, error_class: e.class.name,
184
- end_time: Time.now.to_f).to_json
185
- raise ApiAdaptor::InvalidUrl, e.message
186
- rescue RestClient::Exception => e
187
- # Log the error here, since we have access to loggable, but raise the
188
- # exception up to the calling method to deal with
189
- loggable.merge!(status: e.http_code, end_time: Time.now.to_f, body: e.http_body)
190
- logger.warn loggable.to_json
191
- raise
192
- rescue Errno::ECONNRESET => e
193
- logger.error loggable.merge(status: "connection_reset", error_message: e.message, error_class: e.class.name,
194
- end_time: Time.now.to_f).to_json
195
- raise ApiAdaptor::TimedOutException, e.message
196
- rescue SocketError => e
197
- logger.error loggable.merge(status: "socket_error", error_message: e.message, error_class: e.class.name,
198
- end_time: Time.now.to_f).to_json
199
- raise ApiAdaptor::SocketErrorException, e.message
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 class for lists of multiple items.
8
+ # Response wrapper for paginated API results.
9
9
  #
10
- # This expects responses to be in a common format, with the list of results
11
- # contained under the `results` key. The response may also have previous and
12
- # subsequent pages, indicated by entries in the response's `Link` header.
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
- # The ListResponse is instantiated with a reference back to the API client,
15
- # so it can make requests for the subsequent pages
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
- # Pass calls to `self.each` to the `results` sub-object, so we can iterate
22
- # over the response directly
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
- # Transparently get all results across all pages. Compare this with #each
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
- # Example:
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
- # list_response.with_subsequent_pages.each do |result|
56
- # ...
57
- # end
98
+ # @return [Enumerator] Enumerator for all results across all pages
58
99
  #
59
- # or:
100
+ # @example Iterate over all pages
101
+ # response.with_subsequent_pages.each do |item|
102
+ # puts item["title"]
103
+ # end
60
104
  #
61
- # list_response.with_subsequent_pages.count
105
+ # @example Count all items across all pages
106
+ # total_count = response.with_subsequent_pages.count
62
107
  #
63
- # Pages of results are fetched on demand. When iterating, that means
64
- # fetching pages as results from the current page are exhausted. If you
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 }