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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5c85f0ebd658b3105c5e3041d1d116cfcb7c14ca9068b132b8d2dae719f5fcbd
|
|
4
|
+
data.tar.gz: dc91184d659c12f7e5ca5f74e60298ff8b657265cdb653b0f8e9411e456c543e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2b62de646665e9d210879253e8dd90ff7d565c5c39f04653d2c7a75eba41d1b7069cb47949c306818848a7a211117415485182e9fad991368feb0cd3289159d1
|
|
7
|
+
data.tar.gz: 239269dd7710269d5d8eac6af99fb2e841fd7849f3366c262705143b6631d7b91856becce9b42995df57f3116f2eb69b8539c4f77dbca2ffe4b64822b1e1e8bc
|
data/README.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# VeriSec Ruby SDK
|
|
2
|
+
|
|
3
|
+
The official Ruby library for the VeriSec API: identity (KYC) and Financial checks. Every API is a method, images
|
|
4
|
+
come straight from files, retries never run a charged call twice, live keys are exchanged for tokens for you, and
|
|
5
|
+
webhook deliveries are verified.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Gemfile
|
|
9
|
+
gem "verisec"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Ruby 3.0 or later. No runtime dependencies (net/http, json and openssl from the standard library).
|
|
13
|
+
|
|
14
|
+
## Configure
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
require "verisec"
|
|
18
|
+
|
|
19
|
+
client = VeriSec::Client.new(api_key: ENV["VERISEC_API_KEY"]) # sk_test_… or ak_live_…
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Create keys in the console under **API Keys**. A test key (`sk_test_…`) is used as it is. A live key (`ak_live_…`)
|
|
23
|
+
also needs the client id and secret issued with it; the library exchanges them for a short-lived token, shares it
|
|
24
|
+
between threads and renews it before it expires:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
client = VeriSec::Client.new(
|
|
28
|
+
api_key: ENV["VERISEC_API_KEY"], # ak_live_…
|
|
29
|
+
client_id: ENV["VERISEC_CLIENT_ID"],
|
|
30
|
+
client_secret: ENV["VERISEC_CLIENT_SECRET"]
|
|
31
|
+
)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Every option falls back to the environment (`VERISEC_API_KEY`, `VERISEC_CLIENT_ID`, `VERISEC_CLIENT_SECRET`,
|
|
35
|
+
`VERISEC_BASE_URL`), so `VeriSec::Client.new` alone works too. Options: `base_url:` (default
|
|
36
|
+
`https://verisec.mmose.me`), `timeout:` in seconds per attempt (default 300: a deep cheque read can take up to about
|
|
37
|
+
250 s), `max_retries:` (default 2). A client can be shared between threads.
|
|
38
|
+
|
|
39
|
+
## KYC: pass images as files
|
|
40
|
+
|
|
41
|
+
Every API is a method, grouped the way the API groups them: `client.face`, `client.liveness`, `client.documents`,
|
|
42
|
+
`client.mrz`, `client.screening`, `client.phone.otp`, … Method names are the API's operation names in snake_case
|
|
43
|
+
(`photo.bestFrame` is `client.photo.best_frame`, `age.verifyLive` is `client.age.verify_live`); the full list with
|
|
44
|
+
prices is in `spec/README.md`, and each method's documentation lists its fields and price.
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
result = client.face.verify(image1: "selfie.jpg", image2: "id-photo.jpg", threshold: 0.8)
|
|
48
|
+
result.match # => true
|
|
49
|
+
result.similarity # => 0.998
|
|
50
|
+
|
|
51
|
+
doc = client.documents.read(image_front: "id-front.jpg", image_back: Pathname("id-back.jpg"))
|
|
52
|
+
|
|
53
|
+
# bytes you already hold
|
|
54
|
+
result = client.face.verify(image1: VeriSec::Image.bytes(upload.read), image2: "id-photo.jpg")
|
|
55
|
+
|
|
56
|
+
# lists take the images themselves; the index and ids are filled in for you
|
|
57
|
+
match = client.face.match(images: ["selfie.jpg", "passport.jpg"])
|
|
58
|
+
search = client.face.search(probe: "selfie.jpg", gallery: ["a.jpg", { image: "b.jpg", id: "employee-42" }])
|
|
59
|
+
|
|
60
|
+
screening = client.screening.sanctions(name: "Jane Example")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
An image can be a path (`String` or `Pathname`), an open `IO` (`File`, `StringIO`, an uploaded file),
|
|
64
|
+
`VeriSec::Image.bytes(bytes)`, a `data:` URI, base64 you already have, or a binary String of raw bytes (what
|
|
65
|
+
`File.binread` returns). The library sends plain base64, the way the API takes it. Anything else raises a
|
|
66
|
+
`VeriSec::VeriSecError` naming the field (`image1: not a file that exists, a data: URI or base64.`) before anything
|
|
67
|
+
is sent. Your parameters are never modified.
|
|
68
|
+
|
|
69
|
+
Field names are exactly the API's, which uses both `detectAll` and `image_front`: write them as the method's
|
|
70
|
+
documentation spells them (`client.face.analyze(image: "a.jpg", onlyCentralFace: true)`); they are sent unchanged.
|
|
71
|
+
|
|
72
|
+
## Results
|
|
73
|
+
|
|
74
|
+
A KYC call returns the answer's `data`; a Financial call the object. Read fields as methods or with `[]`:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
result.match # => true
|
|
78
|
+
result[:similarity] # => 0.998
|
|
79
|
+
result.metadata.ctx.userIp # nested objects are wrapped too
|
|
80
|
+
result.dig("compared", 0, "roi") # => [182, 241, 173, 220]
|
|
81
|
+
result.to_h # the plain Hash, exactly the JSON the API sent
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Each answer type is a class (`VeriSec::Types::FaceVerify`, `VeriSec::Types::BinRecord`, …). A field the type defines
|
|
85
|
+
but the answer left out reads as `nil`; a name it does not have raises `NoMethodError`, so typos don't pass silently.
|
|
86
|
+
Fields the API adds later are still readable.
|
|
87
|
+
|
|
88
|
+
The response is on `last_response`:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
result.last_response.request_id # => "req_…" (quote it when you contact support)
|
|
92
|
+
result.last_response.warnings # => [] (KYC: notes about the result that are not errors)
|
|
93
|
+
result.last_response.latency_ms # => 412 (KYC: how long the check took on the server)
|
|
94
|
+
result.last_response.status # => 200
|
|
95
|
+
result.last_response.headers # => {"content-type" => "application/json", …}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Open results.** The engines' full answers (face analyze and match, document read, liveness, MRZ read, IP and
|
|
99
|
+
sanctions screening, proof of address, the contact dossier, …) have no fixed fields: read them as a Hash.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
screening = client.screening.sanctions(name: "Jane Example")
|
|
103
|
+
screening.keys # => ["query", "summary", "results"]
|
|
104
|
+
screening["summary"] # or screening.dig("results", 0), screening.to_h
|
|
105
|
+
screening.last_response.request_id
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Financial: cheques, cards, banks, BIN
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
cheque = client.cheques.read(image: "cheque.jpg") # a path, Pathname or IO
|
|
112
|
+
line = client.cheques.micr_line(image: VeriSec::FileInput.new(bytes, filename: "cheque.jpg"), format: "e13b")
|
|
113
|
+
card = client.cards.check(number: "4242 4242 4242 4242", expiry: "09/29")
|
|
114
|
+
bin = client.bin.lookup("424242")
|
|
115
|
+
bank = client.banks.lookup(name: "Harbor Point Bank", country: "US")
|
|
116
|
+
|
|
117
|
+
cheque.vision.payee # Financial answers are objects with an `object` type ("cheque_read")
|
|
118
|
+
bin.network # => "Visa"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Images are uploaded as `multipart/form-data` with `X-VeriSec-Source: sdk`, so the console files them under "SDK".
|
|
122
|
+
|
|
123
|
+
## One call's options
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
client.cheques.read(image: "cheque.jpg", request_options: { timeout: 600, max_retries: 0, headers: { "X-Trace" => "t1" } })
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Credits
|
|
130
|
+
|
|
131
|
+
Each call costs its API's price in credits, charged only when it succeeds. Refused calls (no credits, no permission,
|
|
132
|
+
rate limited) and calls that could not run are free.
|
|
133
|
+
|
|
134
|
+
## Errors
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
begin
|
|
138
|
+
client.face.verify(image1: "selfie.jpg", image2: "id-photo.jpg")
|
|
139
|
+
rescue VeriSec::InsufficientCreditsError
|
|
140
|
+
warn "Top up credits in the console (nothing was charged)."
|
|
141
|
+
rescue VeriSec::InvalidRequestError => e
|
|
142
|
+
warn "#{e.code} #{e.param}: #{e.message}" # e.g. parameter_unknown, image_unreadable
|
|
143
|
+
rescue VeriSec::RateLimitError => e
|
|
144
|
+
warn "Wait #{e.retry_after} s."
|
|
145
|
+
rescue VeriSec::APIError => e
|
|
146
|
+
warn "#{e.status} #{e.code} #{e.request_id}"
|
|
147
|
+
rescue VeriSec::APIConnectionError => e
|
|
148
|
+
warn e.message # says whether the call may have run
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
| Class | When |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `VeriSec::InvalidRequestError` | 400, 413, 415, 422: a field is missing or not valid, the file is too large or not an image |
|
|
155
|
+
| `VeriSec::AuthenticationError` | 401: no key, or the key or token is not valid |
|
|
156
|
+
| `VeriSec::InsufficientCreditsError` | 402: not enough credits, or the key's spend cap (nothing charged) |
|
|
157
|
+
| `VeriSec::PermissionError` | 403: the key may not call this API |
|
|
158
|
+
| `VeriSec::NotFoundError` | 404 |
|
|
159
|
+
| `VeriSec::RateLimitError` | 429 (with `retry_after`) |
|
|
160
|
+
| `VeriSec::APIServerError` | 5xx: the check could not run (nothing charged) |
|
|
161
|
+
| `VeriSec::APIConnectionError` | no answer; `VeriSec::APITimeoutError` (a subclass) when it took too long |
|
|
162
|
+
|
|
163
|
+
All of them inherit from `VeriSec::VeriSecError` (also `VeriSec::Error`), with `status`, `code`, `message`, `param`,
|
|
164
|
+
`request_id` and `headers`.
|
|
165
|
+
|
|
166
|
+
## Retries
|
|
167
|
+
|
|
168
|
+
The library retries rate limits (429), server errors (5xx) and connections that never opened (refused, DNS, network
|
|
169
|
+
unreachable, connect timeout), up to `max_retries` times with backoff (`Retry-After` wins): none of those were charged.
|
|
170
|
+
It never sends a call again after a timeout or a connection that dropped once the request was sent, because the
|
|
171
|
+
check may already have run and been charged; you get an `APITimeoutError` / `APIConnectionError` saying so.
|
|
172
|
+
|
|
173
|
+
## Webhooks
|
|
174
|
+
|
|
175
|
+
Deliveries are signed as Standard Webhooks (`webhook-id`, `webhook-timestamp`, `webhook-signature`). Pass the raw
|
|
176
|
+
body, the headers (a Hash in any spelling, or the Rack env) and your endpoint's signing secret (`whsec_…`):
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
post "/webhooks/verisec" do
|
|
180
|
+
event = VeriSec::Webhook.construct_event(request.body.read, request.env, ENV["VERISEC_WEBHOOK_SECRET"])
|
|
181
|
+
case event.type
|
|
182
|
+
when "verification.completed" then handle(event.data)
|
|
183
|
+
end
|
|
184
|
+
status 200
|
|
185
|
+
rescue VeriSec::SignatureVerificationError
|
|
186
|
+
halt 400
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`VeriSec::Webhook.verify_signature` returns true or false, `verify_signature!` raises, and
|
|
191
|
+
`generate_test_headers(payload, secret)` signs a payload for your own tests. The tolerance for the timestamp is 300 s
|
|
192
|
+
(`tolerance:` changes it). `client.webhooks` is the same module.
|
|
193
|
+
|
|
194
|
+
## Types
|
|
195
|
+
|
|
196
|
+
`sig/` holds RBS signatures for everything: each method's keyword arguments with their types, and each answer type's
|
|
197
|
+
readers, for Steep or your editor.
|
|
198
|
+
|
|
199
|
+
## Development
|
|
200
|
+
|
|
201
|
+
```sh
|
|
202
|
+
bundle config set --local path vendor/bundle
|
|
203
|
+
bundle install
|
|
204
|
+
ruby script/generate # after the spec changes: lib/verisec/generated and sig/generated, from ../spec/openapi.json
|
|
205
|
+
bundle exec rake test # unit tests, then integration tests against ../test-server on port 4026
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The resources (`lib/verisec/generated/resources.rb`), the answer types (`lib/verisec/generated/types.rb`) and their
|
|
209
|
+
signatures are generated from the spec; adding an operation needs only a re-run. The integration tests start the
|
|
210
|
+
shared test server (`node ../test-server/server.mjs --port 4026`), call every operation through the library, and stop
|
|
211
|
+
it when they end.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Quickstart: three real calls.
|
|
4
|
+
#
|
|
5
|
+
# VERISEC_API_KEY=sk_test_… ruby -Ilib examples/quickstart.rb selfie.jpg id-photo.jpg
|
|
6
|
+
#
|
|
7
|
+
# Against the local test server:
|
|
8
|
+
#
|
|
9
|
+
# node ../test-server/server.mjs --port 4026 &
|
|
10
|
+
# VERISEC_BASE_URL=http://127.0.0.1:4026 VERISEC_API_KEY=sk_test_demo ruby -Ilib examples/quickstart.rb a.png b.png
|
|
11
|
+
|
|
12
|
+
require "verisec"
|
|
13
|
+
|
|
14
|
+
client = VeriSec::Client.new # the key (and VERISEC_BASE_URL, if set) from the environment
|
|
15
|
+
|
|
16
|
+
# MRZ parse: the machine-readable lines of a passport, checked digit by digit
|
|
17
|
+
mrz = client.mrz.parse(
|
|
18
|
+
lines: ["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<", "L898902C36UTO7408122F1204159ZE184226B<<<<<10"]
|
|
19
|
+
)
|
|
20
|
+
puts "MRZ: #{mrz.format}, valid #{mrz.valid}, #{mrz.parsed.surname} #{mrz.parsed.given_name}"
|
|
21
|
+
|
|
22
|
+
# BIN lookup: a card's network and issuer from its first digits
|
|
23
|
+
bin = client.bin.lookup("424242")
|
|
24
|
+
puts "BIN: #{bin.network} #{bin.type}, issued by #{bin.issuer} (#{bin.country})"
|
|
25
|
+
|
|
26
|
+
# Face verify: are the two photos the same person?
|
|
27
|
+
selfie, id_photo = ARGV
|
|
28
|
+
if selfie && id_photo
|
|
29
|
+
result = client.face.verify(image1: selfie, image2: id_photo)
|
|
30
|
+
puts "Same person: #{result.match}, similarity #{result.similarity.round(3)}, " \
|
|
31
|
+
"request #{result.last_response.request_id}"
|
|
32
|
+
end
|
data/lib/verisec/auth.rb
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VeriSec
|
|
4
|
+
# How requests are authenticated: a sandbox secret key as it is, or live
|
|
5
|
+
# credentials exchanged for a short-lived token.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module Auth
|
|
9
|
+
# Sandbox: the secret key (`sk_test_…`) is the bearer token.
|
|
10
|
+
class SecretKey
|
|
11
|
+
def initialize(key)
|
|
12
|
+
@key = key
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @return [String]
|
|
16
|
+
def token
|
|
17
|
+
@key
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A rejected secret key stays rejected.
|
|
21
|
+
#
|
|
22
|
+
# @return [Boolean] false: fetching again can't help
|
|
23
|
+
def invalidate(_token)
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def inspect
|
|
28
|
+
"#<#{self.class.name} #{Util.redact(@key)}>"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Live: an `ak_live_` API key with its client id and secret, exchanged at
|
|
33
|
+
# POST /v2/oauth/token for a bearer token. The token is cached until 60
|
|
34
|
+
# seconds before it expires; threads share one exchange.
|
|
35
|
+
class ClientCredentials
|
|
36
|
+
# Seconds before expiry at which the token is renewed.
|
|
37
|
+
REFRESH_MARGIN = 60
|
|
38
|
+
# Token lifetime assumed when the answer doesn't say.
|
|
39
|
+
DEFAULT_LIFETIME = 3600
|
|
40
|
+
|
|
41
|
+
# @param clock [#call] monotonic seconds
|
|
42
|
+
# @yieldreturn [Hash] the token endpoint's answer (`access_token`, `expires_in`)
|
|
43
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, &exchange)
|
|
44
|
+
@exchange = exchange
|
|
45
|
+
@clock = clock
|
|
46
|
+
@mutex = Mutex.new
|
|
47
|
+
@cached = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [String] a valid access token, exchanging credentials when needed
|
|
51
|
+
def token
|
|
52
|
+
cached = @cached
|
|
53
|
+
return cached[:token] if fresh?(cached)
|
|
54
|
+
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
cached = @cached
|
|
57
|
+
return cached[:token] if fresh?(cached)
|
|
58
|
+
|
|
59
|
+
answer = @exchange.call
|
|
60
|
+
access_token = answer.is_a?(Hash) ? answer["access_token"] : nil
|
|
61
|
+
raise Error, "The token endpoint returned no access_token." unless access_token.is_a?(String) && !access_token.empty?
|
|
62
|
+
|
|
63
|
+
lifetime = answer["expires_in"].is_a?(Numeric) ? answer["expires_in"] : DEFAULT_LIFETIME
|
|
64
|
+
@cached = { token: access_token, expires_at: @clock.call + [lifetime - REFRESH_MARGIN, 0].max }
|
|
65
|
+
access_token
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Forgets a token the API rejected, so the next request exchanges again.
|
|
70
|
+
# A token another thread already replaced is left alone.
|
|
71
|
+
#
|
|
72
|
+
# @return [Boolean] true: a fresh token can help
|
|
73
|
+
def invalidate(rejected)
|
|
74
|
+
@mutex.synchronize { @cached = nil if @cached.nil? || @cached[:token] == rejected }
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def inspect
|
|
79
|
+
"#<#{self.class.name} cached=#{!@cached.nil?}>"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def fresh?(cached)
|
|
85
|
+
!cached.nil? && @clock.call < cached[:expires_at]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VeriSec
|
|
4
|
+
# A client for the VeriSec API, KYC and Financial.
|
|
5
|
+
#
|
|
6
|
+
# client = VeriSec::Client.new(api_key: ENV["VERISEC_API_KEY"]) # sk_test_…
|
|
7
|
+
#
|
|
8
|
+
# result = client.face.verify(image1: "selfie.jpg", image2: VeriSec::Image.bytes(id_photo))
|
|
9
|
+
# result.match # => true
|
|
10
|
+
# result.last_response.request_id # => "req_…"
|
|
11
|
+
#
|
|
12
|
+
# client.cheques.micr_line(image: "cheque.jpg", format: "e13b")
|
|
13
|
+
# client.bin.lookup("424242")
|
|
14
|
+
#
|
|
15
|
+
# Every API is a method, grouped the way the API groups them (`client.face`,
|
|
16
|
+
# `client.documents`, `client.phone.otp`, `client.cheques`, …), named after
|
|
17
|
+
# its operationId in snake_case (`cheques.micrLine` is `client.cheques.micr_line`).
|
|
18
|
+
# Parameters are keyword arguments under the API's own field names (it mixes
|
|
19
|
+
# `detectAll` and `image_front`; they are sent exactly as written), plus
|
|
20
|
+
# `request_options: { timeout:, max_retries:, headers: }` to change one call.
|
|
21
|
+
# A KYC call returns the envelope's `data`; a Financial call the object.
|
|
22
|
+
#
|
|
23
|
+
# A client holds no per-request state, so one can be shared between threads.
|
|
24
|
+
class Client
|
|
25
|
+
# @param api_key [String, nil] a test key (`sk_test_…`) or a live API key
|
|
26
|
+
# (`ak_live_…`). Defaults to `ENV["VERISEC_API_KEY"]`.
|
|
27
|
+
# @param client_id [String, nil] live keys only: the client id issued with
|
|
28
|
+
# the key. Defaults to `ENV["VERISEC_CLIENT_ID"]`.
|
|
29
|
+
# @param client_secret [String, nil] live keys only: the client secret
|
|
30
|
+
# issued with the key. Defaults to `ENV["VERISEC_CLIENT_SECRET"]`.
|
|
31
|
+
# @param base_url [String, nil] defaults to `ENV["VERISEC_BASE_URL"]`, then
|
|
32
|
+
# https://verisec.mmose.me
|
|
33
|
+
# @param timeout [Numeric, nil] seconds per attempt, for each read or write
|
|
34
|
+
# (opening the connection waits at most 30 s of it); default 300, since a
|
|
35
|
+
# deep cheque read can take up to about 250 s
|
|
36
|
+
# @param max_retries [Integer, nil] retries after the first attempt, only
|
|
37
|
+
# where nothing was charged (a connection that never opened, a 429, a
|
|
38
|
+
# 5xx); default 2
|
|
39
|
+
# @raise [VeriSec::VeriSecError] when there is no API key, or a live key
|
|
40
|
+
# comes without its client id and secret
|
|
41
|
+
def initialize(api_key: nil, client_id: nil, client_secret: nil, base_url: nil, timeout: nil, max_retries: nil)
|
|
42
|
+
@transport = Transport.new(
|
|
43
|
+
api_key: api_key, client_id: client_id, client_secret: client_secret, base_url: base_url,
|
|
44
|
+
timeout: timeout, max_retries: max_retries
|
|
45
|
+
)
|
|
46
|
+
build_resources
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Verify and parse webhook deliveries:
|
|
50
|
+
# `client.webhooks.construct_event(payload, headers, secret)`.
|
|
51
|
+
#
|
|
52
|
+
# @return [Module] {VeriSec::Webhook}
|
|
53
|
+
def webhooks
|
|
54
|
+
Webhook
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [String] where requests go
|
|
58
|
+
def base_url
|
|
59
|
+
@transport.base_url
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Numeric] seconds per attempt
|
|
63
|
+
def timeout
|
|
64
|
+
@transport.timeout
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Integer] retries after the first attempt
|
|
68
|
+
def max_retries
|
|
69
|
+
@transport.max_retries
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def inspect
|
|
73
|
+
"#<#{self.class.name} base_url=#{base_url.inspect}>"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VeriSec
|
|
4
|
+
# Base class of every error this library raises.
|
|
5
|
+
#
|
|
6
|
+
# The API fields ({#status}, {#code}, {#param}, {#request_id}) are set when
|
|
7
|
+
# the API answered with an error ({APIError}) and nil otherwise, so they can
|
|
8
|
+
# be read on any VeriSecError without checking its class first.
|
|
9
|
+
#
|
|
10
|
+
# begin
|
|
11
|
+
# client.face.verify(image1: "selfie.jpg", image2: "id-photo.jpg")
|
|
12
|
+
# rescue VeriSec::InsufficientCreditsError
|
|
13
|
+
# # top up credits in the console; nothing was charged
|
|
14
|
+
# rescue VeriSec::InvalidRequestError => e
|
|
15
|
+
# warn "#{e.code} #{e.param}: #{e.message}"
|
|
16
|
+
# rescue VeriSec::VeriSecError => e
|
|
17
|
+
# warn "#{e.class}: #{e.message} (request #{e.request_id})"
|
|
18
|
+
# end
|
|
19
|
+
class VeriSecError < StandardError
|
|
20
|
+
# @return [Integer, nil] the HTTP status code
|
|
21
|
+
attr_reader :status
|
|
22
|
+
# @return [String, nil] the machine-readable reason, e.g. "invalid_input", "insufficient_credits"
|
|
23
|
+
attr_reader :code
|
|
24
|
+
# @return [String, nil] the request field the error is about, in the API's spelling ("images[0].data")
|
|
25
|
+
attr_reader :param
|
|
26
|
+
# @return [String, nil] the request id; quote it when you contact support
|
|
27
|
+
attr_reader :request_id
|
|
28
|
+
|
|
29
|
+
# @return [Hash{String => String}] the response headers (lower-case names); empty without a response
|
|
30
|
+
def headers
|
|
31
|
+
@headers || {}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The short name: `rescue VeriSec::Error` catches everything this library raises.
|
|
36
|
+
Error = VeriSecError
|
|
37
|
+
|
|
38
|
+
# The API answered with an error status (or a 2xx KYC answer with `ok: false`).
|
|
39
|
+
# Rescue a subclass to handle one kind.
|
|
40
|
+
class APIError < VeriSecError
|
|
41
|
+
# @param message [String, nil] the API's message
|
|
42
|
+
# @param status [Integer] the HTTP status
|
|
43
|
+
# @param code [String, nil]
|
|
44
|
+
# @param param [String, nil]
|
|
45
|
+
# @param request_id [String, nil]
|
|
46
|
+
# @param headers [Hash{String => String}] lower-case names
|
|
47
|
+
def initialize(message = nil, status: nil, code: nil, param: nil, request_id: nil, headers: {})
|
|
48
|
+
super(message || "VeriSec API request failed with status #{status}")
|
|
49
|
+
@status = status
|
|
50
|
+
@code = code
|
|
51
|
+
@param = param
|
|
52
|
+
@request_id = request_id
|
|
53
|
+
@headers = headers
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The right subclass for a failed answer.
|
|
57
|
+
#
|
|
58
|
+
# @api private
|
|
59
|
+
# @param status [Integer] the HTTP status
|
|
60
|
+
# @param body [Hash] what {Util.error_body} read from the answer: `code`, `message`, `param`
|
|
61
|
+
# @param headers [Hash{String => String}] the response headers, lower-case names
|
|
62
|
+
# @param request_id [String, nil]
|
|
63
|
+
# @return [APIError]
|
|
64
|
+
def self.from(status, body, headers, request_id)
|
|
65
|
+
class_for(status).new(
|
|
66
|
+
body[:message], status: status, code: body[:code], param: body[:param], request_id: request_id, headers: headers
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @api private
|
|
71
|
+
def self.class_for(status)
|
|
72
|
+
case status
|
|
73
|
+
when 401 then AuthenticationError
|
|
74
|
+
when 402 then InsufficientCreditsError
|
|
75
|
+
when 403 then PermissionError
|
|
76
|
+
when 404 then NotFoundError
|
|
77
|
+
when 429 then RateLimitError
|
|
78
|
+
else status >= 500 ? APIServerError : InvalidRequestError
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
private_class_method :class_for
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# 400, 413, 415, 422 (and any other 4xx without its own class): a field is
|
|
85
|
+
# missing or not valid, or the file is too large or not an image. See {#code}
|
|
86
|
+
# and {#param}.
|
|
87
|
+
class InvalidRequestError < APIError; end
|
|
88
|
+
|
|
89
|
+
# 401: no API key, or the key (or a live token) is not valid, expired or revoked.
|
|
90
|
+
class AuthenticationError < APIError; end
|
|
91
|
+
|
|
92
|
+
# 402: not enough credits, or the key reached its spend cap. Nothing was charged.
|
|
93
|
+
class InsufficientCreditsError < APIError; end
|
|
94
|
+
|
|
95
|
+
# 403: the key may not call this API (permission, environment or IP allow-list).
|
|
96
|
+
class PermissionError < APIError; end
|
|
97
|
+
|
|
98
|
+
# 404: nothing was found (or no such route).
|
|
99
|
+
class NotFoundError < APIError; end
|
|
100
|
+
|
|
101
|
+
# 429: too many requests for this key. Nothing was charged. The library
|
|
102
|
+
# already waited and retried; this is what was left.
|
|
103
|
+
class RateLimitError < APIError
|
|
104
|
+
# @return [Integer, Float, nil] seconds to wait, from the Retry-After header
|
|
105
|
+
def retry_after
|
|
106
|
+
Util.parse_seconds(headers["retry-after"])
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# 5xx: the check could not run. Nothing was charged; it is safe to try again.
|
|
111
|
+
class APIServerError < APIError; end
|
|
112
|
+
|
|
113
|
+
# The request got no answer: the connection could not be opened, or it dropped
|
|
114
|
+
# after the request was sent (then the call may have run, and been charged, on
|
|
115
|
+
# the server, which is why the library does not send it again). The original
|
|
116
|
+
# exception is available as {#cause}.
|
|
117
|
+
class APIConnectionError < VeriSecError; end
|
|
118
|
+
|
|
119
|
+
# No answer within the timeout. The call may still have run (and been charged)
|
|
120
|
+
# on the server, so the library does not send it again.
|
|
121
|
+
class APITimeoutError < APIConnectionError; end
|
|
122
|
+
|
|
123
|
+
# A webhook signature did not verify, or its timestamp is outside the tolerance.
|
|
124
|
+
class SignatureVerificationError < VeriSecError; end
|
|
125
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VeriSec
|
|
4
|
+
# A file to upload: its bytes, a file name and a media type. The Financial
|
|
5
|
+
# reads (cheques, cards) send it as `multipart/form-data` under `image`.
|
|
6
|
+
#
|
|
7
|
+
# You rarely build one yourself: `image:` also takes a path (a String or a
|
|
8
|
+
# Pathname) or an open IO (File, StringIO, Tempfile, an uploaded file), and
|
|
9
|
+
# the library wraps it. Use it to send bytes you already have, or to name them:
|
|
10
|
+
#
|
|
11
|
+
# client.cheques.read(image: VeriSec::FileInput.new(bytes, filename: "cheque.jpg"))
|
|
12
|
+
class FileInput
|
|
13
|
+
# The file name used when none is known.
|
|
14
|
+
DEFAULT_FILENAME = "image"
|
|
15
|
+
|
|
16
|
+
# Media types by file extension, used when the bytes don't say.
|
|
17
|
+
EXTENSION_TYPES = {
|
|
18
|
+
".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".png" => "image/png", ".bmp" => "image/bmp",
|
|
19
|
+
".webp" => "image/webp", ".gif" => "image/gif", ".tif" => "image/tiff", ".tiff" => "image/tiff"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# Guesses an image's media type from its first bytes.
|
|
23
|
+
#
|
|
24
|
+
# @param bytes [String]
|
|
25
|
+
# @return [String, nil]
|
|
26
|
+
def self.sniff(bytes)
|
|
27
|
+
head = bytes.byteslice(0, 12).to_s.b
|
|
28
|
+
if head.start_with?("\xFF\xD8".b) then "image/jpeg"
|
|
29
|
+
elsif head.start_with?("\x89PNG".b) then "image/png"
|
|
30
|
+
elsif head.start_with?("BM".b) then "image/bmp"
|
|
31
|
+
elsif head.start_with?("RIFF".b) && head.byteslice(8, 4) == "WEBP".b then "image/webp"
|
|
32
|
+
elsif head.start_with?("GIF8".b) then "image/gif"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Reads a file from disk now, so a missing file fails before any request.
|
|
37
|
+
#
|
|
38
|
+
# @param path [String, Pathname]
|
|
39
|
+
# @param content_type [String, nil]
|
|
40
|
+
# @return [FileInput]
|
|
41
|
+
# @raise [VeriSecError] when the file can't be read
|
|
42
|
+
def self.from_path(path, content_type: nil, field: "image")
|
|
43
|
+
path = path.to_path if path.respond_to?(:to_path)
|
|
44
|
+
new(File.binread(path), filename: File.basename(path), content_type: content_type)
|
|
45
|
+
rescue SystemCallError, IOError, ArgumentError, EncodingError => e
|
|
46
|
+
raise VeriSecError, "#{field}: cannot read #{Util.shorten(path)} (#{Util.reason(e)})"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Wraps anything an upload accepts: a FileInput, a path (String or
|
|
50
|
+
# Pathname), an IO, a {VeriSec::Image}, or a String of raw bytes in binary
|
|
51
|
+
# encoding (what `File.binread` returns).
|
|
52
|
+
#
|
|
53
|
+
# @api private
|
|
54
|
+
# @param value [FileInput, String, Pathname, IO, Image]
|
|
55
|
+
# @param field [String] the field's name, for errors
|
|
56
|
+
# @return [FileInput]
|
|
57
|
+
# @raise [VeriSecError] for a file that can't be read, or anything else
|
|
58
|
+
def self.wrap(value, field = "image")
|
|
59
|
+
case value
|
|
60
|
+
when FileInput then value
|
|
61
|
+
when Image then new(value.data)
|
|
62
|
+
when String
|
|
63
|
+
bytes = value.encoding == Encoding::BINARY && !value.ascii_only?
|
|
64
|
+
bytes ? new(value) : from_path(value, field: field)
|
|
65
|
+
when Pathname then from_path(value, field: field)
|
|
66
|
+
else
|
|
67
|
+
return new(value) if value.respond_to?(:read)
|
|
68
|
+
return from_path(value, field: field) if value.respond_to?(:to_path)
|
|
69
|
+
|
|
70
|
+
raise VeriSecError, "#{field}: expected a file path, an IO or a VeriSec::FileInput, not " \
|
|
71
|
+
"#{value.nil? ? 'nil' : value.class}."
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @param data [String, IO] the bytes, or an IO to read them from (read once,
|
|
76
|
+
# from its current position)
|
|
77
|
+
# @param filename [String, nil] the name sent with the upload; an IO's own
|
|
78
|
+
# name (its path, or `original_filename`) is used when left out
|
|
79
|
+
# @param content_type [String, nil] the media type; guessed from the bytes,
|
|
80
|
+
# then from the file name, when left out
|
|
81
|
+
def initialize(data, filename: nil, content_type: nil)
|
|
82
|
+
unless data.is_a?(String) || data.respond_to?(:read)
|
|
83
|
+
raise TypeError, "VeriSec::FileInput needs a String of bytes or an IO, not #{data.class}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@data = data
|
|
87
|
+
@filename = filename.nil? ? self.class.send(:name_of, data) : File.basename(filename.to_s)
|
|
88
|
+
@content_type = content_type
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [String] the file name sent with the upload
|
|
92
|
+
def filename
|
|
93
|
+
@filename || DEFAULT_FILENAME
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [String] the file's bytes (binary)
|
|
97
|
+
def bytes
|
|
98
|
+
@bytes ||= begin
|
|
99
|
+
raw = @data.is_a?(String) ? @data : @data.read
|
|
100
|
+
raw.to_s.b.freeze
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [String] the media type
|
|
105
|
+
def content_type
|
|
106
|
+
@content_type ||= self.class.sniff(bytes) ||
|
|
107
|
+
EXTENSION_TYPES[File.extname(filename).downcase] ||
|
|
108
|
+
"application/octet-stream"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def inspect
|
|
112
|
+
"#<#{self.class.name} filename=#{filename.inspect} content_type=#{content_type.inspect} bytes=#{bytes.bytesize}>"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The file name an IO carries: an uploaded file's original name, or its path.
|
|
116
|
+
def self.name_of(data)
|
|
117
|
+
return nil if data.is_a?(String)
|
|
118
|
+
|
|
119
|
+
name = data.original_filename if data.respond_to?(:original_filename)
|
|
120
|
+
name ||= data.path if data.respond_to?(:path) && data.path.is_a?(String)
|
|
121
|
+
name ? File.basename(name) : nil
|
|
122
|
+
end
|
|
123
|
+
private_class_method :name_of
|
|
124
|
+
end
|
|
125
|
+
end
|