usesmileid 12.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/CHANGELOG.md +47 -0
- data/LICENSE +21 -0
- data/README.md +320 -0
- data/lib/usesmileid/client/auth.rb +98 -0
- data/lib/usesmileid/client/client.rb +130 -0
- data/lib/usesmileid/client/config.rb +106 -0
- data/lib/usesmileid/client/resources.rb +320 -0
- data/lib/usesmileid/client/transport.rb +114 -0
- data/lib/usesmileid/errors.rb +125 -0
- data/lib/usesmileid/generated/models.rb +170 -0
- data/lib/usesmileid/generated/operations.rb +98 -0
- data/lib/usesmileid/helpers/consent.rb +85 -0
- data/lib/usesmileid/helpers/multipart.rb +250 -0
- data/lib/usesmileid/helpers/user_details.rb +63 -0
- data/lib/usesmileid/version.rb +5 -0
- data/lib/usesmileid.rb +24 -0
- metadata +90 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module SmileID
|
|
6
|
+
# Immutable client configuration (spec section 2.1). Sandbox by default.
|
|
7
|
+
#
|
|
8
|
+
# Fleet standards (2026-07-03): base_url must be an absolute https URL with
|
|
9
|
+
# no query or fragment — there is deliberately no allow-insecure option.
|
|
10
|
+
# Callback URLs must be https, validated at construction for the default and
|
|
11
|
+
# before send for per-request values.
|
|
12
|
+
class Config
|
|
13
|
+
PARTNER_ID = /\A[1-9]\d*\z/
|
|
14
|
+
BASE_URLS = {
|
|
15
|
+
sandbox: 'https://testapi.smileidentity.com',
|
|
16
|
+
production: 'https://api.smileidentity.com'
|
|
17
|
+
}.freeze
|
|
18
|
+
DEFAULT_TIMEOUT = 30
|
|
19
|
+
DEFAULT_MAX_RETRIES = 2
|
|
20
|
+
|
|
21
|
+
attr_reader :partner_id, :api_key, :environment,
|
|
22
|
+
:default_callback_url, :base_url, :timeout, :max_retries, :http_client
|
|
23
|
+
|
|
24
|
+
def initialize(partner_id:, api_key:, environment: :sandbox,
|
|
25
|
+
default_callback_url: nil, base_url: nil, timeout: DEFAULT_TIMEOUT,
|
|
26
|
+
max_retries: DEFAULT_MAX_RETRIES, http_client: nil)
|
|
27
|
+
@partner_id = partner_id.to_s
|
|
28
|
+
@api_key = api_key.to_s
|
|
29
|
+
@environment = normalize_environment(environment)
|
|
30
|
+
@default_callback_url = default_callback_url
|
|
31
|
+
@timeout = timeout
|
|
32
|
+
@max_retries = max_retries
|
|
33
|
+
@http_client = http_client
|
|
34
|
+
@base_url = (base_url || BASE_URLS.fetch(@environment)).chomp('/')
|
|
35
|
+
validate!
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Validate that a callback URL is an absolute https URL. Raises the local
|
|
39
|
+
# validation error so no request is made with an insecure callback.
|
|
40
|
+
def self.validate_callback_url!(value, field: 'callback_url')
|
|
41
|
+
uri = begin
|
|
42
|
+
URI.parse(value.to_s)
|
|
43
|
+
rescue URI::InvalidURIError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
unless uri.is_a?(URI::Generic) && uri.absolute? && uri.host
|
|
47
|
+
raise Errors::ValidationError.new("#{field} must be an absolute https URL")
|
|
48
|
+
end
|
|
49
|
+
return if uri.scheme == 'https'
|
|
50
|
+
|
|
51
|
+
raise Errors::ValidationError.new("#{field} must use https")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def normalize_environment(env)
|
|
57
|
+
sym = env.to_s.downcase.to_sym
|
|
58
|
+
return sym if BASE_URLS.key?(sym)
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, "environment must be one of #{BASE_URLS.keys.join(', ')}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def validate!
|
|
64
|
+
raise ArgumentError, 'partner_id is required' if @partner_id.empty?
|
|
65
|
+
raise ArgumentError, 'api_key is required' if @api_key.empty?
|
|
66
|
+
unless @partner_id.match?(PARTNER_ID)
|
|
67
|
+
raise ArgumentError, 'partner_id must be a numeric string with no leading zeros'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
validate_base_url!
|
|
71
|
+
return if @default_callback_url.nil?
|
|
72
|
+
|
|
73
|
+
self.class.validate_callback_url!(@default_callback_url, field: 'default_callback_url')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_base_url!
|
|
77
|
+
uri = begin
|
|
78
|
+
URI.parse(@base_url)
|
|
79
|
+
rescue URI::InvalidURIError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
unless uri.is_a?(URI::Generic) && uri.absolute? && uri.host
|
|
83
|
+
raise ArgumentError, 'base_url must be an absolute URL'
|
|
84
|
+
end
|
|
85
|
+
raise ArgumentError, 'base_url must use https' unless uri.scheme == 'https'
|
|
86
|
+
return unless uri.query || uri.fragment
|
|
87
|
+
|
|
88
|
+
raise ArgumentError, 'base_url must not include a query or fragment'
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Telemetry headers sent on every request (spec section 2.4).
|
|
93
|
+
module Telemetry
|
|
94
|
+
SDK_NAME = 'ruby'
|
|
95
|
+
|
|
96
|
+
module_function
|
|
97
|
+
|
|
98
|
+
def headers
|
|
99
|
+
{
|
|
100
|
+
'SmileID-Source-SDK' => SDK_NAME,
|
|
101
|
+
'SmileID-Source-SDK-Version' => SmileID::VERSION,
|
|
102
|
+
'User-Agent' => "smileid-sdk-ruby/#{SmileID::VERSION} (ruby/#{RUBY_VERSION})"
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmileID
|
|
4
|
+
# Resource namespaces exposing the public SDK surface (spec section 4).
|
|
5
|
+
# Each maps a verb to an operation, coerces and validates input, and wraps the
|
|
6
|
+
# response in a typed model.
|
|
7
|
+
module Resources
|
|
8
|
+
# Shared behaviour for all resources.
|
|
9
|
+
class Base
|
|
10
|
+
def initialize(client)
|
|
11
|
+
@client = client
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def coerce_consent(consent)
|
|
17
|
+
Consent.coerce(consent)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def coerce_user_details(user_details)
|
|
21
|
+
UserDetails.coerce(user_details)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Use an explicit callback_url, else fall back to the configured default.
|
|
25
|
+
# Per-request callback URLs must be https (fleet standard); validated
|
|
26
|
+
# before any request is made. The default was validated at construction.
|
|
27
|
+
def resolve_callback(callback_url)
|
|
28
|
+
Config.validate_callback_url!(callback_url) if callback_url
|
|
29
|
+
callback_url || @client.config.default_callback_url
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def accepted(response)
|
|
33
|
+
Generated::Models::AcceptedResponse.from(response.json)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# POST /v3/enhanced_kyc
|
|
38
|
+
class EnhancedKyc < Base
|
|
39
|
+
def verify(country:, id_type:, id_number:, user_details:, consent:,
|
|
40
|
+
callback_url: nil, bank_code: nil, operator: nil,
|
|
41
|
+
partner_params: nil, metadata: nil, user_id: nil, timeout: nil)
|
|
42
|
+
form = {
|
|
43
|
+
'country' => country,
|
|
44
|
+
'id_type' => id_type,
|
|
45
|
+
'id_number' => id_number,
|
|
46
|
+
'user_details' => coerce_user_details(user_details),
|
|
47
|
+
'consent' => coerce_consent(consent),
|
|
48
|
+
'callback_url' => resolve_callback(callback_url),
|
|
49
|
+
'bank_code' => bank_code,
|
|
50
|
+
'operator' => operator,
|
|
51
|
+
'partner_params' => partner_params,
|
|
52
|
+
'metadata' => metadata
|
|
53
|
+
}.compact
|
|
54
|
+
accepted(@client.call(:enhanced_kyc, form: form, user_id_header: user_id, timeout: timeout))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# POST /v3/document_verification and /v3/enhanced_document_verification
|
|
59
|
+
class Documents < Base
|
|
60
|
+
def verify(selfie_image:, liveness_images:, document:, consent:, country:, user_details:,
|
|
61
|
+
document_back: nil, id_type: nil, callback_url: nil,
|
|
62
|
+
partner_params: nil, metadata: nil, user_id: nil, timeout: nil)
|
|
63
|
+
form = document_form(
|
|
64
|
+
selfie_image: selfie_image, liveness_images: liveness_images, document: document,
|
|
65
|
+
document_back: document_back, consent: consent, country: country, id_type: id_type,
|
|
66
|
+
user_details: user_details, callback_url: callback_url,
|
|
67
|
+
partner_params: partner_params, metadata: metadata
|
|
68
|
+
)
|
|
69
|
+
accepted(@client.call(:document_verification, form: form, user_id_header: user_id, timeout: timeout))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# id_type is REQUIRED for enhanced document verification (spec section 6.3).
|
|
73
|
+
def verify_enhanced(id_type:, selfie_image:, liveness_images:, document:, consent:, country:,
|
|
74
|
+
user_details:, document_back: nil, callback_url: nil,
|
|
75
|
+
partner_params: nil, metadata: nil, user_id: nil, timeout: nil)
|
|
76
|
+
if id_type.to_s.empty?
|
|
77
|
+
raise Errors::ValidationError.new('id_type is required for enhanced document verification')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
form = document_form(
|
|
81
|
+
selfie_image: selfie_image, liveness_images: liveness_images, document: document,
|
|
82
|
+
document_back: document_back, consent: consent, country: country, id_type: id_type,
|
|
83
|
+
user_details: user_details, callback_url: callback_url,
|
|
84
|
+
partner_params: partner_params, metadata: metadata
|
|
85
|
+
)
|
|
86
|
+
accepted(@client.call(:enhanced_document_verification, form: form,
|
|
87
|
+
user_id_header: user_id, timeout: timeout))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def document_form(selfie_image:, liveness_images:, document:, document_back:, consent:,
|
|
93
|
+
country:, id_type:, user_details:, callback_url:, partner_params:, metadata:)
|
|
94
|
+
{
|
|
95
|
+
'country' => country,
|
|
96
|
+
'id_type' => id_type,
|
|
97
|
+
'selfie_image' => selfie_image,
|
|
98
|
+
'liveness_images' => liveness_images,
|
|
99
|
+
'document' => document,
|
|
100
|
+
'document_back' => document_back,
|
|
101
|
+
'user_details' => coerce_user_details(user_details),
|
|
102
|
+
'consent' => coerce_consent(consent),
|
|
103
|
+
'callback_url' => resolve_callback(callback_url),
|
|
104
|
+
'partner_params' => partner_params,
|
|
105
|
+
'metadata' => metadata
|
|
106
|
+
}.compact
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# POST /v3/biometric_kyc
|
|
111
|
+
class BiometricKyc < Base
|
|
112
|
+
def verify(selfie_image:, liveness_images:, consent:, country:, id_type:, id_number:,
|
|
113
|
+
user_details:, callback_url: nil, sandbox_result: nil,
|
|
114
|
+
partner_params: nil, metadata: nil, user_id: nil, timeout: nil)
|
|
115
|
+
form = {
|
|
116
|
+
'country' => country,
|
|
117
|
+
'id_type' => id_type,
|
|
118
|
+
'id_number' => id_number,
|
|
119
|
+
'selfie_image' => selfie_image,
|
|
120
|
+
'liveness_images' => liveness_images,
|
|
121
|
+
'user_details' => coerce_user_details(user_details),
|
|
122
|
+
'consent' => coerce_consent(consent),
|
|
123
|
+
'callback_url' => resolve_callback(callback_url),
|
|
124
|
+
'sandbox_result' => sandbox_result,
|
|
125
|
+
'partner_params' => partner_params,
|
|
126
|
+
'metadata' => metadata
|
|
127
|
+
}.compact
|
|
128
|
+
accepted(@client.call(:biometric_kyc, form: form, user_id_header: user_id, timeout: timeout))
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# POST /v3/registration, /v3/authentication, /v3/compare
|
|
133
|
+
class Biometric < Base
|
|
134
|
+
def enroll(selfie_image:, liveness_images:, consent:, user_details:,
|
|
135
|
+
allow_new_enroll: nil, callback_url: nil, sandbox_result: nil,
|
|
136
|
+
partner_params: nil, metadata: nil, user_id: nil, timeout: nil)
|
|
137
|
+
form = {
|
|
138
|
+
'selfie_image' => selfie_image,
|
|
139
|
+
'liveness_images' => liveness_images,
|
|
140
|
+
'user_details' => coerce_user_details(user_details),
|
|
141
|
+
'consent' => coerce_consent(consent),
|
|
142
|
+
'allow_new_enroll' => allow_new_enroll,
|
|
143
|
+
'callback_url' => resolve_callback(callback_url),
|
|
144
|
+
'sandbox_result' => sandbox_result,
|
|
145
|
+
'partner_params' => partner_params,
|
|
146
|
+
'metadata' => metadata
|
|
147
|
+
}.compact
|
|
148
|
+
accepted(@client.call(:registration, form: form, user_id_header: user_id, timeout: timeout))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# user_id goes in the BODY here (required), not the User-ID header.
|
|
152
|
+
def authenticate(user_id:, consent:, user_details:, selfie_image: nil, liveness_images: nil,
|
|
153
|
+
use_enrolled_image: nil, callback_url: nil, sandbox_result: nil,
|
|
154
|
+
partner_params: nil, metadata: nil, timeout: nil)
|
|
155
|
+
require_images!(use_enrolled_image, selfie_image, liveness_images)
|
|
156
|
+
form = {
|
|
157
|
+
'user_id' => user_id,
|
|
158
|
+
'selfie_image' => selfie_image,
|
|
159
|
+
'liveness_images' => liveness_images,
|
|
160
|
+
'user_details' => coerce_user_details(user_details),
|
|
161
|
+
'consent' => coerce_consent(consent),
|
|
162
|
+
'use_enrolled_image' => use_enrolled_image,
|
|
163
|
+
'callback_url' => resolve_callback(callback_url),
|
|
164
|
+
'sandbox_result' => sandbox_result,
|
|
165
|
+
'partner_params' => partner_params,
|
|
166
|
+
'metadata' => metadata
|
|
167
|
+
}.compact
|
|
168
|
+
accepted(@client.call(:authentication, form: form, timeout: timeout))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# user_id is optional here, and goes in the BODY when present.
|
|
172
|
+
def compare(selfie_image:, comparison_image:, comparison_image_type:, consent:, user_details:,
|
|
173
|
+
liveness_images: nil, allow_new_enroll: nil, user_id: nil, callback_url: nil,
|
|
174
|
+
sandbox_result: nil, partner_params: nil, metadata: nil, timeout: nil)
|
|
175
|
+
form = {
|
|
176
|
+
'selfie_image' => selfie_image,
|
|
177
|
+
'comparison_image' => comparison_image,
|
|
178
|
+
'comparison_image_type' => comparison_image_type,
|
|
179
|
+
'liveness_images' => liveness_images,
|
|
180
|
+
'user_details' => coerce_user_details(user_details),
|
|
181
|
+
'consent' => coerce_consent(consent),
|
|
182
|
+
'allow_new_enroll' => allow_new_enroll,
|
|
183
|
+
'user_id' => user_id,
|
|
184
|
+
'callback_url' => resolve_callback(callback_url),
|
|
185
|
+
'sandbox_result' => sandbox_result,
|
|
186
|
+
'partner_params' => partner_params,
|
|
187
|
+
'metadata' => metadata
|
|
188
|
+
}.compact
|
|
189
|
+
accepted(@client.call(:compare, form: form, timeout: timeout))
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
def require_images!(use_enrolled_image, selfie_image, liveness_images)
|
|
195
|
+
return if use_enrolled_image == true
|
|
196
|
+
return unless selfie_image.nil? || liveness_images.nil? || Array(liveness_images).empty?
|
|
197
|
+
|
|
198
|
+
raise Errors::ValidationError.new(
|
|
199
|
+
'selfie_image and liveness_images are required unless use_enrolled_image is true'
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# GET /v3/status, POST /v3/replay, plus the wait_until_complete poll helper.
|
|
205
|
+
class Verifications < Base
|
|
206
|
+
def retrieve(job_id, timeout: nil)
|
|
207
|
+
response = @client.call(:status, path_params: { 'job_id' => job_id }, timeout: timeout)
|
|
208
|
+
Generated::Models::JobStatus.from(response.json)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def wait_until_complete(job_id, interval: 2, timeout: 60, treat_not_found_as_pending: true)
|
|
212
|
+
deadline = monotonic + timeout
|
|
213
|
+
loop do
|
|
214
|
+
status = retrieve(job_id)
|
|
215
|
+
return status if status.complete?
|
|
216
|
+
return status if status.not_found? && !treat_not_found_as_pending
|
|
217
|
+
|
|
218
|
+
if monotonic >= deadline
|
|
219
|
+
raise Errors::TimeoutError.new(
|
|
220
|
+
"wait_until_complete timed out after #{timeout}s waiting for #{job_id}"
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
sleep_interval(interval)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The optional callback_url override goes in a multipart body (any other
|
|
228
|
+
# content type gets a 415 from the backend); no override, no body.
|
|
229
|
+
def replay(job_id, callback_url: nil, timeout: nil)
|
|
230
|
+
Config.validate_callback_url!(callback_url) if callback_url
|
|
231
|
+
form = callback_url ? { 'callback_url' => callback_url } : nil
|
|
232
|
+
response = @client.call(:replay, path_params: { 'job_id' => job_id },
|
|
233
|
+
form: form, timeout: timeout)
|
|
234
|
+
Generated::Models::AcceptedStatusResponse.from(response.json)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
private
|
|
238
|
+
|
|
239
|
+
def monotonic
|
|
240
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def sleep_interval(interval)
|
|
244
|
+
sleep(interval)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# POST /v3/users/{user_id}/report_fraud, plus flag/clear convenience wrappers.
|
|
249
|
+
class Users < Base
|
|
250
|
+
REASONS = %w[
|
|
251
|
+
FIRST_PARTY_FRAUD SECOND_PARTY_FRAUD THIRD_PARTY_FRAUD SYNTHETIC_IDENTITY
|
|
252
|
+
ACCOUNT_TAKEOVER DOCUMENT_FORGERY IDENTITY_FARMING MULE_ACCOUNT OTHER
|
|
253
|
+
].freeze
|
|
254
|
+
|
|
255
|
+
def report_fraud(user_id, is_fraud:, reported_by:, reason: nil, notes: nil, timeout: nil)
|
|
256
|
+
validate_fraud!(is_fraud, reason, notes)
|
|
257
|
+
form = {
|
|
258
|
+
'is_fraud' => is_fraud,
|
|
259
|
+
'reported_by' => reported_by,
|
|
260
|
+
'reason' => reason,
|
|
261
|
+
'notes' => notes
|
|
262
|
+
}.compact
|
|
263
|
+
response = @client.call(:report_fraud, path_params: { 'user_id' => user_id },
|
|
264
|
+
form: form, timeout: timeout)
|
|
265
|
+
Generated::Models::AcceptedStatusResponse.from(response.json)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def flag_fraud(user_id, reason:, reported_by:, notes: nil, timeout: nil)
|
|
269
|
+
report_fraud(user_id, is_fraud: true, reason: reason, notes: notes,
|
|
270
|
+
reported_by: reported_by, timeout: timeout)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def clear_fraud(user_id, notes:, reported_by:, timeout: nil)
|
|
274
|
+
report_fraud(user_id, is_fraud: false, notes: notes, reported_by: reported_by, timeout: timeout)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private
|
|
278
|
+
|
|
279
|
+
def validate_fraud!(is_fraud, reason, notes)
|
|
280
|
+
if is_fraud
|
|
281
|
+
raise Errors::ValidationError.new('reason is required when is_fraud is true') if reason.to_s.empty?
|
|
282
|
+
unless REASONS.include?(reason.to_s)
|
|
283
|
+
raise Errors::ValidationError.new("reason must be one of #{REASONS.join(', ')}")
|
|
284
|
+
end
|
|
285
|
+
if reason.to_s == 'OTHER' && notes.to_s.empty?
|
|
286
|
+
raise Errors::ValidationError.new('notes is required when reason is OTHER')
|
|
287
|
+
end
|
|
288
|
+
elsif notes.to_s.empty?
|
|
289
|
+
raise Errors::ValidationError.new('notes is required when is_fraud is false')
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# GET /v3/services/*
|
|
295
|
+
class Services < Base
|
|
296
|
+
def bank_codes(country: nil, timeout: nil)
|
|
297
|
+
response = @client.call(:bank_codes, query: { 'country' => country }.compact, timeout: timeout)
|
|
298
|
+
Generated::Models::BankCodesResponse.from(response.json)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def supported_id_types(country: nil, timeout: nil)
|
|
302
|
+
response = @client.call(:supported_id_types, query: { 'country' => country }.compact,
|
|
303
|
+
timeout: timeout)
|
|
304
|
+
Generated::Models::SupportedIdTypesResponse.from(response.json)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def supported_documents(continent: nil, country_code: nil, locale: nil, timeout: nil)
|
|
308
|
+
query = { 'continent' => continent, 'country_code' => country_code, 'locale' => locale }.compact
|
|
309
|
+
response = @client.call(:supported_documents, query: query, timeout: timeout)
|
|
310
|
+
Generated::Models::SupportedDocumentsResponse.from(response.json)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def id_status(country:, id_type:, timeout: nil)
|
|
314
|
+
query = { 'country' => country, 'id_type' => id_type }
|
|
315
|
+
response = @client.call(:id_status, query: query, timeout: timeout)
|
|
316
|
+
Generated::Models::IdStatusResponse.from(response.json)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module SmileID
|
|
8
|
+
# Normalized HTTP response returned by the transport.
|
|
9
|
+
class Response
|
|
10
|
+
attr_reader :status, :headers, :raw_body
|
|
11
|
+
|
|
12
|
+
def initialize(status:, headers:, raw_body:)
|
|
13
|
+
@status = status
|
|
14
|
+
@headers = headers
|
|
15
|
+
@raw_body = raw_body
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Parsed JSON body, or nil when the body is empty or not JSON.
|
|
19
|
+
def json
|
|
20
|
+
return @json if defined?(@json)
|
|
21
|
+
|
|
22
|
+
@json = parse
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def parse
|
|
28
|
+
return nil if raw_body.nil? || raw_body.to_s.empty?
|
|
29
|
+
|
|
30
|
+
JSON.parse(raw_body)
|
|
31
|
+
rescue JSON::ParserError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The single HTTP transport (spec section 2.2, 2.6). Builds the Faraday
|
|
37
|
+
# request, sends it, retries idempotent operations on transient failures
|
|
38
|
+
# honouring Retry-After, and normalizes the response. It does not touch auth;
|
|
39
|
+
# the client injects tokens and handles the refresh-on-401 dance.
|
|
40
|
+
class Transport
|
|
41
|
+
RETRY_STATUSES = [408, 429, 500, 502, 503, 504].freeze
|
|
42
|
+
BACKOFF_BASE = 0.5
|
|
43
|
+
# Ceiling for an honoured Retry-After, so a hostile or misconfigured
|
|
44
|
+
# header cannot block the caller indefinitely.
|
|
45
|
+
RETRY_AFTER_CAP = 60
|
|
46
|
+
|
|
47
|
+
def initialize(config)
|
|
48
|
+
@config = config
|
|
49
|
+
@connection = build_connection
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def send_request(method:, url:, headers:, query: {}, body: nil, retryable: false, timeout: nil)
|
|
53
|
+
attempt = 0
|
|
54
|
+
loop do
|
|
55
|
+
response = perform(method, url, headers, query, body, timeout)
|
|
56
|
+
if retryable && RETRY_STATUSES.include?(response.status) && attempt < @config.max_retries
|
|
57
|
+
sleep_for(backoff(attempt, response.headers['retry-after']))
|
|
58
|
+
attempt += 1
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
return response
|
|
62
|
+
rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Faraday::SSLError => e
|
|
63
|
+
raise Errors::ConnectionError.new(e.message) unless retryable && attempt < @config.max_retries
|
|
64
|
+
|
|
65
|
+
sleep_for(backoff(attempt, nil))
|
|
66
|
+
attempt += 1
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def perform(method, url, headers, query, body, timeout)
|
|
73
|
+
resp = @connection.run_request(method, url, body, headers) do |req|
|
|
74
|
+
req.params.update(query) if query && !query.empty?
|
|
75
|
+
req.options.timeout = timeout || @config.timeout
|
|
76
|
+
end
|
|
77
|
+
Response.new(status: resp.status, headers: resp.headers, raw_body: resp.body)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_connection
|
|
81
|
+
return @config.http_client if @config.http_client.is_a?(Faraday::Connection)
|
|
82
|
+
|
|
83
|
+
Faraday.new do |f|
|
|
84
|
+
f.adapter(Faraday.default_adapter)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Exponential backoff with jitter; honour Retry-After (capped) when present.
|
|
89
|
+
def backoff(attempt, retry_after)
|
|
90
|
+
honoured = parse_retry_after(retry_after)
|
|
91
|
+
return [honoured, RETRY_AFTER_CAP].min if honoured
|
|
92
|
+
|
|
93
|
+
(BACKOFF_BASE * (2**attempt)) + (rand * BACKOFF_BASE)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Retry-After is either delta-seconds or an RFC 7231 HTTP-date. Returns
|
|
97
|
+
# the delay in seconds (floored at 0), or nil when absent or unparseable.
|
|
98
|
+
def parse_retry_after(value)
|
|
99
|
+
str = value.to_s.strip
|
|
100
|
+
return nil if str.empty?
|
|
101
|
+
return str.to_f if str.match?(/\A\d+(\.\d+)?\z/)
|
|
102
|
+
|
|
103
|
+
begin
|
|
104
|
+
[Time.httpdate(str) - Time.now, 0].max
|
|
105
|
+
rescue ArgumentError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def sleep_for(seconds)
|
|
111
|
+
sleep(seconds)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmileID
|
|
4
|
+
# Typed error hierarchy over both wire error shapes (spec section 7).
|
|
5
|
+
#
|
|
6
|
+
# Wire shapes handled:
|
|
7
|
+
# - { "status": <text>, "message": <human> } (most endpoints; id_status reorders keys)
|
|
8
|
+
# - { "error": <human>, "code": <string> } (the three unauthenticated services endpoints)
|
|
9
|
+
#
|
|
10
|
+
# The class is chosen by HTTP status, never by body contents.
|
|
11
|
+
module Errors
|
|
12
|
+
# Base class for every error the SDK raises.
|
|
13
|
+
class SmileIDError < StandardError
|
|
14
|
+
# HTTP status code (Integer), or nil for connection/local errors.
|
|
15
|
+
attr_reader :status_code
|
|
16
|
+
# HTTP status text from the body when present (e.g. "Bad Request").
|
|
17
|
+
attr_reader :status
|
|
18
|
+
# Machine code, present only on the services { error, code } shape.
|
|
19
|
+
attr_reader :code
|
|
20
|
+
# Request id, populated from a response header if one exists, else nil.
|
|
21
|
+
attr_reader :request_id
|
|
22
|
+
# The unparsed response body.
|
|
23
|
+
attr_reader :raw_body
|
|
24
|
+
|
|
25
|
+
def initialize(message = nil, status_code: nil, status: nil, code: nil,
|
|
26
|
+
request_id: nil, raw_body: nil)
|
|
27
|
+
super(message)
|
|
28
|
+
@status_code = status_code
|
|
29
|
+
@status = status
|
|
30
|
+
@code = code
|
|
31
|
+
@request_id = request_id
|
|
32
|
+
@raw_body = raw_body
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# 400 and 415 — malformed request, or a failed client-side validation.
|
|
37
|
+
class InvalidRequestError < SmileIDError; end
|
|
38
|
+
|
|
39
|
+
# Raised before send when a local validation rule fails (spec section 5.1, 6.11).
|
|
40
|
+
class ValidationError < InvalidRequestError; end
|
|
41
|
+
|
|
42
|
+
# 401 — token missing, invalid, or expired after a single refresh attempt.
|
|
43
|
+
class AuthenticationError < SmileIDError; end
|
|
44
|
+
|
|
45
|
+
# 402 — insufficient wallet balance.
|
|
46
|
+
class PaymentRequiredError < SmileIDError; end
|
|
47
|
+
|
|
48
|
+
# 403 — not authorized (includes the services { error, code } shape).
|
|
49
|
+
class PermissionError < SmileIDError; end
|
|
50
|
+
|
|
51
|
+
# 404 — not found. Note: jobs.retrieve does NOT raise this (spec section 6.8).
|
|
52
|
+
class NotFoundError < SmileIDError; end
|
|
53
|
+
|
|
54
|
+
# 409 — business-state conflict (replay still processing). Never auto-retried.
|
|
55
|
+
class ConflictError < SmileIDError; end
|
|
56
|
+
|
|
57
|
+
# 413 — payload too large.
|
|
58
|
+
class PayloadTooLargeError < SmileIDError; end
|
|
59
|
+
|
|
60
|
+
# 429 — rate limited.
|
|
61
|
+
class RateLimitError < SmileIDError; end
|
|
62
|
+
|
|
63
|
+
# 5xx — server error.
|
|
64
|
+
class APIError < SmileIDError; end
|
|
65
|
+
|
|
66
|
+
# Network failure or timeout with no HTTP response.
|
|
67
|
+
class ConnectionError < SmileIDError; end
|
|
68
|
+
|
|
69
|
+
# SDK-local — raised by verifications.wait_until_complete when the deadline passes.
|
|
70
|
+
class TimeoutError < SmileIDError; end
|
|
71
|
+
|
|
72
|
+
# A 2xx success response whose body is not the expected JSON object —
|
|
73
|
+
# typically proxy interference or an upstream contract break.
|
|
74
|
+
class UnexpectedResponseError < SmileIDError; end
|
|
75
|
+
|
|
76
|
+
STATUS_CLASSES = {
|
|
77
|
+
400 => InvalidRequestError,
|
|
78
|
+
401 => AuthenticationError,
|
|
79
|
+
402 => PaymentRequiredError,
|
|
80
|
+
403 => PermissionError,
|
|
81
|
+
404 => NotFoundError,
|
|
82
|
+
409 => ConflictError,
|
|
83
|
+
413 => PayloadTooLargeError,
|
|
84
|
+
415 => InvalidRequestError,
|
|
85
|
+
429 => RateLimitError
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
# The response header names we probe for a request id (none is defined in the
|
|
89
|
+
# spec today; populate from a header if the backend ever sends one).
|
|
90
|
+
REQUEST_ID_HEADERS = %w[x-request-id smileid-request-id request-id].freeze
|
|
91
|
+
|
|
92
|
+
# Select the error class for an HTTP status code.
|
|
93
|
+
def self.class_for(status_code)
|
|
94
|
+
return STATUS_CLASSES[status_code] if STATUS_CLASSES.key?(status_code)
|
|
95
|
+
return APIError if status_code && status_code >= 500
|
|
96
|
+
|
|
97
|
+
SmileIDError
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Build a typed error from a response (see parse_error, spec section 2A).
|
|
101
|
+
def self.from_response(status_code:, body:, raw_body:, headers: {})
|
|
102
|
+
body = {} unless body.is_a?(Hash)
|
|
103
|
+
message = body['message'] || body['error']
|
|
104
|
+
klass = class_for(status_code)
|
|
105
|
+
klass.new(
|
|
106
|
+
message,
|
|
107
|
+
status_code: status_code,
|
|
108
|
+
status: body['status'],
|
|
109
|
+
code: body['code'],
|
|
110
|
+
request_id: request_id_from(headers),
|
|
111
|
+
raw_body: raw_body
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.request_id_from(headers)
|
|
116
|
+
return nil unless headers.respond_to?(:[])
|
|
117
|
+
|
|
118
|
+
REQUEST_ID_HEADERS.each do |name|
|
|
119
|
+
value = headers[name] || headers[name.split('-').map(&:capitalize).join('-')]
|
|
120
|
+
return value if value
|
|
121
|
+
end
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|