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,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