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