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,355 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flashpi
|
|
4
|
+
module Internal
|
|
5
|
+
# Requests to the Flashpi API: retries, timeouts, idempotency and typed errors.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class Engine
|
|
9
|
+
USER_AGENT = "flashpi-ruby/#{VERSION}".freeze
|
|
10
|
+
|
|
11
|
+
# Longest `Retry-After`, in seconds, the SDK waits for by itself; longer waits (daily quotas)
|
|
12
|
+
# are raised.
|
|
13
|
+
MAX_RETRY_AFTER = 60
|
|
14
|
+
|
|
15
|
+
INITIAL_RETRY_DELAY = 0.5
|
|
16
|
+
MAX_RETRY_DELAY = 8.0
|
|
17
|
+
# Waits between checks on an earlier attempt that the API is still running.
|
|
18
|
+
IN_PROGRESS_FIRST_WAIT = 1.0
|
|
19
|
+
IN_PROGRESS_MAX_WAIT = 5.0
|
|
20
|
+
IN_PROGRESS = "idempotency_key_in_progress"
|
|
21
|
+
|
|
22
|
+
OPTION_NAMES = %i[idempotency_key timeout max_retries language correlation_id extra_headers].freeze
|
|
23
|
+
|
|
24
|
+
# What every attempt of one call shares, resolved before the first one is sent. It is a local
|
|
25
|
+
# of the methods that raise the call's errors, and error trackers can record those locals: the
|
|
26
|
+
# body (possibly a .p12 and its password) is kept in a Secret, and `inspect` shows the URL only.
|
|
27
|
+
class Call
|
|
28
|
+
attr_reader :options, :max_retries, :timeout, :url, :target, :idempotency_key
|
|
29
|
+
|
|
30
|
+
def initialize(options:, max_retries:, timeout:, url:, target:, idempotency_key:, content:)
|
|
31
|
+
@options = options
|
|
32
|
+
@max_retries = max_retries
|
|
33
|
+
@timeout = timeout
|
|
34
|
+
@url = url
|
|
35
|
+
@target = target
|
|
36
|
+
@idempotency_key = idempotency_key
|
|
37
|
+
@content = Secret.new(content)
|
|
38
|
+
freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [String, nil] the body every attempt sends
|
|
42
|
+
def content
|
|
43
|
+
@content.reveal
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def inspect
|
|
47
|
+
"#<#{self.class.name} #{url}>"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
private_constant :Call
|
|
51
|
+
|
|
52
|
+
# @return [ClientConfig]
|
|
53
|
+
attr_reader :config
|
|
54
|
+
# @return [Transport] how attempts are sent (tests replace it)
|
|
55
|
+
attr_accessor :transport
|
|
56
|
+
# @return [#now, #sleep, #rand] the clock of retries and waits (tests replace it)
|
|
57
|
+
attr_accessor :clock
|
|
58
|
+
|
|
59
|
+
def initialize(config, transport, clock = Clock)
|
|
60
|
+
@config = config
|
|
61
|
+
@transport = transport
|
|
62
|
+
@clock = clock
|
|
63
|
+
@base_path = URI.parse(config.base_url).path.to_s.sub(%r{/+\z}, "")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Sends a request with its retries and recoveries.
|
|
67
|
+
#
|
|
68
|
+
# @param spec [RequestSpec]
|
|
69
|
+
# @return [Flashpi::APIResponse]
|
|
70
|
+
def request(spec)
|
|
71
|
+
call = prepare(spec)
|
|
72
|
+
started = clock.now
|
|
73
|
+
attempt = retries = waits = 0
|
|
74
|
+
# Whether an earlier attempt may have reached the API without its answer reaching us (a
|
|
75
|
+
# network error, a timeout, or a 5xx from a proxy). Only then can a 409
|
|
76
|
+
# idempotency_key_in_progress be this call's own attempt still running.
|
|
77
|
+
unanswered = false
|
|
78
|
+
|
|
79
|
+
loop do
|
|
80
|
+
failure = attempt_once(spec, call)
|
|
81
|
+
return failure if failure.is_a?(APIResponse)
|
|
82
|
+
|
|
83
|
+
# The error of this attempt: an APIError or an APIConnectionError.
|
|
84
|
+
failure.idempotency_key = call.idempotency_key
|
|
85
|
+
|
|
86
|
+
if attempt.positive? && failure.is_a?(APIError)
|
|
87
|
+
document_id = recoverable_document(spec, failure)
|
|
88
|
+
return recover_document(call, document_id) if document_id
|
|
89
|
+
# A DELETE retried after a lost answer finds the resource already gone.
|
|
90
|
+
return deleted(failure) if spec.http_method == :delete && failure.status == 404
|
|
91
|
+
|
|
92
|
+
# This call's earlier attempt is still running on the API (a sync issue whose answer
|
|
93
|
+
# was lost): wait for it with the same key while the call has time left. These checks
|
|
94
|
+
# do not count as retries; once the time is up, the normal retry rules apply.
|
|
95
|
+
if unanswered && failure.status == 409 && failure.code == IN_PROGRESS
|
|
96
|
+
wait = [IN_PROGRESS_FIRST_WAIT * (2**[waits, 16].min), IN_PROGRESS_MAX_WAIT].min
|
|
97
|
+
if clock.now + wait - started <= call.timeout
|
|
98
|
+
waits += 1
|
|
99
|
+
attempt += 1
|
|
100
|
+
clock.sleep(wait)
|
|
101
|
+
next
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
unanswered = true if unanswered?(failure)
|
|
107
|
+
delay = retry_delay(failure, retries)
|
|
108
|
+
raise failure if delay.nil? || retries >= call.max_retries
|
|
109
|
+
|
|
110
|
+
retries += 1
|
|
111
|
+
attempt += 1
|
|
112
|
+
clock.sleep(delay)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# One attempt: its APIResponse, or the APIError or APIConnectionError to handle. Any other
|
|
119
|
+
# Flashpi::Error is raised with the call's Idempotency-Key.
|
|
120
|
+
def attempt_once(spec, call)
|
|
121
|
+
raw = transport.perform(attempt_of(spec, call))
|
|
122
|
+
read_response(raw, spec.response_type)
|
|
123
|
+
rescue APIConnectionError => e
|
|
124
|
+
e
|
|
125
|
+
rescue Error => e
|
|
126
|
+
e.idempotency_key = call.idempotency_key
|
|
127
|
+
raise
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Whether the attempt may have reached the API without its answer reaching us: a network
|
|
131
|
+
# error, a timeout, or a 5xx from a proxy (no Problem Details).
|
|
132
|
+
def unanswered?(failure)
|
|
133
|
+
failure.is_a?(APIConnectionError) || (failure.is_a?(APIError) && failure.status >= 500 && failure.problem.nil?)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Checks the options and builds what the attempts share. Raises before anything is sent.
|
|
137
|
+
def prepare(spec)
|
|
138
|
+
options = check_options(spec.options)
|
|
139
|
+
max_retries = if options[:max_retries].nil?
|
|
140
|
+
config.max_retries
|
|
141
|
+
else
|
|
142
|
+
Validate.retries!("max_retries",
|
|
143
|
+
options[:max_retries])
|
|
144
|
+
end
|
|
145
|
+
timeout = options[:timeout].nil? ? config.timeout : Validate.timeout!("timeout", options[:timeout])
|
|
146
|
+
target = build_target(spec.path, spec.query)
|
|
147
|
+
longest = [spec.min_timeout, timeout].max
|
|
148
|
+
Call.new(
|
|
149
|
+
options:,
|
|
150
|
+
max_retries:,
|
|
151
|
+
timeout: [longest, Validate::MAX_TIMEOUT].min,
|
|
152
|
+
url: config.base_url.sub(%r{/+\z}, "") + target,
|
|
153
|
+
target: @base_path + target,
|
|
154
|
+
idempotency_key: idempotency_key_of(spec.http_method, options),
|
|
155
|
+
# Serialized once: every attempt sends the same bytes under the same Idempotency-Key,
|
|
156
|
+
# even when the caller changes the body meanwhile.
|
|
157
|
+
content: spec.multipart ? spec.multipart.content : body_content(spec)
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def body_content(spec)
|
|
162
|
+
spec.body.nil? ? nil : JSONText.encode(spec.body)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def attempt_of(spec, call)
|
|
166
|
+
Attempt.new(
|
|
167
|
+
http_method: spec.http_method,
|
|
168
|
+
url: call.url,
|
|
169
|
+
target: call.target,
|
|
170
|
+
headers: build_headers(spec, call),
|
|
171
|
+
content: call.content,
|
|
172
|
+
timeout: call.timeout,
|
|
173
|
+
response_type: spec.response_type
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Reads the document an earlier attempt of the call created. A failure to read it keeps the
|
|
178
|
+
# call's key on the error, so trying again later with that key returns the document instead
|
|
179
|
+
# of issuing another.
|
|
180
|
+
def recover_document(call, document_id)
|
|
181
|
+
options = call.options.except(:idempotency_key)
|
|
182
|
+
request(RequestSpec.new(http_method: :get, path: Path.of("/v1/documents/{}", document_id), options:))
|
|
183
|
+
rescue Error => e
|
|
184
|
+
e.idempotency_key = call.idempotency_key
|
|
185
|
+
raise
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Seconds to wait before retrying, or nil when the failure must not be retried.
|
|
189
|
+
#
|
|
190
|
+
# Retried: network errors and timeouts, 408, 429 and 5xx except 501 (with a `Retry-After` of
|
|
191
|
+
# {MAX_RETRY_AFTER} or less), and 409 `idempotency_key_in_progress` (an attempt with the
|
|
192
|
+
# same key is still running). POSTs are safe to retry because they keep their
|
|
193
|
+
# Idempotency-Key.
|
|
194
|
+
def retry_delay(failure, retries)
|
|
195
|
+
return backoff(retries) if failure.is_a?(APIConnectionError)
|
|
196
|
+
return nil unless failure.is_a?(APIError)
|
|
197
|
+
|
|
198
|
+
status = failure.status
|
|
199
|
+
return failure.code == IN_PROGRESS ? [IN_PROGRESS_FIRST_WAIT, backoff(retries)].max : nil if status == 409
|
|
200
|
+
|
|
201
|
+
transient = [408, 429].include?(status) || (status >= 500 && status != 501)
|
|
202
|
+
# The shared demo certificate is missing: no retry within seconds will fix that.
|
|
203
|
+
return nil if !transient || failure.code == "demo_issuer_unavailable"
|
|
204
|
+
|
|
205
|
+
retry_after = failure.retry_after
|
|
206
|
+
return backoff(retries) if retry_after.nil?
|
|
207
|
+
|
|
208
|
+
retry_after <= MAX_RETRY_AFTER ? retry_after.to_f : nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Exponential backoff from 0.5 s up to 8 s, minus up to 25 % of jitter.
|
|
212
|
+
def backoff(retries)
|
|
213
|
+
delay = [INITIAL_RETRY_DELAY * (2**[retries, 16].min), MAX_RETRY_DELAY].min
|
|
214
|
+
(delay * (1 - (clock.rand * 0.25))).round(3).to_f
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The id of the document an earlier attempt of this call created, if the answer shows one.
|
|
218
|
+
def recoverable_document(spec, failure)
|
|
219
|
+
return nil unless spec.recover_document && failure.status == 422 && failure.code == "idempotency_key_reused"
|
|
220
|
+
|
|
221
|
+
document_id = (failure.problem || {})[:documentId]
|
|
222
|
+
document_id.is_a?(String) && !document_id.empty? ? document_id : nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# The result of a DELETE whose resource is gone: a 204 with the 404's headers.
|
|
226
|
+
def deleted(failure)
|
|
227
|
+
dropped = %w[content-length content-type content-encoding transfer-encoding]
|
|
228
|
+
headers = failure.headers.except(*dropped)
|
|
229
|
+
APIResponse.new(data: nil, status: 204, headers:, request_id: failure.request_id)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# The caller's options with the values that become headers checked and trimmed, so the
|
|
233
|
+
# attempts, the errors and a recovered read all use the same ones.
|
|
234
|
+
def check_options(options)
|
|
235
|
+
unknown = options.keys - OPTION_NAMES
|
|
236
|
+
unless unknown.empty?
|
|
237
|
+
raise ArgumentError, "Unknown request options: #{unknown.map(&:inspect).join(", ")}. The options are " \
|
|
238
|
+
"#{OPTION_NAMES.join(", ")}; bodies and list filters go in a Hash before them"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
checked = options.dup
|
|
242
|
+
unless options[:extra_headers].nil?
|
|
243
|
+
checked[:extra_headers] =
|
|
244
|
+
Validate.headers!("extra_headers", options[:extra_headers])
|
|
245
|
+
end
|
|
246
|
+
%i[idempotency_key correlation_id].each do |name|
|
|
247
|
+
checked[name] = Validate.header_value!(name.to_s, options[name]) unless options[name].nil?
|
|
248
|
+
end
|
|
249
|
+
checked[:language] = language("language", options[:language]) unless options[:language].nil?
|
|
250
|
+
checked
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def language(label, value)
|
|
254
|
+
Validate.header_value!(label, value.is_a?(Symbol) ? value.to_s : value)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The caller's Idempotency-Key of a POST (option or header), or a new one.
|
|
258
|
+
def idempotency_key_of(http_method, options)
|
|
259
|
+
return nil unless http_method == :post
|
|
260
|
+
return options[:idempotency_key] unless options[:idempotency_key].nil?
|
|
261
|
+
|
|
262
|
+
# The last spelling wins, as in build_headers.
|
|
263
|
+
header = nil
|
|
264
|
+
(options[:extra_headers] || {}).each { |name, value| header = value if name.downcase == "idempotency-key" }
|
|
265
|
+
header || SecureRandom.uuid
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# The path and query of a request, after the base URL. Query values that are nil are left
|
|
269
|
+
# out.
|
|
270
|
+
def build_target(path, query)
|
|
271
|
+
pairs = (query || {}).filter_map do |name, value|
|
|
272
|
+
[query_name(name), query_value(value)] unless value.nil?
|
|
273
|
+
end #: Array[[String, String]]
|
|
274
|
+
search = URI.encode_www_form(pairs)
|
|
275
|
+
search.empty? ? path : "#{path}?#{search}"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def query_name(name)
|
|
279
|
+
return name.to_s if name.is_a?(Symbol) || name.is_a?(String)
|
|
280
|
+
|
|
281
|
+
raise TypeError, "Filter names must be Strings or Symbols, not #{name.class}"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def query_value(value)
|
|
285
|
+
case value
|
|
286
|
+
when true then "true"
|
|
287
|
+
when false then "false"
|
|
288
|
+
when String, Symbol then query_text(value.to_s)
|
|
289
|
+
when Integer then value.to_s
|
|
290
|
+
when Float
|
|
291
|
+
raise ArgumentError, "A filter cannot be NaN or infinite" unless value.finite?
|
|
292
|
+
|
|
293
|
+
value.to_s
|
|
294
|
+
when Date then value.iso8601
|
|
295
|
+
else raise TypeError, "Filter values must be Strings, numbers, booleans or Dates, not #{value.class}"
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# A filter as UTF-8 text, as the API reads the query.
|
|
300
|
+
def query_text(value)
|
|
301
|
+
text = Validate.utf8(value)
|
|
302
|
+
raise ArgumentError, "A filter must be valid UTF-8 text" if text.nil?
|
|
303
|
+
|
|
304
|
+
text
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# The headers of an attempt, by lowercase name, from the lowest priority to the highest:
|
|
308
|
+
# client language, client headers, request language, request headers, then the SDK's own.
|
|
309
|
+
def build_headers(spec, call)
|
|
310
|
+
headers = { "accept" => spec.accept, "user-agent" => USER_AGENT }
|
|
311
|
+
language = config.language
|
|
312
|
+
headers["accept-language"] = language if language && !language.empty?
|
|
313
|
+
config.default_headers.each { |name, value| headers[name.downcase] = value }
|
|
314
|
+
language = call.options[:language]
|
|
315
|
+
headers["accept-language"] = language if language && !language.empty?
|
|
316
|
+
(call.options[:extra_headers] || {}).each { |name, value| headers[name.downcase] = value }
|
|
317
|
+
headers["x-correlation-id"] = call.options[:correlation_id] unless call.options[:correlation_id].nil?
|
|
318
|
+
headers["idempotency-key"] = call.idempotency_key unless call.idempotency_key.nil?
|
|
319
|
+
if spec.multipart
|
|
320
|
+
headers["content-type"] = spec.multipart.content_type
|
|
321
|
+
elsif !spec.body.nil?
|
|
322
|
+
headers["content-type"] = "application/json"
|
|
323
|
+
end
|
|
324
|
+
headers["authorization"] = "Bearer #{config.api_key}"
|
|
325
|
+
headers
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# The data of a successful answer, or the APIError of an error status.
|
|
329
|
+
def read_response(raw, response_type)
|
|
330
|
+
return Problems.error(raw.status, raw.headers, JSONText.decode(raw.body)) unless (200..299).cover?(raw.status)
|
|
331
|
+
|
|
332
|
+
APIResponse.new(
|
|
333
|
+
data: read_data(raw, response_type),
|
|
334
|
+
status: raw.status,
|
|
335
|
+
headers: raw.headers,
|
|
336
|
+
request_id: raw.headers["x-request-id"]
|
|
337
|
+
)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def read_data(raw, response_type)
|
|
341
|
+
return raw.body if response_type == :bytes
|
|
342
|
+
|
|
343
|
+
text = JSONText.decode(raw.body)
|
|
344
|
+
return text if response_type == :text
|
|
345
|
+
return nil if response_type == :none || raw.status == 204 || text.empty?
|
|
346
|
+
|
|
347
|
+
begin
|
|
348
|
+
JSONText.parse(text)
|
|
349
|
+
rescue JSONText::Invalid
|
|
350
|
+
raise Error, "The API answered #{raw.status} with a body that is not JSON"
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flashpi
|
|
4
|
+
module Internal
|
|
5
|
+
# JSON in and out: request bodies serialized once, answers parsed with Symbol keys.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module JSONText
|
|
9
|
+
# A body that is not JSON.
|
|
10
|
+
class Invalid < StandardError; end
|
|
11
|
+
|
|
12
|
+
MAX_NESTING = 512
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Compact UTF-8 JSON of a request body, like `JSON.stringify`.
|
|
17
|
+
#
|
|
18
|
+
# The body may hold Hashes (String, Symbol or Integer keys), Arrays, Strings, Symbols,
|
|
19
|
+
# Integers, finite Floats, true, false and nil. Anything else is refused before anything is
|
|
20
|
+
# sent: amounts and dates are text in the Flashpi API.
|
|
21
|
+
#
|
|
22
|
+
# @return [String]
|
|
23
|
+
# @raise [TypeError] for a value JSON has no counterpart for
|
|
24
|
+
# @raise [ArgumentError] for NaN or infinite numbers, text that is not valid UTF-8, a key
|
|
25
|
+
# given twice or a Hash or Array that contains itself
|
|
26
|
+
def encode(body)
|
|
27
|
+
JSON.generate(plain(body, {}), max_nesting: false)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Parses JSON text with Symbol keys.
|
|
31
|
+
#
|
|
32
|
+
# @return [Object]
|
|
33
|
+
# @raise [Invalid] when the text is not JSON
|
|
34
|
+
def parse(text)
|
|
35
|
+
JSON.parse(text, symbolize_names: true, max_nesting: MAX_NESTING)
|
|
36
|
+
rescue JSON::ParserError, EncodingError, ArgumentError => e
|
|
37
|
+
raise Invalid, e.class.name
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# UTF-8 text from bytes, with invalid sequences replaced (like the Node SDK's TextDecoder).
|
|
41
|
+
#
|
|
42
|
+
# @return [String]
|
|
43
|
+
def decode(bytes)
|
|
44
|
+
bytes.dup.force_encoding(Encoding::UTF_8).scrub("�")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @api private
|
|
48
|
+
def plain(value, seen)
|
|
49
|
+
case value
|
|
50
|
+
when Hash then plain_hash(value, seen)
|
|
51
|
+
when Array then nested(value, seen) { value.map { |item| plain(item, seen) } }
|
|
52
|
+
when String then text(value)
|
|
53
|
+
when Symbol then text(value.to_s)
|
|
54
|
+
when Integer, true, false, nil then value
|
|
55
|
+
when Float
|
|
56
|
+
raise ArgumentError, "The body has a number JSON cannot hold (NaN or infinite)" unless value.finite?
|
|
57
|
+
|
|
58
|
+
value
|
|
59
|
+
else
|
|
60
|
+
raise TypeError, "The body must hold only Hashes, Arrays, Strings, numbers, booleans and nil, " \
|
|
61
|
+
"not #{value.class} (amounts and dates are Strings)"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @api private
|
|
66
|
+
def plain_hash(hash, seen)
|
|
67
|
+
nested(hash, seen) do
|
|
68
|
+
result = {} #: Hash[String, untyped]
|
|
69
|
+
hash.each do |key, item|
|
|
70
|
+
name = key_text(key)
|
|
71
|
+
raise ArgumentError, "The body has the key #{name.inspect} twice" if result.key?(name)
|
|
72
|
+
|
|
73
|
+
result[name] = plain(item, seen)
|
|
74
|
+
end
|
|
75
|
+
result
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @api private
|
|
80
|
+
def key_text(key)
|
|
81
|
+
case key
|
|
82
|
+
when String then text(key)
|
|
83
|
+
when Symbol, Integer then text(key.to_s)
|
|
84
|
+
else raise TypeError, "The keys of the body must be Strings or Symbols, not #{key.class}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @api private
|
|
89
|
+
def nested(container, seen)
|
|
90
|
+
id = container.object_id
|
|
91
|
+
raise ArgumentError, "The body contains itself" if seen.key?(id)
|
|
92
|
+
|
|
93
|
+
seen[id] = true
|
|
94
|
+
result = yield
|
|
95
|
+
seen.delete(id)
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @api private
|
|
100
|
+
def text(value)
|
|
101
|
+
utf8 = Validate.utf8(value)
|
|
102
|
+
raise ArgumentError, "The body has text that is not valid UTF-8" if utf8.nil?
|
|
103
|
+
|
|
104
|
+
utf8
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flashpi
|
|
4
|
+
module Internal
|
|
5
|
+
# The multipart/form-data body of a .p12 upload, for `issuers.certificates.upload` and
|
|
6
|
+
# `issuer_onboardings.create`: a `password` field, then a `file` part.
|
|
7
|
+
#
|
|
8
|
+
# It is encoded once, so every retry sends the same bytes (an IO could not be read twice), and
|
|
9
|
+
# kept in a {Secret}: its `inspect` lists only the part names, never the .p12 or its password.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
class Multipart
|
|
13
|
+
# Largest .p12 the API accepts (413 `payload_too_large` above it).
|
|
14
|
+
MAX_CERTIFICATE_BYTES = 1_048_576
|
|
15
|
+
DEFAULT_FILENAME = "certificate.p12"
|
|
16
|
+
CONTENT_TYPE = "application/x-pkcs12"
|
|
17
|
+
PIECE = 65_536
|
|
18
|
+
|
|
19
|
+
# @return [String]
|
|
20
|
+
attr_reader :content_type
|
|
21
|
+
|
|
22
|
+
# The body of a certificate upload: `{ file:, password:, filename: }`.
|
|
23
|
+
#
|
|
24
|
+
# Raises before anything is sent: Flashpi::Error for a password that is not a String, an
|
|
25
|
+
# empty file or one over 1 MiB; TypeError for an upload that is not a Hash, a file that is
|
|
26
|
+
# neither a String of bytes nor an IO, or a filename that is not a String. Error trackers
|
|
27
|
+
# can record the local variables of the method that raises, so the errors are raised by
|
|
28
|
+
# {refuse!}, whose frame holds neither the .p12 nor its password.
|
|
29
|
+
#
|
|
30
|
+
# @param upload [Hash]
|
|
31
|
+
# @return [Multipart]
|
|
32
|
+
def self.certificate(upload)
|
|
33
|
+
refuse!(TypeError, "The certificate upload must be a Hash with file and password") unless upload.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
password = member(upload, :password)
|
|
36
|
+
refuse!(Error, "The certificate password must be a String") unless password.is_a?(String)
|
|
37
|
+
# Sent as UTF-8 text, as the API reads it (a password in another encoding would not match).
|
|
38
|
+
password = Validate.utf8(password) || refuse!(ArgumentError, "The certificate password must be valid text")
|
|
39
|
+
|
|
40
|
+
filename = member(upload, :filename) || DEFAULT_FILENAME
|
|
41
|
+
refuse!(TypeError, "The certificate filename must be a String") unless filename.is_a?(String)
|
|
42
|
+
filename = Validate.utf8(filename) || refuse!(ArgumentError, "The certificate filename must be valid text")
|
|
43
|
+
|
|
44
|
+
new(password, read(member(upload, :file)), filename)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @api private
|
|
48
|
+
def self.member(upload, name)
|
|
49
|
+
upload.key?(name) ? upload[name] : upload[name.to_s]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The bytes of the file: a String as it is, an IO read to its end in pieces, up to one byte
|
|
53
|
+
# past the largest certificate, so a big or wrong file is refused without loading it whole.
|
|
54
|
+
#
|
|
55
|
+
# @api private
|
|
56
|
+
def self.read(file)
|
|
57
|
+
content = file.is_a?(String) ? file.b : read_io(file)
|
|
58
|
+
refuse!(Error, "The certificate file is empty") if content.empty?
|
|
59
|
+
if content.bytesize > MAX_CERTIFICATE_BYTES
|
|
60
|
+
refuse!(Error, "The certificate file has more than #{MAX_CERTIFICATE_BYTES} bytes, the most the API accepts")
|
|
61
|
+
end
|
|
62
|
+
content
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @api private
|
|
66
|
+
def self.read_io(file)
|
|
67
|
+
unless file.respond_to?(:read)
|
|
68
|
+
refuse!(TypeError,
|
|
69
|
+
"The certificate file must be a String of bytes or an IO opened in binary mode, not #{file.class}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
content = String.new(encoding: Encoding::BINARY)
|
|
73
|
+
while content.bytesize <= MAX_CERTIFICATE_BYTES
|
|
74
|
+
chunk = read_piece(file, [PIECE, MAX_CERTIFICATE_BYTES + 1 - content.bytesize].min)
|
|
75
|
+
break if chunk.nil?
|
|
76
|
+
|
|
77
|
+
unless chunk.is_a?(String)
|
|
78
|
+
refuse!(TypeError,
|
|
79
|
+
"The read method of the certificate file returned #{chunk.class}, not a String")
|
|
80
|
+
end
|
|
81
|
+
break if chunk.empty?
|
|
82
|
+
|
|
83
|
+
content << chunk.b
|
|
84
|
+
end
|
|
85
|
+
content
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# One read of the file. A failure of the file's own read (a broken gzip, a reset socket) is then
|
|
89
|
+
# raised with this frame on top, which holds no bytes of the .p12.
|
|
90
|
+
#
|
|
91
|
+
# @api private
|
|
92
|
+
def self.read_piece(file, length)
|
|
93
|
+
file.read(length)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Raises from a frame that holds only the error.
|
|
97
|
+
#
|
|
98
|
+
# @api private
|
|
99
|
+
def self.refuse!(error, message)
|
|
100
|
+
raise error, message
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def initialize(password, content, filename)
|
|
104
|
+
boundary = "flashpi-#{SecureRandom.hex(16)}"
|
|
105
|
+
boundary = "flashpi-#{SecureRandom.hex(16)}" while content.include?(boundary) || password.b.include?(boundary)
|
|
106
|
+
@content_type = "multipart/form-data; boundary=#{boundary}"
|
|
107
|
+
@body = Secret.new(encode(boundary, password, content, filename).freeze)
|
|
108
|
+
freeze
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @return [String] the encoded body
|
|
112
|
+
def content
|
|
113
|
+
@body.reveal
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Only the part names: the values are a .p12 and its password.
|
|
117
|
+
def inspect
|
|
118
|
+
"#<#{self.class.name} fields=[\"password\"] files=[\"file\"]>"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def encode(boundary, password, content, filename)
|
|
124
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
125
|
+
body << "--#{boundary}\r\n"
|
|
126
|
+
body << "Content-Disposition: form-data; name=\"password\"\r\n\r\n"
|
|
127
|
+
body << password.b << "\r\n"
|
|
128
|
+
body << "--#{boundary}\r\n"
|
|
129
|
+
body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{escape(filename)}\"\r\n"
|
|
130
|
+
body << "Content-Type: #{CONTENT_TYPE}\r\n\r\n"
|
|
131
|
+
body << content << "\r\n"
|
|
132
|
+
body << "--#{boundary}--\r\n"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# As browsers write a file name (WHATWG): quotes and line breaks percent-encoded.
|
|
136
|
+
def escape(filename)
|
|
137
|
+
filename.b.gsub("\"", "%22").gsub("\r", "%0D").gsub("\n", "%0A")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|