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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.en.md +379 -0
- data/README.md +383 -0
- data/lib/flashpi/api_response.rb +33 -0
- data/lib/flashpi/client.rb +112 -0
- data/lib/flashpi/errors.rb +116 -0
- data/lib/flashpi/internal/engine.rb +355 -0
- data/lib/flashpi/internal/json_text.rb +108 -0
- data/lib/flashpi/internal/multipart.rb +141 -0
- data/lib/flashpi/internal/problems.rb +111 -0
- data/lib/flashpi/internal/request.rb +231 -0
- data/lib/flashpi/internal/resource.rb +125 -0
- data/lib/flashpi/internal/secret.rb +41 -0
- data/lib/flashpi/internal/settings.rb +88 -0
- data/lib/flashpi/internal/transport.rb +432 -0
- data/lib/flashpi/internal/validate.rb +122 -0
- data/lib/flashpi/page.rb +82 -0
- data/lib/flashpi/resources/documents.rb +136 -0
- data/lib/flashpi/resources/events.rb +36 -0
- data/lib/flashpi/resources/issuer_onboardings.rb +47 -0
- data/lib/flashpi/resources/issuers.rb +179 -0
- data/lib/flashpi/resources/webhook_endpoints.rb +110 -0
- data/lib/flashpi/version.rb +6 -0
- data/lib/flashpi/webhooks.rb +223 -0
- data/lib/flashpi.rb +42 -0
- data/sig/flashpi/internal.rbs +376 -0
- data/sig/flashpi/resources.rbs +712 -0
- data/sig/flashpi/sdk_types.rbs +114 -0
- data/sig/flashpi/types.rbs +2495 -0
- data/sig/flashpi.rbs +187 -0
- data/sig/manifest.yaml +8 -0
- metadata +80 -0
|
@@ -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
|
data/lib/flashpi/page.rb
ADDED
|
@@ -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
|