verisec 1.0.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,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VeriSec
4
+ # An image for a KYC API. The KYC APIs take images as base64 inside the JSON
5
+ # body; the library makes that base64 from whatever you have:
6
+ #
7
+ # client.face.verify(
8
+ # image1: "selfie.jpg", # a path (String or Pathname)
9
+ # image2: VeriSec::Image.bytes(id_photo_bytes) # bytes you already hold
10
+ # )
11
+ #
12
+ # An image field (and each entry of an image list) also takes an open IO
13
+ # (File, StringIO, an uploaded file), a `data:` URI, base64 you already have,
14
+ # a {FileInput}, or a String of raw bytes in binary encoding (what
15
+ # `File.binread` returns). A String that is none of those raises a
16
+ # {VeriSecError} naming the field, before anything is sent.
17
+ class Image
18
+ # Base64 the way the API checks it: whole groups of four, `=` padding only at the end.
19
+ BASE64 = %r{\A[A-Za-z0-9+/]+={0,2}\z}.freeze
20
+ # `data:<type>;base64,<data>`
21
+ DATA_URI = /\Adata:[^,]*;base64,(.*)\z/m.freeze
22
+
23
+ # An image from bytes you already hold.
24
+ #
25
+ # @param data [String] the file's bytes
26
+ # @return [Image]
27
+ def self.bytes(data)
28
+ new(data)
29
+ end
30
+
31
+ # @return [String] the image's bytes (binary)
32
+ attr_reader :data
33
+
34
+ # @param data [String] the image's bytes
35
+ def initialize(data)
36
+ raise TypeError, "VeriSec::Image.bytes needs a String of bytes, not #{data.class}" unless data.is_a?(String)
37
+
38
+ @data = data.b.freeze
39
+ end
40
+
41
+ # @return [String] the bytes as plain base64 (no `data:` prefix)
42
+ def base64
43
+ [@data].pack("m0")
44
+ end
45
+
46
+ def inspect
47
+ "#<#{self.class.name} bytes=#{@data.bytesize}>"
48
+ end
49
+
50
+ class << self
51
+ # Any accepted image input as plain base64.
52
+ #
53
+ # @api private
54
+ # @param value [Object] what the caller passed for the field
55
+ # @param field [String] the field's name in the API's spelling, for errors ("images[1].data")
56
+ # @return [String] base64
57
+ # @raise [VeriSecError] when it is none of the accepted forms
58
+ def base64_of(value, field)
59
+ case value
60
+ when Image then value.base64
61
+ when FileInput then [value.bytes].pack("m0")
62
+ when String then string_base64(value, field)
63
+ when Pathname then [read_file(value.to_path, field)].pack("m0")
64
+ else
65
+ if value.respond_to?(:read) then [value.read.to_s.b].pack("m0")
66
+ elsif value.respond_to?(:to_path) then [read_file(value.to_path, field)].pack("m0")
67
+ else
68
+ raise VeriSecError, "#{field}: expected a file path, an IO, VeriSec::Image.bytes(…), a data: URI or " \
69
+ "base64, not #{value.nil? ? 'nil' : value.class}."
70
+ end
71
+ end
72
+ end
73
+
74
+ # A copy of a request body with every image as base64, following the
75
+ # operation's image plan (generated from the spec): `fields` hold one
76
+ # image each (dotted when nested), `lists` are lists of images. In a list
77
+ # whose entries are objects (an `image_key`), an entry can be the image
78
+ # itself: it is wrapped as `{image_key => base64}`, `index_key` is set to
79
+ # the entry's position when left out, and `id_key` to "face-1",
80
+ # "face-2", … (`item_label`) when left out or empty.
81
+ #
82
+ # @api private
83
+ # @param body [Hash{String => Object}] a wire copy of the parameters ({Util.wire}); changed in place
84
+ # @param plan [Hash] `fields: [String]`, `lists: [{name:, image_key:, index_key:, id_key:, item_label:}]`
85
+ # @return [Hash] the body
86
+ def prepare(body, plan)
87
+ Array(plan[:fields]).each do |path|
88
+ value = get(body, path)
89
+ set(body, path, base64_of(value, path)) unless value.nil?
90
+ end
91
+ Array(plan[:lists]).each do |list|
92
+ items = get(body, list[:name])
93
+ next unless items.is_a?(Array)
94
+
95
+ set(body, list[:name], items.each_with_index.map { |item, index| list_entry(item, index, list) })
96
+ end
97
+ body
98
+ end
99
+
100
+ private
101
+
102
+ def list_entry(item, index, list)
103
+ where = "#{list[:name]}[#{index}]"
104
+ image_key = list[:image_key]
105
+ return base64_of(item, where) unless image_key
106
+
107
+ entry = item.is_a?(Hash) ? item.dup : { image_key => item }
108
+ entry[image_key] = base64_of(entry[image_key], "#{where}.#{image_key}")
109
+ index_key = list[:index_key]
110
+ entry[index_key] = index if index_key && !entry.key?(index_key)
111
+ id_key = list[:id_key]
112
+ entry[id_key] = "#{list[:item_label] || 'item'}-#{index + 1}" if id_key && entry[id_key].to_s.empty?
113
+ entry
114
+ end
115
+
116
+ # A String is a data: URI, a path to an existing file, raw bytes, or base64.
117
+ def string_base64(value, field)
118
+ return [value.b].pack("m0") unless value.valid_encoding? # bytes read as text
119
+
120
+ text = value.strip
121
+ uri = DATA_URI.match(text)
122
+ return uri[1].gsub(/\s+/, "") if uri
123
+ return [read_file(text, field)].pack("m0") if file?(text)
124
+ return [value.b].pack("m0") if raw_bytes?(value)
125
+
126
+ compact = text.gsub(/\s+/, "")
127
+ return compact if compact.length >= 16 && (compact.length % 4).zero? && BASE64.match?(compact)
128
+
129
+ raise VeriSecError, "#{field}: not a file that exists, a data: URI or base64."
130
+ end
131
+
132
+ # Bytes rather than text: binary encoding (as File.binread returns) with bytes outside ASCII.
133
+ def raw_bytes?(value)
134
+ value.encoding == Encoding::BINARY && !value.ascii_only?
135
+ end
136
+
137
+ def file?(text)
138
+ !text.empty? && !text.include?("\0") && File.file?(text)
139
+ rescue ArgumentError, EncodingError, SystemCallError
140
+ false
141
+ end
142
+
143
+ def read_file(path, field)
144
+ File.binread(path)
145
+ rescue SystemCallError, IOError, ArgumentError, EncodingError => e
146
+ raise VeriSecError, "#{field}: cannot read #{Util.shorten(path)} (#{Util.reason(e)})"
147
+ end
148
+
149
+ def get(body, path)
150
+ path.split(".").reduce(body) { |node, key| node.is_a?(Hash) ? node[key] : nil }
151
+ end
152
+
153
+ def set(body, path, value)
154
+ *parents, last = path.split(".")
155
+ node = parents.reduce(body) { |inner, key| inner[key] = inner[key].is_a?(Hash) ? inner[key] : {} }
156
+ node[last] = value
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VeriSec
4
+ # Builds `multipart/form-data` bodies (RFC 7578) for the image uploads.
5
+ #
6
+ # @api private
7
+ module Multipart
8
+ module_function
9
+
10
+ # @param fields [Array<Array(String, String)>] text fields, as name and value
11
+ # @param files [Array<Array(String, FileInput)>] file parts, as name and file
12
+ # @return [Array(String, String)] the body (binary) and its Content-Type
13
+ def encode(fields, files)
14
+ boundary = "verisec-#{SecureRandom.hex(16)}"
15
+ body = String.new(encoding: Encoding::BINARY)
16
+ files.each do |name, file|
17
+ body << "--#{boundary}\r\n".b
18
+ body << %(Content-Disposition: form-data; name="#{quote(name)}"; filename="#{quote(file.filename)}"\r\n).b
19
+ body << "Content-Type: #{file.content_type}\r\n\r\n".b
20
+ body << file.bytes << "\r\n".b
21
+ end
22
+ fields.each do |name, value|
23
+ body << "--#{boundary}\r\n".b
24
+ body << %(Content-Disposition: form-data; name="#{quote(name)}"\r\n\r\n).b
25
+ body << value.to_s.b << "\r\n".b
26
+ end
27
+ body << "--#{boundary}--\r\n".b
28
+ [body, "multipart/form-data; boundary=#{boundary}"]
29
+ end
30
+
31
+ # Escapes a name for a quoted header parameter, as browsers do.
32
+ def quote(name)
33
+ name.to_s.gsub(/["\r\n]/, '"' => "%22", "\r" => "%0D", "\n" => "%0A")
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VeriSec
4
+ # The API's resources, one class per group of operations
5
+ # (generated/resources.rb, from the spec).
6
+ module Resources
7
+ # Base class of every resource. Its helpers turn a method call into a
8
+ # request and the answer into a result.
9
+ class Resource
10
+ # The header that files a Financial upload under "SDK" in the console's scan logs.
11
+ SOURCE_HEADER = { "X-VeriSec-Source" => "sdk" }.freeze
12
+
13
+ # @api private
14
+ # @param transport [Transport]
15
+ def initialize(transport)
16
+ @transport = transport
17
+ end
18
+
19
+ def inspect
20
+ "#<#{self.class.name}>"
21
+ end
22
+
23
+ private
24
+
25
+ # A JSON call: the parameters exactly as given, images as base64.
26
+ #
27
+ # @param verb [Symbol] :post
28
+ # @param path [String]
29
+ # @param params [Hash] the keyword arguments, in the API's spelling
30
+ # @param type [Class<VeriSecObject>] the answer type
31
+ # @param envelope [Symbol] :data (KYC) or :object (Financial)
32
+ # @param images [Hash, nil] where the images sit (see {Image.prepare})
33
+ def _json(verb, path, params, type, envelope:, images: nil, request_options: nil)
34
+ body = Util.wire(params)
35
+ Image.prepare(body, images) if images
36
+ data, info = @transport.request(verb, path, body: body, envelope: envelope, request_options: request_options)
37
+ type.construct(data, info)
38
+ end
39
+
40
+ # A Financial read: the image as `multipart/form-data` under `image`, the
41
+ # other parameters as text fields, and `X-VeriSec-Source: sdk`.
42
+ def _upload(verb, path, params, type, operation:, envelope:, request_options: nil)
43
+ fields = Util.wire(params)
44
+ image = fields.delete("image")
45
+ raise ArgumentError, "#{operation} needs image: a file path, an IO or a VeriSec::FileInput" if image.nil?
46
+
47
+ file = FileInput.wrap(image, "image")
48
+ text = fields.map { |name, value| [name, value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s] }
49
+ data, info = @transport.request(verb, path, multipart: [text, [["image", file]]], envelope: envelope,
50
+ headers: SOURCE_HEADER, request_options: request_options)
51
+ type.construct(data, info)
52
+ end
53
+
54
+ # A call without a body: path parameters already in `path`, the rest as the query.
55
+ def _call(verb, path, type, envelope:, query: nil, request_options: nil)
56
+ data, info = @transport.request(verb, path, query: query && Util.wire(query), envelope: envelope,
57
+ request_options: request_options)
58
+ type.construct(data, info)
59
+ end
60
+
61
+ def _segment(value, name)
62
+ Util.path_segment(value, name)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VeriSec
4
+ # Where an answer came from. Every result an API call returns has one as
5
+ # `last_response`:
6
+ #
7
+ # result = client.face.verify(image1: "selfie.jpg", image2: "id-photo.jpg")
8
+ # result.last_response.request_id # => "req_…" (quote it when you contact support)
9
+ # result.last_response.warnings # => [] (notes about the result that are not errors)
10
+ # result.last_response.latency_ms # => 412 (how long the check took on the server)
11
+ # result.last_response.status # => 200
12
+ # result.last_response.headers # => {"content-type" => "application/json", …}
13
+ #
14
+ # `warnings` and `latency_ms` come from the KYC envelope; Financial answers
15
+ # have no warnings and no latency.
16
+ #
17
+ # @!attribute [r] status
18
+ # @return [Integer] the HTTP status
19
+ # @!attribute [r] headers
20
+ # @return [Hash{String => String}] the response headers, with lower-case names
21
+ # @!attribute [r] request_id
22
+ # @return [String, nil] the envelope's `request_id`, else the `X-Request-Id` header
23
+ # @!attribute [r] warnings
24
+ # @return [Array<String>] the envelope's `warnings` (KYC answers)
25
+ # @!attribute [r] latency_ms
26
+ # @return [Numeric, nil] the envelope's `latency_ms` (KYC answers)
27
+ ResponseInfo = Struct.new(:status, :headers, :request_id, :warnings, :latency_ms, keyword_init: true) do
28
+ def inspect
29
+ "#<VeriSec::ResponseInfo status=#{status} request_id=#{request_id.inspect} warnings=#{warnings.inspect} " \
30
+ "latency_ms=#{latency_ms.inspect}>"
31
+ end
32
+ alias_method :to_s, :inspect
33
+ end
34
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VeriSec
4
+ # The HTTP layer every resource uses: authentication, the standard headers,
5
+ # the KYC envelope, error mapping, and the retry rules that keep a charged
6
+ # call from running twice. One request per connection and no shared mutable
7
+ # state but the token cache, so a client can be shared between threads.
8
+ #
9
+ # @api private
10
+ class Transport
11
+ # Seconds per attempt: a deep cheque read can take up to about 250 s, and a
12
+ # call cut off by the client may still finish (and be charged) on the server.
13
+ DEFAULT_TIMEOUT = 300
14
+ # The longest wait to open a connection (TCP and TLS), whatever the timeout:
15
+ # a connection that is slower than this is not coming, and as it never
16
+ # opened, it is retried.
17
+ CONNECT_TIMEOUT = 30
18
+ # Retries after the first attempt, only where nothing was charged.
19
+ DEFAULT_MAX_RETRIES = 2
20
+ # The longest Retry-After the library waits, in seconds.
21
+ MAX_RETRY_WAIT = 60
22
+ # What a single call takes as `request_options:`.
23
+ REQUEST_OPTIONS = %i[timeout max_retries headers].freeze
24
+ USER_AGENT = "verisec-ruby/#{VERSION}".freeze
25
+ TOKEN_PATH = "/v2/oauth/token"
26
+
27
+ HTTP_METHODS = {
28
+ get: Net::HTTP::Get, post: Net::HTTP::Post, put: Net::HTTP::Put, patch: Net::HTTP::Patch,
29
+ delete: Net::HTTP::Delete
30
+ }.freeze
31
+
32
+ # Failures while opening the connection, before any byte of the request
33
+ # left: connection refused, DNS failure, host or network unreachable, a
34
+ # connect timeout. Retrying them can never run (or charge) a call twice.
35
+ NEVER_SENT = [
36
+ Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::EADDRNOTAVAIL, Errno::ETIMEDOUT,
37
+ SocketError, Net::OpenTimeout
38
+ ].freeze
39
+ # Waiting for the answer (or writing the request) took too long. The call
40
+ # may have run, so it is reported, never retried.
41
+ TIMEOUTS = [Net::ReadTimeout, Net::WriteTimeout, Timeout::Error, Errno::ETIMEDOUT].freeze
42
+ # The connection failed after the request was (perhaps partly) sent: a
43
+ # reset, a dropped connection, a broken answer. Reported, never retried.
44
+ DROPPED = [
45
+ IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
46
+ Net::ProtocolError, Zlib::Error
47
+ ].freeze
48
+
49
+ # Raised inside an attempt when the connection could not be opened (the
50
+ # original error is its #cause). Only the {NEVER_SENT} failures are worth
51
+ # another try; a TLS failure, say, is reported without one.
52
+ class NotConnected < StandardError
53
+ attr_reader :retryable
54
+
55
+ def initialize(retryable)
56
+ super("not connected")
57
+ @retryable = retryable
58
+ end
59
+ end
60
+ private_constant :NotConnected
61
+
62
+ # @return [String]
63
+ attr_reader :base_url
64
+ # @return [Numeric]
65
+ attr_reader :timeout
66
+ # @return [Integer]
67
+ attr_reader :max_retries
68
+
69
+ # See {Client#initialize}.
70
+ def initialize(api_key: nil, client_id: nil, client_secret: nil, base_url: nil, timeout: nil, max_retries: nil)
71
+ api_key = Util.presence(api_key) || Util.presence(ENV["VERISEC_API_KEY"])
72
+ raise VeriSecError, "No API key. Pass api_key: or set VERISEC_API_KEY." unless api_key
73
+
74
+ @base_url = (Util.presence(base_url) || Util.presence(ENV["VERISEC_BASE_URL"]) || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
75
+ @uri = parse_base_url(@base_url)
76
+ @timeout = timeout.nil? ? DEFAULT_TIMEOUT : check_timeout(timeout, "timeout")
77
+ @max_retries = max_retries.nil? ? DEFAULT_MAX_RETRIES : check_retries(max_retries, "max_retries")
78
+ @credentials = credentials_for(api_key, client_id, client_secret)
79
+ end
80
+
81
+ # Makes one API call. Retries (up to `max_retries`) only when nothing can
82
+ # have been charged: a connection that never opened, a 429 or a 5xx. A
83
+ # timeout, or a connection that dropped after the request was sent, is
84
+ # raised as {APITimeoutError} / {APIConnectionError}, because the call may
85
+ # have run. A 401 fetches a new live token once and repeats the call.
86
+ #
87
+ # @param method [Symbol] the HTTP verb: :get, :post, …
88
+ # @param path [String] e.g. "/v1/face/verify" (path parameters already encoded)
89
+ # @param query [Hash, nil] query parameters
90
+ # @param body [Hash, nil] a JSON body, sent exactly as given (images already base64)
91
+ # @param multipart [Array(Array, Array), nil] text fields and file parts, see {Multipart.encode}
92
+ # @param envelope [Symbol] :data (a KYC answer: return its `data`), :object
93
+ # (a Financial object: return it as it is) or :raw (the body, no response info)
94
+ # @param request_options [Hash, nil] see {REQUEST_OPTIONS}
95
+ # @param headers [Hash{String => String}, nil] the operation's own headers
96
+ # (`X-VeriSec-Source: sdk` on uploads); the caller's headers win
97
+ # @param anonymous [Boolean] send no bearer token (the token endpoint itself)
98
+ # @return [Array(Object, ResponseInfo)] the result (the envelope's `data` for
99
+ # KYC) and where it came from
100
+ # @raise [APIError, APIConnectionError]
101
+ def request(method, path, query: nil, body: nil, multipart: nil, envelope: :object, request_options: nil,
102
+ headers: nil, anonymous: false)
103
+ options = normalize_options(request_options)
104
+ max_retries = options.key?(:max_retries) ? check_retries(options[:max_retries], "max_retries") : @max_retries
105
+ timeout = options.key?(:timeout) ? check_timeout(options[:timeout], "timeout") : @timeout
106
+ target = request_target(path, query)
107
+ payload, content_type = encode_body(body, multipart)
108
+
109
+ refreshed = false
110
+ attempt = 0
111
+ loop do
112
+ token = anonymous ? nil : @credentials.token
113
+ sent = build_headers(options, headers, token, content_type)
114
+ begin
115
+ raw = perform(method, target, sent, payload, timeout)
116
+ rescue NotConnected => e
117
+ if e.retryable && attempt < max_retries
118
+ sleep_for(retry_delay(attempt, nil))
119
+ attempt += 1
120
+ next
121
+ end
122
+ raise APIConnectionError.new("Could not connect to #{@base_url}: #{e.cause.message} (#{e.cause.class}). " \
123
+ "The request was not sent."), cause: e.cause
124
+ rescue *TIMEOUTS
125
+ raise APITimeoutError, "No answer within #{timeout} s. The call may still have run (and been charged) " \
126
+ "on the server, so it was not sent again."
127
+ rescue *DROPPED => e
128
+ raise APIConnectionError, "The connection to #{@base_url} dropped after the request was sent " \
129
+ "(#{e.message}, #{e.class}). The call may have run (and been charged) on the " \
130
+ "server, so it was not sent again."
131
+ end
132
+
133
+ status = raw.code.to_i
134
+ response_headers = {}
135
+ raw.each_header { |name, value| response_headers[name] = value }
136
+ data = Util.parse_body(raw.body)
137
+ info = response_info(status, response_headers, data)
138
+
139
+ if status.between?(200, 299)
140
+ return [data, info] if envelope == :raw
141
+
142
+ return unwrap(data, envelope, status, response_headers, info)
143
+ end
144
+ if status == 401 && !anonymous && !refreshed && @credentials.invalidate(token)
145
+ refreshed = true # a token refresh isn't a retry
146
+ next
147
+ end
148
+ if retryable?(status) && attempt < max_retries
149
+ sleep_for(retry_delay(attempt, response_headers["retry-after"]))
150
+ attempt += 1
151
+ next
152
+ end
153
+ raise APIError.from(status, Util.error_body(data), response_headers, info.request_id)
154
+ end
155
+ end
156
+
157
+ # Seconds to wait before retry number `attempt + 1`: the server's
158
+ # Retry-After when it sent one (at most {MAX_RETRY_WAIT}), else exponential
159
+ # backoff `min(0.5 * 2^attempt, 8)` with ±25% jitter.
160
+ #
161
+ # @param attempt [Integer] 0 for the first retry
162
+ # @param retry_after [String, nil] the Retry-After header
163
+ # @return [Numeric]
164
+ def retry_delay(attempt, retry_after)
165
+ told = Util.parse_seconds(retry_after)
166
+ return [told, MAX_RETRY_WAIT].min if told
167
+
168
+ [0.5 * (2**attempt), 8.0].min * (0.75 + (rand * 0.5))
169
+ end
170
+
171
+ def inspect
172
+ "#<#{self.class.name} base_url=#{@base_url.inspect} credentials=#{@credentials.inspect}>"
173
+ end
174
+
175
+ private
176
+
177
+ # Calls are charged only when they succeed, so a 429 (refused before
178
+ # running) or a 5xx (the check did not complete) costs nothing.
179
+ def retryable?(status)
180
+ status == 429 || status >= 500
181
+ end
182
+
183
+ def credentials_for(api_key, client_id, client_secret)
184
+ return Auth::SecretKey.new(api_key) unless api_key.start_with?("ak_live_")
185
+
186
+ client_id = Util.presence(client_id) || Util.presence(ENV["VERISEC_CLIENT_ID"])
187
+ client_secret = Util.presence(client_secret) || Util.presence(ENV["VERISEC_CLIENT_SECRET"])
188
+ unless client_id && client_secret
189
+ raise VeriSecError, "Live keys (ak_live_…) also need client_id: and client_secret:, " \
190
+ "or VERISEC_CLIENT_ID and VERISEC_CLIENT_SECRET."
191
+ end
192
+
193
+ body = { "grant_type" => "client_credentials", "api_key" => api_key, "client_id" => client_id,
194
+ "client_secret" => client_secret }
195
+ Auth::ClientCredentials.new do
196
+ request(:post, TOKEN_PATH, body: body, envelope: :raw, anonymous: true, request_options: { max_retries: 0 }).first
197
+ end
198
+ end
199
+
200
+ # One attempt: open the connection (a failure there raises NotConnected),
201
+ # then send the request and read the answer.
202
+ def perform(method, target, headers, payload, timeout)
203
+ http = Net::HTTP.new(@uri.hostname, @uri.port)
204
+ http.use_ssl = @uri.scheme == "https"
205
+ http.open_timeout = [timeout, CONNECT_TIMEOUT].min
206
+ http.read_timeout = timeout
207
+ http.write_timeout = timeout
208
+ http.max_retries = 0 # net/http would resend idempotent requests itself; the retry rules are ours
209
+ begin
210
+ http.start
211
+ rescue *NEVER_SENT
212
+ raise NotConnected.new(true)
213
+ rescue *TIMEOUTS, *DROPPED
214
+ raise NotConnected.new(false)
215
+ end
216
+ begin
217
+ request = HTTP_METHODS.fetch(method).new(target, headers)
218
+ request.body = payload unless payload.nil?
219
+ http.request(request)
220
+ ensure
221
+ http.finish if http.started?
222
+ end
223
+ end
224
+
225
+ def unwrap(data, envelope, status, headers, info)
226
+ unless data.is_a?(Hash)
227
+ raise APIError.new("The API answered #{status} with a body that is not a JSON object.",
228
+ status: status, request_id: info.request_id, headers: headers)
229
+ end
230
+ return [data, info] unless envelope == :data
231
+ # a 2xx always carries a result; `ok: false` here is a handled problem
232
+ raise APIError.from(status, Util.error_body(data), headers, info.request_id) if data["ok"] == false
233
+
234
+ [data["data"], info]
235
+ end
236
+
237
+ def response_info(status, headers, data)
238
+ envelope = data.is_a?(Hash) ? data : {}
239
+ request_id = envelope["request_id"].is_a?(String) ? envelope["request_id"] : headers["x-request-id"]
240
+ warnings = envelope["warnings"].is_a?(Array) ? envelope["warnings"] : []
241
+ latency = envelope["latency_ms"].is_a?(Numeric) ? envelope["latency_ms"] : nil
242
+ ResponseInfo.new(status: status, headers: headers, request_id: request_id, warnings: warnings, latency_ms: latency)
243
+ end
244
+
245
+ # The standard headers, then the operation's, then the caller's, then the
246
+ # ones nobody may change. Names match case-insensitively, so a later one
247
+ # replaces an earlier one whatever its spelling.
248
+ def build_headers(options, operation_headers, token, content_type)
249
+ headers = {}
250
+ put_header(headers, "Accept", "application/json")
251
+ put_header(headers, "User-Agent", USER_AGENT)
252
+ (operation_headers || {}).each { |name, value| put_header(headers, name, value) }
253
+ options.fetch(:headers, {}).each { |name, value| put_header(headers, name, value) }
254
+ put_header(headers, "Authorization", "Bearer #{token}") if token
255
+ put_header(headers, "Content-Type", content_type) if content_type
256
+ headers
257
+ end
258
+
259
+ def put_header(headers, name, value)
260
+ name = name.to_s
261
+ headers.delete_if { |existing, _| existing.casecmp?(name) }
262
+ headers[name] = value.to_s unless value.nil?
263
+ end
264
+
265
+ def encode_body(body, multipart)
266
+ return Multipart.encode(*multipart) if multipart
267
+ return [JSON.generate(body), "application/json"] unless body.nil?
268
+
269
+ [nil, nil]
270
+ end
271
+
272
+ def request_target(path, query)
273
+ target = "#{@uri.path.sub(%r{/+\z}, '')}#{path}"
274
+ search = query.nil? || query.empty? ? "" : Util.query_string(query)
275
+ search.empty? ? target : "#{target}?#{search}"
276
+ end
277
+
278
+ def sleep_for(seconds)
279
+ sleep(seconds) if seconds.positive?
280
+ end
281
+
282
+ def normalize_options(options)
283
+ return {} if options.nil?
284
+ raise ArgumentError, "request_options must be a Hash, not #{options.class}" unless options.is_a?(Hash)
285
+
286
+ options.each_with_object({}) do |(key, value), out|
287
+ name = key.to_s.to_sym
288
+ unless REQUEST_OPTIONS.include?(name)
289
+ raise ArgumentError, "Unknown request option #{key.inspect}; expected one of #{REQUEST_OPTIONS.join(', ')}."
290
+ end
291
+ if name == :headers && !(value.nil? || value.is_a?(Hash))
292
+ raise ArgumentError, "request_options[:headers] must be a Hash"
293
+ end
294
+
295
+ out[name] = value unless value.nil?
296
+ end
297
+ end
298
+
299
+ def parse_base_url(url)
300
+ uri = URI.parse(url)
301
+ unless %w[http https].include?(uri.scheme) && uri.hostname && !uri.hostname.empty?
302
+ raise ArgumentError, "base_url must be an http(s) URL, not #{url.inspect}"
303
+ end
304
+
305
+ uri
306
+ rescue URI::InvalidURIError
307
+ raise ArgumentError, "base_url must be an http(s) URL, not #{url.inspect}"
308
+ end
309
+
310
+ def check_timeout(value, name)
311
+ return value if value.is_a?(Numeric) && value.positive?
312
+
313
+ raise ArgumentError, "#{name} must be a positive number of seconds, not #{value.inspect}"
314
+ end
315
+
316
+ def check_retries(value, name)
317
+ return value if value.is_a?(Integer) && value >= 0
318
+
319
+ raise ArgumentError, "#{name} must be an Integer of 0 or more, not #{value.inspect}"
320
+ end
321
+ end
322
+ end