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,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmileID
4
+ module Generated
5
+ # Wire response models (spec section 5.2). Field names mirror the wire
6
+ # verbatim. These live under generated/ because a generator would own them
7
+ # later; the hand-written client and helpers must survive regeneration.
8
+ module Models
9
+ # Response from the seven entry endpoints (HTTP 202). The `status` value
10
+ # differs by endpoint ("Accepted" or "accepted"); use #accepted? rather
11
+ # than branching on raw casing.
12
+ class AcceptedResponse
13
+ attr_reader :status, :message, :job_id, :user_id, :created_at, :raw
14
+
15
+ def initialize(status:, message: nil, job_id: nil, user_id: nil, created_at: nil, raw: nil)
16
+ @status = status
17
+ @message = message
18
+ @job_id = job_id
19
+ @user_id = user_id
20
+ @created_at = created_at
21
+ @raw = raw
22
+ end
23
+
24
+ # Normalized accessor — true when status is "accepted" in any casing.
25
+ def accepted?
26
+ status.to_s.downcase == 'accepted'
27
+ end
28
+
29
+ def self.from(hash)
30
+ new(
31
+ status: hash['status'],
32
+ message: hash['message'],
33
+ job_id: hash['job_id'],
34
+ user_id: hash['user_id'],
35
+ created_at: hash['created_at'],
36
+ raw: hash
37
+ )
38
+ end
39
+ end
40
+
41
+ # Response from GET /v3/status (spec section 5.2, 6.8). `status` is
42
+ # `processing` while the job runs, `not_found` for a job the API does not
43
+ # know, and otherwise the terminal decision itself: `clear`, `block`,
44
+ # `attention` or `error`. `message` carries no sub-state.
45
+ class JobStatus
46
+ attr_reader :status, :job_id, :user_id, :message, :raw
47
+
48
+ def initialize(status:, job_id: nil, user_id: nil, message: nil, raw: nil)
49
+ @status = status
50
+ @job_id = job_id
51
+ @user_id = user_id
52
+ @message = message
53
+ @raw = raw
54
+ end
55
+
56
+ # Terminal when the job is neither still running nor unknown — the
57
+ # status is then the decision (clear/block/attention/error).
58
+ def complete?
59
+ return false if status.to_s.empty?
60
+
61
+ !processing? && !not_found?
62
+ end
63
+
64
+ def processing?
65
+ status.to_s == 'processing'
66
+ end
67
+
68
+ def not_found?
69
+ status.to_s == 'not_found'
70
+ end
71
+
72
+ def self.from(hash)
73
+ new(
74
+ status: hash['status'],
75
+ job_id: hash['job_id'],
76
+ user_id: hash['user_id'],
77
+ message: hash['message'],
78
+ raw: hash
79
+ )
80
+ end
81
+ end
82
+
83
+ # Wraps the accepted-shaped responses from replay and report_fraud.
84
+ class AcceptedStatusResponse
85
+ attr_reader :status, :message, :job_id, :user_id, :raw
86
+
87
+ def initialize(status:, message: nil, job_id: nil, user_id: nil, raw: nil)
88
+ @status = status
89
+ @message = message
90
+ @job_id = job_id
91
+ @user_id = user_id
92
+ @raw = raw
93
+ end
94
+
95
+ def accepted?
96
+ status.to_s.downcase == 'accepted'
97
+ end
98
+
99
+ def self.from(hash)
100
+ new(
101
+ status: hash['status'],
102
+ message: hash['message'],
103
+ job_id: hash['job_id'],
104
+ user_id: hash['user_id'],
105
+ raw: hash
106
+ )
107
+ end
108
+ end
109
+
110
+ # Service responses (spec section 5.2). Nested collections are exposed as
111
+ # plain hashes with string keys, exactly as they arrive on the wire.
112
+ class BankCodesResponse
113
+ attr_reader :bank_codes, :raw
114
+
115
+ def initialize(bank_codes:, raw: nil)
116
+ @bank_codes = bank_codes
117
+ @raw = raw
118
+ end
119
+
120
+ def self.from(hash)
121
+ new(bank_codes: hash['bank_codes'] || [], raw: hash)
122
+ end
123
+ end
124
+
125
+ class SupportedIdTypesResponse
126
+ attr_reader :id_types, :raw
127
+
128
+ def initialize(id_types:, raw: nil)
129
+ @id_types = id_types
130
+ @raw = raw
131
+ end
132
+
133
+ def self.from(hash)
134
+ new(id_types: hash['id_types'] || [], raw: hash)
135
+ end
136
+ end
137
+
138
+ class SupportedDocumentsResponse
139
+ attr_reader :valid_documents, :raw
140
+
141
+ def initialize(valid_documents:, raw: nil)
142
+ @valid_documents = valid_documents
143
+ @raw = raw
144
+ end
145
+
146
+ def self.from(hash)
147
+ new(valid_documents: hash['valid_documents'] || [], raw: hash)
148
+ end
149
+ end
150
+
151
+ class IdStatusResponse
152
+ attr_reader :last_checked, :last_check_status, :last_hour_success_rate,
153
+ :last_known_status, :last_check_success_rate, :raw
154
+
155
+ def initialize(hash)
156
+ @last_checked = hash['last_checked']
157
+ @last_check_status = hash['last_check_status']
158
+ @last_hour_success_rate = hash['last_hour_success_rate']
159
+ @last_known_status = hash['last_known_status']
160
+ @last_check_success_rate = hash['last_check_success_rate']
161
+ @raw = hash
162
+ end
163
+
164
+ def self.from(hash)
165
+ new(hash)
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmileID
4
+ module Generated
5
+ # Per-operation descriptors (spec section 6). One entry per HTTP operation.
6
+ # A generator would own this table later; the hand-written transport reads it
7
+ # to route auth, headers, body kind, and retry behaviour.
8
+ module Operations
9
+ Operation = Struct.new(
10
+ :http_method, # :get / :post
11
+ :path, # path template, e.g. "/v3/status/{job_id}"
12
+ :authenticated, # attach SmileID-Token?
13
+ :partner_id_header, # attach SmileID-Partner-ID?
14
+ :body_kind, # :multipart / :json / nil
15
+ :idempotent, # safe to auto-retry? (GETs + token only)
16
+ :success_statuses, # statuses treated as success (not raised)
17
+ keyword_init: true
18
+ )
19
+
20
+ OPS = {
21
+ enhanced_kyc: Operation.new(
22
+ http_method: :post, path: '/v3/enhanced_kyc', authenticated: true,
23
+ partner_id_header: false, body_kind: :multipart, idempotent: false,
24
+ success_statuses: [202]
25
+ ),
26
+ document_verification: Operation.new(
27
+ http_method: :post, path: '/v3/document_verification', authenticated: true,
28
+ partner_id_header: true, body_kind: :multipart, idempotent: false,
29
+ success_statuses: [202]
30
+ ),
31
+ enhanced_document_verification: Operation.new(
32
+ http_method: :post, path: '/v3/enhanced_document_verification', authenticated: true,
33
+ partner_id_header: true, body_kind: :multipart, idempotent: false,
34
+ success_statuses: [202]
35
+ ),
36
+ biometric_kyc: Operation.new(
37
+ http_method: :post, path: '/v3/biometric_kyc', authenticated: true,
38
+ partner_id_header: true, body_kind: :multipart, idempotent: false,
39
+ success_statuses: [202]
40
+ ),
41
+ registration: Operation.new(
42
+ http_method: :post, path: '/v3/registration', authenticated: true,
43
+ partner_id_header: false, body_kind: :multipart, idempotent: false,
44
+ success_statuses: [202]
45
+ ),
46
+ authentication: Operation.new(
47
+ http_method: :post, path: '/v3/authentication', authenticated: true,
48
+ partner_id_header: false, body_kind: :multipart, idempotent: false,
49
+ success_statuses: [202]
50
+ ),
51
+ compare: Operation.new(
52
+ http_method: :post, path: '/v3/compare', authenticated: true,
53
+ partner_id_header: false, body_kind: :multipart, idempotent: false,
54
+ success_statuses: [202]
55
+ ),
56
+ status: Operation.new(
57
+ http_method: :get, path: '/v3/status/{job_id}', authenticated: true,
58
+ partner_id_header: false, body_kind: nil, idempotent: true,
59
+ success_statuses: [200, 202, 404]
60
+ ),
61
+ replay: Operation.new(
62
+ http_method: :post, path: '/v3/replay/{job_id}', authenticated: true,
63
+ partner_id_header: false, body_kind: :multipart, idempotent: false,
64
+ success_statuses: [202]
65
+ ),
66
+ report_fraud: Operation.new(
67
+ http_method: :post, path: '/v3/users/{user_id}/report_fraud', authenticated: true,
68
+ partner_id_header: false, body_kind: :multipart, idempotent: false,
69
+ success_statuses: [202]
70
+ ),
71
+ bank_codes: Operation.new(
72
+ http_method: :get, path: '/v3/services/bank_codes', authenticated: false,
73
+ partner_id_header: false, body_kind: nil, idempotent: true,
74
+ success_statuses: [200]
75
+ ),
76
+ supported_id_types: Operation.new(
77
+ http_method: :get, path: '/v3/services/supported_id_types', authenticated: false,
78
+ partner_id_header: false, body_kind: nil, idempotent: true,
79
+ success_statuses: [200]
80
+ ),
81
+ supported_documents: Operation.new(
82
+ http_method: :get, path: '/v3/services/supported_documents', authenticated: false,
83
+ partner_id_header: false, body_kind: nil, idempotent: true,
84
+ success_statuses: [200]
85
+ ),
86
+ id_status: Operation.new(
87
+ http_method: :get, path: '/v3/services/id_status', authenticated: true,
88
+ partner_id_header: false, body_kind: nil, idempotent: true,
89
+ success_statuses: [200]
90
+ )
91
+ }.freeze
92
+
93
+ def self.fetch(name)
94
+ OPS.fetch(name)
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module SmileID
6
+ # Builder for the shared `consent` object required on all seven entry
7
+ # endpoints (spec section 5.1). Serialized as a JSON multipart part.
8
+ #
9
+ # SmileID::Consent.granted(
10
+ # granted_at: Time.now.utc,
11
+ # notice_language: "EN",
12
+ # notice_privacy_policy_url: "https://example.com/privacy"
13
+ # )
14
+ class Consent
15
+ NOTICE_LANGUAGE = /\A[A-Z]{2}\z/
16
+
17
+ attr_reader :granted, :granted_at, :notice_language, :notice_privacy_policy_url
18
+
19
+ def initialize(granted:, granted_at:, notice_language:, notice_privacy_policy_url:)
20
+ @granted = granted
21
+ @granted_at = granted_at
22
+ @notice_language = notice_language
23
+ @notice_privacy_policy_url = notice_privacy_policy_url
24
+ end
25
+
26
+ # Build a consent object with granted set to true.
27
+ def self.granted(granted_at:, notice_language:, notice_privacy_policy_url:)
28
+ new(
29
+ granted: true,
30
+ granted_at: granted_at,
31
+ notice_language: notice_language,
32
+ notice_privacy_policy_url: notice_privacy_policy_url
33
+ )
34
+ end
35
+
36
+ def to_h
37
+ {
38
+ 'granted' => granted,
39
+ 'granted_at' => format_time(granted_at),
40
+ 'notice_language' => notice_language,
41
+ 'notice_privacy_policy_url' => notice_privacy_policy_url
42
+ }
43
+ end
44
+
45
+ # Coerce a Consent or a plain Hash into a validated wire hash.
46
+ def self.coerce(input)
47
+ consent = input.is_a?(Consent) ? input : from_hash(input)
48
+ consent.validate!
49
+ consent.to_h
50
+ end
51
+
52
+ def self.from_hash(hash)
53
+ h = stringify(hash)
54
+ new(
55
+ granted: h.key?('granted') ? h['granted'] : true,
56
+ granted_at: h['granted_at'],
57
+ notice_language: h['notice_language'],
58
+ notice_privacy_policy_url: h['notice_privacy_policy_url']
59
+ )
60
+ end
61
+
62
+ def validate!
63
+ raise Errors::ValidationError.new('consent.granted must be true') unless granted == true
64
+ raise Errors::ValidationError.new('consent.granted_at is required') if granted_at.nil?
65
+ unless notice_language.to_s.match?(NOTICE_LANGUAGE)
66
+ raise Errors::ValidationError.new('consent.notice_language must be a two-letter uppercase code')
67
+ end
68
+ return unless notice_privacy_policy_url.to_s.empty?
69
+
70
+ raise Errors::ValidationError.new('consent.notice_privacy_policy_url is required')
71
+ end
72
+
73
+ def self.stringify(hash)
74
+ raise Errors::ValidationError.new('consent is required') if hash.nil?
75
+
76
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
77
+ end
78
+
79
+ def format_time(value)
80
+ return value.utc.strftime('%Y-%m-%dT%H:%M:%S.%LZ') if value.is_a?(Time)
81
+
82
+ value
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require 'faraday'
6
+ require 'faraday/multipart'
7
+
8
+ module SmileID
9
+ module Helpers
10
+ # Hand-assembles multipart/form-data bodies to guarantee the exact wire shape
11
+ # required by spec section 5.3:
12
+ # - repeated `liveness_images` parts (one per image, never CSV/indexed),
13
+ # - object/array fields as JSON parts with Content-Type: application/json,
14
+ # - single binary parts with a filename and content type,
15
+ # - scalar fields as plain text parts.
16
+ #
17
+ # Faraday's multipart middleware indexes array parts as `liveness_images[0]`,
18
+ # so the body is built here rather than delegated to it (spec section 5.3
19
+ # item 4, the OpenAPI-Generator bug to avoid).
20
+ module Multipart
21
+ # Fields serialized as JSON parts.
22
+ JSON_PART_FIELDS = %w[consent user_details partner_params metadata].freeze
23
+ # Single binary fields.
24
+ BINARY_FIELDS = %w[selfie_image document document_back comparison_image].freeze
25
+ # Repeated binary fields.
26
+ BINARY_ARRAY_FIELDS = %w[liveness_images].freeze
27
+
28
+ DEFAULT_CONTENT_TYPES = {
29
+ 'selfie_image' => 'image/jpeg',
30
+ 'document' => 'image/jpeg',
31
+ 'document_back' => 'image/jpeg',
32
+ 'comparison_image' => 'image/jpeg',
33
+ 'liveness_images' => 'image/jpeg'
34
+ }.freeze
35
+
36
+ # Only document and document_back may be PNG (spec section 5.3 rule 3);
37
+ # selfie, liveness and comparison images are always image/jpeg.
38
+ PNG_CAPABLE_FIELDS = %w[document document_back].freeze
39
+ PNG_MAGIC = "\x89PNG".b.freeze
40
+
41
+ # RFC 6838 type/subtype tokens — anything else is rejected before send.
42
+ MEDIA_TYPE = %r{\A[A-Za-z0-9!\#$&^_.+-]+/[A-Za-z0-9!\#$&^_.+-]+\z}
43
+
44
+ DEFAULT_FILENAMES = {
45
+ 'selfie_image' => 'selfie.jpg',
46
+ 'document' => 'document.jpg',
47
+ 'document_back' => 'document_back.jpg',
48
+ 'comparison_image' => 'comparison.jpg'
49
+ }.freeze
50
+
51
+ CRLF = "\r\n"
52
+
53
+ module_function
54
+
55
+ # Build a multipart body from an ordered field hash.
56
+ #
57
+ # @return [Array(String, String)] content-type header value and body bytes.
58
+ def build(form, boundary: default_boundary)
59
+ body = +''.b
60
+ form.each do |name, value|
61
+ next if value.nil?
62
+
63
+ field = name.to_s
64
+ append_field(body, field, value, boundary)
65
+ end
66
+ body << "--#{boundary}--#{CRLF}".b
67
+ ["multipart/form-data; boundary=#{boundary}", body]
68
+ end
69
+
70
+ def default_boundary
71
+ "----smileid#{SecureRandom.hex(16)}"
72
+ end
73
+
74
+ def append_field(body, field, value, boundary)
75
+ if BINARY_ARRAY_FIELDS.include?(field)
76
+ Array(value).each_with_index do |img, index|
77
+ body << binary_part(field, img, boundary, index: index)
78
+ end
79
+ elsif BINARY_FIELDS.include?(field)
80
+ body << binary_part(field, value, boundary)
81
+ elsif JSON_PART_FIELDS.include?(field)
82
+ body << json_part(field, value, boundary)
83
+ else
84
+ body << text_part(field, scalar_string(value), boundary)
85
+ end
86
+ end
87
+
88
+ def text_part(name, value, boundary)
89
+ part = "--#{boundary}#{CRLF}"
90
+ part << "Content-Disposition: form-data; name=\"#{name}\"#{CRLF}#{CRLF}"
91
+ part << value.to_s
92
+ part << CRLF
93
+ part.b
94
+ end
95
+
96
+ def json_part(name, value, boundary)
97
+ part = "--#{boundary}#{CRLF}"
98
+ part << "Content-Disposition: form-data; name=\"#{name}\"#{CRLF}"
99
+ part << "Content-Type: application/json#{CRLF}#{CRLF}"
100
+ part << JSON.generate(value)
101
+ part << CRLF
102
+ part.b
103
+ end
104
+
105
+ def binary_part(name, input, boundary, index: nil)
106
+ upload = coerce_binary(name, input, index: index)
107
+ # Sanitization runs here so it covers EVERY input path — FilePart,
108
+ # hash, path, raw bytes, IO — including explicit content types.
109
+ filename = sanitize_filename(upload[:filename])
110
+ content_type = validate_content_type!(upload[:content_type])
111
+ header = "--#{boundary}#{CRLF}"
112
+ header << 'Content-Disposition: form-data; ' \
113
+ "name=\"#{name}\"; filename=\"#{filename}\"#{CRLF}"
114
+ header << "Content-Type: #{content_type}#{CRLF}#{CRLF}"
115
+ (header.b + upload[:bytes].b + CRLF.b)
116
+ end
117
+
118
+ # Strip header-injection vectors from filenames: CR, LF and other
119
+ # control characters are removed; double quotes are percent-encoded so
120
+ # they cannot terminate the quoted filename attribute.
121
+ def sanitize_filename(filename)
122
+ filename.to_s.gsub(/[\x00-\x1f\x7f]/, '').gsub('"', '%22')
123
+ end
124
+
125
+ # Content types must be a plain media type token pair; anything else
126
+ # (including CR/LF injection) fails validation before send.
127
+ def validate_content_type!(content_type)
128
+ value = content_type.to_s
129
+ return value if value.match?(MEDIA_TYPE)
130
+
131
+ raise Errors::ValidationError.new('content_type must be a valid media type')
132
+ end
133
+
134
+ # Coerce a binary input (path, bytes, IO, Hash, or Faraday FilePart) into
135
+ # { bytes:, filename:, content_type: }. An explicitly provided content
136
+ # type wins; otherwise the field's default applies, with PNG detection
137
+ # for the document fields only.
138
+ def coerce_binary(field, input, index: nil)
139
+ default_fn = filename_for(field, index)
140
+
141
+ upload =
142
+ case input
143
+ when Faraday::Multipart::FilePart
144
+ from_file_part(input, default_fn)
145
+ when Hash
146
+ from_hash(input, default_fn)
147
+ when String
148
+ from_string(input, default_fn)
149
+ else
150
+ unless input.respond_to?(:read)
151
+ raise Errors::ValidationError.new("#{field} must be a file path, bytes, IO, or FilePart")
152
+ end
153
+
154
+ from_io(input, default_fn)
155
+ end
156
+
157
+ resolve_content_type(field, upload)
158
+ end
159
+
160
+ def resolve_content_type(field, upload)
161
+ return upload if upload[:content_type]
162
+
163
+ default = DEFAULT_CONTENT_TYPES[field] || 'application/octet-stream'
164
+ if PNG_CAPABLE_FIELDS.include?(field) && png?(upload)
165
+ upload[:content_type] = 'image/png'
166
+ if upload[:filename] == DEFAULT_FILENAMES[field]
167
+ upload[:filename] = upload[:filename].sub(/\.jpg\z/, '.png')
168
+ end
169
+ else
170
+ upload[:content_type] = default
171
+ end
172
+ upload
173
+ end
174
+
175
+ def png?(upload)
176
+ upload[:filename].to_s.downcase.end_with?('.png') ||
177
+ upload[:bytes].to_s.b.start_with?(PNG_MAGIC)
178
+ end
179
+
180
+ def filename_for(field, index)
181
+ return DEFAULT_FILENAMES[field] if DEFAULT_FILENAMES.key?(field)
182
+
183
+ "#{field}_#{(index || 0) + 1}.jpg"
184
+ end
185
+
186
+ def from_file_part(part, default_fn)
187
+ io = part.instance_variable_get(:@io)
188
+ local_path = part.instance_variable_get(:@local_path)
189
+ bytes = io ? read_all(io) : File.binread(local_path)
190
+ {
191
+ bytes: bytes,
192
+ filename: part.original_filename || default_fn,
193
+ content_type: part.content_type
194
+ }
195
+ end
196
+
197
+ def from_hash(hash, default_fn)
198
+ h = hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
199
+ bytes =
200
+ if h[:bytes]
201
+ h[:bytes]
202
+ elsif h[:path]
203
+ File.binread(h[:path])
204
+ elsif h[:io]
205
+ read_all(h[:io])
206
+ else
207
+ raise Errors::ValidationError.new('binary hash needs one of :bytes, :path, or :io')
208
+ end
209
+ {
210
+ bytes: bytes,
211
+ filename: h[:filename] || (h[:path] ? File.basename(h[:path]) : default_fn),
212
+ content_type: h[:content_type]
213
+ }
214
+ end
215
+
216
+ def from_string(str, default_fn)
217
+ if plausible_path?(str) && File.exist?(str)
218
+ { bytes: File.binread(str), filename: File.basename(str), content_type: nil }
219
+ else
220
+ { bytes: str, filename: default_fn, content_type: nil }
221
+ end
222
+ end
223
+
224
+ # Raw image bytes routinely contain null bytes or invalid UTF-8, which
225
+ # File.exist? rejects with ArgumentError. Only probe the filesystem for
226
+ # strings that could plausibly be paths.
227
+ def plausible_path?(str)
228
+ str.length < 4096 && str.valid_encoding? && !str.include?("\0")
229
+ end
230
+
231
+ def from_io(io, default_fn)
232
+ filename = io.respond_to?(:path) && io.path ? File.basename(io.path) : default_fn
233
+ { bytes: read_all(io), filename: filename, content_type: nil }
234
+ end
235
+
236
+ def read_all(io)
237
+ io.rewind if io.respond_to?(:rewind)
238
+ io.read
239
+ end
240
+
241
+ def scalar_string(value)
242
+ case value
243
+ when true then 'true'
244
+ when false then 'false'
245
+ else value.to_s
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmileID
4
+ # Builder and validator for the shared `user_details` object required on all
5
+ # seven entry endpoints (spec section 5.1). Serialized as a JSON multipart part.
6
+ #
7
+ # At least one of email / phone_number MUST be present — enforced client-side
8
+ # before the request is sent.
9
+ class UserDetails
10
+ PHONE_NUMBER = /\A\+[1-9]\d{6,14}\z/
11
+
12
+ attr_reader :given_names, :last_name, :email, :phone_number
13
+
14
+ def initialize(given_names:, last_name:, email: nil, phone_number: nil)
15
+ @given_names = given_names
16
+ @last_name = last_name
17
+ @email = email
18
+ @phone_number = phone_number
19
+ end
20
+
21
+ def to_h
22
+ {
23
+ 'given_names' => given_names,
24
+ 'last_name' => last_name,
25
+ 'email' => email,
26
+ 'phone_number' => phone_number
27
+ }.compact
28
+ end
29
+
30
+ # Coerce a UserDetails or plain Hash into a validated wire hash.
31
+ def self.coerce(input)
32
+ details = input.is_a?(UserDetails) ? input : from_hash(input)
33
+ details.validate!
34
+ details.to_h
35
+ end
36
+
37
+ def self.from_hash(hash)
38
+ raise Errors::ValidationError.new('user_details is required') if hash.nil?
39
+
40
+ h = hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
41
+ new(
42
+ given_names: h['given_names'],
43
+ last_name: h['last_name'],
44
+ email: h['email'],
45
+ phone_number: h['phone_number']
46
+ )
47
+ end
48
+
49
+ def validate!
50
+ raise Errors::ValidationError.new('user_details.given_names is required') if given_names.to_s.empty?
51
+ raise Errors::ValidationError.new('user_details.last_name is required') if last_name.to_s.empty?
52
+
53
+ if email.to_s.empty? && phone_number.to_s.empty?
54
+ raise Errors::ValidationError.new(
55
+ 'user_details requires at least one of email or phone_number'
56
+ )
57
+ end
58
+ return unless !phone_number.to_s.empty? && !phone_number.to_s.match?(PHONE_NUMBER)
59
+
60
+ raise Errors::ValidationError.new('user_details.phone_number must be E.164 format')
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmileID
4
+ VERSION = '12.0.0'
5
+ end
data/lib/usesmileid.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'usesmileid/version'
4
+ require 'usesmileid/errors'
5
+ require 'usesmileid/generated/models'
6
+ require 'usesmileid/generated/operations'
7
+ require 'usesmileid/helpers/consent'
8
+ require 'usesmileid/helpers/user_details'
9
+ require 'usesmileid/helpers/multipart'
10
+ require 'usesmileid/client/config'
11
+ require 'usesmileid/client/transport'
12
+ require 'usesmileid/client/auth'
13
+ require 'usesmileid/client/resources'
14
+ require 'usesmileid/client/client'
15
+
16
+ # Smile ID's official server-side SDK for Ruby, covering the V3 APIs.
17
+ #
18
+ # Construct a client and call resource verbs:
19
+ #
20
+ # require "usesmileid"
21
+ # smile = SmileID::Client.new(partner_id: "1234", api_key: ENV.fetch("SMILE_API_KEY"))
22
+ # accepted = smile.enhanced_kyc.verify(...)
23
+ module SmileID
24
+ end