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,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
|