dinie-sdk-sandbox 1.1.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 +40 -0
- data/LICENSE +21 -0
- data/README.md +280 -0
- data/lib/dinie/generated/api_version.rb +8 -0
- data/lib/dinie/generated/client.rb +96 -0
- data/lib/dinie/generated/errors/registry.rb +40 -0
- data/lib/dinie/generated/events/base.rb +11 -0
- data/lib/dinie/generated/events/credit_offer.rb +56 -0
- data/lib/dinie/generated/events/customer_created.rb +42 -0
- data/lib/dinie/generated/events/customer_denied.rb +39 -0
- data/lib/dinie/generated/events/customer_kyc_updated.rb +36 -0
- data/lib/dinie/generated/events/customer_status.rb +48 -0
- data/lib/dinie/generated/events/deserializers.rb +35 -0
- data/lib/dinie/generated/events/loan_active.rb +35 -0
- data/lib/dinie/generated/events/loan_created.rb +38 -0
- data/lib/dinie/generated/events/loan_payment_received.rb +46 -0
- data/lib/dinie/generated/events/loan_processing.rb +37 -0
- data/lib/dinie/generated/events/loan_signature_received.rb +48 -0
- data/lib/dinie/generated/events/loan_status.rb +73 -0
- data/lib/dinie/generated/events.rb +4 -0
- data/lib/dinie/generated/resources/banks.rb +25 -0
- data/lib/dinie/generated/resources/biometrics.rb +27 -0
- data/lib/dinie/generated/resources/credentials.rb +56 -0
- data/lib/dinie/generated/resources/credit_offers.rb +59 -0
- data/lib/dinie/generated/resources/customers.rb +200 -0
- data/lib/dinie/generated/resources/loans.rb +70 -0
- data/lib/dinie/generated/resources/webhook_endpoints.rb +97 -0
- data/lib/dinie/generated/resources.rb +9 -0
- data/lib/dinie/generated/types/bank.rb +17 -0
- data/lib/dinie/generated/types/biometrics_session.rb +16 -0
- data/lib/dinie/generated/types/biometrics_session_exchange_response.rb +23 -0
- data/lib/dinie/generated/types/credential.rb +52 -0
- data/lib/dinie/generated/types/credit_offer.rb +62 -0
- data/lib/dinie/generated/types/customer.rb +46 -0
- data/lib/dinie/generated/types/customer_bank_account.rb +33 -0
- data/lib/dinie/generated/types/ids.rb +18 -0
- data/lib/dinie/generated/types/kyc.rb +458 -0
- data/lib/dinie/generated/types/kyc_attachment_response.rb +16 -0
- data/lib/dinie/generated/types/loan.rb +51 -0
- data/lib/dinie/generated/types/money.rb +4 -0
- data/lib/dinie/generated/types/simulation.rb +35 -0
- data/lib/dinie/generated/types/transaction.rb +43 -0
- data/lib/dinie/generated/types/webhook_endpoint.rb +52 -0
- data/lib/dinie/generated/types/webhook_secret_rotation.rb +17 -0
- data/lib/dinie/generated/types.rb +18 -0
- data/lib/dinie/runtime/errors.rb +295 -0
- data/lib/dinie/runtime/http.rb +327 -0
- data/lib/dinie/runtime/idempotency.rb +34 -0
- data/lib/dinie/runtime/logger.rb +326 -0
- data/lib/dinie/runtime/model.rb +162 -0
- data/lib/dinie/runtime/multipart.rb +77 -0
- data/lib/dinie/runtime/paginator.rb +164 -0
- data/lib/dinie/runtime/rate_limit.rb +150 -0
- data/lib/dinie/runtime/request_options.rb +112 -0
- data/lib/dinie/runtime/retry.rb +74 -0
- data/lib/dinie/runtime/token_manager.rb +341 -0
- data/lib/dinie/runtime/webhooks.rb +194 -0
- data/lib/dinie/version.rb +7 -0
- data/lib/dinie.rb +37 -0
- data/sig/_external/faraday.rbs +44 -0
- data/sig/dinie/generated/client.rbs +45 -0
- data/sig/dinie/generated/errors/registry.rbs +40 -0
- data/sig/dinie/generated/events/base.rbs +17 -0
- data/sig/dinie/generated/events/credit_offer.rbs +33 -0
- data/sig/dinie/generated/events/customer_created.rbs +27 -0
- data/sig/dinie/generated/events/customer_denied.rbs +25 -0
- data/sig/dinie/generated/events/customer_kyc_updated.rbs +21 -0
- data/sig/dinie/generated/events/customer_status.rbs +26 -0
- data/sig/dinie/generated/events/deserializers.rbs +9 -0
- data/sig/dinie/generated/events/loan_active.rbs +20 -0
- data/sig/dinie/generated/events/loan_created.rbs +23 -0
- data/sig/dinie/generated/events/loan_payment_received.rbs +28 -0
- data/sig/dinie/generated/events/loan_processing.rbs +23 -0
- data/sig/dinie/generated/events/loan_signature_received.rbs +30 -0
- data/sig/dinie/generated/events/loan_status.rbs +40 -0
- data/sig/dinie/generated/resources/banks.rbs +15 -0
- data/sig/dinie/generated/resources/credentials.rbs +21 -0
- data/sig/dinie/generated/resources/credit_offers.rbs +19 -0
- data/sig/dinie/generated/resources/customers.rbs +58 -0
- data/sig/dinie/generated/resources/loans.rbs +26 -0
- data/sig/dinie/generated/resources/webhook_endpoints.rbs +35 -0
- data/sig/dinie/generated/types/bank.rbs +12 -0
- data/sig/dinie/generated/types/biometrics_session.rbs +11 -0
- data/sig/dinie/generated/types/credential.rbs +26 -0
- data/sig/dinie/generated/types/credit_offer.rbs +24 -0
- data/sig/dinie/generated/types/customer.rbs +25 -0
- data/sig/dinie/generated/types/customer_bank_account.rbs +26 -0
- data/sig/dinie/generated/types/enums.rbs +66 -0
- data/sig/dinie/generated/types/ids.rbs +21 -0
- data/sig/dinie/generated/types/kyc/attachment.rbs +14 -0
- data/sig/dinie/generated/types/kyc/common.rbs +42 -0
- data/sig/dinie/generated/types/kyc/requirements.rbs +117 -0
- data/sig/dinie/generated/types/kyc/submitted.rbs +21 -0
- data/sig/dinie/generated/types/kyc/uploads.rbs +24 -0
- data/sig/dinie/generated/types/loan.rbs +32 -0
- data/sig/dinie/generated/types/money.rbs +6 -0
- data/sig/dinie/generated/types/simulation.rbs +28 -0
- data/sig/dinie/generated/types/transaction.rbs +24 -0
- data/sig/dinie/generated/types/webhook_endpoint.rbs +38 -0
- data/sig/dinie/runtime/errors.rbs +106 -0
- data/sig/dinie/runtime/http.rbs +59 -0
- data/sig/dinie/runtime/idempotency.rbs +15 -0
- data/sig/dinie/runtime/logger.rbs +89 -0
- data/sig/dinie/runtime/model.rbs +51 -0
- data/sig/dinie/runtime/multipart.rbs +25 -0
- data/sig/dinie/runtime/paginator.rbs +50 -0
- data/sig/dinie/runtime/rate_limit.rbs +46 -0
- data/sig/dinie/runtime/request_options.rbs +35 -0
- data/sig/dinie/runtime/retry.rbs +29 -0
- data/sig/dinie/runtime/token_manager.rbs +51 -0
- data/sig/dinie/runtime/webhooks.rbs +31 -0
- data/sig/dinie/version.rbs +7 -0
- metadata +316 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Dinie
|
|
7
|
+
# Root of every error the SDK raises.
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Base for everything that originates from talking to the Dinie API.
|
|
11
|
+
#
|
|
12
|
+
# `code` and `request_id` are first-class here so every API error answers them (a
|
|
13
|
+
# connection error simply carries `nil`). The extraction logic that reads them off the
|
|
14
|
+
# RFC 9457 body / response headers lives once in {APIStatusError}, never per marker
|
|
15
|
+
# class — mirroring the V0.2 freeze and the future generator's "emit once into the base".
|
|
16
|
+
class APIError < Error
|
|
17
|
+
# @return [String, nil] machine-readable error code from the Problem Details `code` extension
|
|
18
|
+
attr_reader :code
|
|
19
|
+
|
|
20
|
+
# @return [String, nil] per-request correlation id (from `x-request-id`, body fallback)
|
|
21
|
+
attr_reader :request_id
|
|
22
|
+
|
|
23
|
+
# @param message [String, nil]
|
|
24
|
+
# @param code [String, nil]
|
|
25
|
+
# @param request_id [String, nil]
|
|
26
|
+
def initialize(message = nil, code: nil, request_id: nil)
|
|
27
|
+
super(message)
|
|
28
|
+
@code = code
|
|
29
|
+
@request_id = request_id
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A request never produced a response (DNS failure, socket reset, timeout…). Client-side:
|
|
34
|
+
# there is no server response to describe, so it lives in `runtime/`.
|
|
35
|
+
class APIConnectionError < APIError
|
|
36
|
+
# @param message [String]
|
|
37
|
+
def initialize(message = "Connection error.")
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The request exceeded the configured timeout.
|
|
43
|
+
class APITimeoutError < APIConnectionError
|
|
44
|
+
# @param message [String]
|
|
45
|
+
def initialize(message = "Request timed out.")
|
|
46
|
+
super
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The API returned a non-2xx response. Base of the openapi-mirrored marker catalog
|
|
51
|
+
# (`generated/errors/registry.rb`); the markers are empty subclasses and inherit this
|
|
52
|
+
# constructor, so {Internal::Errors.from_response} can build any of them uniformly.
|
|
53
|
+
class APIStatusError < APIError
|
|
54
|
+
# @return [Integer] HTTP status code
|
|
55
|
+
attr_reader :status
|
|
56
|
+
# @return [Hash] raw response headers
|
|
57
|
+
attr_reader :headers
|
|
58
|
+
# @return [Hash, String, nil] parsed Problem Details, raw body text, or nil
|
|
59
|
+
attr_reader :body
|
|
60
|
+
# @return [String, nil] Problem Details `type` URL
|
|
61
|
+
attr_reader :type
|
|
62
|
+
# @return [String, nil] Problem Details `title`
|
|
63
|
+
attr_reader :title
|
|
64
|
+
# @return [String, nil] Problem Details `detail`
|
|
65
|
+
attr_reader :detail
|
|
66
|
+
# @return [String, nil] Problem Details `instance`
|
|
67
|
+
attr_reader :instance
|
|
68
|
+
|
|
69
|
+
# @param status [Integer]
|
|
70
|
+
# @param body [Hash, String, nil] parsed Problem Details (symbol keys), raw text, or nil
|
|
71
|
+
# @param headers [Hash]
|
|
72
|
+
# @param request_id [String, nil]
|
|
73
|
+
def initialize(status:, body:, headers:, request_id: nil)
|
|
74
|
+
@status = status
|
|
75
|
+
@headers = headers
|
|
76
|
+
@body = body
|
|
77
|
+
problem = body.is_a?(Hash) ? body : nil
|
|
78
|
+
@type = string_field(problem, :type)
|
|
79
|
+
@title = string_field(problem, :title)
|
|
80
|
+
@detail = string_field(problem, :detail)
|
|
81
|
+
@instance = string_field(problem, :instance)
|
|
82
|
+
super(self.class.build_message(status, body, request_id),
|
|
83
|
+
code: string_field(problem, :code), request_id: request_id)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @api private
|
|
87
|
+
# @return [String]
|
|
88
|
+
def self.build_message(status, body, request_id)
|
|
89
|
+
summary = message_summary(body)
|
|
90
|
+
suffix = request_id.nil? ? "" : " (request_id: #{request_id})"
|
|
91
|
+
summary ? "#{status} #{summary}#{suffix}" : "#{status} status code (no body)#{suffix}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @api private
|
|
95
|
+
def self.message_summary(body)
|
|
96
|
+
if body.is_a?(Hash)
|
|
97
|
+
body[:detail] || body[:title]
|
|
98
|
+
elsif body.is_a?(String) && !body.empty?
|
|
99
|
+
body
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
private_class_method :message_summary
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def string_field(problem, key)
|
|
107
|
+
return nil unless problem
|
|
108
|
+
|
|
109
|
+
value = problem[key]
|
|
110
|
+
value.is_a?(String) ? value : nil
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# ── Client-side errors (outside the APIError tree — no server response) ────────
|
|
115
|
+
|
|
116
|
+
# OAuth2 client-credentials token acquisition/refresh failed.
|
|
117
|
+
class OAuthError < Error; end
|
|
118
|
+
|
|
119
|
+
# A session-mode token was successfully exchanged once but has since expired (via
|
|
120
|
+
# {Dinie::Internal::TokenManager#invalidate!} or TTL). Session tokens are single-use:
|
|
121
|
+
# once expired the original authorization code cannot be re-exchanged. Callers should
|
|
122
|
+
# surface this to the consumer so they can obtain a fresh code.
|
|
123
|
+
class SessionTokenExpiredError < Error; end
|
|
124
|
+
|
|
125
|
+
# A webhook payload failed HMAC signature verification.
|
|
126
|
+
class WebhookSignatureError < Error; end
|
|
127
|
+
|
|
128
|
+
# A webhook timestamp fell outside the tolerance window.
|
|
129
|
+
class WebhookTimestampError < Error; end
|
|
130
|
+
|
|
131
|
+
# A webhook signature verified, but the payload's `type` is not in the openapi event
|
|
132
|
+
# catalog, so there is no per-type deserializer for it. Raising (rather than passing the
|
|
133
|
+
# raw event through) is deliberate — a new event `type` is a contract change. The
|
|
134
|
+
# unrecognized `type` is preserved on {#event_type}.
|
|
135
|
+
class UnknownWebhookEventError < Error
|
|
136
|
+
# @return [String] the unrecognized (but verified) webhook event `type`
|
|
137
|
+
attr_reader :event_type
|
|
138
|
+
|
|
139
|
+
# @param event_type [String]
|
|
140
|
+
def initialize(event_type)
|
|
141
|
+
@event_type = event_type
|
|
142
|
+
super("Unknown webhook event type: #{event_type.inspect}. It is not in the openapi event " \
|
|
143
|
+
"catalog (generated/events). If Dinie added a new event type, the SDK must be updated " \
|
|
144
|
+
"from the contract before it can deserialize this payload.")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Parse a `Retry-After` value to **seconds**, capped at 60 (architecture §7/§10).
|
|
149
|
+
#
|
|
150
|
+
# Precedence mirrors the retry loop: a `Retry-After-Ms` value (milliseconds) wins, then
|
|
151
|
+
# `Retry-After` as delta-seconds, then `Retry-After` as an HTTP-date (delta from now).
|
|
152
|
+
# Each argument accepts a String or an Array (the first element is used, for repeated
|
|
153
|
+
# headers). A past HTTP-date clamps to 0; an abusive value clamps to 60. Returns the wait
|
|
154
|
+
# in seconds (Float) or `nil` when nothing parseable was given.
|
|
155
|
+
#
|
|
156
|
+
# Public runtime helper, consumed by the transport's retry loop (story 003) and usable in
|
|
157
|
+
# custom post-`RateLimitError` logic.
|
|
158
|
+
#
|
|
159
|
+
# @param retry_after [String, Array<String>, nil] the `Retry-After` header value
|
|
160
|
+
# @param retry_after_ms [String, Array<String>, nil] the `Retry-After-Ms` header value
|
|
161
|
+
# @return [Float, nil] seconds to wait (0..60), or nil
|
|
162
|
+
def self.parse_retry_after(retry_after = nil, retry_after_ms: nil)
|
|
163
|
+
seconds = retry_after_ms_seconds(retry_after_ms) || retry_after_seconds(retry_after)
|
|
164
|
+
return nil if seconds.nil?
|
|
165
|
+
|
|
166
|
+
seconds.clamp(0.0, RETRY_AFTER_CAP_SECONDS.to_f)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Maximum honored `Retry-After`, in seconds (architecture §10 — verbatim from the V0.2 freeze).
|
|
170
|
+
RETRY_AFTER_CAP_SECONDS = 60
|
|
171
|
+
|
|
172
|
+
# @api private
|
|
173
|
+
def self.retry_after_ms_seconds(value)
|
|
174
|
+
raw = first_header_value(value)
|
|
175
|
+
return nil if raw.nil?
|
|
176
|
+
|
|
177
|
+
ms = Float(raw, exception: false)
|
|
178
|
+
ms.nil? ? nil : ms / 1000.0
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @api private
|
|
182
|
+
def self.retry_after_seconds(value)
|
|
183
|
+
raw = first_header_value(value)&.strip
|
|
184
|
+
return nil if raw.nil? || raw.empty?
|
|
185
|
+
|
|
186
|
+
seconds = Float(raw, exception: false)
|
|
187
|
+
return seconds if seconds
|
|
188
|
+
|
|
189
|
+
http_date_delta(raw)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @api private
|
|
193
|
+
def self.http_date_delta(raw)
|
|
194
|
+
Time.httpdate(raw) - Time.now
|
|
195
|
+
rescue ArgumentError
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# @api private
|
|
200
|
+
def self.first_header_value(value)
|
|
201
|
+
raw = value.is_a?(Array) ? value.first : value
|
|
202
|
+
raw&.to_s
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private_class_method :retry_after_ms_seconds, :retry_after_seconds, :http_date_delta, :first_header_value
|
|
206
|
+
|
|
207
|
+
module Internal
|
|
208
|
+
# The transport-agnostic error dispatcher. Builds the right {Dinie::APIError} from an
|
|
209
|
+
# HTTP error response and reads the RFC 9457 Problem Details body.
|
|
210
|
+
#
|
|
211
|
+
# This is the controlled runtime → generated seam (architecture §4): it references the
|
|
212
|
+
# **generated** constant {Dinie::Internal::ERROR_REGISTRY} (defined in
|
|
213
|
+
# `generated/errors/registry.rb`). The reference is the forcing-function — if the error
|
|
214
|
+
# surface changes without regenerating the table, dispatch specs break. The runtime
|
|
215
|
+
# never `require`s the generated layer; the barrel loads the registry before any call.
|
|
216
|
+
module Errors
|
|
217
|
+
# Header carrying the per-request correlation id, surfaced as `error.request_id`.
|
|
218
|
+
REQUEST_ID_HEADER = "x-request-id"
|
|
219
|
+
|
|
220
|
+
module_function
|
|
221
|
+
|
|
222
|
+
# Build the typed error for an HTTP error response: dispatch by Problem Details `type`
|
|
223
|
+
# URL, then by HTTP status, then by the ≥500 band fallback. The body is parsed as JSON
|
|
224
|
+
# once (symbol keys); a non-JSON body is preserved as raw text.
|
|
225
|
+
#
|
|
226
|
+
# @param status [Integer] HTTP status code
|
|
227
|
+
# @param headers [Hash] response headers
|
|
228
|
+
# @param body [String, nil] raw response body
|
|
229
|
+
# @param force [Class, nil] when given, skip dispatch and build this exact class
|
|
230
|
+
# (used by the transport to guarantee an `AuthError` on a persistent 401)
|
|
231
|
+
# @return [Dinie::APIStatusError]
|
|
232
|
+
def from_response(status:, headers: {}, body: nil, force: nil)
|
|
233
|
+
record = parse_problem(body)
|
|
234
|
+
type_url = string_value(record, :type)
|
|
235
|
+
request_id = header_request_id(headers) || string_value(record, :request_id)
|
|
236
|
+
ctor = force || dispatch(type_url, status)
|
|
237
|
+
ctor.new(status: status, body: record || presentable_body(body), headers: headers || {},
|
|
238
|
+
request_id: request_id)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# @api private
|
|
242
|
+
def dispatch(type_url, status)
|
|
243
|
+
registry = Dinie::Internal::ERROR_REGISTRY
|
|
244
|
+
(type_url && registry[:by_type][type_url]) ||
|
|
245
|
+
registry[:by_status][status] ||
|
|
246
|
+
fallback_ctor(status)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# @api private
|
|
250
|
+
def fallback_ctor(status)
|
|
251
|
+
return Dinie::Internal::ERROR_REGISTRY[:fallback_5xx] if status >= 500
|
|
252
|
+
|
|
253
|
+
Dinie::APIStatusError
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# @api private
|
|
257
|
+
def parse_problem(body)
|
|
258
|
+
return nil unless body.is_a?(String) && !body.empty?
|
|
259
|
+
|
|
260
|
+
parsed = JSON.parse(body, symbolize_names: true)
|
|
261
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
262
|
+
rescue JSON::ParserError
|
|
263
|
+
nil
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# @api private
|
|
267
|
+
def presentable_body(body)
|
|
268
|
+
body.is_a?(String) && !body.empty? ? body : nil
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# @api private
|
|
272
|
+
def header_request_id(headers)
|
|
273
|
+
value = header_lookup(headers, REQUEST_ID_HEADER)
|
|
274
|
+
value.is_a?(Array) ? value.first : value
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# @api private
|
|
278
|
+
def header_lookup(headers, name)
|
|
279
|
+
return nil unless headers.respond_to?(:each)
|
|
280
|
+
|
|
281
|
+
target = name.downcase
|
|
282
|
+
headers.each { |key, value| return value if key.to_s.downcase == target }
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# @api private
|
|
287
|
+
def string_value(record, key)
|
|
288
|
+
return nil unless record
|
|
289
|
+
|
|
290
|
+
value = record[key]
|
|
291
|
+
value.is_a?(String) ? value : nil
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "faraday"
|
|
6
|
+
require "faraday/multipart"
|
|
7
|
+
require "faraday/net_http_persistent"
|
|
8
|
+
require_relative "multipart"
|
|
9
|
+
|
|
10
|
+
module Dinie
|
|
11
|
+
module Internal
|
|
12
|
+
# `HttpClient` — the request-lifecycle orchestrator and heart of the runtime (architecture
|
|
13
|
+
# §10, RB15), porting `sdk-js` `src/runtime/http.ts`. It owns one `Faraday::Connection` per
|
|
14
|
+
# {Dinie::Client} (adapter `:net_http_persistent` — a real connection pool with keep-alive,
|
|
15
|
+
# thread-safe) and, for every logical request, runs the full lifecycle:
|
|
16
|
+
#
|
|
17
|
+
# 1. mint an `X-Idempotency-Key` ONCE, before the loop, for non-GET writes, so every
|
|
18
|
+
# retry of the same logical request reuses it (never a duplicate resource);
|
|
19
|
+
# 2. obtain a Bearer token from the injected `TokenManager`;
|
|
20
|
+
# 3. assemble headers (auth, telemetry, idempotency, retry-count, content-type);
|
|
21
|
+
# 4. dispatch through Faraday — **one round-trip per call**; the retry loop is ours;
|
|
22
|
+
# 5. fold `X-RateLimit-*` headers into the snapshot `client.rate_limit` reads;
|
|
23
|
+
# 6. on success (< 300), parse and return the raw body (resources deserialize);
|
|
24
|
+
# 7. on 401, run the one-shot re-auth (`token_manager.invalidate!` + a fresh token), then
|
|
25
|
+
# give up with `AuthError` if a second 401 follows (no loop);
|
|
26
|
+
# 8. on a retryable status (`{408,429,500,502,503,504}`) or a transient transport error,
|
|
27
|
+
# back off (`Retry.retry_delay`) and retry while attempts remain, bumping
|
|
28
|
+
# `X-Dinie-Retry-Count`;
|
|
29
|
+
# 9. otherwise map the response to a typed error via {Errors.from_response}.
|
|
30
|
+
#
|
|
31
|
+
# ── DI seam (architecture §10, RB15) ──
|
|
32
|
+
# The `TokenManager` is **injected** (`HttpClient.new(token_manager:, …)`). Story 004 builds
|
|
33
|
+
# the real, concurrency-safe one; specs inject a fake. `auth_headers` is `token_manager
|
|
34
|
+
# .access_token`; the 401 one-shot is `token_manager.invalidate!`. No global/singleton, no
|
|
35
|
+
# placeholder hack — this is what lets `with_options` (story 004) clone a client while
|
|
36
|
+
# sharing the same token cache.
|
|
37
|
+
#
|
|
38
|
+
# ── controlled runtime → generated seam (openapi-SoT forcing function, architecture §4) ──
|
|
39
|
+
# The rule is "runtime/ never imports generated/". This module is one declared exception:
|
|
40
|
+
# it references {Dinie::AuthError} (forced on a persistent 401) and dispatches non-2xx
|
|
41
|
+
# responses through {Errors.from_response}, which reads the **generated**
|
|
42
|
+
# {Dinie::Internal::ERROR_REGISTRY}. The reference is the forcing function — an error not in
|
|
43
|
+
# `openapi.yaml` is not in `generated/`, so there is nowhere to dispatch it.
|
|
44
|
+
#
|
|
45
|
+
# Runtime-internal: {Dinie::Client} and the resources construct and call it; it is not part
|
|
46
|
+
# of the public SDK surface.
|
|
47
|
+
#
|
|
48
|
+
# The ClassLength cop is disabled below: the request lifecycle (prepare → loop → dispatch →
|
|
49
|
+
# error/retry/re-auth → parse) is one cohesive orchestrator; splitting it across classes would
|
|
50
|
+
# scatter the lifecycle and obscure the parity with `sdk-js` `http.ts`.
|
|
51
|
+
class HttpClient # rubocop:disable Metrics/ClassLength
|
|
52
|
+
# Default production API base URL — carries the `/api/v3` version prefix (openapi
|
|
53
|
+
# `servers[0]`). Resource paths are bare (`/customers`), so the version lives here.
|
|
54
|
+
DEFAULT_BASE_URL = "https://api.dinie.com.br/api/v3"
|
|
55
|
+
# Default per-request timeout, in **seconds** (RB17 — Faraday idiom; the TS SDK uses ms).
|
|
56
|
+
DEFAULT_TIMEOUT_SECONDS = 30
|
|
57
|
+
# Default retry budget after the first attempt.
|
|
58
|
+
DEFAULT_MAX_RETRIES = 3
|
|
59
|
+
# `Content-Type` for JSON request bodies.
|
|
60
|
+
JSON_CONTENT_TYPE = "application/json"
|
|
61
|
+
# Methods that auto-carry an `X-Idempotency-Key` when the caller does not say otherwise.
|
|
62
|
+
AUTO_IDEMPOTENT_METHODS = %i[post patch].freeze
|
|
63
|
+
# `User-Agent` sent on every request (architecture §5.1, telemetry AC). The api-version comes
|
|
64
|
+
# from the generated constant {Dinie::Generated::API_VERSION} (== openapi info.version).
|
|
65
|
+
USER_AGENT = format(
|
|
66
|
+
"Dinie-SDK-Ruby/%<sdk>s (api-version=%<api>s; ruby/%<rt>s)",
|
|
67
|
+
sdk: Dinie::VERSION, api: Dinie::Generated::API_VERSION, rt: RUBY_VERSION
|
|
68
|
+
).freeze
|
|
69
|
+
|
|
70
|
+
# Normalized, per-call request descriptor threaded through the retry loop. Keeping it in
|
|
71
|
+
# one struct holds the helper signatures small (and the body is serialized once, up front).
|
|
72
|
+
# `http_method` (not `method`) avoids shadowing `Object#method`.
|
|
73
|
+
PreparedRequest = Struct.new(
|
|
74
|
+
:http_method, :url, :body, :content_type, :idempotency_key, :max_retries, :timeout, :header_overrides,
|
|
75
|
+
keyword_init: true
|
|
76
|
+
)
|
|
77
|
+
private_constant :PreparedRequest
|
|
78
|
+
|
|
79
|
+
# @param token_manager [#access_token, #invalidate!] injected OAuth2 token source (story 004)
|
|
80
|
+
# @param base_url [String, nil] API base URL (default {DEFAULT_BASE_URL})
|
|
81
|
+
# @param timeout [Numeric] per-request timeout in seconds (default {DEFAULT_TIMEOUT_SECONDS})
|
|
82
|
+
# @param max_retries [Integer] retry budget after the first attempt (default {DEFAULT_MAX_RETRIES})
|
|
83
|
+
# @param idempotency [Boolean] auto-generate `X-Idempotency-Key` on POST/PATCH (default true)
|
|
84
|
+
# @param adapter [Symbol, nil] Faraday adapter (default `:net_http_persistent`)
|
|
85
|
+
# @param connection [Faraday::Connection, nil] injected connection (pool-sharing/test seam);
|
|
86
|
+
# requests use absolute URLs, so its `url_prefix` is irrelevant
|
|
87
|
+
# @param sleeper [#call, nil] backoff sleep (tests inject an instant, recording spy)
|
|
88
|
+
def initialize(token_manager:, base_url: nil, timeout: DEFAULT_TIMEOUT_SECONDS, # rubocop:disable Metrics/ParameterLists
|
|
89
|
+
max_retries: DEFAULT_MAX_RETRIES, idempotency: true, adapter: nil,
|
|
90
|
+
connection: nil, sleeper: nil)
|
|
91
|
+
@token_manager = token_manager
|
|
92
|
+
@base_url = (base_url || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
|
|
93
|
+
@timeout = timeout
|
|
94
|
+
@max_retries = max_retries
|
|
95
|
+
@idempotency = idempotency
|
|
96
|
+
@rate_limit_tracker = RateLimitTracker.new
|
|
97
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
98
|
+
@connection = connection || build_connection(adapter)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Latest rate-limit snapshot (read by `client.rate_limit`); `nil` before the first
|
|
102
|
+
# response that carried valid `X-RateLimit-*` headers.
|
|
103
|
+
#
|
|
104
|
+
# @return [Dinie::RateLimit, nil]
|
|
105
|
+
def rate_limit
|
|
106
|
+
@rate_limit_tracker.snapshot
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Run one logical request end-to-end and return the parsed body (resources deserialize it).
|
|
110
|
+
# `204`/empty bodies return `nil`; a JSON body is parsed with symbol keys; a non-JSON body
|
|
111
|
+
# is returned as raw text.
|
|
112
|
+
#
|
|
113
|
+
# @param method [Symbol, String] HTTP method (`:get`, `:post`, `:patch`, `:delete`, …)
|
|
114
|
+
# @param path [String] bare resource path (e.g. `/customers`); the base URL adds `/api/v3`
|
|
115
|
+
# @param query [Hash, nil] query params (`nil` values are dropped)
|
|
116
|
+
# @param body [Object, nil] request body (Hash/Array → JSON; String passed through)
|
|
117
|
+
# @param idempotent [Boolean, nil] force/suppress the idempotency key; `nil` ⇒ auto on POST/PATCH
|
|
118
|
+
# @param request_options [Dinie::Internal::RequestOptions, Hash, nil] per-call overrides
|
|
119
|
+
# @return [Object, nil] the parsed response body
|
|
120
|
+
# @raise [Dinie::APIStatusError] a typed error for any non-2xx that is not retried
|
|
121
|
+
# @raise [Dinie::AuthError] a 401 that persists after the one-shot re-auth
|
|
122
|
+
# @raise [Dinie::APITimeoutError] the request timed out and the retry budget is exhausted
|
|
123
|
+
# @raise [Dinie::APIConnectionError] a transport failure
|
|
124
|
+
def request(method:, path:, query: nil, body: nil, idempotent: nil, request_options: nil) # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
|
|
125
|
+
options = RequestOptions.coerce(request_options)
|
|
126
|
+
normalized_method = method.to_s.downcase.to_sym
|
|
127
|
+
serialized_body, content_type = serialize_body(body)
|
|
128
|
+
execute(PreparedRequest.new(
|
|
129
|
+
http_method: normalized_method,
|
|
130
|
+
url: build_full_url(path, query),
|
|
131
|
+
body: serialized_body,
|
|
132
|
+
content_type: content_type,
|
|
133
|
+
idempotency_key: resolve_idempotency_key(idempotent, normalized_method, options),
|
|
134
|
+
max_retries: options.max_retries || @max_retries,
|
|
135
|
+
timeout: options.timeout || @timeout,
|
|
136
|
+
header_overrides: options.headers
|
|
137
|
+
))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# The retry loop. `attempt` increments on every continued path (a transport retry, a
|
|
143
|
+
# status retry, AND the 401 one-shot), so `X-Dinie-Retry-Count` mirrors the TS SDK exactly.
|
|
144
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
145
|
+
def execute(prepared)
|
|
146
|
+
attempt = 0
|
|
147
|
+
reauthed = false
|
|
148
|
+
loop do
|
|
149
|
+
token = @token_manager.access_token
|
|
150
|
+
headers = build_headers(prepared, token, attempt)
|
|
151
|
+
|
|
152
|
+
begin
|
|
153
|
+
response = dispatch(prepared, headers)
|
|
154
|
+
rescue Faraday::Error => e
|
|
155
|
+
raise translate_network_error(e) unless Retry.retryable_network_error?(e) && attempt < prepared.max_retries
|
|
156
|
+
|
|
157
|
+
sleep_for(Retry.retry_delay(attempt))
|
|
158
|
+
attempt += 1
|
|
159
|
+
next
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
@rate_limit_tracker.update(response.headers)
|
|
163
|
+
status = response.status
|
|
164
|
+
return parse_body(response) if status < 300
|
|
165
|
+
|
|
166
|
+
# 401 one-shot: drop the cached token, re-auth once, retry.
|
|
167
|
+
if status == 401 && !reauthed
|
|
168
|
+
@token_manager.invalidate!
|
|
169
|
+
reauthed = true
|
|
170
|
+
attempt += 1
|
|
171
|
+
next
|
|
172
|
+
end
|
|
173
|
+
# Persistent 401 after the one-shot: give up with a typed AuthError (no loop). Forcing
|
|
174
|
+
# the class guarantees the type even if the body lacks the openapi `type` URL.
|
|
175
|
+
raise build_error(response, force: Dinie::AuthError) if status == 401
|
|
176
|
+
|
|
177
|
+
if Retry.should_retry?(status) && attempt < prepared.max_retries
|
|
178
|
+
sleep_for(Retry.retry_delay(attempt,
|
|
179
|
+
retry_after: header(response, "retry-after"),
|
|
180
|
+
retry_after_ms: header(response, "retry-after-ms")))
|
|
181
|
+
attempt += 1
|
|
182
|
+
next
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
raise build_error(response)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
189
|
+
|
|
190
|
+
# One Faraday round-trip. Absolute URL ⇒ the connection's `url_prefix` is irrelevant, so
|
|
191
|
+
# `base_url='…/api/v3'` never collides with a leading-slash resource path.
|
|
192
|
+
def dispatch(prepared, headers)
|
|
193
|
+
@connection.run_request(prepared.http_method, prepared.url, prepared.body, headers) do |req|
|
|
194
|
+
req.options.timeout = prepared.timeout
|
|
195
|
+
req.options.open_timeout = prepared.timeout
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Assemble the outgoing headers: Bearer auth, telemetry, the Idempotency-Key (when
|
|
200
|
+
# present), `X-Dinie-Retry-Count` on retries, and `Content-Type` when there is a body. A
|
|
201
|
+
# caller header with a `nil` value removes the matching default; any other value overrides.
|
|
202
|
+
def build_headers(prepared, token, attempt)
|
|
203
|
+
headers = base_headers(token)
|
|
204
|
+
headers["content-type"] = prepared.content_type unless prepared.content_type.nil?
|
|
205
|
+
headers["x-idempotency-key"] = prepared.idempotency_key unless prepared.idempotency_key.nil?
|
|
206
|
+
headers["x-dinie-retry-count"] = attempt.to_s if attempt.positive?
|
|
207
|
+
apply_header_overrides(headers, prepared.header_overrides)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Auth + telemetry defaults present on every request (architecture §5.1).
|
|
211
|
+
def base_headers(token)
|
|
212
|
+
{
|
|
213
|
+
"authorization" => "Bearer #{token}",
|
|
214
|
+
"accept" => JSON_CONTENT_TYPE,
|
|
215
|
+
"user-agent" => USER_AGENT,
|
|
216
|
+
"x-dinie-sdk-language" => "ruby",
|
|
217
|
+
"x-dinie-sdk-version" => Dinie::VERSION,
|
|
218
|
+
"x-dinie-sdk-runtime" => "ruby/#{RUBY_VERSION}"
|
|
219
|
+
}
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Apply per-call header overrides: `nil` removes a default, any String replaces it.
|
|
223
|
+
def apply_header_overrides(headers, overrides)
|
|
224
|
+
return headers if overrides.nil?
|
|
225
|
+
|
|
226
|
+
overrides.each do |key, value|
|
|
227
|
+
lower = key.to_s.downcase
|
|
228
|
+
if value.nil?
|
|
229
|
+
headers.delete(lower)
|
|
230
|
+
else
|
|
231
|
+
headers[lower] = value
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
headers
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Resolve the `X-Idempotency-Key` (architecture §10). A per-call override always wins —
|
|
238
|
+
# even when auto-idempotency is opted out globally, an explicit key is an explicit opt-in.
|
|
239
|
+
# Otherwise a key is auto-minted for POST/PATCH unless opted out. `nil` ⇒ send no key.
|
|
240
|
+
def resolve_idempotency_key(idempotent, method, options)
|
|
241
|
+
idempotent = AUTO_IDEMPOTENT_METHODS.include?(method) if idempotent.nil?
|
|
242
|
+
return nil unless idempotent
|
|
243
|
+
return options.idempotency_key unless options.idempotency_key.nil?
|
|
244
|
+
return nil unless @idempotency
|
|
245
|
+
|
|
246
|
+
Idempotency.generate_key
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Serialize a request body: a {Multipart} becomes a Faraday multipart body tagged with a bare
|
|
250
|
+
# `multipart/form-data` content type (Faraday's `:multipart` middleware appends the boundary
|
|
251
|
+
# and encodes it — story 009); a Hash/Array → JSON; a String is assumed already-serialized
|
|
252
|
+
# JSON; `nil` sends no body. Returns `[body_or_nil, content_type_or_nil]`.
|
|
253
|
+
def serialize_body(body)
|
|
254
|
+
return [nil, nil] if body.nil?
|
|
255
|
+
return [body.to_faraday_body, Multipart::CONTENT_TYPE] if body.is_a?(Multipart)
|
|
256
|
+
return [body, JSON_CONTENT_TYPE] if body.is_a?(String)
|
|
257
|
+
|
|
258
|
+
[JSON.generate(body), JSON_CONTENT_TYPE]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Read + parse a successful response body. `204`/empty ⇒ `nil`; JSON ⇒ parsed with symbol
|
|
262
|
+
# keys (resources read `raw[:field]`); non-JSON ⇒ raw text.
|
|
263
|
+
def parse_body(response)
|
|
264
|
+
return nil if response.status == 204
|
|
265
|
+
|
|
266
|
+
body = response.body
|
|
267
|
+
return nil if body.nil? || body.empty?
|
|
268
|
+
|
|
269
|
+
JSON.parse(body, symbolize_names: true)
|
|
270
|
+
rescue JSON::ParserError
|
|
271
|
+
body
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Prepend the base URL and append the query string. Absolute URLs sidestep Faraday's
|
|
275
|
+
# leading-slash base-path replacement (the trap `sdk-js` handles via `#basePath`).
|
|
276
|
+
def build_full_url(path, query)
|
|
277
|
+
url = "#{@base_url}#{path}"
|
|
278
|
+
encoded = encode_query(query)
|
|
279
|
+
return url if encoded.empty?
|
|
280
|
+
|
|
281
|
+
"#{url}#{path.include?("?") ? "&" : "?"}#{encoded}"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Encode query params, dropping `nil` values.
|
|
285
|
+
def encode_query(query)
|
|
286
|
+
return "" if query.nil? || query.empty?
|
|
287
|
+
|
|
288
|
+
URI.encode_www_form(query.compact)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# First value of a (possibly repeated) response header.
|
|
292
|
+
def header(response, name)
|
|
293
|
+
value = response.headers[name]
|
|
294
|
+
value.is_a?(Array) ? value.first : value
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Build the typed error for a non-2xx response (story 002 dispatch). `body` is the raw
|
|
298
|
+
# String (JSON is parsed inside `from_response`); `force` skips dispatch (persistent 401).
|
|
299
|
+
def build_error(response, force: nil)
|
|
300
|
+
Errors.from_response(status: response.status, headers: response.headers.to_h,
|
|
301
|
+
body: response.body, force: force)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Map an exhausted/non-retryable transport error to the right connection-error class.
|
|
305
|
+
def translate_network_error(error)
|
|
306
|
+
return Dinie::APITimeoutError.new if error.is_a?(Faraday::TimeoutError)
|
|
307
|
+
|
|
308
|
+
Dinie::APIConnectionError.new(error.message)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def sleep_for(seconds)
|
|
312
|
+
@sleeper.call(seconds)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Standalone fallback connection, used only when no `connection:` is injected (tests, direct
|
|
316
|
+
# use). The composed SDK always injects the SHARED connection built by {Dinie::Client}, which
|
|
317
|
+
# is where the story 005 logging middleware mounts (so it also observes the TokenManager's
|
|
318
|
+
# token POST). This builder is intentionally plain — do NOT mount logging here.
|
|
319
|
+
def build_connection(adapter)
|
|
320
|
+
Faraday.new(url: @base_url) do |faraday|
|
|
321
|
+
faraday.request(:multipart)
|
|
322
|
+
faraday.adapter(adapter || :net_http_persistent)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Dinie
|
|
6
|
+
module Internal
|
|
7
|
+
# Idempotency-Key generation (architecture §10, RB15; mirrors `sdk-js` `idempotency.ts`).
|
|
8
|
+
#
|
|
9
|
+
# One job: mint a fresh auto-generated key. The `dinie-sdk-retry-` prefix lets the Dinie
|
|
10
|
+
# backend tell an SDK-generated key apart from a partner-supplied one.
|
|
11
|
+
#
|
|
12
|
+
# The *policy* around the key lives in {HttpClient} (story 003), NOT here:
|
|
13
|
+
# - only non-GET writes get one (auto on POST/PATCH),
|
|
14
|
+
# - the key is generated ONCE before the retry loop, so every attempt of the same logical
|
|
15
|
+
# request reuses it (a retry must never create a duplicate resource),
|
|
16
|
+
# - a caller can override it via `request_options[:idempotency_key]`,
|
|
17
|
+
# - the whole behavior can be opted out globally with `idempotency: false`.
|
|
18
|
+
#
|
|
19
|
+
# Runtime-internal: consumed directly by {HttpClient}, not part of the public surface.
|
|
20
|
+
module Idempotency
|
|
21
|
+
# Prefix marking a key as SDK-auto-generated (vs. partner-supplied) on the backend.
|
|
22
|
+
KEY_PREFIX = "dinie-sdk-retry-"
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# A fresh auto-generated Idempotency-Key: `dinie-sdk-retry-<uuid v4>`.
|
|
27
|
+
#
|
|
28
|
+
# @return [String]
|
|
29
|
+
def generate_key
|
|
30
|
+
"#{KEY_PREFIX}#{SecureRandom.uuid}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|