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.
Files changed (114) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +40 -0
  3. data/LICENSE +21 -0
  4. data/README.md +280 -0
  5. data/lib/dinie/generated/api_version.rb +8 -0
  6. data/lib/dinie/generated/client.rb +96 -0
  7. data/lib/dinie/generated/errors/registry.rb +40 -0
  8. data/lib/dinie/generated/events/base.rb +11 -0
  9. data/lib/dinie/generated/events/credit_offer.rb +56 -0
  10. data/lib/dinie/generated/events/customer_created.rb +42 -0
  11. data/lib/dinie/generated/events/customer_denied.rb +39 -0
  12. data/lib/dinie/generated/events/customer_kyc_updated.rb +36 -0
  13. data/lib/dinie/generated/events/customer_status.rb +48 -0
  14. data/lib/dinie/generated/events/deserializers.rb +35 -0
  15. data/lib/dinie/generated/events/loan_active.rb +35 -0
  16. data/lib/dinie/generated/events/loan_created.rb +38 -0
  17. data/lib/dinie/generated/events/loan_payment_received.rb +46 -0
  18. data/lib/dinie/generated/events/loan_processing.rb +37 -0
  19. data/lib/dinie/generated/events/loan_signature_received.rb +48 -0
  20. data/lib/dinie/generated/events/loan_status.rb +73 -0
  21. data/lib/dinie/generated/events.rb +4 -0
  22. data/lib/dinie/generated/resources/banks.rb +25 -0
  23. data/lib/dinie/generated/resources/biometrics.rb +27 -0
  24. data/lib/dinie/generated/resources/credentials.rb +56 -0
  25. data/lib/dinie/generated/resources/credit_offers.rb +59 -0
  26. data/lib/dinie/generated/resources/customers.rb +200 -0
  27. data/lib/dinie/generated/resources/loans.rb +70 -0
  28. data/lib/dinie/generated/resources/webhook_endpoints.rb +97 -0
  29. data/lib/dinie/generated/resources.rb +9 -0
  30. data/lib/dinie/generated/types/bank.rb +17 -0
  31. data/lib/dinie/generated/types/biometrics_session.rb +16 -0
  32. data/lib/dinie/generated/types/biometrics_session_exchange_response.rb +23 -0
  33. data/lib/dinie/generated/types/credential.rb +52 -0
  34. data/lib/dinie/generated/types/credit_offer.rb +62 -0
  35. data/lib/dinie/generated/types/customer.rb +46 -0
  36. data/lib/dinie/generated/types/customer_bank_account.rb +33 -0
  37. data/lib/dinie/generated/types/ids.rb +18 -0
  38. data/lib/dinie/generated/types/kyc.rb +458 -0
  39. data/lib/dinie/generated/types/kyc_attachment_response.rb +16 -0
  40. data/lib/dinie/generated/types/loan.rb +51 -0
  41. data/lib/dinie/generated/types/money.rb +4 -0
  42. data/lib/dinie/generated/types/simulation.rb +35 -0
  43. data/lib/dinie/generated/types/transaction.rb +43 -0
  44. data/lib/dinie/generated/types/webhook_endpoint.rb +52 -0
  45. data/lib/dinie/generated/types/webhook_secret_rotation.rb +17 -0
  46. data/lib/dinie/generated/types.rb +18 -0
  47. data/lib/dinie/runtime/errors.rb +295 -0
  48. data/lib/dinie/runtime/http.rb +327 -0
  49. data/lib/dinie/runtime/idempotency.rb +34 -0
  50. data/lib/dinie/runtime/logger.rb +326 -0
  51. data/lib/dinie/runtime/model.rb +162 -0
  52. data/lib/dinie/runtime/multipart.rb +77 -0
  53. data/lib/dinie/runtime/paginator.rb +164 -0
  54. data/lib/dinie/runtime/rate_limit.rb +150 -0
  55. data/lib/dinie/runtime/request_options.rb +112 -0
  56. data/lib/dinie/runtime/retry.rb +74 -0
  57. data/lib/dinie/runtime/token_manager.rb +341 -0
  58. data/lib/dinie/runtime/webhooks.rb +194 -0
  59. data/lib/dinie/version.rb +7 -0
  60. data/lib/dinie.rb +37 -0
  61. data/sig/_external/faraday.rbs +44 -0
  62. data/sig/dinie/generated/client.rbs +45 -0
  63. data/sig/dinie/generated/errors/registry.rbs +40 -0
  64. data/sig/dinie/generated/events/base.rbs +17 -0
  65. data/sig/dinie/generated/events/credit_offer.rbs +33 -0
  66. data/sig/dinie/generated/events/customer_created.rbs +27 -0
  67. data/sig/dinie/generated/events/customer_denied.rbs +25 -0
  68. data/sig/dinie/generated/events/customer_kyc_updated.rbs +21 -0
  69. data/sig/dinie/generated/events/customer_status.rbs +26 -0
  70. data/sig/dinie/generated/events/deserializers.rbs +9 -0
  71. data/sig/dinie/generated/events/loan_active.rbs +20 -0
  72. data/sig/dinie/generated/events/loan_created.rbs +23 -0
  73. data/sig/dinie/generated/events/loan_payment_received.rbs +28 -0
  74. data/sig/dinie/generated/events/loan_processing.rbs +23 -0
  75. data/sig/dinie/generated/events/loan_signature_received.rbs +30 -0
  76. data/sig/dinie/generated/events/loan_status.rbs +40 -0
  77. data/sig/dinie/generated/resources/banks.rbs +15 -0
  78. data/sig/dinie/generated/resources/credentials.rbs +21 -0
  79. data/sig/dinie/generated/resources/credit_offers.rbs +19 -0
  80. data/sig/dinie/generated/resources/customers.rbs +58 -0
  81. data/sig/dinie/generated/resources/loans.rbs +26 -0
  82. data/sig/dinie/generated/resources/webhook_endpoints.rbs +35 -0
  83. data/sig/dinie/generated/types/bank.rbs +12 -0
  84. data/sig/dinie/generated/types/biometrics_session.rbs +11 -0
  85. data/sig/dinie/generated/types/credential.rbs +26 -0
  86. data/sig/dinie/generated/types/credit_offer.rbs +24 -0
  87. data/sig/dinie/generated/types/customer.rbs +25 -0
  88. data/sig/dinie/generated/types/customer_bank_account.rbs +26 -0
  89. data/sig/dinie/generated/types/enums.rbs +66 -0
  90. data/sig/dinie/generated/types/ids.rbs +21 -0
  91. data/sig/dinie/generated/types/kyc/attachment.rbs +14 -0
  92. data/sig/dinie/generated/types/kyc/common.rbs +42 -0
  93. data/sig/dinie/generated/types/kyc/requirements.rbs +117 -0
  94. data/sig/dinie/generated/types/kyc/submitted.rbs +21 -0
  95. data/sig/dinie/generated/types/kyc/uploads.rbs +24 -0
  96. data/sig/dinie/generated/types/loan.rbs +32 -0
  97. data/sig/dinie/generated/types/money.rbs +6 -0
  98. data/sig/dinie/generated/types/simulation.rbs +28 -0
  99. data/sig/dinie/generated/types/transaction.rbs +24 -0
  100. data/sig/dinie/generated/types/webhook_endpoint.rbs +38 -0
  101. data/sig/dinie/runtime/errors.rbs +106 -0
  102. data/sig/dinie/runtime/http.rbs +59 -0
  103. data/sig/dinie/runtime/idempotency.rbs +15 -0
  104. data/sig/dinie/runtime/logger.rbs +89 -0
  105. data/sig/dinie/runtime/model.rbs +51 -0
  106. data/sig/dinie/runtime/multipart.rbs +25 -0
  107. data/sig/dinie/runtime/paginator.rbs +50 -0
  108. data/sig/dinie/runtime/rate_limit.rbs +46 -0
  109. data/sig/dinie/runtime/request_options.rbs +35 -0
  110. data/sig/dinie/runtime/retry.rbs +29 -0
  111. data/sig/dinie/runtime/token_manager.rbs +51 -0
  112. data/sig/dinie/runtime/webhooks.rbs +31 -0
  113. data/sig/dinie/version.rbs +7 -0
  114. 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