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,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "logger"
5
+ require "set"
6
+ require "securerandom"
7
+ require "faraday"
8
+ require_relative "model"
9
+
10
+ module Dinie
11
+ module Internal
12
+ # Pure, stateless redaction + truncation helpers shared by {RuntimeLogger}. A financial
13
+ # backend carries PII and credentials in headers and bodies, so every value the logger emits
14
+ # passes through here first (architecture §9, RB12). No I/O, no state — trivially testable.
15
+ #
16
+ # The body-field redaction list is **derived from** {Dinie::Internal::Model::REDACTED_ATTRIBUTES}
17
+ # (story 002) so the logger and the model `#inspect` can never drift apart: one source of truth
18
+ # for "what counts as PII" (the story's stated risk — divergence here is a leak).
19
+ module LogRedaction
20
+ # Mask substituted for any redacted header value or body field.
21
+ REDACTED = "[REDACTED]"
22
+ # Bodies whose serialized size reaches this many bytes are truncated (architecture §9).
23
+ MAX_BODY_BYTES = 2048
24
+ # Header names (lowercased) whose values carry credentials/signatures → masked. Covers the
25
+ # `Authorization: Basic` of the token POST (client secret), the webhook HMAC, the per-call
26
+ # client-secret header, and any forward-proxy auth.
27
+ REDACTED_HEADERS = Set.new(
28
+ %w[authorization webhook-signature x-dinie-client-secret proxy-authorization]
29
+ ).freeze
30
+ # Body field names (lowercased) carrying PII/secrets → masked recursively. Mirrors the
31
+ # model's `#inspect` redaction set verbatim (the canonical list, story 002).
32
+ REDACTED_BODY_FIELDS = Set.new(Model::REDACTED_ATTRIBUTES.map(&:to_s)).freeze
33
+
34
+ # Sentinel telling {format_body} that a String body was not JSON (so it is logged as-is,
35
+ # not re-serialized). Distinct from a real JSON `null`, which parses to `nil`.
36
+ NOT_JSON = Object.new
37
+ private_constant :NOT_JSON
38
+
39
+ module_function
40
+
41
+ # Replace the value of any sensitive header with the mask (case-insensitive). Accepts a Hash
42
+ # or `Faraday::Utils::Headers`; returns a plain Hash with the original key casing preserved.
43
+ #
44
+ # @param headers [#each, nil] request/response headers
45
+ # @return [Hash{String => Object}]
46
+ def redact_headers(headers)
47
+ return {} if headers.nil?
48
+
49
+ headers.each_with_object({}) do |(key, value), out|
50
+ out[key.to_s] = REDACTED_HEADERS.include?(key.to_s.downcase) ? REDACTED : value
51
+ end
52
+ end
53
+
54
+ # Deep-copy `value`, masking any Hash key (case-insensitive) that names a PII/secret field.
55
+ # Arrays and nested objects are walked; scalars pass through untouched.
56
+ #
57
+ # @param value [Object] a parsed body (Hash/Array/scalar)
58
+ # @return [Object] the redacted copy
59
+ def redact_body(value)
60
+ case value
61
+ when Array then value.map { |item| redact_body(item) }
62
+ when Hash then redact_hash(value)
63
+ else value
64
+ end
65
+ end
66
+
67
+ # @api private
68
+ def redact_hash(hash)
69
+ hash.each_with_object({}) do |(key, value), out|
70
+ out[key] = REDACTED_BODY_FIELDS.include?(key.to_s.downcase) ? REDACTED : redact_body(value)
71
+ end
72
+ end
73
+
74
+ # Render a body for logging: redact PII, serialize to JSON, then truncate. A String body is
75
+ # JSON-parsed first (so its fields are redacted) and logged verbatim when it is not JSON.
76
+ # `nil`/empty bodies return `nil` (the caller drops the key).
77
+ #
78
+ # @param body [Object, nil] the raw request/response body
79
+ # @return [String, nil]
80
+ def format_body(body)
81
+ return nil if body.nil? || (body.is_a?(String) && body.empty?)
82
+
83
+ truncate_body(serialize_for_log(body))
84
+ end
85
+
86
+ # @api private
87
+ def serialize_for_log(body)
88
+ return safe_generate(redact_body(body)) unless body.is_a?(String)
89
+
90
+ parsed = parse_json(body)
91
+ parsed.equal?(NOT_JSON) ? body : safe_generate(redact_body(parsed))
92
+ end
93
+
94
+ # Truncate to {MAX_BODY_BYTES} on a codepoint boundary, appending `…[truncated,
95
+ # full_size=NNN]` (NNN = full UTF-8 byte size). Returns the input unchanged when under the
96
+ # threshold (so a binary/multipart blob never floods the log — story risk note).
97
+ #
98
+ # @param text [String]
99
+ # @return [String]
100
+ def truncate_body(text)
101
+ full_size = text.bytesize
102
+ return text if full_size < MAX_BODY_BYTES
103
+
104
+ "#{clip_to_bytes(text, MAX_BODY_BYTES)}…[truncated, full_size=#{full_size}]"
105
+ end
106
+
107
+ # @api private
108
+ # Take whole characters until the next one would exceed `max_bytes`.
109
+ def clip_to_bytes(text, max_bytes)
110
+ bytes = 0
111
+ clipped = +""
112
+ text.each_char do |char|
113
+ char_bytes = char.bytesize
114
+ break if bytes + char_bytes > max_bytes
115
+
116
+ bytes += char_bytes
117
+ clipped << char
118
+ end
119
+ clipped
120
+ end
121
+
122
+ # @api private
123
+ def parse_json(text)
124
+ JSON.parse(text)
125
+ rescue JSON::ParserError
126
+ NOT_JSON
127
+ end
128
+
129
+ # @api private
130
+ # `JSON.generate` that never raises (a log line must not blow up the request).
131
+ def safe_generate(value)
132
+ JSON.generate(value)
133
+ rescue StandardError
134
+ "[unserializable]"
135
+ end
136
+ end
137
+
138
+ # Leveled logging facade with PII redaction (architecture §9, RB12). Owns the effective level
139
+ # (resolved once) and an injectable sink; gates every call and routes request/response detail
140
+ # through {LogRedaction} before emitting. Constructed by {Middleware::Logging} from the
141
+ # `log_level:` / `logger:` the {Dinie::Client} captured.
142
+ #
143
+ # Defaults to `:off` — a financial SDK emits nothing unless the caller opts in. Mirrors the
144
+ # leveling/redaction approach of the TS `RuntimeLogger`; the divergence is the **hook point**
145
+ # (TS owns a custom logger instance; Ruby fills the OpenAI-Ruby gap with a Faraday middleware —
146
+ # the `comparison.md` axis).
147
+ class RuntimeLogger
148
+ # Level ordering for gating: a call at `level` emits when `level <= configured` (so `:off`
149
+ # emits nothing, `:debug` emits everything).
150
+ LEVELS = { off: 0, error: 1, warn: 2, info: 3, debug: 4 }.freeze
151
+ # Environment variable read when no explicit `level:` is given.
152
+ ENV_VAR = "DINIE_LOG"
153
+
154
+ # The effective level after `level:` + `DINIE_LOG` resolution.
155
+ # @return [Symbol]
156
+ attr_reader :level
157
+
158
+ # @param level [Symbol, String, nil] explicit level; a valid one wins over the env var
159
+ # @param logger [Object, nil] custom sink responding to `#debug/#info/#warn/#error`
160
+ # (default `::Logger.new($stdout)`)
161
+ # @param env [String, nil] `DINIE_LOG` override (injectable for tests)
162
+ def initialize(level: nil, logger: nil, env: ENV.fetch(ENV_VAR, nil))
163
+ @level = resolve_level(level, env)
164
+ @sink = logger || ::Logger.new($stdout)
165
+ end
166
+
167
+ # Whether a call at `level` would emit under the configured level.
168
+ #
169
+ # @param level [Symbol] one of {LEVELS}
170
+ # @return [Boolean]
171
+ def enabled?(level)
172
+ LEVELS.fetch(level) <= LEVELS.fetch(@level)
173
+ end
174
+
175
+ # @return [void]
176
+ def error(message) = emit(:error, message)
177
+ # @return [void]
178
+ def warn(message) = emit(:warn, message)
179
+ # @return [void]
180
+ def info(message) = emit(:info, message)
181
+ # @return [void]
182
+ def debug(message) = emit(:debug, message)
183
+
184
+ # Log an outgoing request at `debug` with redacted headers + body and the correlation triple.
185
+ #
186
+ # @param method [Symbol, String] HTTP method
187
+ # @param url [String] request URL
188
+ # @param headers [#each, nil] request headers
189
+ # @param body [Object, nil] request body
190
+ # @param correlation [Hash] `request_log_id` / `retry_of` / `attempt`
191
+ # @return [void]
192
+ def log_request(method:, url:, headers:, body:, correlation:)
193
+ return unless enabled?(:debug)
194
+
195
+ emit_line("→ request", correlation.merge(method: method.to_s.upcase, url: url), headers, body)
196
+ end
197
+
198
+ # Log an incoming response at `debug` with redacted headers + body, duration, and request id.
199
+ #
200
+ # @param status [Integer] HTTP status
201
+ # @param url [String] request URL
202
+ # @param headers [#each, nil] response headers
203
+ # @param body [Object, nil] response body
204
+ # @param duration_ms [Float] round-trip time in milliseconds
205
+ # @param request_id [String, nil] the response `X-Request-Id` (APM correlation)
206
+ # @param correlation [Hash] `request_log_id` / `retry_of` / `attempt`
207
+ # @return [void]
208
+ def log_response(status:, url:, headers:, body:, duration_ms:, request_id:, correlation:) # rubocop:disable Metrics/ParameterLists
209
+ return unless enabled?(:debug)
210
+
211
+ fields = correlation.merge(status: status, url: url, duration_ms: duration_ms, request_id: request_id)
212
+ emit_line("← response", fields, headers, body)
213
+ end
214
+
215
+ private
216
+
217
+ def emit(level, message)
218
+ @sink.public_send(level, message) if enabled?(level)
219
+ end
220
+
221
+ # Render one debug line as `[dinie] <label> k=v … headers={…} body=<json>`. Scalar fields use
222
+ # `k=v`; headers are inspected (a small map); the body is appended RAW (the already-redacted,
223
+ # truncated JSON) so it is not double-escaped — readable in logs and grep-able in tests.
224
+ def emit_line(label, fields, headers, body)
225
+ parts = ["[dinie] #{label}"]
226
+ fields.compact.each { |key, value| parts << "#{key}=#{value}" }
227
+ parts << "headers=#{LogRedaction.redact_headers(headers).inspect}" unless headers.nil?
228
+ formatted = LogRedaction.format_body(body)
229
+ parts << "body=#{formatted}" unless formatted.nil?
230
+ @sink.debug(parts.join(" "))
231
+ end
232
+
233
+ # Explicit valid `level:` wins; else a valid `DINIE_LOG`; else `:off`. An unset/garbage value
234
+ # never raises — it degrades to `:off`.
235
+ def resolve_level(level, env)
236
+ return level.to_sym if valid_level?(level)
237
+ return env.strip.to_sym if !env.nil? && valid_level?(env.strip)
238
+
239
+ :off
240
+ end
241
+
242
+ def valid_level?(value)
243
+ !value.nil? && LEVELS.key?(value.to_s.to_sym)
244
+ end
245
+ end
246
+
247
+ # Namespace for the SDK's Faraday middleware (currently just {Middleware::Logging}).
248
+ module Middleware
249
+ # `Logging` — the Faraday response middleware that makes logging the natural hook point
250
+ # (architecture §9, RB12), filling the gap OpenAI Ruby leaves (no logger at all). {Dinie::Client}
251
+ # mounts it on the ONE shared connection, BEFORE the adapter, so it observes the FINAL
252
+ # request/response of EVERY call — including the {TokenManager}'s `POST /auth/token`, whose
253
+ # `Authorization: Basic` header (the client secret) is redacted here automatically.
254
+ #
255
+ # ── Retry correlation across a Faraday-level middleware ──
256
+ # The retry loop lives ABOVE this middleware (in {HttpClient}), so each attempt is a separate
257
+ # round-trip through `#call`. To still stitch a request and its retries together in an APM, the
258
+ # first attempt (no `X-Dinie-Retry-Count`) mints an origin `request_log_id` and stashes it on
259
+ # the current thread; each retry — run synchronously in that SAME thread by the retry loop —
260
+ # reports `retry_of: <origin>`. This is the Ruby manifestation of the TS logger's
261
+ # `requestLogID`/`retryOf`, adapted to the middleware hook point.
262
+ class Logging < Faraday::Middleware
263
+ # Thread-local key holding the origin request's log id for the current logical request.
264
+ ORIGIN_LOG_ID_KEY = :__dinie_origin_request_log_id
265
+ # Request header {HttpClient} sets on retried attempts (`"1"`, `"2"`, …); absent on the first.
266
+ RETRY_COUNT_HEADER = "x-dinie-retry-count"
267
+ # Response header carrying the server-side request id (architecture §7 — `err.request_id`).
268
+ REQUEST_ID_HEADER = "x-request-id"
269
+
270
+ # @param app [#call] the next middleware/adapter in the stack
271
+ # @param level [Symbol, String, nil] log level (forwarded to {RuntimeLogger})
272
+ # @param logger [Object, nil] custom sink (forwarded to {RuntimeLogger})
273
+ def initialize(app, level: nil, logger: nil)
274
+ super(app)
275
+ @logger = RuntimeLogger.new(level: level, logger: logger)
276
+ end
277
+
278
+ # @param request_env [Faraday::Env]
279
+ # @return [Faraday::Response]
280
+ def call(request_env)
281
+ correlation = correlate(request_env.request_headers)
282
+ log_request(request_env, correlation)
283
+ started = monotonic_now
284
+ @app.call(request_env).on_complete do |response_env|
285
+ log_response(response_env, correlation, started)
286
+ end
287
+ end
288
+
289
+ private
290
+
291
+ def log_request(env, correlation)
292
+ @logger.log_request(method: env.method, url: env.url.to_s,
293
+ headers: env.request_headers, body: env.body, correlation: correlation)
294
+ end
295
+
296
+ def log_response(env, correlation, started)
297
+ @logger.log_response(status: env.status, url: env.url.to_s,
298
+ headers: env.response_headers, body: env.body,
299
+ duration_ms: elapsed_ms(started), request_id: env.response_headers[REQUEST_ID_HEADER],
300
+ correlation: correlation)
301
+ end
302
+
303
+ # Build the `request_log_id` / `retry_of` / `attempt` triple (see the class doc for the
304
+ # thread-local rationale).
305
+ def correlate(request_headers)
306
+ attempt = request_headers[RETRY_COUNT_HEADER].to_i
307
+ request_log_id = "req_#{SecureRandom.hex(6)}"
308
+ if attempt.zero?
309
+ Thread.current[ORIGIN_LOG_ID_KEY] = request_log_id
310
+ { request_log_id: request_log_id, retry_of: nil, attempt: attempt }
311
+ else
312
+ { request_log_id: request_log_id, retry_of: Thread.current[ORIGIN_LOG_ID_KEY], attempt: attempt }
313
+ end
314
+ end
315
+
316
+ def monotonic_now
317
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
318
+ end
319
+
320
+ def elapsed_ms(started)
321
+ ((monotonic_now - started) * 1000).round(1)
322
+ end
323
+ end
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dinie
4
+ # `Internal::` holds everything that is NOT part of the frozen public surface: the
5
+ # transport, the token manager, the error registry, the model base, and the `OMIT`
6
+ # sentinel. It is the Ruby equivalent of "not re-exported by the barrel" in `sdk-js`
7
+ # (architecture §3.1). The generated layer (V0.4) emits subclasses of {Model}; the
8
+ # hand-written runtime references `Dinie::Internal::*`.
9
+ module Internal
10
+ # The sentinel for "this optional argument was not provided" (RB5, architecture §6.2).
11
+ #
12
+ # Ruby collapses "absent" and `nil`; `OMIT` recovers the distinction TypeScript keeps
13
+ # via `exactOptionalPropertyTypes`. Request serializers default optional keyword
14
+ # arguments to `OMIT` and drop any key still equal to it, so an omitted field never
15
+ # reaches the wire — while an explicit `nil` is sent as JSON `null` (a required-nullable
16
+ # field). Compare by identity: `value.equal?(Dinie::Internal::OMIT)` or the
17
+ # {Internal.omitted?} helper. There is exactly one instance and it is frozen.
18
+ OMIT = Object.new
19
+
20
+ # @return [String]
21
+ def OMIT.inspect = "#<Dinie::Internal::OMIT>"
22
+
23
+ # @return [String]
24
+ def OMIT.to_s = inspect
25
+
26
+ OMIT.freeze
27
+
28
+ # Identity check for the {OMIT} sentinel.
29
+ #
30
+ # @param value [Object] any value
31
+ # @return [Boolean] true only when `value` IS the {OMIT} sentinel
32
+ def self.omitted?(value)
33
+ value.equal?(OMIT)
34
+ end
35
+
36
+ # Base class for every generated model (architecture §5.1, RB3/RB18).
37
+ #
38
+ # Gives POROs value-semantics without `Data.define`, so the floor stays at Ruby 3.1
39
+ # (Block 0). A subclass declares its fields with {attribute} in alphabetical order
40
+ # (R-ORDER); the source-emission order is the single source of truth for `to_h` and
41
+ # `inspect`, so the runtime never sorts at call time. Instances are frozen after
42
+ # construction, mirroring the read-only character of the `sdk-js` models.
43
+ #
44
+ # @example
45
+ # class Customer < Dinie::Internal::Model
46
+ # attribute :cpf, :id, :name, :status
47
+ # end
48
+ #
49
+ # c = Customer.new(cpf: "***", id: "cust_1", name: "Ana", status: "active")
50
+ # c.id # => "cust_1"
51
+ # c.to_h # => {cpf: "***", id: "cust_1", name: "Ana", status: "active"}
52
+ # c == Customer.new(...) # value-equality by attributes
53
+ # case c; in {status:}; end # pattern matching via deconstruct_keys
54
+ class Model
55
+ # PII attribute names redacted by {#inspect}. Kept in sync with the logger's body
56
+ # redaction list (architecture §9 / story 005, which owns the canonical set).
57
+ REDACTED_ATTRIBUTES = %i[
58
+ access_token account client_secret cnpj cpf cvv password phone secret
59
+ ].freeze
60
+
61
+ class << self
62
+ # The declared attribute names, in declaration (R-ORDER) order. Subclasses inherit
63
+ # and may extend their parent's list.
64
+ #
65
+ # @return [Array<Symbol>]
66
+ def attributes
67
+ @attributes ||= []
68
+ end
69
+
70
+ # Declare one or more attributes: defines an `attr_reader` for each and appends it
71
+ # to {attributes} in declaration order. Idempotent per name.
72
+ #
73
+ # @param names [Array<Symbol, String>] attribute names, alphabetical (R-ORDER)
74
+ # @return [void]
75
+ def attribute(*names)
76
+ names.each do |name|
77
+ sym = name.to_sym
78
+ next if attributes.include?(sym)
79
+
80
+ attributes << sym
81
+ attr_reader(sym)
82
+ end
83
+ end
84
+
85
+ # Carry the parent's attribute list into each subclass.
86
+ #
87
+ # @param subclass [Class]
88
+ # @return [void]
89
+ def inherited(subclass)
90
+ super
91
+ subclass.instance_variable_set(:@attributes, attributes.dup)
92
+ end
93
+ end
94
+
95
+ # Build a frozen instance from keyword arguments — one per declared attribute.
96
+ #
97
+ # Every declared attribute is required (hydration always passes a value, `nil`
98
+ # included), and unknown keywords are rejected, so a contract drift surfaces loudly
99
+ # instead of silently dropping data.
100
+ #
101
+ # @param attrs [Hash{Symbol => Object}] one value per declared attribute
102
+ # @raise [ArgumentError] when a declared attribute is missing or an unknown one is given
103
+ def initialize(**attrs)
104
+ expected = self.class.attributes
105
+ validate_keys!(expected, attrs)
106
+ expected.each { |name| instance_variable_set(:"@#{name}", attrs[name]) }
107
+ freeze
108
+ end
109
+
110
+ # @return [Hash{Symbol => Object}] attributes in declaration (R-ORDER) order — deterministic
111
+ def to_h
112
+ self.class.attributes.to_h { |name| [name, public_send(name)] }
113
+ end
114
+ alias to_hash to_h
115
+
116
+ # Enable pattern matching (`case model; in {status: "active"}`).
117
+ #
118
+ # @param _keys [Array<Symbol>, nil] requested keys (ignored — the full hash matches)
119
+ # @return [Hash{Symbol => Object}]
120
+ def deconstruct_keys(_keys) = to_h
121
+
122
+ # Value-equality: same class and same attribute values.
123
+ #
124
+ # @param other [Object]
125
+ # @return [Boolean]
126
+ def ==(other)
127
+ other.class == self.class && other.to_h == to_h
128
+ end
129
+ alias eql? ==
130
+
131
+ # @return [Integer] consistent with {eql?} so instances work as Hash keys
132
+ def hash
133
+ [self.class, to_h].hash
134
+ end
135
+
136
+ # Like {to_h}, but with PII redacted — safe for logs and the console.
137
+ #
138
+ # @return [String]
139
+ def inspect
140
+ pairs = self.class.attributes.map do |name|
141
+ rendered = REDACTED_ATTRIBUTES.include?(name) ? "[REDACTED]" : public_send(name).inspect
142
+ "#{name}=#{rendered}"
143
+ end
144
+ "#<#{self.class.name} #{pairs.join(", ")}>"
145
+ end
146
+
147
+ private
148
+
149
+ def validate_keys!(expected, attrs)
150
+ missing = expected - attrs.keys
151
+ unless missing.empty?
152
+ raise ArgumentError, "missing keyword#{"s" if missing.size > 1}: #{missing.map(&:inspect).join(", ")}"
153
+ end
154
+
155
+ extra = attrs.keys - expected
156
+ return if extra.empty?
157
+
158
+ raise ArgumentError, "unknown keyword#{"s" if extra.size > 1}: #{extra.map(&:inspect).join(", ")}"
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "faraday/multipart"
5
+
6
+ module Dinie
7
+ module Internal
8
+ # Transport wrapper for a `multipart/form-data` request body (architecture §10/§14, story 009).
9
+ #
10
+ # The runtime serializes a normal request body to a JSON String (the JSON-only path in
11
+ # {HttpClient#serialize_body}). The KYC attachment upload — the project's only multipart
12
+ # endpoint (`POST /customers/{id}/kyc-attachments`) — needs `multipart/form-data` instead, so
13
+ # this is the seam Ruby adds that the V0.2 TS runtime deferred (the tracked RUNTIME GAP).
14
+ #
15
+ # An instance holds the deterministic scalar field-map (`fields`) plus the optional binary
16
+ # `file` part. {HttpClient} recognizes it in `serialize_body`, hands {#to_faraday_body} to
17
+ # Faraday with a bare `multipart/form-data` content type, and Faraday's `:multipart` request
18
+ # middleware (mounted on the shared connection) appends the boundary and encodes every entry
19
+ # — scalars become plain form-data parts, the `file` becomes a binary part. Setting the
20
+ # content type explicitly forces multipart encoding even for the email variant, which carries
21
+ # no `file` part (only a scalar `value`).
22
+ #
23
+ # ── runtime ↔ generated boundary (architecture §4) ──
24
+ # Lives in `runtime/` (CODEOWNER humans). It is transport-aware (Faraday multipart parts) so
25
+ # the generated KYC layer stays transport-agnostic: `Dinie::Kyc.serialize_upload` produces a
26
+ # plain {Dinie::Kyc::UploadForm} (the conformance unit — fields + opaque file), and the
27
+ # customers resource wraps it here at the boundary.
28
+ class Multipart
29
+ # Content type Faraday's multipart middleware matches on; it appends `; boundary=…` itself.
30
+ CONTENT_TYPE = "multipart/form-data"
31
+ # Fallback MIME type for a wrapped file part when the caller passes a bare IO/String. The API
32
+ # validates the real format from the bytes; the SDK does not sniff it.
33
+ DEFAULT_FILE_CONTENT_TYPE = "application/octet-stream"
34
+ # Fallback filename for a wrapped file part with no `#path`.
35
+ DEFAULT_FILE_FILENAME = "upload"
36
+
37
+ # @return [Hash{Symbol => String}] the scalar form fields (alphabetical — R-ORDER)
38
+ attr_reader :fields
39
+ # @return [Object, nil] the file part (a `Faraday::Multipart::FilePart`, an IO, or raw bytes)
40
+ attr_reader :file
41
+
42
+ # @param fields [Hash{Symbol => String}] scalar form fields
43
+ # @param file [Object, nil] optional binary file: a `Faraday::Multipart::FilePart`/`ParamPart`
44
+ # (passed through), an IO (`File.open(...)`), or a String of raw bytes
45
+ def initialize(fields:, file: nil)
46
+ @fields = fields
47
+ @file = file
48
+ end
49
+
50
+ # The Faraday multipart body: the scalar fields plus a `file` part wrapped for upload when
51
+ # present. Faraday's `:multipart` middleware encodes the returned Hash.
52
+ #
53
+ # @return [Hash{Symbol => Object}]
54
+ def to_faraday_body
55
+ body = @fields.dup
56
+ body[:file] = wrap_file(@file) unless @file.nil?
57
+ body
58
+ end
59
+
60
+ private
61
+
62
+ # Wrap a caller-supplied file into a Faraday multipart part. An already-wrapped part passes
63
+ # through; an IO is wrapped directly; a String is treated as raw bytes via a StringIO.
64
+ def wrap_file(file)
65
+ return file if multipart_part?(file)
66
+
67
+ io = file.respond_to?(:read) ? file : StringIO.new(file.to_s)
68
+ filename = file.respond_to?(:path) && file.path ? File.basename(file.path) : DEFAULT_FILE_FILENAME
69
+ Faraday::Multipart::FilePart.new(io, DEFAULT_FILE_CONTENT_TYPE, filename)
70
+ end
71
+
72
+ def multipart_part?(file)
73
+ file.is_a?(Faraday::Multipart::FilePart) || file.is_a?(Faraday::Multipart::ParamPart)
74
+ end
75
+ end
76
+ end
77
+ end