didit-sdk 1.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.
Files changed (234) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +25 -0
  3. data/LICENSE +21 -0
  4. data/README.md +277 -0
  5. data/lib/didit/authentication/base64_url.rb +34 -0
  6. data/lib/didit/authentication/cache.rb +201 -0
  7. data/lib/didit/authentication/http.rb +150 -0
  8. data/lib/didit/authentication/issuer.rb +115 -0
  9. data/lib/didit/authentication/jws.rb +231 -0
  10. data/lib/didit/authentication/key_set.rb +89 -0
  11. data/lib/didit/authentication/pkce.rb +64 -0
  12. data/lib/didit/authentication/types.rb +175 -0
  13. data/lib/didit/authentication.rb +546 -0
  14. data/lib/didit/client.rb +211 -0
  15. data/lib/didit/errors.rb +153 -0
  16. data/lib/didit/file_part.rb +85 -0
  17. data/lib/didit/internal/config.rb +94 -0
  18. data/lib/didit/internal/errors.rb +83 -0
  19. data/lib/didit/internal/log.rb +41 -0
  20. data/lib/didit/internal/multipart.rb +60 -0
  21. data/lib/didit/internal/request.rb +322 -0
  22. data/lib/didit/internal/transport.rb +198 -0
  23. data/lib/didit/internal/util.rb +76 -0
  24. data/lib/didit/internal.rb +15 -0
  25. data/lib/didit/model.rb +199 -0
  26. data/lib/didit/page.rb +130 -0
  27. data/lib/didit/raw_response.rb +57 -0
  28. data/lib/didit/resource.rb +15 -0
  29. data/lib/didit/resources/account.rb +309 -0
  30. data/lib/didit/resources/address_validation.rb +73 -0
  31. data/lib/didit/resources/age_estimation.rb +110 -0
  32. data/lib/didit/resources/aml.rb +166 -0
  33. data/lib/didit/resources/aml_monitoring.rb +30 -0
  34. data/lib/didit/resources/analytics.rb +87 -0
  35. data/lib/didit/resources/bank.rb +65 -0
  36. data/lib/didit/resources/billing.rb +61 -0
  37. data/lib/didit/resources/biometric_templates.rb +234 -0
  38. data/lib/didit/resources/blocklist.rb +130 -0
  39. data/lib/didit/resources/business_sessions.rb +29 -0
  40. data/lib/didit/resources/businesses.rb +471 -0
  41. data/lib/didit/resources/cases.rb +2116 -0
  42. data/lib/didit/resources/customization.rb +153 -0
  43. data/lib/didit/resources/database_validation.rb +327 -0
  44. data/lib/didit/resources/document_ai.rb +145 -0
  45. data/lib/didit/resources/email.rb +254 -0
  46. data/lib/didit/resources/face_match.rb +103 -0
  47. data/lib/didit/resources/face_search.rb +103 -0
  48. data/lib/didit/resources/fraud_checks.rb +130 -0
  49. data/lib/didit/resources/id_verification.rb +163 -0
  50. data/lib/didit/resources/id_wallets.rb +1468 -0
  51. data/lib/didit/resources/investigator.rb +419 -0
  52. data/lib/didit/resources/ip.rb +68 -0
  53. data/lib/didit/resources/kyb.rb +256 -0
  54. data/lib/didit/resources/lists.rb +332 -0
  55. data/lib/didit/resources/monitoring_events.rb +466 -0
  56. data/lib/didit/resources/networks.rb +427 -0
  57. data/lib/didit/resources/passive_liveness.rb +103 -0
  58. data/lib/didit/resources/phone.rb +271 -0
  59. data/lib/didit/resources/pricing.rb +40 -0
  60. data/lib/didit/resources/proof_of_address.rb +167 -0
  61. data/lib/didit/resources/questionnaires.rb +170 -0
  62. data/lib/didit/resources/regulatory_reports.rb +314 -0
  63. data/lib/didit/resources/report_templates.rb +146 -0
  64. data/lib/didit/resources/sessions.rb +2820 -0
  65. data/lib/didit/resources/transactions.rb +981 -0
  66. data/lib/didit/resources/travel_rule.rb +1202 -0
  67. data/lib/didit/resources/users.rb +521 -0
  68. data/lib/didit/resources/wallet_screening.rb +190 -0
  69. data/lib/didit/resources/webhooks.rb +396 -0
  70. data/lib/didit/resources/workflows.rb +214 -0
  71. data/lib/didit/resources.rb +263 -0
  72. data/lib/didit/types/account.rb +11 -0
  73. data/lib/didit/types/account_components.rb +105 -0
  74. data/lib/didit/types/address_validation.rb +8 -0
  75. data/lib/didit/types/age_estimation.rb +89 -0
  76. data/lib/didit/types/aml.rb +193 -0
  77. data/lib/didit/types/aml_monitoring.rb +73 -0
  78. data/lib/didit/types/analytics.rb +62 -0
  79. data/lib/didit/types/bank.rb +8 -0
  80. data/lib/didit/types/billing.rb +18 -0
  81. data/lib/didit/types/biometric_templates.rb +292 -0
  82. data/lib/didit/types/blocklist.rb +31 -0
  83. data/lib/didit/types/businesses.rb +45 -0
  84. data/lib/didit/types/cases.rb +368 -0
  85. data/lib/didit/types/components.rb +12206 -0
  86. data/lib/didit/types/customization.rb +8 -0
  87. data/lib/didit/types/database_validation.rb +177 -0
  88. data/lib/didit/types/document_ai.rb +135 -0
  89. data/lib/didit/types/email.rb +116 -0
  90. data/lib/didit/types/face_match.rb +93 -0
  91. data/lib/didit/types/face_search.rb +129 -0
  92. data/lib/didit/types/fraud_checks.rb +8 -0
  93. data/lib/didit/types/id_verification.rb +231 -0
  94. data/lib/didit/types/id_wallets.rb +8 -0
  95. data/lib/didit/types/investigator.rb +221 -0
  96. data/lib/didit/types/ip.rb +8 -0
  97. data/lib/didit/types/kyb.rb +1391 -0
  98. data/lib/didit/types/lists.rb +111 -0
  99. data/lib/didit/types/monitoring_events.rb +235 -0
  100. data/lib/didit/types/networks.rb +120 -0
  101. data/lib/didit/types/passive_liveness.rb +95 -0
  102. data/lib/didit/types/phone.rb +69 -0
  103. data/lib/didit/types/proof_of_address.rb +166 -0
  104. data/lib/didit/types/questionnaires.rb +29 -0
  105. data/lib/didit/types/regulatory_reports.rb +100 -0
  106. data/lib/didit/types/report_templates.rb +41 -0
  107. data/lib/didit/types/sessions.rb +1441 -0
  108. data/lib/didit/types/transactions.rb +245 -0
  109. data/lib/didit/types/travel_rule.rb +168 -0
  110. data/lib/didit/types/users.rb +180 -0
  111. data/lib/didit/types/wallet_screening.rb +49 -0
  112. data/lib/didit/types/webhooks.rb +355 -0
  113. data/lib/didit/types/workflows.rb +32 -0
  114. data/lib/didit/types.rb +1681 -0
  115. data/lib/didit/version.rb +5 -0
  116. data/lib/didit/webhooks/canonical_json.rb +187 -0
  117. data/lib/didit/webhooks/client_methods.rb +23 -0
  118. data/lib/didit/webhooks/events.rb +221 -0
  119. data/lib/didit/webhooks/headers.rb +83 -0
  120. data/lib/didit/webhooks/verifier.rb +260 -0
  121. data/lib/didit/webhooks.rb +99 -0
  122. data/lib/didit-sdk.rb +4 -0
  123. data/lib/didit.rb +31 -0
  124. data/sig/didit/authentication/base64_url.rbs +10 -0
  125. data/sig/didit/authentication/cache.rbs +60 -0
  126. data/sig/didit/authentication/http.rbs +37 -0
  127. data/sig/didit/authentication/issuer.rbs +17 -0
  128. data/sig/didit/authentication/jws.rbs +41 -0
  129. data/sig/didit/authentication/jwt.rbs +14 -0
  130. data/sig/didit/authentication/key_set.rbs +25 -0
  131. data/sig/didit/authentication/pkce.rbs +20 -0
  132. data/sig/didit/authentication/types.rbs +114 -0
  133. data/sig/didit/authentication.rbs +81 -0
  134. data/sig/didit/client.rbs +61 -0
  135. data/sig/didit/errors.rbs +80 -0
  136. data/sig/didit/file_part.rbs +19 -0
  137. data/sig/didit/internal.rbs +199 -0
  138. data/sig/didit/model.rbs +42 -0
  139. data/sig/didit/page.rbs +34 -0
  140. data/sig/didit/raw_response.rbs +20 -0
  141. data/sig/didit/resource.rbs +8 -0
  142. data/sig/didit/resources/account.rbs +23 -0
  143. data/sig/didit/resources/address_validation.rbs +9 -0
  144. data/sig/didit/resources/age_estimation.rbs +9 -0
  145. data/sig/didit/resources/aml.rbs +10 -0
  146. data/sig/didit/resources/aml_monitoring.rbs +9 -0
  147. data/sig/didit/resources/analytics.rbs +9 -0
  148. data/sig/didit/resources/bank.rbs +9 -0
  149. data/sig/didit/resources/billing.rbs +10 -0
  150. data/sig/didit/resources/biometric_templates.rbs +13 -0
  151. data/sig/didit/resources/blocklist.rbs +11 -0
  152. data/sig/didit/resources/business_sessions.rbs +9 -0
  153. data/sig/didit/resources/businesses.rbs +22 -0
  154. data/sig/didit/resources/cases.rbs +103 -0
  155. data/sig/didit/resources/customization.rbs +10 -0
  156. data/sig/didit/resources/database_validation.rbs +9 -0
  157. data/sig/didit/resources/document_ai.rbs +9 -0
  158. data/sig/didit/resources/email.rbs +11 -0
  159. data/sig/didit/resources/face_match.rbs +9 -0
  160. data/sig/didit/resources/face_search.rbs +9 -0
  161. data/sig/didit/resources/fraud_checks.rbs +11 -0
  162. data/sig/didit/resources/id_verification.rbs +9 -0
  163. data/sig/didit/resources/id_wallets.rbs +37 -0
  164. data/sig/didit/resources/investigator.rbs +23 -0
  165. data/sig/didit/resources/ip.rbs +9 -0
  166. data/sig/didit/resources/kyb.rbs +12 -0
  167. data/sig/didit/resources/lists.rbs +20 -0
  168. data/sig/didit/resources/monitoring_events.rbs +16 -0
  169. data/sig/didit/resources/networks.rbs +25 -0
  170. data/sig/didit/resources/passive_liveness.rbs +9 -0
  171. data/sig/didit/resources/phone.rbs +11 -0
  172. data/sig/didit/resources/pricing.rbs +9 -0
  173. data/sig/didit/resources/proof_of_address.rbs +9 -0
  174. data/sig/didit/resources/questionnaires.rbs +13 -0
  175. data/sig/didit/resources/regulatory_reports.rbs +16 -0
  176. data/sig/didit/resources/report_templates.rbs +13 -0
  177. data/sig/didit/resources/sessions.rbs +78 -0
  178. data/sig/didit/resources/transactions.rbs +43 -0
  179. data/sig/didit/resources/travel_rule.rbs +62 -0
  180. data/sig/didit/resources/users.rbs +21 -0
  181. data/sig/didit/resources/wallet_screening.rbs +11 -0
  182. data/sig/didit/resources/webhooks.rbs +23 -0
  183. data/sig/didit/resources/workflows.rbs +13 -0
  184. data/sig/didit/resources.rbs +50 -0
  185. data/sig/didit/types/account.rbs +22 -0
  186. data/sig/didit/types/account_components.rbs +94 -0
  187. data/sig/didit/types/address_validation.rbs +7 -0
  188. data/sig/didit/types/age_estimation.rbs +80 -0
  189. data/sig/didit/types/aml.rbs +174 -0
  190. data/sig/didit/types/aml_monitoring.rbs +67 -0
  191. data/sig/didit/types/analytics.rbs +57 -0
  192. data/sig/didit/types/bank.rbs +9 -0
  193. data/sig/didit/types/billing.rbs +19 -0
  194. data/sig/didit/types/biometric_templates.rbs +268 -0
  195. data/sig/didit/types/blocklist.rbs +33 -0
  196. data/sig/didit/types/businesses.rbs +54 -0
  197. data/sig/didit/types/cases.rbs +414 -0
  198. data/sig/didit/types/components.rbs +10920 -0
  199. data/sig/didit/types/customization.rbs +7 -0
  200. data/sig/didit/types/database_validation.rbs +152 -0
  201. data/sig/didit/types/document_ai.rbs +123 -0
  202. data/sig/didit/types/email.rbs +112 -0
  203. data/sig/didit/types/face_match.rbs +86 -0
  204. data/sig/didit/types/face_search.rbs +112 -0
  205. data/sig/didit/types/fraud_checks.rbs +9 -0
  206. data/sig/didit/types/id_verification.rbs +196 -0
  207. data/sig/didit/types/id_wallets.rbs +55 -0
  208. data/sig/didit/types/investigator.rbs +212 -0
  209. data/sig/didit/types/ip.rbs +7 -0
  210. data/sig/didit/types/kyb.rbs +1254 -0
  211. data/sig/didit/types/lists.rbs +106 -0
  212. data/sig/didit/types/monitoring_events.rbs +234 -0
  213. data/sig/didit/types/networks.rbs +113 -0
  214. data/sig/didit/types/passive_liveness.rbs +87 -0
  215. data/sig/didit/types/phone.rbs +65 -0
  216. data/sig/didit/types/proof_of_address.rbs +144 -0
  217. data/sig/didit/types/questionnaires.rbs +30 -0
  218. data/sig/didit/types/regulatory_reports.rbs +101 -0
  219. data/sig/didit/types/report_templates.rbs +42 -0
  220. data/sig/didit/types/sessions.rbs +1349 -0
  221. data/sig/didit/types/transactions.rbs +273 -0
  222. data/sig/didit/types/travel_rule.rbs +188 -0
  223. data/sig/didit/types/users.rbs +181 -0
  224. data/sig/didit/types/wallet_screening.rbs +40 -0
  225. data/sig/didit/types/webhooks.rbs +311 -0
  226. data/sig/didit/types/workflows.rbs +30 -0
  227. data/sig/didit/webhooks/canonical_json.rbs +25 -0
  228. data/sig/didit/webhooks/client_methods.rbs +11 -0
  229. data/sig/didit/webhooks/events.rbs +125 -0
  230. data/sig/didit/webhooks/headers.rbs +22 -0
  231. data/sig/didit/webhooks/verifier.rbs +54 -0
  232. data/sig/didit/webhooks.rbs +37 -0
  233. data/sig/didit.rbs +20 -0
  234. metadata +300 -0
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "set"
5
+
6
+ module Didit
7
+ # The Didit API client (docs/DESIGN.md section 6). Thread safe: create one per process
8
+ # and share it.
9
+ #
10
+ # didit = Didit::Client.new # reads DIDIT_API_KEY
11
+ # didit = Didit::Client.new(api_key: "...", organization_id: "...", application_id: "...")
12
+ # session = didit.sessions.create(workflow_id: "...", vendor_data: "user-42")
13
+ class Client
14
+ include Resources::ClientMethods
15
+
16
+ DEFAULT_BASE_URL = Internal::Config::DEFAULT_BASE_URL
17
+ DEFAULT_ACCOUNT_BASE_URL = Internal::Config::DEFAULT_ACCOUNT_BASE_URL
18
+ DEFAULT_TIMEOUT = Internal::Config::DEFAULT_TIMEOUT
19
+ DEFAULT_MAX_RETRIES = Internal::Config::DEFAULT_MAX_RETRIES
20
+
21
+ @deprecation_lock = Mutex.new
22
+ @deprecation_warned = Set.new
23
+
24
+ class << self
25
+ # @api private
26
+ attr_reader :deprecation_lock, :deprecation_warned
27
+ end
28
+
29
+ # @param api_key [String, nil] defaults to the DIDIT_API_KEY environment variable
30
+ # @param organization_id [String, nil] defaults to DIDIT_ORGANIZATION_ID; needed by application-scoped methods
31
+ # @param application_id [String, nil] defaults to DIDIT_APPLICATION_ID; needed by application-scoped methods
32
+ # @param base_url [String] the verification API
33
+ # @param account_base_url [String] the account API (+didit.account+)
34
+ # @param account_token [String, nil] the Bearer token of +didit.account+ methods, from +didit.account.login+
35
+ # @param timeout [Numeric] seconds per attempt (connect, write and read each)
36
+ # @param max_retries [Integer] retries after the first attempt
37
+ # @param default_headers [Hash{String => String}] sent with every request
38
+ # @param logger [#debug, nil] receives debug lines; DIDIT_LOG=debug prints them to stderr instead
39
+ # @param http_client [#call, nil] returns a configured, not yet started +Net::HTTP+ for a URI
40
+ # (proxies, CA bundle, ...); the SDK sets the timeouts and owns the connection
41
+ def initialize(api_key: nil, organization_id: nil, application_id: nil, base_url: nil,
42
+ account_base_url: nil, account_token: nil, timeout: DEFAULT_TIMEOUT,
43
+ max_retries: DEFAULT_MAX_RETRIES, default_headers: {}, logger: nil, http_client: nil)
44
+ config = Internal::Config.new(
45
+ api_key: api_key || ENV.fetch("DIDIT_API_KEY", nil),
46
+ organization_id: organization_id || ENV.fetch("DIDIT_ORGANIZATION_ID", nil),
47
+ application_id: application_id || ENV.fetch("DIDIT_APPLICATION_ID", nil),
48
+ base_url: base_url, account_base_url: account_base_url, account_token: account_token, timeout: timeout,
49
+ max_retries: max_retries, default_headers: default_headers, logger: logger, http_client: http_client
50
+ )
51
+ setup(config, Internal::Transport.new(http_client: config.http_client), raw: false)
52
+ end
53
+
54
+ # @return [String]
55
+ def base_url = @config.base_url
56
+ # @return [String]
57
+ def account_base_url = @config.account_base_url
58
+ # @return [String, nil]
59
+ def organization_id = @config.organization_id
60
+ # @return [String, nil]
61
+ def application_id = @config.application_id
62
+ # @return [Float]
63
+ def timeout = @config.timeout
64
+ # @return [Integer]
65
+ def max_retries = @config.max_retries
66
+
67
+ # A client with some options changed. It shares this client's connections.
68
+ # Passing nil clears an id or a token.
69
+ #
70
+ # didit.with_options(timeout: 5, max_retries: 0).sessions.retrieve(id)
71
+ # account = didit.with_options(account_token: login.access_token)
72
+ #
73
+ # @return [Didit::Client]
74
+ def with_options(api_key: OMIT, account_token: OMIT, organization_id: OMIT, application_id: OMIT, base_url: OMIT,
75
+ account_base_url: OMIT, timeout: OMIT, max_retries: OMIT, default_headers: OMIT, logger: OMIT)
76
+ changes = {
77
+ api_key: api_key, account_token: account_token, organization_id: organization_id,
78
+ application_id: application_id, base_url: base_url, account_base_url: account_base_url, timeout: timeout,
79
+ max_retries: max_retries, default_headers: default_headers, logger: logger
80
+ }.reject { |_, value| value.equal?(OMIT) }
81
+ copy(@config.merge(changes), raw: @raw)
82
+ end
83
+
84
+ # A client whose methods return the {Didit::RawResponse} (status, headers, request id)
85
+ # instead of the parsed result; +#parse+ gives the result.
86
+ #
87
+ # @return [Didit::Client]
88
+ def with_raw_response
89
+ copy(@config, raw: true)
90
+ end
91
+
92
+ # Whether methods return {Didit::RawResponse}.
93
+ def raw_response?
94
+ @raw
95
+ end
96
+
97
+ # Closes the idle connections (they also close by themselves).
98
+ def close
99
+ @transport.close
100
+ end
101
+
102
+ def inspect
103
+ "#<Didit::Client base_url=#{base_url.inspect} organization_id=#{organization_id.inspect} " \
104
+ "application_id=#{application_id.inspect} api_key=#{@config.api_key ? "[set]" : "nil"}>"
105
+ end
106
+ alias to_s inspect
107
+
108
+ # Sends a request to any API path; the generated resource methods call this.
109
+ #
110
+ # @param http_method [Symbol] :get, :post, :put, :patch or :delete
111
+ # @param path [String] a path with +{name}+ placeholders filled from +path_params+
112
+ # @param response [Object] the type of the result: a Didit::Model subclass, +[:array, type]+,
113
+ # +:binary+, a Hash of types by status, or nil for no content
114
+ # @param page [Object] the item type of a paginated list (returns a {Didit::Page})
115
+ # @return [Object] the typed result, or a {Didit::RawResponse} from {#with_raw_response}
116
+ def request(http_method, path, path_params: {}, query: nil, body: nil, encoding: :json, file_fields: [],
117
+ headers: {}, auth: :api_key, api: :verification, scope: nil, response: nil, page: nil,
118
+ accept: "application/json", request_options: nil, &block)
119
+ opts = Internal::Request.options(@config, request_options)
120
+ prepared = Internal::Request.prepare(
121
+ @config, http_method, path, opts,
122
+ path_params: path_params, query: query, body: body, encoding: encoding, file_fields: file_fields,
123
+ headers: headers, auth: auth, api: api, scope: scope, accept: accept
124
+ )
125
+ stream = response == :binary ? block : nil
126
+ result = Internal::Request.perform(@config, @transport, prepared, &stream)
127
+ parser = -> { parse(prepared, result.response, response, page) }
128
+ return parser.call unless @raw
129
+
130
+ raw = check_status(prepared, result.response)
131
+ RawResponse.new(status: raw.status, headers: raw.headers, body: raw.body,
132
+ retries_taken: result.retries_taken, elapsed: result.elapsed, parser: parser)
133
+ end
134
+
135
+ # Fetches a page's +next+ link with the request options of the first page.
136
+ # @api private
137
+ def fetch_page(prepared, uri, item, seen)
138
+ next_request = prepared.dup
139
+ next_request.uri = uri
140
+ next_request.http_method = :get
141
+ next_request.body = nil
142
+ result = Internal::Request.perform(@config, @transport, next_request)
143
+ response = check_status(next_request, result.response)
144
+ Page.new(client: self, prepared: next_request, item: item, data: parse_json(next_request, response),
145
+ seen: seen)
146
+ end
147
+
148
+ # Warns once per process that a deprecated method was called (Ruby's :deprecated warning category).
149
+ # @api private
150
+ def deprecated(name, note)
151
+ first = Client.deprecation_lock.synchronize { Client.deprecation_warned.add?(name) }
152
+ Kernel.warn("Didit: #{name} is deprecated. #{note}", category: :deprecated) if first
153
+ nil
154
+ end
155
+
156
+ protected
157
+
158
+ attr_reader :config, :transport
159
+
160
+ def setup(config, transport, raw:)
161
+ @config = config
162
+ @transport = transport
163
+ @raw = raw
164
+ self
165
+ end
166
+
167
+ private
168
+
169
+ def copy(config, raw:)
170
+ same = config.http_client.equal?(@config.http_client)
171
+ transport = same ? @transport : Internal::Transport.new(http_client: config.http_client)
172
+ Client.allocate.send(:setup, config, transport, raw: raw)
173
+ end
174
+
175
+ def parse(prepared, response, type, page)
176
+ check_status(prepared, response)
177
+ return Page.new(client: self, prepared: prepared, item: page, data: parse_json(prepared, response)) if page
178
+ return response.body if type == :binary
179
+ return nil if type.nil?
180
+
181
+ type = type.fetch(response.status) { type.values.first } if type.is_a?(Hash)
182
+ return nil if response.status == 204 || response.body.nil? || response.body.empty?
183
+
184
+ Model.coerce(parse_json(prepared, response), type)
185
+ end
186
+
187
+ def check_status(prepared, response)
188
+ return response if response.status.between?(200, 299)
189
+
190
+ raise Internal::Errors.status_error(status: response.status, headers: response.headers,
191
+ raw_body: response.body, redact: prepared.redact)
192
+ end
193
+
194
+ def parse_json(prepared, response)
195
+ text = response.body.to_s.dup.force_encoding(Encoding::UTF_8)
196
+ begin
197
+ raise JSON::ParserError, "invalid UTF-8" unless text.valid_encoding?
198
+
199
+ return JSON.parse(text)
200
+ rescue JSON::ParserError
201
+ nil
202
+ end
203
+ raise APIResponseValidationError.new(
204
+ "Expected a JSON response from #{prepared.http_method.to_s.upcase} #{prepared.uri.path}, got " \
205
+ "#{response.headers["content-type"] || "no content type"}.",
206
+ status: response.status, headers: response.headers,
207
+ body: Internal::Util.redact(text.scrub, prepared.redact), request_id: Internal::Util.request_id(response.headers)
208
+ )
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Didit
4
+ # Base class of every error the SDK raises (docs/DESIGN.md section 7).
5
+ class DiditError < StandardError
6
+ end
7
+
8
+ # The client is missing something it needs (an API key, an account token, the
9
+ # organization or application id, a webhook secret) or an argument is invalid.
10
+ # Raised before any request is sent.
11
+ class ConfigurationError < DiditError
12
+ end
13
+
14
+ # No response arrived: the connection failed or broke.
15
+ class APIConnectionError < DiditError
16
+ # @return [String, nil] the HTTP method of the request
17
+ attr_reader :http_method
18
+ # @return [String, nil] the request URL without its query string
19
+ attr_reader :url
20
+
21
+ def initialize(message = "Connection error.", http_method: nil, url: nil)
22
+ super(message)
23
+ @http_method = http_method
24
+ @url = url
25
+ end
26
+ end
27
+
28
+ # The request timed out before a response arrived.
29
+ class APITimeoutError < APIConnectionError
30
+ def initialize(message = "Request timed out.", http_method: nil, url: nil)
31
+ super
32
+ end
33
+ end
34
+
35
+ # Shared readers of the errors that carry an HTTP response.
36
+ module ResponseDetails
37
+ # @return [Integer] the HTTP status code
38
+ attr_reader :status
39
+ # @return [Hash{String => String}] response headers, names in lower case
40
+ attr_reader :headers
41
+ # @return [Object, nil] the parsed JSON body, else the text, else nil
42
+ attr_reader :body
43
+ # @return [String, nil] the request id the API returned, if any
44
+ attr_reader :request_id
45
+
46
+ # @return [Integer] alias of {#status}
47
+ def status_code
48
+ status
49
+ end
50
+ end
51
+
52
+ # A successful response could not be read as the documented content (for
53
+ # example HTML where JSON was expected, or a pagination link that loops).
54
+ class APIResponseValidationError < DiditError
55
+ include ResponseDetails
56
+
57
+ def initialize(message, status:, headers: {}, body: nil, request_id: nil)
58
+ super(message)
59
+ @status = status
60
+ @headers = headers
61
+ @body = body
62
+ @request_id = request_id
63
+ end
64
+ end
65
+
66
+ # The API answered with a non-2xx status.
67
+ class APIStatusError < DiditError
68
+ include ResponseDetails
69
+
70
+ def initialize(message, status:, headers: {}, body: nil, request_id: nil)
71
+ super(request_id ? "#{message} (request id #{request_id})" : message)
72
+ @status = status
73
+ @headers = headers
74
+ @body = body
75
+ @request_id = request_id
76
+ end
77
+ end
78
+
79
+ # HTTP 400.
80
+ class BadRequestError < APIStatusError
81
+ end
82
+
83
+ # HTTP 401.
84
+ class AuthenticationError < APIStatusError
85
+ end
86
+
87
+ # HTTP 403.
88
+ class PermissionDeniedError < APIStatusError
89
+ end
90
+
91
+ # HTTP 404.
92
+ class NotFoundError < APIStatusError
93
+ end
94
+
95
+ # HTTP 409.
96
+ class ConflictError < APIStatusError
97
+ end
98
+
99
+ # HTTP 422.
100
+ class UnprocessableEntityError < APIStatusError
101
+ end
102
+
103
+ # HTTP 429.
104
+ class RateLimitError < APIStatusError
105
+ end
106
+
107
+ # HTTP 5xx.
108
+ class InternalServerError < APIStatusError
109
+ end
110
+
111
+ # A webhook delivery failed verification (docs/DESIGN.md section 9.3).
112
+ #
113
+ # {#reason} is machine readable: +:missing_signature+, +:invalid_signature+,
114
+ # +:missing_timestamp+, +:timestamp_mismatch+, +:timestamp_out_of_tolerance+
115
+ # or +:invalid_payload+.
116
+ class WebhookVerificationError < DiditError
117
+ REASONS = %i[
118
+ missing_signature invalid_signature missing_timestamp
119
+ timestamp_mismatch timestamp_out_of_tolerance invalid_payload
120
+ ].freeze
121
+
122
+ # @return [Symbol]
123
+ attr_reader :reason
124
+
125
+ def initialize(reason, message)
126
+ super(message)
127
+ @reason = reason
128
+ end
129
+ end
130
+
131
+ # An Authentication access token or ID token failed verification: do not trust
132
+ # any of its claims (docs/DESIGN.md section 10). The message never contains the token.
133
+ #
134
+ # {#reason} is machine readable: +:malformed+, +:unsupported_algorithm+,
135
+ # +:invalid_type+, +:unknown_key+, +:invalid_signature+, +:invalid_issuer+,
136
+ # +:invalid_audience+, +:expired+, +:not_yet_valid+, +:invalid_issued_at+,
137
+ # +:invalid_nonce+ or +:missing_claim+.
138
+ class TokenVerificationError < DiditError
139
+ REASONS = %i[
140
+ malformed unsupported_algorithm invalid_type unknown_key invalid_signature
141
+ invalid_issuer invalid_audience expired not_yet_valid invalid_issued_at
142
+ invalid_nonce missing_claim
143
+ ].freeze
144
+
145
+ # @return [Symbol]
146
+ attr_reader :reason
147
+
148
+ def initialize(reason, message)
149
+ super(message)
150
+ @reason = reason
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Didit
6
+ # A file to upload (docs/DESIGN.md section 8), with an optional file name and content type.
7
+ #
8
+ # Upload arguments accept a +Pathname+ (read from disk), an +IO+ such as +File+ or
9
+ # +StringIO+ (read once), a +String+ holding the file's bytes, or a FilePart:
10
+ #
11
+ # didit.id_verification.verify(front_image: Pathname("passport.jpg"))
12
+ # didit.id_verification.verify(front_image: File.open("passport.jpg", "rb"))
13
+ # didit.id_verification.verify(front_image: Didit::FilePart.new(bytes, filename: "front.png"))
14
+ class FilePart
15
+ # File types by extension. A fixed table, so uploads do not depend on the host's MIME database.
16
+ CONTENT_TYPES = {
17
+ ".bmp" => "image/bmp", ".csv" => "text/csv", ".gif" => "image/gif", ".heic" => "image/heic",
18
+ ".heif" => "image/heif", ".jpeg" => "image/jpeg", ".jpg" => "image/jpeg", ".json" => "application/json",
19
+ ".mov" => "video/quicktime", ".mp4" => "video/mp4", ".pdf" => "application/pdf", ".png" => "image/png",
20
+ ".tif" => "image/tiff", ".tiff" => "image/tiff", ".txt" => "text/plain", ".webm" => "video/webm",
21
+ ".webp" => "image/webp", ".zip" => "application/zip"
22
+ }.freeze
23
+ DEFAULT_CONTENT_TYPE = "application/octet-stream"
24
+
25
+ # @return [String, Pathname, IO]
26
+ attr_reader :content
27
+ # @return [String, nil]
28
+ attr_reader :filename
29
+
30
+ # @param content [String, Pathname, IO] the bytes, a path to read, or an IO to read
31
+ # @param filename [String, nil] defaults to the path's base name
32
+ # @param content_type [String, nil] defaults to a type guessed from the file name
33
+ def initialize(content, filename: nil, content_type: nil)
34
+ unless content.is_a?(String) || content.is_a?(Pathname) || content.respond_to?(:read)
35
+ raise ConfigurationError, "A file must be a Pathname, an IO or a String of bytes, got #{content.class}."
36
+ end
37
+
38
+ @content = content
39
+ @filename = filename || FilePart.default_filename(content)
40
+ @content_type = content_type
41
+ end
42
+
43
+ # A FilePart for a path on disk.
44
+ def self.from_path(path, filename: nil, content_type: nil)
45
+ new(Pathname(path), filename: filename, content_type: content_type)
46
+ end
47
+
48
+ # Wraps an upload argument; +field+ names the file when nothing else does.
49
+ # @api private
50
+ def self.wrap(value, field)
51
+ part = value.is_a?(FilePart) ? value : new(value)
52
+ part.filename ? part : new(part.content, filename: field, content_type: part.content_type)
53
+ end
54
+
55
+ # @api private
56
+ def self.default_filename(content)
57
+ path = if content.is_a?(Pathname)
58
+ content
59
+ elsif content.respond_to?(:path) && content.path
60
+ content.path
61
+ end
62
+ path ? File.basename(path.to_s) : nil
63
+ end
64
+
65
+ # @return [String]
66
+ def content_type
67
+ @content_type || CONTENT_TYPES.fetch(File.extname(filename.to_s).downcase, DEFAULT_CONTENT_TYPE)
68
+ end
69
+
70
+ # The file's bytes (an IO is read once, from its current position).
71
+ # @return [String]
72
+ def read
73
+ data = case content
74
+ when Pathname then File.binread(content.to_s)
75
+ when String then content
76
+ else content.read
77
+ end
78
+ data.to_s.b
79
+ end
80
+
81
+ def inspect
82
+ "#<Didit::FilePart filename=#{filename.inspect} content_type=#{content_type.inspect}>"
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Didit
6
+ module Internal
7
+ # The resolved, immutable options of a Didit::Client (docs/DESIGN.md section 6).
8
+ # @api private
9
+ class Config
10
+ DEFAULT_BASE_URL = "https://verification.didit.me"
11
+ DEFAULT_ACCOUNT_BASE_URL = "https://apx.didit.me/auth/v2"
12
+ DEFAULT_TIMEOUT = 60.0
13
+ DEFAULT_MAX_RETRIES = 2
14
+ FIELDS = %i[
15
+ api_key account_token organization_id application_id base_url account_base_url timeout max_retries
16
+ default_headers logger http_client
17
+ ].freeze
18
+
19
+ attr_reader(*FIELDS)
20
+
21
+ def initialize(**options)
22
+ unknown = options.keys - FIELDS
23
+ raise ConfigurationError, "Unknown client options #{unknown.map(&:inspect).join(", ")}." unless unknown.empty?
24
+
25
+ @api_key, @account_token, @organization_id, @application_id =
26
+ options.values_at(:api_key, :account_token, :organization_id, :application_id).map { |v| present(v) }
27
+ @base_url = Config.check_url(:base_url, options[:base_url] || DEFAULT_BASE_URL)
28
+ @account_base_url = Config.check_url(:account_base_url, options[:account_base_url] || DEFAULT_ACCOUNT_BASE_URL)
29
+ @timeout = Config.check_timeout(options.fetch(:timeout, DEFAULT_TIMEOUT))
30
+ @max_retries = Config.check_max_retries(options.fetch(:max_retries, DEFAULT_MAX_RETRIES))
31
+ @default_headers = check_headers(options[:default_headers] || {})
32
+ @logger = options[:logger]
33
+ @http_client = check_http_client(options[:http_client])
34
+ freeze
35
+ end
36
+
37
+ # A copy with +changes+ applied; a nil value clears an id or token.
38
+ def merge(changes)
39
+ Config.new(**FIELDS.to_h { |f| [f, public_send(f)] }, **changes)
40
+ end
41
+
42
+ def self.check_url(name, url)
43
+ uri = URI(url.to_s)
44
+ unless %w[http https].include?(uri.scheme) && uri.host && !uri.host.empty? && uri.query.nil? &&
45
+ uri.fragment.nil? && uri.userinfo.nil?
46
+ raise ConfigurationError, "#{name} must be an absolute http(s) URL without query or credentials, " \
47
+ "got #{url.to_s.inspect}."
48
+ end
49
+
50
+ uri.to_s.sub(%r{/+\z}, "")
51
+ rescue URI::InvalidURIError
52
+ raise ConfigurationError, "#{name} must be an absolute http(s) URL, got #{url.to_s.inspect}."
53
+ end
54
+
55
+ def self.check_timeout(value)
56
+ unless value.is_a?(Numeric) && value.positive? && value.finite?
57
+ raise ConfigurationError, "timeout must be a positive number of seconds, got #{value.inspect}."
58
+ end
59
+
60
+ (_ = value).to_f
61
+ end
62
+
63
+ def self.check_max_retries(value)
64
+ unless value.is_a?(Integer) && !value.negative?
65
+ raise ConfigurationError, "max_retries must be an Integer of 0 or more, got #{value.inspect}."
66
+ end
67
+
68
+ value
69
+ end
70
+
71
+ private
72
+
73
+ def present(value)
74
+ return nil if value.nil?
75
+
76
+ text = value.to_s.strip
77
+ text.empty? ? nil : text
78
+ end
79
+
80
+ def check_headers(headers)
81
+ raise ConfigurationError, "default_headers must be a Hash." unless headers.is_a?(Hash)
82
+
83
+ headers.to_h { |k, v| [k.to_s, v&.to_s] }.freeze
84
+ end
85
+
86
+ def check_http_client(value)
87
+ return nil if value.nil?
88
+ return value if value.respond_to?(:call)
89
+
90
+ raise ConfigurationError, "http_client must respond to call(uri) and return a Net::HTTP."
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Didit
6
+ module Internal
7
+ # Builds {Didit::APIStatusError} subclasses from responses.
8
+ # @api private
9
+ module Errors
10
+ STATUS_ERRORS = {
11
+ 400 => BadRequestError,
12
+ 401 => AuthenticationError,
13
+ 403 => PermissionDeniedError,
14
+ 404 => NotFoundError,
15
+ 409 => ConflictError,
16
+ 422 => UnprocessableEntityError,
17
+ 429 => RateLimitError
18
+ }.freeze
19
+ DETAIL_KEYS = %w[detail message error error_description].freeze
20
+
21
+ module_function
22
+
23
+ # @param status [Integer]
24
+ # @param headers [Hash{String => String}] names in lower case
25
+ # @param raw_body [String, nil]
26
+ # @param redact [Array<String, nil>] secrets to replace with "[redacted]" in the message and body
27
+ # @return [Didit::APIStatusError]
28
+ def status_error(status:, headers:, raw_body:, redact: [])
29
+ body = Util.redact(parse_body(raw_body), redact)
30
+ message = Util.redact(message_for(status, body), redact)
31
+ klass = STATUS_ERRORS.fetch(status) { status >= 500 ? InternalServerError : APIStatusError }
32
+ klass.new(message, status: status, headers: headers, body: body, request_id: Util.request_id(headers))
33
+ end
34
+
35
+ # The parsed JSON body, else the text, else nil.
36
+ def parse_body(raw_body)
37
+ return nil if raw_body.nil? || raw_body.empty?
38
+
39
+ text = raw_body.dup.force_encoding(Encoding::UTF_8)
40
+ text = text.scrub unless text.valid_encoding?
41
+ JSON.parse(text)
42
+ rescue JSON::ParserError
43
+ text
44
+ end
45
+
46
+ def message_for(status, body)
47
+ detail = detail_of(body)
48
+ detail ? "HTTP #{status}: #{detail}" : "HTTP #{status}"
49
+ end
50
+
51
+ def detail_of(body)
52
+ case body
53
+ when Hash then hash_detail(body)
54
+ when Array then body.join("; ") if !body.empty? && body.all?(String)
55
+ when String
56
+ text = body.strip
57
+ text unless text.empty? || text.length > 500
58
+ end
59
+ end
60
+
61
+ def hash_detail(body)
62
+ error = body["error"]
63
+ description = body["error_description"]
64
+ # OAuth 2.0 errors (RFC 6749 section 5.2): the code and what it means.
65
+ return "#{error}: #{description}" if present?(error) && present?(description)
66
+
67
+ DETAIL_KEYS.each { |key| return body[key] if present?(body[key]) }
68
+ parts = body.first(5).filter_map do |key, value|
69
+ if value.is_a?(Array) && value.first.is_a?(String)
70
+ "#{key}: #{value.first}"
71
+ elsif value.is_a?(String)
72
+ "#{key}: #{value}"
73
+ end
74
+ end
75
+ parts.join("; ") unless parts.empty?
76
+ end
77
+
78
+ def present?(value)
79
+ value.is_a?(String) && !value.empty?
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Didit
4
+ module Internal
5
+ # Debug logging (docs/DESIGN.md section 6): method, path, status, duration and request
6
+ # id, never bodies, headers or query strings.
7
+ # @api private
8
+ module Log
9
+ module_function
10
+
11
+ def response(config, prepared, response, elapsed)
12
+ return unless enabled?(config)
13
+
14
+ request_id = Util.request_id(response.headers) || "-"
15
+ write(config, format("%<method>s %<path>s -> %<status>d in %<ms>.0f ms (request id %<id>s)",
16
+ method: prepared.http_method.to_s.upcase, path: prepared.uri.path,
17
+ status: response.status, ms: elapsed * 1000, id: request_id))
18
+ end
19
+
20
+ def retry(config, prepared, reason, delay, retries_left)
21
+ return unless enabled?(config)
22
+
23
+ write(config, format("%<method>s %<path>s: %<reason>s; retrying in %<delay>.2f s (%<left>d retries left)",
24
+ method: prepared.http_method.to_s.upcase, path: prepared.uri.path, reason: reason,
25
+ delay: delay, left: retries_left))
26
+ end
27
+
28
+ def enabled?(config)
29
+ !config.logger.nil? || Util.debug_from_env?
30
+ end
31
+
32
+ def write(config, message)
33
+ if config.logger
34
+ config.logger.debug("didit") { message }
35
+ else
36
+ $stderr.puts("[didit] #{message}") # rubocop:disable Style/StderrPuts
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end