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,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module Didit
7
+ module Internal
8
+ # multipart/form-data bodies. Files are read into memory, so a retry can send them again.
9
+ # @api private
10
+ module Multipart
11
+ module_function
12
+
13
+ # @return [Array(String, String)] the body and its content type
14
+ def encode(fields, file_fields)
15
+ boundary = "didit-#{SecureRandom.hex(16)}"
16
+ body = +"".b
17
+ fields.each do |name, value|
18
+ next if value.nil?
19
+
20
+ if file_fields.include?(name) || value.is_a?(FilePart)
21
+ (value.is_a?(Array) ? value : [value]).each do |item|
22
+ file_part(body, boundary, name, FilePart.wrap(item, name))
23
+ end
24
+ else
25
+ field_part(body, boundary, name, text(value))
26
+ end
27
+ end
28
+ body << "--#{boundary}--\r\n"
29
+ [body, "multipart/form-data; boundary=#{boundary}"]
30
+ end
31
+
32
+ def text(value)
33
+ case value
34
+ when Hash, Array then JSON.generate(Request.json_value(value))
35
+ when Time then value.utc.iso8601(6)
36
+ when Date then value.iso8601
37
+ else value.to_s
38
+ end
39
+ end
40
+
41
+ def field_part(body, boundary, name, value)
42
+ body << "--#{boundary}\r\n"
43
+ body << "Content-Disposition: form-data; name=\"#{quote(name)}\"\r\n\r\n"
44
+ body << value.to_s.b << "\r\n"
45
+ end
46
+
47
+ def file_part(body, boundary, name, part)
48
+ body << "--#{boundary}\r\n"
49
+ body << "Content-Disposition: form-data; name=\"#{quote(name)}\"; filename=\"#{quote(part.filename)}\"\r\n"
50
+ body << "Content-Type: #{part.content_type}\r\n\r\n"
51
+ body << part.read << "\r\n"
52
+ end
53
+
54
+ # Escapes a name for a Content-Disposition parameter (WHATWG multipart rules).
55
+ def quote(value)
56
+ value.to_s.b.gsub("\r", "%0D").gsub("\n", "%0A").gsub('"', "%22")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+ require "time"
6
+ require "uri"
7
+
8
+ module Didit
9
+ module Internal
10
+ # Builds requests and sends them with the retry policy of docs/DESIGN.md section 7.
11
+ # @api private
12
+ module Request
13
+ # A request with everything resolved.
14
+ Prepared = Struct.new(:http_method, :uri, :headers, :body, :timeout, :max_retries, :origin, :redact,
15
+ keyword_init: true)
16
+ # A response plus how it was obtained.
17
+ Result = Struct.new(:response, :retries_taken, :elapsed, keyword_init: true)
18
+
19
+ IDEMPOTENT = %i[get head put delete options].freeze
20
+ INITIAL_RETRY_DELAY = 0.5
21
+ MAX_RETRY_DELAY = 8.0
22
+ MAX_RETRY_AFTER = 60.0
23
+ OPTION_KEYS = %i[timeout max_retries extra_headers extra_query extra_body].freeze
24
+ UNRESERVED = /[^A-Za-z0-9\-._~]/
25
+
26
+ module_function
27
+
28
+ # Per-request options (+request_options:+ of every method) merged over the client's.
29
+ def options(config, raw)
30
+ raw ||= {} # : Hash[untyped, untyped]
31
+ raise ConfigurationError, "request_options must be a Hash, got #{raw.class}." unless raw.is_a?(Hash)
32
+
33
+ unknown = raw.keys.map(&:to_sym) - OPTION_KEYS
34
+ unless unknown.empty?
35
+ raise ConfigurationError, "Unknown request_options #{unknown.map(&:inspect).join(", ")}; " \
36
+ "use #{OPTION_KEYS.map(&:inspect).join(", ")}."
37
+ end
38
+ opts = raw.transform_keys(&:to_sym)
39
+ {
40
+ timeout: Config.check_timeout(opts.fetch(:timeout, config.timeout)),
41
+ max_retries: Config.check_max_retries(opts.fetch(:max_retries, config.max_retries)),
42
+ extra_headers: opts[:extra_headers] || {},
43
+ extra_query: opts[:extra_query] || {},
44
+ extra_body: opts[:extra_body] || {}
45
+ }
46
+ end
47
+
48
+ # @return [Prepared]
49
+ def prepare(config, http_method, path, opts, path_params: {}, query: nil, body: nil, encoding: :json,
50
+ file_fields: [], headers: {}, auth: :api_key, api: :verification, scope: nil,
51
+ accept: "application/json")
52
+ params = scope == :application ? scoped(config, path_params) : path_params
53
+ check_credentials(config, auth)
54
+ base = api == :account ? config.account_base_url : config.base_url
55
+ uri = URI("#{base}#{encode_path(path, params)}")
56
+ query_string = encode_query(merge(query, opts[:extra_query]))
57
+ uri.query = query_string unless query_string.empty?
58
+ body_bytes, content_type = encode_body(body, opts[:extra_body], encoding, file_fields)
59
+ Prepared.new(
60
+ http_method: http_method, uri: uri, origin: Util.origin(base),
61
+ headers: build_headers(config, auth, accept: accept, content_type: content_type, op_headers: headers,
62
+ extra_headers: opts[:extra_headers]),
63
+ body: body_bytes, timeout: opts[:timeout], max_retries: opts[:max_retries],
64
+ redact: [config.api_key, config.account_token].compact
65
+ )
66
+ end
67
+
68
+ # The path parameters plus the organization and application ids of an application-scoped method.
69
+ def scoped(config, path_params)
70
+ organization_id = config.organization_id
71
+ application_id = config.application_id
72
+ unless organization_id && application_id
73
+ raise ConfigurationError,
74
+ "This method is application scoped: pass organization_id and application_id to " \
75
+ "Didit::Client.new (or set DIDIT_ORGANIZATION_ID and DIDIT_APPLICATION_ID)."
76
+ end
77
+
78
+ path_params.merge("organization_id" => organization_id, "application_id" => application_id)
79
+ end
80
+
81
+ def check_credentials(config, auth)
82
+ case auth
83
+ when :api_key
84
+ return if config.api_key
85
+
86
+ raise ConfigurationError,
87
+ "No API key: pass api_key: to Didit::Client.new or set the DIDIT_API_KEY environment variable."
88
+ when :account_token
89
+ return if config.account_token
90
+
91
+ raise ConfigurationError,
92
+ "This account method needs an account token: call didit.account.login(...) and pass its " \
93
+ "access_token as account_token: (didit.with_options(account_token: ...))."
94
+ end
95
+ end
96
+
97
+ # Fills +{name}+ placeholders with percent-encoded single path segments.
98
+ def encode_path(path, params)
99
+ path.gsub(/\{([^}]+)\}/) do
100
+ name = Regexp.last_match(1).to_s
101
+ value = params.fetch(name) { raise ConfigurationError, "Missing path parameter #{name}." }
102
+ segment = path_segment(name, value)
103
+ segment.b.gsub(UNRESERVED) { |c| format("%%%02X", c.ord) }.force_encoding(Encoding::US_ASCII)
104
+ end
105
+ end
106
+
107
+ def path_segment(name, value)
108
+ unless value.is_a?(String) || value.is_a?(Integer) || value.is_a?(Symbol)
109
+ raise ConfigurationError, "Path parameter #{name} must be a String, got #{value.class}."
110
+ end
111
+
112
+ text = value.to_s
113
+ raise ConfigurationError, "Path parameter #{name} must not be empty." if text.empty?
114
+ raise ConfigurationError, "Path parameter #{name} must not be \".\" or \"..\"." if %w[. ..].include?(text)
115
+
116
+ text.encode(Encoding::UTF_8)
117
+ end
118
+
119
+ def merge(values, extra)
120
+ # @type var out: Hash[String, untyped]
121
+ out = {}
122
+ (values || {}).each { |k, v| out[k.to_s] = v unless v.equal?(OMIT) }
123
+ (extra || {}).each { |k, v| out[k.to_s] = v }
124
+ out
125
+ end
126
+
127
+ # Query values: nil and omitted values are left out, arrays repeat the key.
128
+ def encode_query(values)
129
+ # @type var pairs: Array[[String, String]]
130
+ pairs = []
131
+ values.each do |key, value|
132
+ Array(value.is_a?(Hash) ? JSON.generate(json_value(value)) : value).each do |item|
133
+ pairs << [key, query_value(item)] unless item.nil? || item.equal?(OMIT)
134
+ end
135
+ end
136
+ URI.encode_www_form(pairs)
137
+ end
138
+
139
+ def query_value(value)
140
+ case value
141
+ when Time then value.utc.iso8601(6)
142
+ when Date then value.iso8601
143
+ else value.to_s
144
+ end
145
+ end
146
+
147
+ # @return [Array(String, String), Array(nil, nil)] body bytes and content type
148
+ def encode_body(body, extra_body, encoding, file_fields)
149
+ return [nil, nil] if body.nil? && (extra_body.nil? || extra_body.empty?)
150
+
151
+ fields = merge(body, extra_body)
152
+ multipart = encoding == :multipart ||
153
+ (encoding == :auto && file_fields.any? { |f| !fields[f].nil? })
154
+ if multipart
155
+ Multipart.encode(fields, file_fields)
156
+ else
157
+ [JSON.generate(json_value(fields)), "application/json"]
158
+ end
159
+ end
160
+
161
+ # A value ready for JSON: string keys, Time and Date as ISO 8601.
162
+ def json_value(value)
163
+ case value
164
+ when Hash
165
+ # @type var out: Hash[String, untyped]
166
+ out = {}
167
+ value.each { |k, v| out[k.to_s] = json_value(v) unless v.equal?(OMIT) }
168
+ out
169
+ when Array then value.map { |v| json_value(v) }
170
+ when Time then value.utc.iso8601(6)
171
+ when Date then value.iso8601
172
+ when Symbol then value.to_s
173
+ when Didit::Model then json_value(value.to_h)
174
+ when FilePart
175
+ raise ConfigurationError, "A file can only be sent to an operation that accepts uploads."
176
+ else value
177
+ end
178
+ end
179
+
180
+ def build_headers(config, auth, accept:, content_type:, op_headers:, extra_headers:)
181
+ # @type var pairs: Array[[untyped, untyped]]
182
+ pairs = [["Accept", accept], ["User-Agent", Util.user_agent], ["X-Didit-Client", Util.client_header]]
183
+ pairs.concat(config.default_headers.to_a)
184
+ credential = credential_header(config, auth)
185
+ pairs << credential if credential
186
+ pairs << ["Content-Type", content_type] if content_type
187
+ pairs.concat(op_headers.reject { |_, value| value.equal?(OMIT) }.to_a)
188
+ pairs.concat(extra_headers.to_a)
189
+ merge_headers(pairs)
190
+ end
191
+
192
+ # Header names are case-insensitive: a later value replaces an earlier one whatever its
193
+ # case, so no header is sent twice; nil removes the header.
194
+ def merge_headers(pairs)
195
+ # @type var headers: Hash[String, String]
196
+ headers = {}
197
+ pairs.each do |name, value|
198
+ name = name.to_s
199
+ headers.delete_if { |key, _| key.casecmp?(name) }
200
+ headers[name] = value.to_s unless value.nil?
201
+ end
202
+ headers
203
+ end
204
+
205
+ # The credential header for +auth+, sent only to the origin the request was built for.
206
+ def credential_header(config, auth)
207
+ case auth
208
+ when :api_key then ["x-api-key", config.api_key.to_s]
209
+ when :account_token then ["Authorization", "Bearer #{config.account_token}"]
210
+ end
211
+ end
212
+
213
+ # One try: the response, or the failure and whether any body chunk was already streamed.
214
+ Attempt = Struct.new(:response, :failure, :streamed, :elapsed, keyword_init: true)
215
+
216
+ # Sends +prepared+, retrying as docs/DESIGN.md section 7 says.
217
+ #
218
+ # @return [Result]
219
+ def perform(config, transport, prepared, &block)
220
+ retries = 0
221
+ loop do
222
+ attempt = attempt(transport, prepared, &block)
223
+ delay = retry_delay(config, prepared, attempt, retries)
224
+ if delay.nil?
225
+ failure = attempt.failure
226
+ raise connection_error(prepared, failure), cause: failure if failure
227
+
228
+ response = attempt.response or raise Transport::Failure.new(IOError.new("no response"), sent: true)
229
+ return Result.new(response: response, retries_taken: retries, elapsed: attempt.elapsed)
230
+ end
231
+ retries += 1
232
+ Util.pause(delay)
233
+ end
234
+ end
235
+
236
+ def attempt(transport, prepared)
237
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
238
+ result = Attempt.new(streamed: false)
239
+ # @type var stream: (^(String) -> void)?
240
+ stream = nil
241
+ if block_given?
242
+ stream = lambda do |chunk|
243
+ result.streamed = true
244
+ yield(chunk)
245
+ end
246
+ end
247
+ begin
248
+ result.response = transport.execute(prepared.http_method, prepared.uri, prepared.headers, prepared.body,
249
+ prepared.timeout, &stream)
250
+ rescue Transport::Failure => e
251
+ result.failure = e
252
+ end
253
+ result.elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
254
+ result
255
+ end
256
+
257
+ # Seconds to wait before the next try, or nil when this attempt is final.
258
+ def retry_delay(config, prepared, attempt, retries)
259
+ left = prepared.max_retries - retries
260
+ if (failure = attempt.failure)
261
+ return nil if attempt.streamed || left <= 0 || !retry_failure?(prepared.http_method, failure)
262
+
263
+ delay = backoff(retries)
264
+ Log.retry(config, prepared, failure.timeout? ? "timed out" : failure.error.class.name, delay, left)
265
+ return delay
266
+ end
267
+ response = attempt.response
268
+ return nil unless response
269
+
270
+ Log.response(config, prepared, response, attempt.elapsed)
271
+ return nil if left <= 0 || !retry_status?(prepared.http_method, response.status)
272
+
273
+ delay = retry_after(response.headers["retry-after"]) || backoff(retries)
274
+ Log.retry(config, prepared, "HTTP #{response.status}", delay, left)
275
+ delay
276
+ end
277
+
278
+ def retry_status?(http_method, status)
279
+ return true if status == 429
280
+ return false unless IDEMPOTENT.include?(http_method)
281
+
282
+ status == 408 || status.between?(500, 599)
283
+ end
284
+
285
+ def retry_failure?(http_method, failure)
286
+ !failure.sent || IDEMPOTENT.include?(http_method)
287
+ end
288
+
289
+ # Exponential backoff from 0.5 s with full jitter, capped at 8 s.
290
+ def backoff(retries_taken)
291
+ Kernel.rand * [MAX_RETRY_DELAY, INITIAL_RETRY_DELAY * (2**retries_taken)].min
292
+ end
293
+
294
+ # Seconds to wait from a Retry-After header (seconds or HTTP date), capped at 60.
295
+ def retry_after(value, now: Time.now)
296
+ return nil if value.nil?
297
+
298
+ text = value.strip
299
+ seconds = text.match?(/\A\d+(\.\d+)?\z/) ? text.to_f : http_date_seconds(text, now)
300
+ seconds&.clamp(0.0, MAX_RETRY_AFTER)
301
+ end
302
+
303
+ def http_date_seconds(text, now)
304
+ Time.httpdate(text) - now
305
+ rescue ArgumentError
306
+ nil
307
+ end
308
+
309
+ def connection_error(prepared, failure)
310
+ url = Util.display_url(prepared.uri)
311
+ method = prepared.http_method.to_s.upcase
312
+ if failure.timeout?
313
+ APITimeoutError.new("Request timed out (#{method} #{url}).", http_method: method, url: url)
314
+ else
315
+ detail = Util.redact(failure.error.message.to_s, prepared.redact)
316
+ APIConnectionError.new("Connection error (#{method} #{url}): #{failure.error.class}: #{detail}",
317
+ http_method: method, url: url)
318
+ end
319
+ end
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+ require "zlib"
7
+
8
+ module Didit
9
+ module Internal
10
+ # Sends HTTP requests over persistent net/http connections, one small pool per origin.
11
+ # Thread safe and fork safe: a child process never reuses its parent's sockets.
12
+ # @api private
13
+ class Transport
14
+ # What came back: status, headers (lower-case names) and the body bytes.
15
+ Response = Struct.new(:status, :headers, :body, keyword_init: true)
16
+
17
+ # Raised by {#execute} when no response arrived; +sent+ says whether the request may
18
+ # have reached the server.
19
+ class Failure < StandardError
20
+ attr_reader :error, :sent
21
+
22
+ def initialize(error, sent:)
23
+ super(error.message)
24
+ @error = error
25
+ @sent = sent
26
+ end
27
+
28
+ def timeout?
29
+ TIMEOUT_ERRORS.any? { |klass| error.is_a?(klass) }
30
+ end
31
+ end
32
+
33
+ # Errors that happen before any byte of the request leaves this process.
34
+ NOT_SENT_ERRORS = [
35
+ Net::OpenTimeout, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::EADDRNOTAVAIL,
36
+ SocketError
37
+ ].freeze
38
+ # Errors after which the request may have reached the server.
39
+ TRANSPORT_ERRORS = [
40
+ Net::ReadTimeout, Net::WriteTimeout, Timeout::Error, EOFError, IOError, SystemCallError,
41
+ OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::ProtocolError, Zlib::Error
42
+ ].freeze
43
+ TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error, Errno::ETIMEDOUT].freeze
44
+ MAX_IDLE = 8
45
+
46
+ METHODS = {
47
+ get: Net::HTTP::Get, post: Net::HTTP::Post, put: Net::HTTP::Put, patch: Net::HTTP::Patch,
48
+ delete: Net::HTTP::Delete, head: Net::HTTP::Head, options: Net::HTTP::Options
49
+ }.freeze
50
+
51
+ # @param http_client [#call, nil] returns a configured, not yet started Net::HTTP for a URI
52
+ def initialize(http_client: nil)
53
+ @http_client = http_client
54
+ @mutex = Mutex.new
55
+ @idle = {}
56
+ @pid = Process.pid
57
+ end
58
+
59
+ attr_reader :http_client
60
+
61
+ # Sends one request. With a block, a 2xx body is yielded in chunks instead of returned.
62
+ #
63
+ # @return [Response]
64
+ # @raise [Failure]
65
+ def execute(http_method, uri, headers, body, timeout, &)
66
+ http = checkout(uri, timeout)
67
+ reusable = false
68
+ begin
69
+ request = build(http_method, uri, headers, body)
70
+ response = perform(http, request, &)
71
+ reusable = response.headers["connection"]&.casecmp?("close") != true
72
+ response
73
+ rescue *NOT_SENT_ERRORS => e
74
+ raise Failure.new(e, sent: false)
75
+ rescue *TRANSPORT_ERRORS => e
76
+ raise Failure.new(e, sent: true)
77
+ ensure
78
+ reusable ? checkin(uri, http) : discard(http)
79
+ end
80
+ end
81
+
82
+ # Closes every idle connection.
83
+ def close
84
+ sessions = @mutex.synchronize do
85
+ all = @idle.values.flatten
86
+ @idle = {}
87
+ all
88
+ end
89
+ sessions.each { |http| discard(http) }
90
+ nil
91
+ end
92
+
93
+ private
94
+
95
+ def perform(http, request, &block)
96
+ # @type var result: Response?
97
+ result = nil
98
+ http.request(request) do |res|
99
+ # @type var headers: Hash[String, String]
100
+ headers = {}
101
+ res.each_header { |name, value| headers[name.downcase] = value }
102
+ status = res.code.to_i
103
+ if block && status.between?(200, 299)
104
+ res.read_body(&block)
105
+ result = Response.new(status: status, headers: headers, body: nil)
106
+ else
107
+ result = Response.new(status: status, headers: headers, body: res.read_body.to_s.b)
108
+ end
109
+ end
110
+ result || raise(Net::HTTPBadResponse, "no response")
111
+ end
112
+
113
+ def build(http_method, uri, headers, body)
114
+ klass = METHODS.fetch(http_method)
115
+ request = klass.new(target(uri), headers)
116
+ request.body = body if body
117
+ request
118
+ end
119
+
120
+ # The request target: path ("/" when empty) and query.
121
+ def target(uri)
122
+ path = uri.path.to_s.empty? ? "/" : uri.path.to_s
123
+ uri.query ? "#{path}?#{uri.query}" : path
124
+ end
125
+
126
+ def checkout(uri, timeout)
127
+ key = pool_key(uri)
128
+ http = @mutex.synchronize do
129
+ reset_after_fork
130
+ @idle[key]&.pop
131
+ end
132
+ http ||= new_session(uri)
133
+ configure(http, timeout)
134
+ start(http) unless http.started?
135
+ http
136
+ end
137
+
138
+ def start(http)
139
+ http.start
140
+ rescue *NOT_SENT_ERRORS, *TRANSPORT_ERRORS => e
141
+ # Nothing was sent: the connection itself failed.
142
+ discard(http)
143
+ raise Failure.new(e, sent: false)
144
+ end
145
+
146
+ def checkin(uri, http)
147
+ keep = @mutex.synchronize do
148
+ list = (@idle[pool_key(uri)] ||= [])
149
+ if @pid == Process.pid && list.size < MAX_IDLE
150
+ list.push(http)
151
+ true
152
+ else
153
+ false
154
+ end
155
+ end
156
+ discard(http) unless keep
157
+ end
158
+
159
+ def discard(http)
160
+ http.finish if http.started?
161
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
162
+ nil
163
+ end
164
+
165
+ def reset_after_fork
166
+ return if @pid == Process.pid
167
+
168
+ # The sockets belong to the parent process: forget them without closing them.
169
+ @idle = {}
170
+ @pid = Process.pid
171
+ end
172
+
173
+ def new_session(uri)
174
+ factory = @http_client
175
+ http = factory ? factory.call(uri) : Net::HTTP.new(uri.hostname.to_s, uri.port)
176
+ unless http.is_a?(Net::HTTP)
177
+ raise ConfigurationError, "http_client must return a Net::HTTP instance, got #{http.class}."
178
+ end
179
+
180
+ http.use_ssl = true if uri.scheme == "https" && !http.use_ssl?
181
+ http
182
+ end
183
+
184
+ def configure(http, timeout)
185
+ http.open_timeout = timeout
186
+ http.read_timeout = timeout
187
+ http.write_timeout = timeout
188
+ http.ssl_timeout = timeout if http.use_ssl?
189
+ # The SDK decides about retries (docs/DESIGN.md section 7), net/http never does.
190
+ http.max_retries = 0
191
+ end
192
+
193
+ def pool_key(uri)
194
+ "#{uri.scheme}://#{uri.host}:#{uri.port}"
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Didit
6
+ module Internal
7
+ # Small helpers shared by the client, webhooks and Authentication.
8
+ # @api private
9
+ module Util
10
+ REQUEST_ID_HEADERS = %w[x-request-id request-id x-amzn-requestid x-correlation-id].freeze
11
+ REDACTED = "[redacted]"
12
+
13
+ module_function
14
+
15
+ # +User-Agent: didit-ruby/<version> (<engine> <version>; <platform>)+ (docs/DESIGN.md section 6).
16
+ def user_agent
17
+ "didit-ruby/#{Didit::VERSION} (#{RUBY_ENGINE} #{RUBY_ENGINE_VERSION}; #{RUBY_PLATFORM})"
18
+ end
19
+
20
+ # +X-Didit-Client: ruby/<version>+.
21
+ def client_header
22
+ "ruby/#{Didit::VERSION}"
23
+ end
24
+
25
+ # The request id from response headers (names in lower case), if any.
26
+ def request_id(headers)
27
+ REQUEST_ID_HEADERS.each do |name|
28
+ value = headers[name]
29
+ return value unless value.nil? || value.empty?
30
+ end
31
+ nil
32
+ end
33
+
34
+ # Replaces every secret in +value+ (a String, or Hash/Array of them) with "[redacted]".
35
+ def redact(value, secrets)
36
+ secrets = secrets.compact.reject(&:empty?)
37
+ return value if secrets.empty?
38
+
39
+ redact_value(value, secrets)
40
+ end
41
+
42
+ def redact_value(value, secrets)
43
+ case value
44
+ when String then secrets.reduce(value) { |text, secret| text.gsub(secret, REDACTED) }
45
+ when Hash then value.to_h { |k, v| [redact_value(k, secrets), redact_value(v, secrets)] }
46
+ when Array then value.map { |v| redact_value(v, secrets) }
47
+ else value
48
+ end
49
+ end
50
+
51
+ # +scheme://host[:port]+ with the default port left out.
52
+ def origin(uri)
53
+ uri = URI(uri) unless uri.is_a?(URI::Generic)
54
+ default = { "http" => 80, "https" => 443 }[uri.scheme]
55
+ port = uri.port.nil? || uri.port == default ? "" : ":#{uri.port}"
56
+ "#{uri.scheme}://#{uri.host&.downcase}#{port}"
57
+ end
58
+
59
+ # The URL without query string, fragment or credentials (for errors and logs).
60
+ def display_url(uri)
61
+ uri = URI(uri) unless uri.is_a?(URI::Generic)
62
+ "#{origin(uri)}#{uri.path}"
63
+ end
64
+
65
+ # Sleeps between retries (a separate method, so tests can skip the wait).
66
+ def pause(seconds)
67
+ Kernel.sleep(seconds) if seconds.positive?
68
+ end
69
+
70
+ # Whether the environment variable asks for debug logging.
71
+ def debug_from_env?
72
+ ENV.fetch("DIDIT_LOG", "").strip.casecmp?("debug")
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Didit
4
+ # Implementation details. Nothing under this module is public API.
5
+ # @api private
6
+ module Internal
7
+ autoload :Config, "didit/internal/config"
8
+ autoload :Errors, "didit/internal/errors"
9
+ autoload :Log, "didit/internal/log"
10
+ autoload :Multipart, "didit/internal/multipart"
11
+ autoload :Request, "didit/internal/request"
12
+ autoload :Transport, "didit/internal/transport"
13
+ autoload :Util, "didit/internal/util"
14
+ end
15
+ end