flashpi 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.
@@ -0,0 +1,432 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flashpi
4
+ module Internal
5
+ # The time an attempt has left, from sending it to reading the whole answer.
6
+ #
7
+ # @api private
8
+ class Deadline
9
+ def initialize(seconds, clock = Clock)
10
+ @clock = clock
11
+ @end = clock.now + seconds
12
+ end
13
+
14
+ # @return [Float] seconds left (0 or less once it has passed)
15
+ def left
16
+ @end - @clock.now
17
+ end
18
+
19
+ # @return [Boolean]
20
+ def passed?
21
+ left <= 0
22
+ end
23
+
24
+ # The seconds left; `error` once there are none.
25
+ #
26
+ # @return [Float]
27
+ def left!(error)
28
+ seconds = left
29
+ raise error, "The attempt's deadline passed" if seconds <= 0
30
+
31
+ seconds
32
+ end
33
+
34
+ # `timeout` cut to the seconds left; `error` once there are none.
35
+ #
36
+ # @return [Float]
37
+ def limit(timeout, error)
38
+ seconds = left!(error)
39
+ timeout.nil? ? seconds : [timeout, seconds].min
40
+ end
41
+
42
+ def inspect
43
+ "#<#{self.class.name} left=#{left.round(3)}>"
44
+ end
45
+ end
46
+
47
+ # The deadline of an attempt, down to each network operation of net/http.
48
+ #
49
+ # net/http gives connecting, the TLS handshake, each write and each wait for data a timeout of
50
+ # its own, so an answer that trickles in could keep an attempt going for as long as it
51
+ # trickles. Extended onto each Net::HTTP the SDK creates, this module limits connecting and
52
+ # the TLS handshake to the time the attempt has left, and wraps the connection's socket in a
53
+ # {DeadlineSocket} that does the same for every read and write. It hooks into net/http
54
+ # through `connect`, `ssl_socket_connect` and `on_connect`, and replaces the `@socket` that
55
+ # `connect` builds (pinned by test/transport_test.rb): if net/http changes them, the socket is
56
+ # left as it is and the timeouts set at the start of each attempt still apply.
57
+ #
58
+ # @api private
59
+ module DeadlineHTTP
60
+ # @return [Deadline, nil] the deadline of the attempt running on this connection
61
+ attr_accessor :flashpi_deadline
62
+
63
+ private
64
+
65
+ def connect
66
+ deadline = flashpi_deadline
67
+ return super unless deadline # steep:ignore
68
+
69
+ @open_timeout = deadline.left!(Net::OpenTimeout)
70
+ return super unless proxy? && use_ssl? # steep:ignore
71
+
72
+ # Through a proxy, net/http sends the CONNECT of an https connection and reads its answer
73
+ # on a socket this module does not wrap, each read with a timeout of its own: the whole
74
+ # connect is limited to the time the attempt has left instead (net/http itself limits
75
+ # connecting this way before 0.6).
76
+ Timeout.timeout(deadline.left!(Net::OpenTimeout), Net::OpenTimeout) { super } # steep:ignore
77
+ end
78
+
79
+ def ssl_socket_connect(socket, timeout)
80
+ deadline = flashpi_deadline
81
+ super(socket, deadline ? deadline.limit(timeout, Net::OpenTimeout) : timeout) # steep:ignore
82
+ end
83
+
84
+ def on_connect
85
+ super # steep:ignore
86
+ socket = @socket
87
+ return unless socket.is_a?(Net::BufferedIO) && !socket.io.is_a?(DeadlineSocket) # steep:ignore
88
+
89
+ @socket = Net::BufferedIO.new( # steep:ignore
90
+ DeadlineSocket.new(socket.io, self),
91
+ read_timeout: socket.read_timeout,
92
+ write_timeout: socket.write_timeout,
93
+ continue_timeout: socket.continue_timeout,
94
+ debug_output: socket.debug_output
95
+ )
96
+ end
97
+ end
98
+
99
+ # A connection's socket whose reads, writes and waits keep the deadline of the attempt
100
+ # running on it. Net::BufferedIO waits on `to_io`, which is this object.
101
+ #
102
+ # @api private
103
+ class DeadlineSocket
104
+ # @return [IO, OpenSSL::SSL::SSLSocket] the socket
105
+ attr_reader :io
106
+
107
+ def initialize(io, http)
108
+ @io = io
109
+ @http = http
110
+ end
111
+
112
+ def read_nonblock(length, buffer = nil, exception: true)
113
+ deadline&.left!(Net::ReadTimeout)
114
+ @io.read_nonblock(length, buffer, exception:)
115
+ end
116
+
117
+ def write_nonblock(data, exception: true)
118
+ deadline&.left!(Net::WriteTimeout)
119
+ @io.write_nonblock(data, exception:)
120
+ end
121
+
122
+ def to_io
123
+ self
124
+ end
125
+
126
+ # Waits until the socket is readable, at most `timeout` seconds and the time the attempt has
127
+ # left. A zero timeout (net/http checking an idle connection) answers nil when it is not;
128
+ # otherwise the timeout raises here, from a frame that holds no request data (error
129
+ # trackers can record the local variables of the method that raises).
130
+ #
131
+ # @return [Object] truthy
132
+ def wait_readable(timeout = nil)
133
+ return @io.to_io.wait_readable(timeout) if timeout&.zero?
134
+
135
+ @io.to_io.wait_readable(limited(timeout, Net::ReadTimeout)) || raise(Net::ReadTimeout)
136
+ end
137
+
138
+ # Waits until the socket is writable, as {#wait_readable}.
139
+ #
140
+ # @return [Object] truthy
141
+ def wait_writable(timeout = nil)
142
+ return @io.to_io.wait_writable(timeout) if timeout&.zero?
143
+
144
+ @io.to_io.wait_writable(limited(timeout, Net::WriteTimeout)) || raise(Net::WriteTimeout)
145
+ end
146
+
147
+ def close
148
+ @io.close
149
+ end
150
+
151
+ def closed?
152
+ @io.closed?
153
+ end
154
+
155
+ # Always true. net/http calls it only to check an idle connection that has something to
156
+ # read before reusing it: such a connection is closed instead (on TLS, the socket's own
157
+ # eof? could block, without a timeout, on a record that never ends).
158
+ def eof?
159
+ true
160
+ end
161
+
162
+ def inspect
163
+ @io.inspect
164
+ end
165
+
166
+ def respond_to_missing?(name, include_private = false)
167
+ @io.respond_to?(name, include_private) || super
168
+ end
169
+
170
+ def method_missing(name, ...)
171
+ @io.respond_to?(name) ? @io.public_send(name, ...) : super
172
+ end
173
+
174
+ private
175
+
176
+ def deadline
177
+ @http.flashpi_deadline
178
+ end
179
+
180
+ def limited(timeout, error)
181
+ current = deadline
182
+ current ? current.limit(timeout, error) : timeout
183
+ end
184
+ end
185
+
186
+ # The proxy of a client's connections, from the environment variables of its base URL's scheme,
187
+ # as curl and httpx read them: `https_proxy` (or `HTTPS_PROXY`) for https, `http_proxy` for
188
+ # http (`HTTP_PROXY` too, except in a CGI request, where a client can set it), and
189
+ # `no_proxy` (or `NO_PROXY`, `*` for every host). A loopback host never goes through a
190
+ # proxy. Read once, when the client is created, and only http:// proxies (net/http before
191
+ # 0.6 cannot reach a proxy over TLS). net/http's own lookup would read `http_proxy` for
192
+ # https too and look the host up in DNS on every connection.
193
+ #
194
+ # @api private
195
+ module Proxy
196
+ LOOPBACK = /\A(?:localhost|127(?:\.[0-9]{1,3}){3}|::1)\z/i
197
+
198
+ module_function
199
+
200
+ # @return [Array(String, Integer, String, String), nil] host, port, user and password
201
+ # @raise [Flashpi::Error] for a proxy that is not an http:// URL, without showing it
202
+ def from_env(uri, env = ENV)
203
+ name = "#{uri.scheme}_proxy"
204
+ value = env[name]
205
+ value = env[name.upcase] if value.nil? && !(name == "http_proxy" && env.key?("REQUEST_METHOD"))
206
+ return nil if value.nil? || value.strip.empty?
207
+
208
+ host = uri.hostname.to_s
209
+ return nil if LOOPBACK.match?(host) || bypassed?(uri, env["no_proxy"] || env["NO_PROXY"])
210
+
211
+ parse(name, value.strip)
212
+ end
213
+
214
+ # @api private
215
+ def bypassed?(uri, no_proxy)
216
+ return false if no_proxy.nil? || no_proxy.strip.empty?
217
+ return true if no_proxy.strip == "*"
218
+
219
+ host = uri.hostname.to_s
220
+ # The host as its own address: a CIDR of no_proxy matches an IP host, and is skipped for a
221
+ # name, without a DNS lookup.
222
+ !URI::Generic.use_proxy?(host, host, uri.port || 0, no_proxy)
223
+ end
224
+
225
+ # @api private
226
+ def parse(name, value)
227
+ proxy = URI.parse(value.include?("://") ? value : "http://#{value}")
228
+ refuse!(name) unless proxy.scheme == "http" && !proxy.hostname.to_s.empty?
229
+
230
+ user = (encoded = proxy.user) && URI.decode_uri_component(encoded)
231
+ password = (encoded = proxy.password) && URI.decode_uri_component(encoded)
232
+ [proxy.hostname.to_s, proxy.port || 80, user, password]
233
+ rescue URI::Error
234
+ refuse!(name)
235
+ end
236
+
237
+ # Raises from a frame that holds only the variable's name: its value may carry a password.
238
+ #
239
+ # @api private
240
+ def refuse!(name)
241
+ raise Error, "The #{name} environment variable is not an http:// proxy URL", cause: nil
242
+ end
243
+ end
244
+
245
+ # Sends attempts with net/http and reads their whole answer within the attempt's deadline.
246
+ #
247
+ # Connections are kept alive and reused, one attempt at a time each, from a pool that is safe
248
+ # to share between threads. After a fork the child drops the parent's connections without
249
+ # closing them (closing would end the parent's TLS sessions). The proxy comes from the
250
+ # environment ({Proxy}). Redirects are never followed: net/http does not follow them.
251
+ #
252
+ # @api private
253
+ class Transport
254
+ MAX_IDLE = 10
255
+
256
+ # Not Timeout::Error itself: a caller's Timeout.timeout around a call must stop it, not be
257
+ # retried. net/http's own timeouts are the TIMEOUT_ERRORS.
258
+ CONNECTION_ERRORS = [
259
+ SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse,
260
+ Net::HTTPHeaderSyntaxError, Net::ProtocolError, Zlib::Error
261
+ ].freeze
262
+ TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, IO::TimeoutError].freeze
263
+
264
+ REQUESTS = {
265
+ get: Net::HTTP::Get,
266
+ post: Net::HTTP::Post,
267
+ patch: Net::HTTP::Patch,
268
+ delete: Net::HTTP::Delete
269
+ }.freeze
270
+
271
+ # @param base_url [String] an http(s) URL with a host
272
+ def initialize(base_url, clock = Clock)
273
+ uri = URI.parse(base_url)
274
+ @host = uri.hostname.to_s
275
+ @port = uri.port || (uri.scheme == "https" ? 443 : 80)
276
+ @use_ssl = uri.scheme == "https"
277
+ @proxy = Proxy.from_env(uri)
278
+ @clock = clock
279
+ @idle = []
280
+ @mutex = Mutex.new
281
+ @pid = Process.pid
282
+ end
283
+
284
+ # Sends one attempt and reads its whole answer before its deadline.
285
+ #
286
+ # @param attempt [Attempt]
287
+ # @return [RawResponse]
288
+ # @raise [Flashpi::APITimeoutError] when the deadline passed
289
+ # @raise [Flashpi::APIConnectionError] when there was no answer
290
+ # @raise [Flashpi::Error] when net/http refused to send the request
291
+ def perform(attempt)
292
+ deadline = Deadline.new(attempt.timeout, @clock)
293
+ http = checkout
294
+ reusable = false
295
+ begin
296
+ response = exchange(http, attempt, deadline)
297
+ reusable = true
298
+ response
299
+ rescue *TIMEOUT_ERRORS
300
+ raise APITimeoutError, "#{attempt.verb} #{attempt.url} timed out after #{seconds(attempt.timeout)} seconds"
301
+ rescue *CONNECTION_ERRORS => e
302
+ reason = e.message.empty? ? e.class.name : e.message
303
+ raise APIConnectionError, "#{attempt.verb} #{attempt.url} failed: #{reason}"
304
+ ensure
305
+ reusable ? checkin(http) : discard(http)
306
+ end
307
+ end
308
+
309
+ # Closes the idle connections. The transport can still be used: it opens new ones.
310
+ def close
311
+ idle = @mutex.synchronize do
312
+ connections = forked? ? [] : @idle #: Array[Net::HTTP & DeadlineHTTP]
313
+ @idle = []
314
+ connections
315
+ end
316
+ idle.each { |http| discard(http) }
317
+ nil
318
+ end
319
+
320
+ def inspect
321
+ "#<#{self.class.name} #{@use_ssl ? "https" : "http"}://#{@host}:#{@port}>"
322
+ end
323
+
324
+ private
325
+
326
+ def exchange(http, attempt, deadline)
327
+ inflate = nil #: Zlib::Inflate?
328
+ http.flashpi_deadline = deadline
329
+ limit_timeouts(http, deadline)
330
+ http.start unless http.started?
331
+ status = 0
332
+ headers = {} #: Hash[String, String]
333
+ body = String.new(encoding: Encoding::BINARY)
334
+ http.request(build_request(attempt), attempt.content) do |response|
335
+ status = response.code.to_i
336
+ response.each_header { |name, value| headers[name] = value.dup.force_encoding(Encoding::UTF_8).scrub }
337
+ inflate = gzip_inflater(response)
338
+ response.read_body do |chunk|
339
+ deadline.left!(Net::ReadTimeout)
340
+ body << (inflate ? inflate.inflate(chunk) : chunk)
341
+ end
342
+ end
343
+ # A gzip stream that ends early raises Zlib::BufError here: no answer, retried.
344
+ body << inflate.finish if inflate&.total_in&.positive?
345
+ # Also an answer whose end came late.
346
+ deadline.left!(Net::ReadTimeout)
347
+ RawResponse.new(status, headers, body)
348
+ ensure
349
+ inflate&.close # steep:ignore FallbackAny
350
+ http.flashpi_deadline = nil
351
+ end
352
+
353
+ # The SDK decompresses gzip answers itself: net/http would return what it had inflated so
354
+ # far of a gzip stream that ends early, as a complete answer.
355
+ def gzip_inflater(response)
356
+ encoding = response["content-encoding"].to_s.strip.downcase
357
+ Zlib::Inflate.new(32 + Zlib::MAX_WBITS) if %w[gzip x-gzip].include?(encoding)
358
+ end
359
+
360
+ def limit_timeouts(http, deadline)
361
+ seconds = deadline.left!(Net::OpenTimeout)
362
+ http.open_timeout = seconds
363
+ http.read_timeout = seconds
364
+ http.write_timeout = seconds
365
+ end
366
+
367
+ # A request net/http refuses to build raises Flashpi::Error, not retried, and without its
368
+ # reason, which may quote the refused header (possibly the Authorization one).
369
+ def build_request(attempt)
370
+ request_class = REQUESTS.fetch(attempt.http_method) #: singleton(Net::HTTPRequest)
371
+ # Asking for gzip turns net/http's own decompression off (see #gzip_inflater).
372
+ request = request_class.new(attempt.target, { "accept-encoding" => "gzip" }.merge(attempt.headers))
373
+ # net/http before 0.7 (Ruby 3.3 and 3.4) gives a POST without a body the Content-Type
374
+ # application/x-www-form-urlencoded; it is sent with Content-Length: 0 and no Content-Type,
375
+ # as the other SDKs send it.
376
+ request.define_singleton_method(:supply_default_content_type) { nil } if attempt.content.nil?
377
+ request
378
+ rescue ArgumentError
379
+ raise Error, "#{attempt.verb} #{attempt.url} was not sent: net/http refused it as invalid HTTP", cause: nil
380
+ end
381
+
382
+ def checkout
383
+ connection = @mutex.synchronize do
384
+ if forked?
385
+ @idle = []
386
+ @pid = Process.pid
387
+ end
388
+ @idle.pop
389
+ end
390
+ connection || connect
391
+ end
392
+
393
+ def checkin(http)
394
+ kept = @mutex.synchronize do
395
+ if !forked? && @idle.size < MAX_IDLE
396
+ @idle.push(http)
397
+ true
398
+ else
399
+ false
400
+ end
401
+ end
402
+ discard(http) unless kept
403
+ end
404
+
405
+ def discard(http)
406
+ http.finish if http.started?
407
+ rescue StandardError
408
+ nil
409
+ end
410
+
411
+ def connect
412
+ proxy_host, proxy_port, proxy_user, proxy_password = @proxy
413
+ http = Net::HTTP.new(@host, @port, proxy_host, proxy_port, proxy_user, proxy_password)
414
+ http.use_ssl = @use_ssl
415
+ # The SDK retries by its own rules (idempotency keys, recoveries), never net/http.
416
+ http.max_retries = 0
417
+ # An answer cut before its Content-Length is a failure (retried), not a shorter answer.
418
+ http.ignore_eof = false # steep:ignore
419
+ http.extend(DeadlineHTTP)
420
+ _ = http
421
+ end
422
+
423
+ def forked?
424
+ @pid != Process.pid
425
+ end
426
+
427
+ def seconds(value)
428
+ format("%.3f", value).sub(/\.?0+\z/, "")
429
+ end
430
+ end
431
+ end
432
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flashpi
4
+ module Internal
5
+ # Checks of the settings, shared by the client and every request.
6
+ #
7
+ # Header names and values are checked by the SDK itself before anything is sent, so a bad one
8
+ # is never retried as a network failure. Messages never show a value, which may be a secret.
9
+ #
10
+ # @api private
11
+ module Validate
12
+ # The longest timeout, in seconds: the Node SDK's limit (2147483647 ms), so every SDK accepts
13
+ # the same values.
14
+ MAX_TIMEOUT = 2_147_483.647
15
+
16
+ # A header name (RFC 9110 `token`).
17
+ TOKEN = /\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/
18
+
19
+ # Trimmed from both ends of a header value, as the Fetch standard's `Headers` does.
20
+ AROUND = /\A[ \t\r\n]+|[ \t\r\n]+\z/
21
+
22
+ # What HTTP refuses inside a header value: control characters other than the tab.
23
+ CONTROL = /[\x00-\x08\x0a-\x1f\x7f]/
24
+
25
+ # Headers net/http sets from the body: another value would frame the body wrongly.
26
+ FRAMING = %w[content-length transfer-encoding].freeze
27
+
28
+ module_function
29
+
30
+ # A timeout in seconds: more than 0 and at most {MAX_TIMEOUT}.
31
+ #
32
+ # @return [Float]
33
+ # @raise [Flashpi::Error]
34
+ def timeout!(name, value)
35
+ unless (value.is_a?(Integer) || value.is_a?(Float)) && value.positive? && value <= MAX_TIMEOUT
36
+ raise Error, "#{name} must be more than 0 and at most #{MAX_TIMEOUT} seconds"
37
+ end
38
+
39
+ value.to_f
40
+ end
41
+
42
+ # A number of retries: a whole number, 0 or more.
43
+ #
44
+ # @return [Integer]
45
+ # @raise [Flashpi::Error]
46
+ def retries!(name, value)
47
+ raise Error, "#{name} must be a whole number, 0 or more" unless value.is_a?(Integer) && value >= 0
48
+
49
+ value
50
+ end
51
+
52
+ # A header name: an RFC 9110 token, as a String or a Symbol.
53
+ #
54
+ # @return [String]
55
+ # @raise [TypeError] when it is not text
56
+ # @raise [ArgumentError] when it is not a token
57
+ def header_name!(name)
58
+ text = name.is_a?(Symbol) ? name.to_s : name
59
+ raise TypeError, "Header names must be String or Symbol, not #{name.class}" unless text.is_a?(String)
60
+ raise ArgumentError, "#{text.inspect} is not a valid header name" unless TOKEN.match?(text)
61
+
62
+ text
63
+ end
64
+
65
+ # A header value without the spaces, tabs and line breaks around it, as the Node SDK's
66
+ # `Headers` sends it, in UTF-8.
67
+ #
68
+ # @return [String]
69
+ # @raise [TypeError] when it is not a String
70
+ # @raise [ArgumentError] when a line break or another control character other than a tab is
71
+ # left, or when it is not valid text
72
+ def header_value!(label, value)
73
+ raise TypeError, "#{label} must be a String, not #{value.class}" unless value.is_a?(String)
74
+
75
+ text = utf8(value)
76
+ raise ArgumentError, "#{label} has a character that cannot be sent as UTF-8" if text.nil?
77
+
78
+ trimmed = text.gsub(AROUND, "")
79
+ raise ArgumentError, "#{label} has a line break or another control character" if CONTROL.match?(trimmed)
80
+
81
+ trimmed
82
+ end
83
+
84
+ # The headers of a Hash, each name and value checked. Content-Length and Transfer-Encoding
85
+ # are refused: net/http sets them from the body.
86
+ #
87
+ # @return [Hash{String => String}]
88
+ def headers!(label, headers)
89
+ return {} if headers.nil?
90
+ raise TypeError, "#{label} must be a Hash of header names to values" unless headers.is_a?(Hash)
91
+
92
+ checked = {} #: Hash[String, String]
93
+ headers.each do |name, value|
94
+ valid = header_name!(name)
95
+ if FRAMING.include?(valid.downcase)
96
+ raise ArgumentError,
97
+ "#{valid.inspect} is set by the SDK from the body: leave it out"
98
+ end
99
+
100
+ checked[valid] = header_value!("The value of header #{valid.inspect}", value)
101
+ end
102
+ checked
103
+ end
104
+
105
+ # The text as valid UTF-8, or nil when it cannot be. Binary strings are read as UTF-8.
106
+ #
107
+ # @return [String, nil]
108
+ def utf8(text)
109
+ if [Encoding::BINARY, Encoding::UTF_8].include?(text.encoding)
110
+ copy = text.dup.force_encoding(Encoding::UTF_8)
111
+ copy.valid_encoding? ? copy : nil
112
+ elsif !text.valid_encoding?
113
+ nil
114
+ else
115
+ text.encode(Encoding::UTF_8)
116
+ end
117
+ rescue EncodingError
118
+ nil
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flashpi
4
+ # One page of a list, newest first (establishments and emission points: by code).
5
+ #
6
+ # It is Enumerable over every item of this page and the following ones, fetched as needed:
7
+ #
8
+ # flashpi.documents.list({ status: "authorized" }).each do |document|
9
+ # puts document[:accessKey]
10
+ # end
11
+ #
12
+ # {#data}, {#has_next_page?} and {#next_page} move one page at a time.
13
+ class Page
14
+ include Enumerable
15
+
16
+ # @return [Array<Hash{Symbol => Object}>] the items of this page
17
+ attr_reader :data
18
+ # @return [String, nil] the cursor of the next page, or nil on the last one
19
+ attr_reader :next_cursor
20
+
21
+ # @param body [Hash] the body of a list answer: `data` and `pagination`
22
+ # @yieldparam cursor [String] the cursor of the next page
23
+ # @yieldreturn [Page] the next page
24
+ def initialize(body, &fetch_page)
25
+ members = body.is_a?(Hash) ? body : {} #: Hash[untyped, untyped]
26
+ data = members[:data]
27
+ pagination = members[:pagination]
28
+ unless data.is_a?(Array) && pagination.is_a?(Hash)
29
+ raise Error,
30
+ "The API answered a list without data and pagination"
31
+ end
32
+
33
+ @data = data
34
+ @has_more = pagination[:hasMore] == true
35
+ cursor = pagination[:nextCursor]
36
+ @next_cursor = cursor.is_a?(String) ? cursor : nil
37
+ @fetch_page = fetch_page
38
+ end
39
+
40
+ # @return [Boolean] whether more items follow this page
41
+ def has_more? # rubocop:disable Naming/PredicatePrefix -- the API's hasMore
42
+ @has_more
43
+ end
44
+
45
+ # @return [Boolean] whether {#next_page} will return a page
46
+ def has_next_page? # rubocop:disable Naming/PredicatePrefix
47
+ @has_more && !@next_cursor.nil?
48
+ end
49
+
50
+ # The next page (same filters and options), or nil after the last page.
51
+ #
52
+ # @return [Page, nil]
53
+ def next_page
54
+ cursor = @next_cursor
55
+ fetch_page = @fetch_page
56
+ return nil unless @has_more && cursor && fetch_page
57
+
58
+ fetch_page.call(cursor)
59
+ end
60
+
61
+ # Yields every item of this page and of the following ones, fetching them as needed.
62
+ #
63
+ # @yieldparam item [Hash{Symbol => Object}]
64
+ # @return [Enumerator] without a block
65
+ def each(&block)
66
+ return enum_for(:each) unless block
67
+
68
+ # A while loop, not Kernel#loop, which would swallow a StopIteration of the caller's block.
69
+ # @type var page: Page[untyped]?
70
+ page = self
71
+ while page
72
+ page.data.each(&block)
73
+ page = page.next_page
74
+ end
75
+ self
76
+ end
77
+
78
+ def inspect
79
+ "#<#{self.class.name} data=<#{data.size} items> has_more=#{@has_more} next_cursor=#{next_cursor.inspect}>"
80
+ end
81
+ end
82
+ end