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