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,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flashpi
4
+ # Verification of the webhooks Flashpi sends (Standard Webhooks). It needs no API key, and is
5
+ # also available as `client.webhooks`.
6
+ #
7
+ # A webhook is signed with HMAC-SHA256 over `webhook-id.webhook-timestamp.body` using the
8
+ # endpoint's `whsec_` secret. During a secret rotation Flashpi signs with both secrets, so the
9
+ # `webhook-signature` header lists several signatures.
10
+ #
11
+ # event = Flashpi::Webhooks.unwrap(request.raw_post, request.headers, ENV.fetch("FLASHPI_WEBHOOK_SECRET"))
12
+ # case event[:type]
13
+ # when "webhook_endpoint.verification" then render json: { challenge: event[:data][:challenge] }
14
+ # when "invoice.authorized" then ...
15
+ # end
16
+ #
17
+ # Error trackers can record the local variables of the method that raises, so every
18
+ # verification error is raised by a method that holds neither the secret, its key nor the
19
+ # expected signature.
20
+ module Webhooks
21
+ # Default largest gap, in seconds, between a webhook's timestamp and the receiver's clock.
22
+ TOLERANCE_SECONDS = 300
23
+
24
+ SECRET_PREFIX = "whsec_"
25
+ # Key sizes of a Standard Webhooks secret (Flashpi makes 32-byte keys).
26
+ MIN_SECRET_BYTES = 24
27
+ MAX_SECRET_BYTES = 64
28
+ SIGNATURE_BYTES = 32
29
+ TIMESTAMP = /\A[0-9]{1,12}\z/
30
+ BASE64 = %r{\A[A-Za-z0-9+/]*={0,2}\z}
31
+ NO_MATCH = "No webhook-signature matches the body and the secret"
32
+
33
+ class << self
34
+ # Checks that a webhook comes from Flashpi and is recent.
35
+ #
36
+ # Raises a {WebhookVerificationError} otherwise: answer 400 and ignore the request. Pass the
37
+ # raw body exactly as received; a body parsed and serialized again does not match.
38
+ #
39
+ # @param payload [String] the raw body
40
+ # @param headers [Hash, #each] the request headers: a Hash in any letter case, a Rack env
41
+ # (`HTTP_WEBHOOK_ID`) or Rails' `request.headers`; repeated headers may come as Arrays
42
+ # @param secret [String] the endpoint's signing secret (`whsec_...`)
43
+ # @param tolerance [Integer, Float, nil] largest gap, in seconds, between
44
+ # `webhook-timestamp` and now (default 300)
45
+ # @param now [Time, Integer, Float, nil] the current time, for tests: a Time or Unix seconds
46
+ # @return [nil]
47
+ # @raise [WebhookVerificationError]
48
+ # @raise [TypeError, ArgumentError] for a bad `tolerance` or `now`
49
+ def verify_signature(payload, headers, secret, tolerance: nil, now: nil)
50
+ tolerance_seconds, now_seconds = verify_options(tolerance, now)
51
+ key = signing_key(secret)
52
+ webhook_id = header(headers, "webhook-id")
53
+ timestamp = header(headers, "webhook-timestamp")
54
+ signatures = header(headers, "webhook-signature")
55
+ if webhook_id.nil? || timestamp.nil? || signatures.nil?
56
+ reject!(:missing_headers, "The webhook-id, webhook-timestamp and webhook-signature headers are required")
57
+ end
58
+ reject!(:invalid_timestamp, "The webhook-timestamp is not valid") unless TIMESTAMP.match?(timestamp)
59
+ # Written so that anything unexpected fails closed.
60
+ unless (now_seconds.floor - timestamp.to_i).abs <= tolerance_seconds
61
+ reject!(:timestamp_out_of_range,
62
+ "The webhook-timestamp is more than #{shown(tolerance_seconds)} seconds away from now")
63
+ end
64
+
65
+ body = payload_bytes(payload)
66
+ reject!(:no_matching_signature, NO_MATCH) unless signed?(key, "#{webhook_id.b}.#{timestamp}.".b + body,
67
+ signatures)
68
+ nil
69
+ end
70
+
71
+ # Verifies a webhook (see {verify_signature}) and returns its parsed body, with Symbol keys.
72
+ #
73
+ # Branch on `event[:type]`. Endpoints in thin mode receive no `data`: read the event with
74
+ # `client.events.get(event[:id])`.
75
+ #
76
+ # @return [Hash{Symbol => Object}] the event
77
+ # @raise [WebhookVerificationError]
78
+ def unwrap(payload, headers, secret, tolerance: nil, now: nil)
79
+ verify_signature(payload, headers, secret, tolerance:, now:)
80
+ event = parse(payload)
81
+ reject!(:invalid_payload, "The webhook body is not a JSON object") unless event.is_a?(Hash)
82
+
83
+ _ = event
84
+ end
85
+
86
+ private
87
+
88
+ def parse(payload)
89
+ Internal::JSONText.parse(Internal::JSONText.decode(payload.b))
90
+ rescue Internal::JSONText::Invalid
91
+ reject!(:invalid_payload, "The webhook body is not JSON")
92
+ end
93
+
94
+ # Raises from a frame that holds only the reason.
95
+ def reject!(reason, message)
96
+ raise WebhookVerificationError.new(reason, message)
97
+ end
98
+
99
+ # A header without letter case, or in a Rack env's form (`HTTP_WEBHOOK_ID`). Repeated
100
+ # headers: signature lists are joined, otherwise the first. An empty value is missing. Names
101
+ # and values are read as bytes (as Rack servers give them), so no encoding of theirs can
102
+ # make the checks below raise anything but a WebhookVerificationError.
103
+ def header(headers, name)
104
+ unless headers.respond_to?(:each)
105
+ raise TypeError, "headers must be a Hash (or Rack env, or Rails request.headers), not #{headers.class}"
106
+ end
107
+
108
+ env_name = "HTTP_#{name.upcase.tr("-", "_")}"
109
+ values = [] #: Array[String]
110
+ headers.each do |key, value|
111
+ next unless key.is_a?(String) || key.is_a?(Symbol)
112
+
113
+ field = key.to_s.b
114
+ next unless field.casecmp?(name) || field == env_name
115
+
116
+ values << value.b if value.is_a?(String)
117
+ values.concat(value.grep(String).map(&:b)) if value.is_a?(Array)
118
+ end
119
+ found = name == "webhook-signature" ? values.join(" ") : values.first
120
+ found.nil? || found.empty? ? nil : found
121
+ end
122
+
123
+ # The decoded key of a `whsec_` secret, in a {Internal::Secret}.
124
+ def signing_key(secret)
125
+ unless secret.is_a?(String) && !secret.b.strip.empty?
126
+ reject!(:invalid_secret,
127
+ "The webhook secret is empty: copy it from the console or webhook_endpoints.get_secret")
128
+ end
129
+
130
+ size = key_size(secret)
131
+ if size.zero?
132
+ reject!(:invalid_secret, "The webhook secret is not a whsec_ secret (base64 after the prefix)")
133
+ elsif size < MIN_SECRET_BYTES || size > MAX_SECRET_BYTES
134
+ reject!(:invalid_secret, "The webhook secret is not a complete whsec_ secret: copy it again from the " \
135
+ "console or webhook_endpoints.get_secret")
136
+ end
137
+ Internal::Secret.new(decode_base64(encoded_key(secret)) || "")
138
+ end
139
+
140
+ # The size of a secret's key, or 0 when it is not base64. It returns instead of raising, so
141
+ # the key never stays in the frame of an error.
142
+ def key_size(secret)
143
+ key = decode_base64(encoded_key(secret))
144
+ key ? key.bytesize : 0
145
+ end
146
+
147
+ # The base64 after the prefix, read as bytes.
148
+ def encoded_key(secret)
149
+ secret.b.strip.delete_prefix(SECRET_PREFIX)
150
+ end
151
+
152
+ # Whether a v1 signature of the header signs `message`. The signatures are read before the
153
+ # expected one is computed, and nothing after that can raise, so the expected signature
154
+ # never stays in the frame of an error.
155
+ def signed?(key, message, signatures)
156
+ candidates = v1_signatures(signatures)
157
+ expected = OpenSSL::HMAC.digest("SHA256", key.reveal, message)
158
+ candidates.any? { |signature| OpenSSL.fixed_length_secure_compare(signature, expected) }
159
+ end
160
+
161
+ # The decoded v1 signatures of a `webhook-signature` header that have the right size.
162
+ def v1_signatures(signatures)
163
+ signatures.b.split(/ /).filter_map do |entry|
164
+ version, comma, encoded = entry.partition(",")
165
+ next if comma.empty? || version != "v1"
166
+
167
+ signature = decode_base64(encoded)
168
+ signature if signature && signature.bytesize == SIGNATURE_BYTES
169
+ end
170
+ end
171
+
172
+ # Standard base64 to bytes, or nil when it is not base64 (padding optional, like atob).
173
+ def decode_base64(value)
174
+ return nil unless BASE64.match?(value) && value.length % 4 != 1
175
+
176
+ data = (value.length % 4).zero? ? value.sub(/=+\z/, "") : value
177
+ return nil if data.include?("=") || data.length % 4 == 1
178
+
179
+ # Only the alphabet is left, in a length that decodes once padded.
180
+ "#{data}#{"=" * (-data.length % 4)}".unpack1("m").to_s
181
+ end
182
+
183
+ def payload_bytes(payload)
184
+ unless payload.is_a?(String)
185
+ reject!(:invalid_payload,
186
+ "Pass the raw webhook body as a String (not parsed JSON)")
187
+ end
188
+
189
+ payload.b
190
+ end
191
+
192
+ # The checked tolerance and clock. Bad values are programming errors: TypeError or
193
+ # ArgumentError, never a skipped time check.
194
+ def verify_options(tolerance, now)
195
+ value = tolerance.nil? ? TOLERANCE_SECONDS : tolerance
196
+ unless value.is_a?(Integer) || value.is_a?(Float)
197
+ raise TypeError,
198
+ "tolerance must be a number of seconds, 0 or more"
199
+ end
200
+ raise ArgumentError, "tolerance must be a number of seconds, 0 or more" unless value.to_f.finite? && value >= 0
201
+
202
+ [value.to_f, clock(now)]
203
+ end
204
+
205
+ def clock(now)
206
+ seconds = case now
207
+ when nil then Time.now.to_f
208
+ when Time then now.to_f
209
+ when Integer then now.bit_length > 1000 ? Float::INFINITY : now.to_f
210
+ when Float then now
211
+ else raise TypeError, "now must be a Time or a number of Unix seconds"
212
+ end
213
+ raise ArgumentError, "now must be a Time or a number of Unix seconds" unless seconds.finite?
214
+
215
+ seconds
216
+ end
217
+
218
+ def shown(seconds)
219
+ seconds == seconds.floor ? seconds.to_i.to_s : seconds.to_s
220
+ end
221
+ end
222
+ end
223
+ end
data/lib/flashpi.rb ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "json"
5
+ require "net/http"
6
+ require "openssl"
7
+ require "securerandom"
8
+ require "time"
9
+ require "uri"
10
+ require "zlib"
11
+
12
+ require_relative "flashpi/version"
13
+ require_relative "flashpi/errors"
14
+ require_relative "flashpi/api_response"
15
+ require_relative "flashpi/page"
16
+ require_relative "flashpi/internal/secret"
17
+ require_relative "flashpi/internal/validate"
18
+ require_relative "flashpi/internal/json_text"
19
+ require_relative "flashpi/internal/problems"
20
+ require_relative "flashpi/internal/request"
21
+ require_relative "flashpi/internal/multipart"
22
+ require_relative "flashpi/internal/transport"
23
+ require_relative "flashpi/internal/engine"
24
+ require_relative "flashpi/internal/resource"
25
+ require_relative "flashpi/internal/settings"
26
+ require_relative "flashpi/resources/documents"
27
+ require_relative "flashpi/resources/issuers"
28
+ require_relative "flashpi/resources/issuer_onboardings"
29
+ require_relative "flashpi/resources/webhook_endpoints"
30
+ require_relative "flashpi/resources/events"
31
+ require_relative "flashpi/webhooks"
32
+ require_relative "flashpi/client"
33
+
34
+ # The official Ruby SDK for Flashpi, the electronic invoicing API for Ecuador's SRI.
35
+ #
36
+ # require "flashpi"
37
+ #
38
+ # flashpi = Flashpi::Client.new # reads FLASHPI_API_KEY
39
+ # invoice = flashpi.invoices.create({ issuerId: "...", ... })
40
+ # invoice[:accessKey]
41
+ module Flashpi
42
+ end
@@ -0,0 +1,376 @@
1
+ # The SDK's internals: not part of its API, and free to change in any release.
2
+ module Flashpi
3
+ module Internal
4
+ class Secret[out T]
5
+ @reader: ^() -> T
6
+
7
+ def initialize: (T value) -> void
8
+ def reveal: () -> T
9
+ def marshal_dump: () -> bot
10
+ def _dump: (Integer level) -> bot
11
+ end
12
+
13
+ module Validate
14
+ MAX_TIMEOUT: Float
15
+ TOKEN: Regexp
16
+ AROUND: Regexp
17
+ CONTROL: Regexp
18
+ FRAMING: Array[String]
19
+
20
+ def self?.timeout!: (String name, untyped value) -> Float
21
+ def self?.retries!: (String name, untyped value) -> Integer
22
+ def self?.header_name!: (untyped name) -> String
23
+ def self?.header_value!: (String label, untyped value) -> String
24
+ def self?.headers!: (String label, untyped headers) -> Hash[String, String]
25
+ def self?.utf8: (String text) -> String?
26
+ end
27
+
28
+ module JSONText
29
+ class Invalid < StandardError
30
+ end
31
+
32
+ MAX_NESTING: Integer
33
+
34
+ def self?.encode: (untyped body) -> String
35
+ def self?.parse: (String text) -> untyped
36
+ def self?.decode: (String bytes) -> String
37
+ def self?.plain: (untyped value, Hash[Integer, bool] seen) -> untyped
38
+ def self?.plain_hash: (Hash[untyped, untyped] hash, Hash[Integer, bool] seen) -> Hash[String, untyped]
39
+ def self?.key_text: (untyped key) -> String
40
+ def self?.nested: [R] (Object container, Hash[Integer, bool] seen) { () -> R } -> R
41
+ def self?.text: (String value) -> String
42
+ end
43
+
44
+ module Problems
45
+ ERROR_CLASSES: Hash[Integer, singleton(APIError)]
46
+ DELTA_SECONDS: Regexp
47
+ MAX_DELTA_DIGITS: Integer
48
+ MAX_DELTA: Integer
49
+ TIME_ZONE: Regexp
50
+
51
+ def self?.error: (Integer status, Hash[String, String] headers, String body_text) -> APIError
52
+ def self?.parse: (String body_text) -> Types::problem?
53
+ def self?.message: (Integer status, Types::problem? problem, String body_text) -> String
54
+ def self?.text: (untyped value) -> String?
55
+ def self?.retry_after: (String? value, ?Float now) -> Integer?
56
+ def self?.date: (String value) -> Float?
57
+ def self?.iso8601: (String value) -> Float?
58
+ end
59
+
60
+ class ClientConfig
61
+ @api_key: Secret[String]
62
+
63
+ attr_reader base_url: String
64
+ attr_reader timeout: Float
65
+ attr_reader max_retries: Integer
66
+ attr_reader language: String?
67
+ attr_reader default_headers: Hash[String, String]
68
+
69
+ def initialize: (
70
+ api_key: String, base_url: String, timeout: Float, max_retries: Integer, language: String?,
71
+ default_headers: Hash[String, String]
72
+ ) -> void
73
+ def api_key: () -> String
74
+ end
75
+
76
+ type http_method = :get | :post | :patch | :delete
77
+ type response_type = :json | :text | :bytes | :none
78
+
79
+ class RequestSpec
80
+ attr_reader http_method: http_method
81
+ attr_reader path: String
82
+ attr_reader query: Hash[untyped, untyped]?
83
+ attr_reader body: untyped
84
+ attr_reader multipart: Multipart?
85
+ attr_reader accept: String
86
+ attr_reader response_type: response_type
87
+ attr_reader options: Hash[Symbol, untyped]
88
+ attr_reader min_timeout: Float
89
+ attr_reader recover_document: bool
90
+
91
+ def initialize: (
92
+ http_method: http_method, path: String, ?query: Hash[untyped, untyped]?, ?body: untyped,
93
+ ?multipart: Multipart?, ?accept: String, ?response_type: response_type,
94
+ ?options: Hash[Symbol, untyped], ?min_timeout: Float, ?recover_document: bool
95
+ ) -> void
96
+ def with_query: (Hash[untyped, untyped] query) -> RequestSpec
97
+ end
98
+
99
+ class Attempt
100
+ @headers: Secret[Hash[String, String]]
101
+ @content: Secret[String?]
102
+
103
+ attr_reader http_method: http_method
104
+ attr_reader url: String
105
+ attr_reader target: String
106
+ attr_reader timeout: Float
107
+ attr_reader response_type: response_type
108
+
109
+ def initialize: (
110
+ http_method: http_method, url: String, target: String, headers: Hash[String, String],
111
+ content: String?, timeout: Float, response_type: response_type
112
+ ) -> void
113
+ def headers: () -> Hash[String, String]
114
+ def content: () -> String?
115
+ def verb: () -> String
116
+ end
117
+
118
+ class RawResponse
119
+ attr_reader status: Integer
120
+ attr_reader headers: Hash[String, String]
121
+ attr_reader body: String
122
+
123
+ def initialize: (Integer status, Hash[String, String] headers, String body) -> void
124
+ end
125
+
126
+ module Path
127
+ UNRESERVED: Regexp
128
+ BLANK: Regexp
129
+
130
+ def self?.of: (String template, *untyped ids) -> String
131
+ def self?.segment: (untyped id, String shown) -> String
132
+ def self?.encode: (String text) -> String
133
+ end
134
+
135
+ interface _Clock
136
+ def now: () -> Float
137
+ def sleep: (Float seconds) -> untyped
138
+ def rand: () -> Float
139
+ end
140
+
141
+ module Clock
142
+ def self?.now: () -> Float
143
+ def self?.sleep: (Float seconds) -> untyped
144
+ def self?.rand: () -> Float
145
+ end
146
+
147
+ class Multipart
148
+ MAX_CERTIFICATE_BYTES: Integer
149
+ DEFAULT_FILENAME: String
150
+ CONTENT_TYPE: String
151
+ PIECE: Integer
152
+
153
+ @body: Secret[String]
154
+
155
+ attr_reader content_type: String
156
+
157
+ def self.certificate: (untyped upload) -> Multipart
158
+ def self.member: (Hash[untyped, untyped] upload, Symbol name) -> untyped
159
+ def self.read: (untyped file) -> String
160
+ def self.read_io: (untyped file) -> String
161
+ def self.read_piece: (untyped file, Integer length) -> untyped
162
+ def self.refuse!: (singleton(Exception) error, String message) -> bot
163
+
164
+ def initialize: (String password, String content, String filename) -> void
165
+ def content: () -> String
166
+
167
+ private
168
+
169
+ def encode: (String boundary, String password, String content, String filename) -> String
170
+ def escape: (String filename) -> String
171
+ end
172
+
173
+ class Deadline
174
+ @clock: _Clock
175
+ @end: Float
176
+
177
+ def initialize: (Float seconds, ?_Clock clock) -> void
178
+ def left: () -> Float
179
+ def passed?: () -> bool
180
+ def left!: (singleton(Exception) error) -> Float
181
+ def limit: (Float? timeout, singleton(Exception) error) -> Float
182
+ end
183
+
184
+ module DeadlineHTTP : Net::HTTP
185
+ @socket: untyped
186
+
187
+ attr_accessor flashpi_deadline: Deadline?
188
+
189
+ private
190
+
191
+ def connect: () -> void
192
+ def ssl_socket_connect: (OpenSSL::SSL::SSLSocket socket, Float? timeout) -> void
193
+ def on_connect: () -> void
194
+ end
195
+
196
+ class DeadlineSocket
197
+ @http: DeadlineHTTP
198
+
199
+ attr_reader io: untyped
200
+
201
+ def initialize: (untyped io, DeadlineHTTP http) -> void
202
+ def read_nonblock: (Integer length, ?String? buffer, ?exception: bool) -> (String | Symbol | nil)
203
+ def write_nonblock: (String data, ?exception: bool) -> (Integer | Symbol)
204
+ def to_io: () -> self
205
+ def wait_readable: (?Float? timeout) -> untyped
206
+ def wait_writable: (?Float? timeout) -> untyped
207
+ def close: () -> untyped
208
+ def closed?: () -> bool
209
+ def eof?: () -> bool
210
+ def respond_to_missing?: (Symbol name, ?bool include_private) -> bool
211
+ def method_missing: (Symbol name, *untyped args, **untyped options) ?{ (*untyped) -> untyped } -> untyped
212
+
213
+ private
214
+
215
+ def deadline: () -> Deadline?
216
+ def limited: (Float? timeout, singleton(Exception) error) -> Float?
217
+ end
218
+
219
+ module Proxy
220
+ LOOPBACK: Regexp
221
+
222
+ # The environment, or a Hash for the tests.
223
+ interface _Env
224
+ def []: (String name) -> String?
225
+ def key?: (String name) -> bool
226
+ end
227
+
228
+ def self?.from_env: (URI::Generic uri, ?_Env env) -> [String, Integer, String?, String?]?
229
+ def self?.bypassed?: (URI::Generic uri, String? no_proxy) -> bool
230
+ def self?.parse: (String name, String value) -> [String, Integer, String?, String?]
231
+ def self?.refuse!: (String name) -> bot
232
+ end
233
+
234
+ class Transport
235
+ MAX_IDLE: Integer
236
+ CONNECTION_ERRORS: Array[singleton(Exception)]
237
+ TIMEOUT_ERRORS: Array[singleton(Exception)]
238
+ REQUESTS: Hash[Symbol, singleton(Net::HTTP::Get) | singleton(Net::HTTP::Post) | singleton(Net::HTTP::Patch) | singleton(Net::HTTP::Delete)]
239
+
240
+ @host: String
241
+ @port: Integer
242
+ @use_ssl: bool
243
+ @proxy: [String, Integer, String?, String?]?
244
+ @clock: _Clock
245
+ @idle: Array[Net::HTTP & DeadlineHTTP]
246
+ @mutex: Thread::Mutex
247
+ @pid: Integer
248
+
249
+ def initialize: (String base_url, ?_Clock clock) -> void
250
+ def perform: (Attempt attempt) -> RawResponse
251
+ def close: () -> nil
252
+
253
+ private
254
+
255
+ def exchange: (Net::HTTP & DeadlineHTTP http, Attempt attempt, Deadline deadline) -> RawResponse
256
+ def gzip_inflater: (Net::HTTPResponse response) -> Zlib::Inflate?
257
+ def limit_timeouts: (Net::HTTP http, Deadline deadline) -> void
258
+ def build_request: (Attempt attempt) -> Net::HTTPRequest
259
+ def checkout: () -> (Net::HTTP & DeadlineHTTP)
260
+ def checkin: (Net::HTTP & DeadlineHTTP http) -> void
261
+ def discard: (Net::HTTP http) -> void
262
+ def connect: () -> (Net::HTTP & DeadlineHTTP)
263
+ def forked?: () -> bool
264
+ def seconds: (Float value) -> String
265
+ end
266
+
267
+ class Engine
268
+ USER_AGENT: String
269
+ MAX_RETRY_AFTER: Integer
270
+ INITIAL_RETRY_DELAY: Float
271
+ MAX_RETRY_DELAY: Float
272
+ IN_PROGRESS_FIRST_WAIT: Float
273
+ IN_PROGRESS_MAX_WAIT: Float
274
+ IN_PROGRESS: String
275
+ OPTION_NAMES: Array[Symbol]
276
+
277
+ class Call
278
+ @content: Secret[String?]
279
+
280
+ attr_reader options: Hash[Symbol, untyped]
281
+ attr_reader max_retries: Integer
282
+ attr_reader timeout: Float
283
+ attr_reader url: String
284
+ attr_reader target: String
285
+ attr_reader idempotency_key: String?
286
+
287
+ def initialize: (
288
+ options: Hash[Symbol, untyped], max_retries: Integer, timeout: Float, url: String, target: String,
289
+ idempotency_key: String?, content: String?
290
+ ) -> void
291
+ def content: () -> String?
292
+ end
293
+
294
+ @base_path: String
295
+
296
+ attr_reader config: ClientConfig
297
+ attr_accessor transport: Transport
298
+ attr_accessor clock: _Clock
299
+
300
+ def initialize: (ClientConfig config, Transport transport, ?_Clock clock) -> void
301
+ def request: (RequestSpec spec) -> APIResponse[untyped]
302
+
303
+ private
304
+
305
+ def unanswered?: (Error failure) -> bool
306
+ def attempt_once: (RequestSpec spec, Call call) -> (APIResponse[untyped] | APIError | APIConnectionError)
307
+ def prepare: (RequestSpec spec) -> Call
308
+ def body_content: (RequestSpec spec) -> String?
309
+ def attempt_of: (RequestSpec spec, Call call) -> Attempt
310
+ def recover_document: (Call call, String document_id) -> APIResponse[untyped]
311
+ def retry_delay: (Error failure, Integer retries) -> Float?
312
+ def backoff: (Integer retries) -> Float
313
+ def recoverable_document: (RequestSpec spec, APIError failure) -> String?
314
+ def deleted: (APIError failure) -> APIResponse[nil]
315
+ def check_options: (Hash[Symbol, untyped] options) -> Hash[Symbol, untyped]
316
+ def language: (String label, untyped value) -> String
317
+ def idempotency_key_of: (http_method http_method, Hash[Symbol, untyped] options) -> String?
318
+ def build_target: (String path, Hash[untyped, untyped]? query) -> String
319
+ def query_name: (untyped name) -> String
320
+ def query_value: (untyped value) -> String
321
+ def query_text: (String value) -> String
322
+ def build_headers: (RequestSpec spec, Call call) -> Hash[String, String]
323
+ def read_response: (RawResponse raw, response_type response_type) -> (APIResponse[untyped] | APIError)
324
+ def read_data: (RawResponse raw, response_type response_type) -> untyped
325
+ end
326
+
327
+ class Resource
328
+ @engine: Engine
329
+ @with_response: ResponseView?
330
+
331
+ def self.response_methods: (*Symbol names) -> void
332
+
333
+ def initialize: (Engine engine) -> void
334
+ def with_response: () -> ResponseView
335
+
336
+ private
337
+
338
+ def operation: (http_method http_method, String template, *untyped ids, **untyped settings) -> RequestSpec
339
+ def send_request: (RequestSpec spec) -> untyped
340
+ def send_call: (RequestSpec spec) -> APIResponse[untyped]
341
+ def paginate: (RequestSpec spec) -> Page[untyped]
342
+ def fetch_page: (RequestSpec spec, RequestSpec page_spec) -> APIResponse[Page[untyped]]
343
+ end
344
+
345
+ class ResponseView
346
+ SLOT: Symbol
347
+
348
+ @resource: Resource
349
+
350
+ def self.record: (APIResponse[untyped] result) -> void
351
+
352
+ def initialize: (Resource resource) -> void
353
+
354
+ private
355
+
356
+ attr_reader resource: Resource
357
+
358
+ def capture: [T] () { () -> T } -> APIResponse[T]
359
+ end
360
+
361
+ module Settings
362
+ API_KEY_CHARACTERS: Regexp
363
+ JS_SPACE: String
364
+ AROUND: Regexp
365
+
366
+ def self?.configure: (
367
+ untyped api_key, base_url: untyped, timeout: untyped, max_retries: untyped, language: untyped,
368
+ default_headers: untyped
369
+ ) -> ClientConfig
370
+ def self?.refuse!: (String message) -> bot
371
+ def self?.http_url?: (untyped url) -> bool
372
+ def self?.trimmed_key: (untyped api_key) -> String
373
+ def self?.key_problem: (String key) -> String?
374
+ end
375
+ end
376
+ end