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.
- checksums.yaml +7 -0
- data/README.md +211 -0
- data/examples/quickstart.rb +32 -0
- data/lib/verisec/auth.rb +89 -0
- data/lib/verisec/client.rb +76 -0
- data/lib/verisec/errors.rb +125 -0
- data/lib/verisec/file_input.rb +125 -0
- data/lib/verisec/generated/resources.rb +1800 -0
- data/lib/verisec/generated/types.rb +4695 -0
- data/lib/verisec/image.rb +160 -0
- data/lib/verisec/multipart.rb +36 -0
- data/lib/verisec/resource.rb +66 -0
- data/lib/verisec/response_info.rb +34 -0
- data/lib/verisec/transport.rb +322 -0
- data/lib/verisec/util.rb +171 -0
- data/lib/verisec/verisec_object.rb +187 -0
- data/lib/verisec/version.rb +6 -0
- data/lib/verisec/webhook.rb +136 -0
- data/lib/verisec.rb +43 -0
- data/sig/generated/verisec.rbs +2212 -0
- data/sig/verisec.rbs +213 -0
- metadata +68 -0
|
@@ -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
|