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,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flashpi
|
|
4
|
+
module Internal
|
|
5
|
+
# Reading the error answers of the API (RFC 9457 Problem Details) and their Retry-After.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module Problems
|
|
9
|
+
# The error class of each status; other 5xx are InternalServerError, the rest APIError.
|
|
10
|
+
ERROR_CLASSES = {
|
|
11
|
+
400 => BadRequestError,
|
|
12
|
+
401 => AuthenticationError,
|
|
13
|
+
403 => PermissionDeniedError,
|
|
14
|
+
404 => NotFoundError,
|
|
15
|
+
409 => ConflictError,
|
|
16
|
+
422 => UnprocessableEntityError,
|
|
17
|
+
429 => RateLimitError
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
DELTA_SECONDS = /\A[0-9]+\z/
|
|
21
|
+
# Longer digit strings are past any useful wait.
|
|
22
|
+
MAX_DELTA_DIGITS = 18
|
|
23
|
+
MAX_DELTA = 1_000_000_000_000_000_000
|
|
24
|
+
TIME_ZONE = /(?:Z|[+-][0-9]{2}(?::?[0-9]{2})?)\z/i
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# The error that matches the status of an answer.
|
|
29
|
+
#
|
|
30
|
+
# @param status [Integer]
|
|
31
|
+
# @param headers [Hash{String => String}]
|
|
32
|
+
# @param body_text [String]
|
|
33
|
+
# @return [Flashpi::APIError]
|
|
34
|
+
def error(status, headers, body_text)
|
|
35
|
+
error_class = ERROR_CLASSES.fetch(status) { status >= 500 ? InternalServerError : APIError }
|
|
36
|
+
error_class.new(status, headers, parse(body_text), body_text)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The Problem Details of a body: a JSON object whose `code` is a String, or nil (an answer
|
|
40
|
+
# from the edge, such as a CDN or a firewall, is not one).
|
|
41
|
+
#
|
|
42
|
+
# @return [Hash{Symbol => Object}, nil]
|
|
43
|
+
def parse(body_text)
|
|
44
|
+
return nil if body_text.empty?
|
|
45
|
+
|
|
46
|
+
parsed = JSONText.parse(body_text)
|
|
47
|
+
parsed.is_a?(Hash) && parsed[:code].is_a?(String) ? parsed : nil
|
|
48
|
+
rescue JSONText::Invalid
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The message of an error answer.
|
|
53
|
+
#
|
|
54
|
+
# @return [String]
|
|
55
|
+
def message(status, problem, body_text)
|
|
56
|
+
if problem
|
|
57
|
+
detail = text(problem[:detail]) || text(problem[:title])
|
|
58
|
+
return detail ? "#{status} #{problem[:code]}: #{detail}" : "#{status} #{problem[:code]}"
|
|
59
|
+
end
|
|
60
|
+
excerpt = body_text.split(/[[:space:]]+/).reject(&:empty?).join(" ")[0, 200] || ""
|
|
61
|
+
excerpt.empty? ? "#{status} (no response body)" : "#{status} #{excerpt}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [String, nil] the value when it is a String
|
|
65
|
+
def text(value)
|
|
66
|
+
value.is_a?(String) ? value : nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Seconds from a `Retry-After` value (delta-seconds or an HTTP date), or nil.
|
|
70
|
+
#
|
|
71
|
+
# @param value [String, nil]
|
|
72
|
+
# @param now [Float] the current Unix time in seconds
|
|
73
|
+
# @return [Integer, nil]
|
|
74
|
+
def retry_after(value, now = Time.now.to_f)
|
|
75
|
+
return nil unless value.is_a?(String)
|
|
76
|
+
|
|
77
|
+
trimmed = value.strip
|
|
78
|
+
if DELTA_SECONDS.match?(trimmed)
|
|
79
|
+
return trimmed.length <= MAX_DELTA_DIGITS ? trimmed.to_i : MAX_DELTA
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
moment = date(trimmed)
|
|
83
|
+
moment && [0, (moment - now).ceil].max
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Unix seconds of an HTTP date (or an ISO 8601 one, UTC when it has no zone), or nil.
|
|
87
|
+
#
|
|
88
|
+
# @return [Float, nil]
|
|
89
|
+
def date(value)
|
|
90
|
+
Time.httpdate(value).to_f
|
|
91
|
+
rescue ArgumentError
|
|
92
|
+
begin
|
|
93
|
+
Time.rfc2822(value).to_f
|
|
94
|
+
rescue ArgumentError
|
|
95
|
+
iso8601(value)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [Float, nil]
|
|
100
|
+
def iso8601(value)
|
|
101
|
+
if /\A[0-9]{4}-[0-9]{2}-[0-9]{2}\z/.match?(value)
|
|
102
|
+
parts = value.split("-").map(&:to_i)
|
|
103
|
+
return Time.utc(parts.fetch(0), parts.fetch(1), parts.fetch(2)).to_f
|
|
104
|
+
end
|
|
105
|
+
Time.iso8601(TIME_ZONE.match?(value) ? value : "#{value}Z").to_f
|
|
106
|
+
rescue ArgumentError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flashpi
|
|
4
|
+
module Internal
|
|
5
|
+
# The settings of a client that the requests need. The API key is kept in a {Secret}.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class ClientConfig
|
|
9
|
+
attr_reader :base_url, :timeout, :max_retries, :language, :default_headers
|
|
10
|
+
|
|
11
|
+
def initialize(api_key:, base_url:, timeout:, max_retries:, language:, default_headers:)
|
|
12
|
+
@api_key = Secret.new(api_key)
|
|
13
|
+
@base_url = base_url
|
|
14
|
+
@timeout = timeout
|
|
15
|
+
@max_retries = max_retries
|
|
16
|
+
@language = language
|
|
17
|
+
@default_headers = default_headers.freeze
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [String]
|
|
22
|
+
def api_key
|
|
23
|
+
@api_key.reveal
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Never the API key.
|
|
27
|
+
def inspect
|
|
28
|
+
"#<#{self.class.name} base_url=#{base_url.inspect} timeout=#{timeout} max_retries=#{max_retries}>"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# One API operation, as the resources declare it.
|
|
33
|
+
#
|
|
34
|
+
# @api private
|
|
35
|
+
class RequestSpec
|
|
36
|
+
# @return [Symbol] :get, :post, :patch or :delete
|
|
37
|
+
attr_reader :http_method
|
|
38
|
+
# @return [String] the path, built with {Path.of} when it carries ids
|
|
39
|
+
attr_reader :path
|
|
40
|
+
# @return [Hash, nil] query parameters; nil values are left out
|
|
41
|
+
attr_reader :query
|
|
42
|
+
# @return [Object] a JSON body, serialized once for every attempt; nil sends no body
|
|
43
|
+
attr_reader :body
|
|
44
|
+
# @return [Multipart, nil] a multipart body, instead of `body`
|
|
45
|
+
attr_reader :multipart
|
|
46
|
+
# @return [String] the Accept header
|
|
47
|
+
attr_reader :accept
|
|
48
|
+
# @return [Symbol] how to read a successful answer: :json, :text, :bytes or :none
|
|
49
|
+
attr_reader :response_type
|
|
50
|
+
# @return [Hash{Symbol => Object}] the caller's options
|
|
51
|
+
attr_reader :options
|
|
52
|
+
# @return [Float] a floor, in seconds, for the timeout of requests the API holds open
|
|
53
|
+
attr_reader :min_timeout
|
|
54
|
+
# @return [Boolean] for document POSTs: return the document an earlier attempt created
|
|
55
|
+
attr_reader :recover_document
|
|
56
|
+
|
|
57
|
+
def initialize(http_method:, path:, query: nil, body: nil, multipart: nil, accept: "application/json",
|
|
58
|
+
response_type: :json, options: {}, min_timeout: 0.0, recover_document: false)
|
|
59
|
+
@http_method = http_method
|
|
60
|
+
@path = path
|
|
61
|
+
@query = query
|
|
62
|
+
@body = body
|
|
63
|
+
@multipart = multipart
|
|
64
|
+
@accept = accept
|
|
65
|
+
@response_type = response_type
|
|
66
|
+
@options = options
|
|
67
|
+
@min_timeout = min_timeout
|
|
68
|
+
@recover_document = recover_document
|
|
69
|
+
freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The same request with other query parameters (the next page of a list).
|
|
73
|
+
#
|
|
74
|
+
# @return [RequestSpec]
|
|
75
|
+
def with_query(query)
|
|
76
|
+
RequestSpec.new(http_method:, path:, query:, body:, multipart:, accept:, response_type:, options:,
|
|
77
|
+
min_timeout:, recover_document:)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def inspect
|
|
81
|
+
"#<#{self.class.name} #{http_method.to_s.upcase} #{path}>"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# One attempt of a call: what the transport sends.
|
|
86
|
+
#
|
|
87
|
+
# @api private
|
|
88
|
+
class Attempt
|
|
89
|
+
attr_reader :http_method, :url, :target, :timeout, :response_type
|
|
90
|
+
|
|
91
|
+
# @param http_method [Symbol]
|
|
92
|
+
# @param url [String] the whole URL, for messages
|
|
93
|
+
# @param target [String] the path and query sent in the request line
|
|
94
|
+
# @param headers [Hash{String => String}] by lowercase name; they carry the API key
|
|
95
|
+
# @param content [String, nil] the body
|
|
96
|
+
# @param timeout [Float] seconds the attempt may take
|
|
97
|
+
# @param response_type [Symbol]
|
|
98
|
+
def initialize(http_method:, url:, target:, headers:, content:, timeout:, response_type:)
|
|
99
|
+
@http_method = http_method
|
|
100
|
+
@url = url
|
|
101
|
+
@target = target
|
|
102
|
+
@headers = Secret.new(headers.freeze)
|
|
103
|
+
@content = Secret.new(content)
|
|
104
|
+
@timeout = timeout
|
|
105
|
+
@response_type = response_type
|
|
106
|
+
freeze
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [String, nil] the body, which may be a .p12 and its password
|
|
110
|
+
def content
|
|
111
|
+
@content.reveal
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [Hash{String => String}] the headers, with the API key
|
|
115
|
+
def headers
|
|
116
|
+
@headers.reveal
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [String] the method in capitals
|
|
120
|
+
def verb
|
|
121
|
+
http_method.to_s.upcase
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def inspect
|
|
125
|
+
"#<#{self.class.name} #{verb} #{url}>"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The answer of an attempt, as the transport read it.
|
|
130
|
+
#
|
|
131
|
+
# @api private
|
|
132
|
+
class RawResponse
|
|
133
|
+
attr_reader :status, :headers, :body
|
|
134
|
+
|
|
135
|
+
# @param status [Integer]
|
|
136
|
+
# @param headers [Hash{String => String}] by lowercase name
|
|
137
|
+
# @param body [String] the bytes of the body
|
|
138
|
+
def initialize(status, headers, body)
|
|
139
|
+
@status = status
|
|
140
|
+
@headers = headers.freeze
|
|
141
|
+
@body = body
|
|
142
|
+
freeze
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def inspect
|
|
146
|
+
"#<#{self.class.name} #{status}>"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Paths with ids.
|
|
151
|
+
#
|
|
152
|
+
# @api private
|
|
153
|
+
module Path
|
|
154
|
+
# What `encodeURIComponent` leaves as it is.
|
|
155
|
+
UNRESERVED = /[^A-Za-z0-9\-_.!~*'()]/
|
|
156
|
+
# An id of spaces only.
|
|
157
|
+
BLANK = /\A[[:space:]]*\z/
|
|
158
|
+
|
|
159
|
+
module_function
|
|
160
|
+
|
|
161
|
+
# A path from a template with `{}` placeholders, each id percent-encoded.
|
|
162
|
+
#
|
|
163
|
+
# Path.of("/v1/issuers/{}", issuer_id)
|
|
164
|
+
#
|
|
165
|
+
# Rejects ids that are not Strings or are blank, and `.` and `..`, which URL normalization
|
|
166
|
+
# would turn into the parent path (another resource).
|
|
167
|
+
#
|
|
168
|
+
# @return [String]
|
|
169
|
+
# @raise [Flashpi::Error]
|
|
170
|
+
def of(template, *ids)
|
|
171
|
+
parts = template.split("{}", -1)
|
|
172
|
+
if parts.size != ids.size + 1
|
|
173
|
+
raise ArgumentError,
|
|
174
|
+
"#{template} has #{parts.size - 1} placeholders, not #{ids.size}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
shown = template.gsub("{}", "{id}")
|
|
178
|
+
path = parts.first.to_s
|
|
179
|
+
ids.each_with_index do |id, index|
|
|
180
|
+
path += encode(segment(id, shown)) + parts.fetch(index + 1)
|
|
181
|
+
end
|
|
182
|
+
path
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# An id as UTF-8 text, checked once converted: not blank (Unicode spaces included, as
|
|
186
|
+
# Python's `strip`), and not `.` or `..` in any encoding.
|
|
187
|
+
#
|
|
188
|
+
# @return [String]
|
|
189
|
+
# @raise [Flashpi::Error]
|
|
190
|
+
def segment(id, shown)
|
|
191
|
+
raise Error, "Missing id in #{shown}: got #{id.inspect}" unless id.is_a?(String)
|
|
192
|
+
|
|
193
|
+
text = Validate.utf8(id)
|
|
194
|
+
raise Error, "Invalid id in #{shown}: it is not valid UTF-8 text" if text.nil?
|
|
195
|
+
raise Error, "Missing id in #{shown}: got #{id.inspect}" if BLANK.match?(text)
|
|
196
|
+
raise Error, "Invalid id in #{shown}: #{text.inspect}" if [".", ".."].include?(text)
|
|
197
|
+
|
|
198
|
+
text
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Percent-encodes UTF-8 text as `encodeURIComponent` does.
|
|
202
|
+
#
|
|
203
|
+
# @return [String]
|
|
204
|
+
def encode(text)
|
|
205
|
+
text.gsub(UNRESERVED) { |char| char.unpack("C*").map { |byte| format("%%%02X", byte) }.join }
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The clock of the requests: monotonic time, sleeping and jitter. Tests replace it.
|
|
210
|
+
#
|
|
211
|
+
# @api private
|
|
212
|
+
module Clock
|
|
213
|
+
module_function
|
|
214
|
+
|
|
215
|
+
# @return [Float] monotonic seconds
|
|
216
|
+
def now
|
|
217
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @param seconds [Float]
|
|
221
|
+
def sleep(seconds)
|
|
222
|
+
Kernel.sleep(seconds)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# @return [Float] a random number in [0, 1)
|
|
226
|
+
def rand
|
|
227
|
+
Random.rand
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flashpi
|
|
4
|
+
module Internal
|
|
5
|
+
# Base of every resource: resources only declare their operations as {RequestSpec}s.
|
|
6
|
+
#
|
|
7
|
+
# class Documents < Internal::Resource
|
|
8
|
+
# response_methods :get
|
|
9
|
+
#
|
|
10
|
+
# def get(document_id, **options)
|
|
11
|
+
# send_request(RequestSpec.new(http_method: :get, path: Path.of("/v1/documents/{}", document_id),
|
|
12
|
+
# options:))
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# @api private
|
|
17
|
+
class Resource
|
|
18
|
+
# Declares the methods of the resource's `with_response` view: each runs the method and
|
|
19
|
+
# returns an {APIResponse} with its data and the answer it came from.
|
|
20
|
+
def self.response_methods(*names)
|
|
21
|
+
view = Class.new(ResponseView)
|
|
22
|
+
names.each do |name|
|
|
23
|
+
# Defined at run time, so its body is outside Steep's view of the class.
|
|
24
|
+
view.define_method(name) do |*args, **options, &block|
|
|
25
|
+
capture { resource.public_send(name, *args, **options, &block) } # steep:ignore
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
const_set(:WithResponse, view)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param engine [Engine]
|
|
32
|
+
def initialize(engine)
|
|
33
|
+
@engine = engine
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The same methods, returning an {APIResponse} with the HTTP status, headers and request id.
|
|
37
|
+
#
|
|
38
|
+
# @return [ResponseView]
|
|
39
|
+
def with_response
|
|
40
|
+
@with_response ||= self.class::WithResponse.new(self) # steep:ignore UnknownConstant
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def inspect
|
|
44
|
+
"#<#{self.class.name}>"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# The request of an operation: its path from a template and its ids (see {Path.of}).
|
|
50
|
+
def operation(http_method, template, *ids, **settings)
|
|
51
|
+
RequestSpec.new(http_method:, path: Path.of(template, *ids), **settings)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Sends the request (with its retries) and returns its data.
|
|
55
|
+
def send_request(spec)
|
|
56
|
+
send_call(spec).data
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Sends the request (with its retries) and returns its data with the answer.
|
|
60
|
+
def send_call(spec)
|
|
61
|
+
result = @engine.request(spec)
|
|
62
|
+
ResponseView.record(result)
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The first page of a list; iterating it walks every page.
|
|
67
|
+
def paginate(spec)
|
|
68
|
+
first = fetch_page(spec, spec)
|
|
69
|
+
ResponseView.record(first)
|
|
70
|
+
first.data
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def fetch_page(spec, page_spec)
|
|
74
|
+
result = @engine.request(page_spec)
|
|
75
|
+
page = Page.new(result.data) do |cursor|
|
|
76
|
+
query = (spec.query || {}).reject { |name, _| name.to_s == "cursor" }
|
|
77
|
+
fetch_page(spec, spec.with_query(query.merge(cursor:))).data
|
|
78
|
+
end
|
|
79
|
+
APIResponse.new(data: page, status: result.status, headers: result.headers, request_id: result.request_id)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A resource's `with_response` view. The wrapped call records the last successful answer in a
|
|
84
|
+
# fiber-local slot, so the resource's methods themselves stay unchanged.
|
|
85
|
+
#
|
|
86
|
+
# @api private
|
|
87
|
+
class ResponseView
|
|
88
|
+
SLOT = :__flashpi_response_capture
|
|
89
|
+
|
|
90
|
+
# Keeps `result` for the `with_response` call running in this fiber, if any.
|
|
91
|
+
def self.record(result)
|
|
92
|
+
slot = Thread.current[SLOT]
|
|
93
|
+
slot[:result] = result if slot
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def initialize(resource)
|
|
97
|
+
@resource = resource
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def inspect
|
|
101
|
+
"#<#{self.class.name}>"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
attr_reader :resource
|
|
107
|
+
|
|
108
|
+
def capture
|
|
109
|
+
previous = Thread.current[SLOT]
|
|
110
|
+
slot = {} #: Hash[Symbol, APIResponse[untyped]]
|
|
111
|
+
data = nil #: untyped
|
|
112
|
+
Thread.current[SLOT] = slot
|
|
113
|
+
begin
|
|
114
|
+
data = yield
|
|
115
|
+
ensure
|
|
116
|
+
Thread.current[SLOT] = previous
|
|
117
|
+
end
|
|
118
|
+
result = slot[:result]
|
|
119
|
+
raise Error, "The call sent no request, so there is no response to return" if result.nil?
|
|
120
|
+
|
|
121
|
+
APIResponse.new(data:, status: result.status, headers: result.headers, request_id: result.request_id)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flashpi
|
|
4
|
+
module Internal
|
|
5
|
+
# A value that must never be shown: an API key, a .p12 and its password.
|
|
6
|
+
#
|
|
7
|
+
# The value lives in a closure, not in an instance variable, so `inspect`, `pp`, `to_s`,
|
|
8
|
+
# ActiveSupport's `as_json` (which reads instance variables) and `Marshal.dump` (which refuses
|
|
9
|
+
# a Proc) never reveal it. {#reveal} is the only way to read it.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
class Secret
|
|
13
|
+
# @param value [Object]
|
|
14
|
+
def initialize(value)
|
|
15
|
+
@reader = -> { value }
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Object] the value
|
|
20
|
+
def reveal
|
|
21
|
+
@reader.call
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [String]
|
|
25
|
+
def inspect
|
|
26
|
+
"#<#{self.class.name} [FILTERED]>"
|
|
27
|
+
end
|
|
28
|
+
alias to_s inspect
|
|
29
|
+
|
|
30
|
+
# @raise [TypeError] always: a secret is never serialized.
|
|
31
|
+
def marshal_dump
|
|
32
|
+
raise TypeError, "A Flashpi secret cannot be serialized"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @raise [TypeError] always: a secret is never serialized.
|
|
36
|
+
def _dump(_level)
|
|
37
|
+
raise TypeError, "A Flashpi secret cannot be serialized"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flashpi
|
|
4
|
+
module Internal
|
|
5
|
+
# The settings of a client, checked before anything is sent.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module Settings
|
|
9
|
+
# What an API key is made of: visible ASCII, without spaces.
|
|
10
|
+
API_KEY_CHARACTERS = /\A[\x21-\x7e]+\z/
|
|
11
|
+
# What JavaScript's `trim()` removes around a string, the Node SDK's key included: its white
|
|
12
|
+
# space and line breaks, and the byte order mark (U+FEFF).
|
|
13
|
+
JS_SPACE = "[\t\n\v\f\r -
]"
|
|
14
|
+
AROUND = /\A#{JS_SPACE}+|#{JS_SPACE}+\z/o
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# The checked settings. The key is checked first, so a missing or bad key is the error
|
|
19
|
+
# reported, and every error is raised by a method that holds no copy of the key (error
|
|
20
|
+
# trackers can record the local variables of the method that raises).
|
|
21
|
+
def configure(api_key, base_url:, timeout:, max_retries:, language:, default_headers:)
|
|
22
|
+
problem = key_problem(trimmed_key(api_key))
|
|
23
|
+
refuse!(problem) if problem
|
|
24
|
+
headers = Internal::Validate.headers!("default_headers", default_headers)
|
|
25
|
+
unless language.nil?
|
|
26
|
+
language = Internal::Validate.header_value!("language",
|
|
27
|
+
language.is_a?(Symbol) ? language.to_s : language)
|
|
28
|
+
end
|
|
29
|
+
if headers.keys.any? { |name| name.casecmp?("idempotency-key") }
|
|
30
|
+
refuse!("default_headers cannot carry an Idempotency-Key: it must be unique per operation. " \
|
|
31
|
+
"Pass idempotency_key: to each create instead")
|
|
32
|
+
end
|
|
33
|
+
url = base_url.nil? ? DEFAULT_BASE_URL : base_url
|
|
34
|
+
refuse!("base_url must be an http:// or https:// URL, got #{url.inspect}") unless http_url?(url)
|
|
35
|
+
|
|
36
|
+
Internal::ClientConfig.new(
|
|
37
|
+
api_key: trimmed_key(api_key),
|
|
38
|
+
base_url: url,
|
|
39
|
+
timeout: Internal::Validate.timeout!("timeout", timeout.nil? ? DEFAULT_TIMEOUT : timeout),
|
|
40
|
+
max_retries: Internal::Validate.retries!("max_retries", max_retries.nil? ? DEFAULT_MAX_RETRIES : max_retries),
|
|
41
|
+
language:,
|
|
42
|
+
default_headers: headers
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def refuse!(message)
|
|
47
|
+
raise Error, message
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def http_url?(url)
|
|
51
|
+
return false unless url.is_a?(String)
|
|
52
|
+
|
|
53
|
+
parsed = URI.parse(url)
|
|
54
|
+
%w[http https].include?(parsed.scheme) && !parsed.host.to_s.empty?
|
|
55
|
+
rescue URI::InvalidURIError
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The key: the argument or FLASHPI_API_KEY, trimmed as JavaScript's `trim()`, the byte order
|
|
60
|
+
# mark included. Anything that is not a String is no key.
|
|
61
|
+
def trimmed_key(api_key)
|
|
62
|
+
value = api_key.nil? ? ENV.fetch("FLASHPI_API_KEY", nil) : api_key
|
|
63
|
+
return "" unless value.is_a?(String)
|
|
64
|
+
|
|
65
|
+
text = Internal::Validate.utf8(value)
|
|
66
|
+
# A key that is not valid text is refused for its characters below.
|
|
67
|
+
text ? text.gsub(AROUND, "") : value.b
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Why a trimmed key cannot be used, or nil. It returns instead of raising, so its frame is
|
|
71
|
+
# never part of an error.
|
|
72
|
+
def key_problem(key)
|
|
73
|
+
if key.empty?
|
|
74
|
+
"Missing API key: pass it to Flashpi::Client.new(api_key: ...) or set the FLASHPI_API_KEY " \
|
|
75
|
+
"environment variable"
|
|
76
|
+
elsif key.start_with?("whsec_")
|
|
77
|
+
"That is a webhook signing secret (whsec_...), not an API key: use it with Flashpi::Webhooks"
|
|
78
|
+
elsif !key.start_with?("fp_test_", "fp_live_")
|
|
79
|
+
"The API key must start with fp_test_ or fp_live_"
|
|
80
|
+
elsif !API_KEY_CHARACTERS.match?(key)
|
|
81
|
+
# Never the key: net/http would refuse it in the Authorization header and quote it.
|
|
82
|
+
"The API key has spaces, line breaks or other characters an API key cannot have: copy it again " \
|
|
83
|
+
"from the Flashpi console"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|