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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0b3aa92d6b398bde9e998a5e5b128c688a94ac72331b7f5be584df7008286458
4
+ data.tar.gz: f44668a9c23b92c59708176ba20153ef729b3f840431a3fd2fd32fab8121efa4
5
+ SHA512:
6
+ metadata.gz: 2f3017edcda29325e2b49d5a6824d20f6c649538044e840f2a6b08aea08d44d65fa9e8e07c821544519181726d9a3fa3853ee796c6216808c18169f094d65a5e
7
+ data.tar.gz: 146f765cb0b59e9aa473718c614ae3287fd94e064e51bca8f546755c09e1b3f4f60c22816c538041aeb01750c1728da2b9b3b3c65d0238edbe74a3a88106b289
data/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Changed
11
+
12
+ - Renamed the gem from `smile-identity-core` to `smileid`. The require path
13
+ (`require "smileid"`) and the `SmileID` module are unchanged.
14
+ - Set the version to 12.0.0, aligning the server SDKs with the V12 mobile
15
+ SDKs.
16
+ - `base_url` must now be an absolute https URL with no query or fragment,
17
+ validated at construction. There is no allow-insecure option.
18
+ - `default_callback_url` (at construction) and per-request `callback_url`
19
+ values (entry operations and replay) must be https; insecure values raise
20
+ a validation error before any request is sent.
21
+ - `job_id` and `user_id` path parameters are URL-encoded as single path
22
+ segments before interpolation.
23
+ - Multipart filenames and content types are sanitized against header
24
+ injection on every input path, including caller-supplied content types.
25
+ Filenames have control characters stripped and quotes encoded; content
26
+ types must be a valid media type.
27
+
28
+ ### Added
29
+
30
+ - `SmileID::Errors::UnexpectedResponseError`, raised when a 2xx success
31
+ response body is not the expected JSON object.
32
+
33
+ ### Added
34
+
35
+ - Full V3 API coverage: enhanced KYC, document verification, enhanced document
36
+ verification, biometric KYC, biometric enrollment, authentication and compare,
37
+ verification status with a `wait_until_complete` polling helper, callback
38
+ replay, fraud reporting (with `flag_fraud` / `clear_fraud` wrappers), and the
39
+ four services endpoints.
40
+ - Internal JWT authentication with a thread-safe cache and a single automatic
41
+ refresh on 401.
42
+ - Typed error hierarchy under `SmileID::Errors`, covering both API error body
43
+ shapes.
44
+ - Automatic retries with exponential backoff and `Retry-After` support for
45
+ idempotent calls only.
46
+ - Consent builder and client-side validation for user details and fraud
47
+ reports.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Smile Identity Limited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,320 @@
1
+ # usesmileid
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/usesmileid.svg)](https://rubygems.org/gems/usesmileid)
4
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ Official Smile ID server-side SDK for Ruby, covering the V3 APIs.
7
+
8
+ The SDK handles authentication, request serialization, retries and typed errors so you can call Smile ID from Ruby with plain method calls. You never handle tokens yourself.
9
+
10
+ Requires Ruby 3.0 or later.
11
+
12
+ ## Installation
13
+
14
+ Add the gem to your Gemfile:
15
+
16
+ ```ruby
17
+ gem "usesmileid"
18
+ ```
19
+
20
+ Or install it directly:
21
+
22
+ ```bash
23
+ gem install usesmileid
24
+ ```
25
+
26
+ ## Getting started
27
+
28
+ Construct one client with your partner id and API key. The client is thread-safe and can be shared across your application.
29
+
30
+ ```ruby
31
+ require "usesmileid"
32
+
33
+ smile = SmileID::Client.new(
34
+ partner_id: "1234",
35
+ api_key: ENV.fetch("SMILE_API_KEY"),
36
+ environment: :sandbox, # default
37
+ default_callback_url: "https://app.example.com/cb" # optional
38
+ )
39
+ ```
40
+
41
+ Partner ids are displayed zero-padded (for example 002) but must be passed without leading zeros (2).
42
+
43
+ Authentication is internal: the SDK fetches a short-lived token from the API, caches it until just before expiry, and refreshes it once automatically if a request returns 401. You never see or pass a token.
44
+
45
+ ### Environment selection
46
+
47
+ The client uses the sandbox by default. Set `environment: :production` to go live. Only `:sandbox` and `:production` are named.
48
+
49
+ | Environment | Base URL |
50
+ |---|---|
51
+ | `:sandbox` (default) | `https://testapi.smileidentity.com` |
52
+ | `:production` | `https://api.smileidentity.com` |
53
+
54
+ Any other host needs an explicit `base_url:`, which wins over `environment`:
55
+
56
+ ```ruby
57
+ smile = SmileID::Client.new(
58
+ partner_id: "2",
59
+ api_key: ENV.fetch("SMILE_API_KEY"),
60
+ base_url: "https://your-environment.example.com"
61
+ )
62
+ ```
63
+
64
+ A custom `base_url` must be an absolute https URL with no query string or fragment. There is deliberately no way to use plain http. Callback URLs (`default_callback_url` and any per-request `callback_url`) must also be https; an insecure callback raises a validation error before any request is sent.
65
+
66
+ Non-production environments match test identities on given names + last name + email; an unrecognised identity resolves to `block`.
67
+
68
+ ### Other client options
69
+
70
+ | Option | Default | Purpose |
71
+ |---|---|---|
72
+ | `timeout` | 30 | Per-request timeout in seconds. Every method also accepts a per-call `timeout:` override. |
73
+ | `max_retries` | 2 | Retries for idempotent calls only (status and services reads, and the internal token fetch). Job submissions are never retried automatically. |
74
+ | `http_client` | SDK default | Inject your own `Faraday::Connection` for testing or proxies. |
75
+
76
+ ## Shared inputs
77
+
78
+ All verification submissions need consent and user details.
79
+
80
+ ```ruby
81
+ consent = SmileID::Consent.granted(
82
+ granted_at: Time.now.utc,
83
+ notice_language: "EN",
84
+ notice_privacy_policy_url: "https://example.com/privacy"
85
+ )
86
+
87
+ user_details = {
88
+ given_names: "John",
89
+ last_name: "Doe",
90
+ email: "john@example.com" # at least one of email / phone_number is required
91
+ }
92
+ ```
93
+
94
+ Image inputs accept a file path (`"selfie.jpg"`), raw bytes, an IO object, or a hash such as `{ path: "front.png", content_type: "image/png" }`.
95
+
96
+ ## Methods
97
+
98
+ ### Enhanced KYC
99
+
100
+ ```ruby
101
+ accepted = smile.enhanced_kyc.verify(
102
+ country: "NG",
103
+ id_type: "NIN",
104
+ id_number: "12345678901",
105
+ user_details: user_details,
106
+ consent: consent,
107
+ user_id: "user_01h8x9y2z3a4b5c6d7e8f9g0h1" # optional
108
+ )
109
+ accepted.job_id # => "job_..."
110
+ accepted.accepted? # => true
111
+ ```
112
+
113
+ ### Document verification
114
+
115
+ ```ruby
116
+ accepted = smile.documents.verify(
117
+ selfie_image: "selfie.jpg",
118
+ liveness_images: ["live1.jpg", "live2.jpg", "live3.jpg",
119
+ "live4.jpg", "live5.jpg", "live6.jpg"],
120
+ document: "doc_front.jpg",
121
+ document_back: "doc_back.jpg", # optional
122
+ country: "NG",
123
+ user_details: user_details,
124
+ consent: consent
125
+ )
126
+ ```
127
+
128
+ ### Enhanced document verification
129
+
130
+ Same shape as document verification, but `id_type` is required.
131
+
132
+ ```ruby
133
+ accepted = smile.documents.verify_enhanced(
134
+ id_type: "PASSPORT",
135
+ selfie_image: "selfie.jpg",
136
+ liveness_images: ["live1.jpg", "live2.jpg", "live3.jpg",
137
+ "live4.jpg", "live5.jpg", "live6.jpg"],
138
+ document: "doc_front.jpg",
139
+ country: "NG",
140
+ user_details: user_details,
141
+ consent: consent
142
+ )
143
+ ```
144
+
145
+ ### Biometric KYC
146
+
147
+ ```ruby
148
+ accepted = smile.biometric_kyc.verify(
149
+ selfie_image: "selfie.jpg",
150
+ liveness_images: ["live1.jpg", "live2.jpg", "live3.jpg",
151
+ "live4.jpg", "live5.jpg", "live6.jpg"],
152
+ country: "NG",
153
+ id_type: "NIN",
154
+ id_number: "12345678901",
155
+ user_details: user_details,
156
+ consent: consent
157
+ )
158
+ ```
159
+
160
+ ### Biometric enrollment
161
+
162
+ ```ruby
163
+ accepted = smile.biometric.enroll(
164
+ selfie_image: "selfie.jpg",
165
+ liveness_images: ["live1.jpg", "live2.jpg", "live3.jpg",
166
+ "live4.jpg", "live5.jpg", "live6.jpg"],
167
+ user_details: user_details,
168
+ consent: consent,
169
+ user_id: "user-42" # optional partner-provided id
170
+ )
171
+ ```
172
+
173
+ ### Biometric authentication
174
+
175
+ `user_id` is required and must match an enrolled user. Images are required unless you set `use_enrolled_image: true`.
176
+
177
+ ```ruby
178
+ accepted = smile.biometric.authenticate(
179
+ user_id: "user-42",
180
+ selfie_image: "selfie.jpg",
181
+ liveness_images: ["live1.jpg", "live2.jpg", "live3.jpg",
182
+ "live4.jpg", "live5.jpg", "live6.jpg"],
183
+ user_details: user_details,
184
+ consent: consent
185
+ )
186
+ ```
187
+
188
+ ### Biometric compare
189
+
190
+ ```ruby
191
+ accepted = smile.biometric.compare(
192
+ selfie_image: "selfie.jpg",
193
+ comparison_image: "id_photo.jpg",
194
+ comparison_image_type: "ID_PHOTO", # DOCUMENT | ID_PHOTO | PORTRAIT
195
+ user_details: user_details,
196
+ consent: consent
197
+ )
198
+ ```
199
+
200
+ ### Check a verification's status
201
+
202
+ ```ruby
203
+ status = smile.verifications.retrieve("job_01h8x9y2z3a4b5c6d7e8f9g0h1")
204
+ status.status # "processing", "not_found", or the decision: "clear", "block", "attention", "error"
205
+ status.complete? # true when terminal, i.e. neither processing nor not_found
206
+ status.message # e.g. "Job completed"
207
+ ```
208
+
209
+ A job the API does not know yet returns a status of `not_found` rather than raising an error, so you can poll safely right after submission.
210
+
211
+ ### Wait for a verification to complete
212
+
213
+ ```ruby
214
+ status = smile.verifications.wait_until_complete(
215
+ "job_01h8x9y2z3a4b5c6d7e8f9g0h1",
216
+ interval: 2, # seconds between polls (default 2)
217
+ timeout: 60 # give up after this many seconds (default 60)
218
+ )
219
+ ```
220
+
221
+ Polling stops as soon as the job reaches a terminal decision (`clear`, `block`, `attention` or `error`). Raises `SmileID::Errors::TimeoutError` if the job does not complete in time. Pass `treat_not_found_as_pending: false` to return immediately when the job is unknown instead of polling on.
222
+
223
+ ### Replay a callback
224
+
225
+ ```ruby
226
+ smile.verifications.replay(
227
+ "job_01h8x9y2z3a4b5c6d7e8f9g0h1",
228
+ callback_url: "https://app.example.com/cb" # optional override
229
+ )
230
+ ```
231
+
232
+ Replaying a job that is still processing raises `SmileID::Errors::ConflictError`.
233
+
234
+ ### Report user fraud
235
+
236
+ ```ruby
237
+ smile.users.report_fraud(
238
+ "user-42",
239
+ is_fraud: true,
240
+ reason: "ACCOUNT_TAKEOVER",
241
+ reported_by: "risk@example.com"
242
+ )
243
+ ```
244
+
245
+ Or use the convenience wrappers:
246
+
247
+ ```ruby
248
+ smile.users.flag_fraud("user-42", reason: "ACCOUNT_TAKEOVER", reported_by: "risk@example.com")
249
+ smile.users.clear_fraud("user-42", notes: "Cleared after review", reported_by: "risk@example.com")
250
+ ```
251
+
252
+ When flagging, `reason` is required (and `notes` too if the reason is `OTHER`). When clearing, `notes` is required.
253
+
254
+ ### Services
255
+
256
+ Bank codes, supported ID types and supported documents need no authentication.
257
+
258
+ ```ruby
259
+ smile.services.bank_codes(country: "NG").bank_codes
260
+ # => [{ "code" => "044", "country" => "NG", "name" => "Access Bank" }, ...]
261
+
262
+ smile.services.supported_id_types(country: "NG").id_types
263
+ # => [{ "country" => "NG", "type" => "BVN", "label" => ..., "regex" => ..., ... }, ...]
264
+
265
+ smile.services.supported_documents(country_code: "NG").valid_documents
266
+ # => [{ "country" => { "code" => "NG", ... }, "id_types" => [...] }, ...]
267
+
268
+ smile.services.id_status(country: "NG", id_type: "BVN")
269
+ # => #<IdStatusResponse last_known_status="online" last_hour_success_rate="95%" ...>
270
+ ```
271
+
272
+ ## Error handling
273
+
274
+ Every API failure raises a typed error under `SmileID::Errors`, keyed on the HTTP status:
275
+
276
+ | Error | Raised on |
277
+ |---|---|
278
+ | `InvalidRequestError` | 400, 415, and failed client-side validation (`ValidationError` subclass) |
279
+ | `AuthenticationError` | 401 after one automatic token refresh |
280
+ | `PaymentRequiredError` | 402 — insufficient wallet balance |
281
+ | `PermissionError` | 403 |
282
+ | `NotFoundError` | 404 (except `verifications.retrieve`, which returns a `not_found` status) |
283
+ | `ConflictError` | 409 — for example replaying a job that is still processing |
284
+ | `PayloadTooLargeError` | 413 |
285
+ | `RateLimitError` | 429 |
286
+ | `APIError` | any 5xx |
287
+ | `ConnectionError` | network failure or timeout with no HTTP response |
288
+ | `TimeoutError` | `wait_until_complete` deadline passed (no HTTP response) |
289
+ | `UnexpectedResponseError` | a 2xx response whose body is not the expected JSON object, for example proxy interference |
290
+
291
+ Each error exposes `status_code`, `status`, `message`, `code`, `request_id` and `raw_body`.
292
+
293
+ ```ruby
294
+ begin
295
+ smile.enhanced_kyc.verify(...)
296
+ rescue SmileID::Errors::PaymentRequiredError => e
297
+ e.status_code # 402
298
+ e.message # "Insufficient wallet balance."
299
+ rescue SmileID::Errors::SmileIDError => e
300
+ # catch-all for anything the API raised
301
+ end
302
+ ```
303
+
304
+ ## Telemetry
305
+
306
+ The SDK sends three telemetry headers on every request: `SmileID-Source-SDK` (`ruby`), `SmileID-Source-SDK-Version` and a `User-Agent` identifying the SDK and Ruby version. These identify the SDK for observability. They are never used for authentication and carry no personal data.
307
+
308
+ ## Development
309
+
310
+ ```bash
311
+ bundle install
312
+ bundle exec rspec # unit tests, fully offline
313
+ bundle exec rubocop
314
+ ```
315
+
316
+ The end-to-end sandbox test runs only when `SMILE_PARTNER_ID` and `SMILE_API_KEY` are set in the environment; otherwise it skips. Set `SMILE_BASE_URL` to run it against a host other than the sandbox.
317
+
318
+ ## Contributing
319
+
320
+ See [SECURITY.md](SECURITY.md) for how to report a security issue.
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+
6
+ module SmileID
7
+ # Internal JWT lifecycle (spec section 2.3, 2A). Partners never see the token.
8
+ #
9
+ # The cache is thread-safe: concurrent calls do not stampede the token
10
+ # endpoint. The token is cached until its JWT `exp` claim minus a 60s skew; a
11
+ # token whose expiry cannot be decoded is treated as single-use (refreshed on
12
+ # the next call, and always on a 401).
13
+ class TokenManager
14
+ EXPIRY_SKEW = 60
15
+
16
+ def initialize(config, transport)
17
+ @config = config
18
+ @transport = transport
19
+ @mutex = Mutex.new
20
+ @cached = nil
21
+ end
22
+
23
+ # Return a valid token, fetching one if the cache is empty or expired.
24
+ def token
25
+ @mutex.synchronize do
26
+ return @cached[:jwt] if valid?
27
+
28
+ fetch
29
+ end
30
+ end
31
+
32
+ # Force a refresh (used after a 401). Fetches a new token unconditionally.
33
+ def force_refresh
34
+ @mutex.synchronize do
35
+ @cached = nil
36
+ fetch
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def valid?
43
+ @cached && Time.now < @cached[:expires_at]
44
+ end
45
+
46
+ def fetch
47
+ response = @transport.send_request(
48
+ method: :post,
49
+ url: "#{@config.base_url}/v3/token",
50
+ headers: Telemetry.headers.merge(
51
+ 'smileid-partner-id' => @config.partner_id,
52
+ 'smileid-api-key' => @config.api_key
53
+ ),
54
+ query: {},
55
+ body: nil,
56
+ retryable: true
57
+ )
58
+
59
+ unless response.status == 200
60
+ raise Errors.from_response(
61
+ status_code: response.status,
62
+ body: response.json,
63
+ raw_body: response.raw_body,
64
+ headers: response.headers
65
+ )
66
+ end
67
+
68
+ jwt = response.json && response.json['token']
69
+ raise Errors::AuthenticationError.new('token endpoint returned no token') if jwt.nil?
70
+
71
+ @cached = { jwt: jwt, expires_at: expires_at(jwt) }
72
+ jwt
73
+ end
74
+
75
+ def expires_at(jwt)
76
+ exp = decode_exp(jwt)
77
+ exp ? Time.at(exp) - EXPIRY_SKEW : Time.now
78
+ end
79
+
80
+ # Decode the `exp` claim from a JWT without verifying the signature. Returns
81
+ # nil when the token cannot be decoded.
82
+ def decode_exp(jwt)
83
+ payload_segment = jwt.to_s.split('.')[1]
84
+ return nil if payload_segment.nil?
85
+
86
+ payload = JSON.parse(Base64.urlsafe_decode64(pad(payload_segment)))
87
+ exp = payload['exp']
88
+ exp.is_a?(Numeric) ? exp : nil
89
+ rescue ArgumentError, JSON::ParserError
90
+ nil
91
+ end
92
+
93
+ def pad(segment)
94
+ remainder = segment.length % 4
95
+ remainder.zero? ? segment : segment + ('=' * (4 - remainder))
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'json'
5
+
6
+ module SmileID
7
+ # The Smile ID client (spec section 2.1, 4). Construct once, then call
8
+ # resource verbs, e.g. client.enhanced_kyc.verify(...).
9
+ class Client
10
+ attr_reader :config
11
+
12
+ def initialize(partner_id:, api_key:, environment: :sandbox,
13
+ default_callback_url: nil, base_url: nil, timeout: Config::DEFAULT_TIMEOUT,
14
+ max_retries: Config::DEFAULT_MAX_RETRIES, http_client: nil)
15
+ @config = Config.new(
16
+ partner_id: partner_id, api_key: api_key, environment: environment,
17
+ default_callback_url: default_callback_url,
18
+ base_url: base_url, timeout: timeout, max_retries: max_retries, http_client: http_client
19
+ )
20
+ @transport = Transport.new(@config)
21
+ @token_manager = TokenManager.new(@config, @transport)
22
+ end
23
+
24
+ def enhanced_kyc
25
+ @enhanced_kyc ||= Resources::EnhancedKyc.new(self)
26
+ end
27
+
28
+ def documents
29
+ @documents ||= Resources::Documents.new(self)
30
+ end
31
+
32
+ def biometric_kyc
33
+ @biometric_kyc ||= Resources::BiometricKyc.new(self)
34
+ end
35
+
36
+ def biometric
37
+ @biometric ||= Resources::Biometric.new(self)
38
+ end
39
+
40
+ def verifications
41
+ @verifications ||= Resources::Verifications.new(self)
42
+ end
43
+
44
+ def users
45
+ @users ||= Resources::Users.new(self)
46
+ end
47
+
48
+ def services
49
+ @services ||= Resources::Services.new(self)
50
+ end
51
+
52
+ # Execute an operation end to end: build the request, attach auth and
53
+ # telemetry, sign if configured, send it, refresh-on-401 once, and either
54
+ # return the response or raise a typed error (spec section 2A, 7).
55
+ #
56
+ # @return [SmileID::Response] on a success status for the operation.
57
+ def call(op_key, form: nil, path_params: {}, query: {}, user_id_header: nil, timeout: nil)
58
+ op = Generated::Operations.fetch(op_key)
59
+ url = @config.base_url + interpolate(op.path, path_params)
60
+ content_type, body = serialize(op, form)
61
+ refreshed = false
62
+
63
+ loop do
64
+ headers = build_headers(op, content_type, user_id_header)
65
+ response = @transport.send_request(
66
+ method: op.http_method, url: url, headers: headers, query: query || {},
67
+ body: body, retryable: op.idempotent, timeout: timeout
68
+ )
69
+
70
+ if response.status == 401 && op.authenticated && !refreshed
71
+ @token_manager.force_refresh
72
+ refreshed = true
73
+ next
74
+ end
75
+
76
+ return handle(op, response)
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ # Multipart bodies only; an op with an optional body (replay) sends no
83
+ # body at all when there are no fields.
84
+ def serialize(op, form)
85
+ return [nil, nil] unless op.body_kind == :multipart
86
+ return [nil, nil] if form.nil? || form.empty?
87
+
88
+ Helpers::Multipart.build(form)
89
+ end
90
+
91
+ def build_headers(op, content_type, user_id_header)
92
+ headers = Telemetry.headers
93
+ headers['Content-Type'] = content_type if content_type
94
+ headers['SmileID-Token'] = @token_manager.token if op.authenticated
95
+ headers['SmileID-Partner-ID'] = @config.partner_id if op.partner_id_header
96
+ headers['User-ID'] = user_id_header if user_id_header
97
+ headers
98
+ end
99
+
100
+ def handle(op, response)
101
+ if op.success_statuses.include?(response.status)
102
+ return response if response.json.is_a?(Hash)
103
+
104
+ raise Errors::UnexpectedResponseError.new(
105
+ 'expected a JSON object response body',
106
+ status_code: response.status,
107
+ request_id: Errors.request_id_from(response.headers),
108
+ raw_body: response.raw_body
109
+ )
110
+ end
111
+
112
+ raise Errors.from_response(
113
+ status_code: response.status,
114
+ body: response.json,
115
+ raw_body: response.raw_body,
116
+ headers: response.headers
117
+ )
118
+ end
119
+
120
+ def interpolate(path, path_params)
121
+ path.gsub(/\{(\w+)\}/) do
122
+ key = Regexp.last_match(1)
123
+ value = path_params[key] || path_params[key.to_sym]
124
+ raise ArgumentError, "missing path parameter: #{key}" if value.nil?
125
+
126
+ ERB::Util.url_encode(value.to_s)
127
+ end
128
+ end
129
+ end
130
+ end