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