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,341 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "faraday"
|
|
5
|
+
require "faraday/net_http_persistent"
|
|
6
|
+
|
|
7
|
+
module Dinie
|
|
8
|
+
module Internal
|
|
9
|
+
# `TokenManager` — the OAuth2 *client_credentials* token cache (architecture §10, RB-T,
|
|
10
|
+
# `runtime-patterns.md` §4), porting `sdk-js` `src/runtime/token-manager.ts`. It acquires and
|
|
11
|
+
# transparently refreshes the Bearer token every request rides on, speaking RFC 6749:
|
|
12
|
+
#
|
|
13
|
+
# POST {base_url}/auth/token
|
|
14
|
+
# Authorization: Basic base64("{client_id}:{client_secret}")
|
|
15
|
+
# Content-Type: application/x-www-form-urlencoded
|
|
16
|
+
# body: grant_type=client_credentials
|
|
17
|
+
# → 200 { access_token, token_type: "bearer", expires_in }
|
|
18
|
+
#
|
|
19
|
+
# Three behaviours make it one of the risky-core modules:
|
|
20
|
+
#
|
|
21
|
+
# 1. **Proactive refresh** — the cached token is treated as stale {REFRESH_MARGIN_SECONDS}
|
|
22
|
+
# (300s) BEFORE its real expiry, so a live request never races the boundary.
|
|
23
|
+
# 2. **Concurrency lock** — a `Mutex` + `ConditionVariable` (+ an `@refreshing` flag)
|
|
24
|
+
# serialize refreshes: N simultaneous `#access_token` callers trigger exactly ONE token
|
|
25
|
+
# POST. The claim ("I will refresh") is made UNDER the mutex, so it is impossible for two
|
|
26
|
+
# threads to both start a refresh; the losers park on the condition variable and, after a
|
|
27
|
+
# **double-check** on wake, return the freshly-cached token. The POST itself runs OUTSIDE
|
|
28
|
+
# the lock so a slow handshake never blocks the validity check.
|
|
29
|
+
# 3. **401 invalidation** — `#invalidate!` drops the cached token. The 401 one-shot re-auth
|
|
30
|
+
# itself is orchestrated by {HttpClient} (story 003); this module only exposes the seam
|
|
31
|
+
# (`#access_token` / `#invalidate!`) and never loops on requests itself.
|
|
32
|
+
#
|
|
33
|
+
# ── DI seam (architecture §10, RB15) ──
|
|
34
|
+
# This is the REAL token source {HttpClient} expects via its injected `token_manager:`
|
|
35
|
+
# (`auth_headers` calls `#access_token`; the 401 one-shot calls `#invalidate!`). {Dinie::Client}
|
|
36
|
+
# builds ONE `Faraday::Connection` and injects it into BOTH the HttpClient and this manager, so
|
|
37
|
+
# the token POST rides the SAME connection pool as every other request — and, once story 005
|
|
38
|
+
# mounts the logging middleware on that shared connection, the `Authorization: Basic` header
|
|
39
|
+
# (which carries the client secret) is redacted there. URLs are absolute, so the connection's
|
|
40
|
+
# `url_prefix` is irrelevant (mirrors {HttpClient}). A `connection:` is optional: standalone use
|
|
41
|
+
# (and most specs) lets the manager build its own.
|
|
42
|
+
#
|
|
43
|
+
# ── runtime ↔ generated boundary ──
|
|
44
|
+
# Lives in `runtime/`, imports only `errors` (for {Dinie::OAuthError}) + Faraday, and is NOT
|
|
45
|
+
# part of the public barrel: {Dinie::Client}/{HttpClient} construct it internally.
|
|
46
|
+
#
|
|
47
|
+
# The ClassLength cop is disabled below: the token lifecycle (validity check → claim → POST →
|
|
48
|
+
# parse → cache → wake) is one cohesive concurrency unit; splitting it would scatter the
|
|
49
|
+
# invariant (the refresh claim and the cache mutation must share one mutex).
|
|
50
|
+
class TokenManager # rubocop:disable Metrics/ClassLength
|
|
51
|
+
# Bare token-endpoint path, appended to the configured base URL (which already carries the
|
|
52
|
+
# `/api/v3` version prefix, so the full URL is `…/api/v3/auth/token`).
|
|
53
|
+
TOKEN_PATH = "/auth/token"
|
|
54
|
+
# Session-exchange endpoint path — step 2 of the two-step session-mode flow. POSTed with
|
|
55
|
+
# the cc-bearer in `Authorization: Bearer …` and `{ code }` JSON body.
|
|
56
|
+
SESSION_EXCHANGE_PATH = "/biometrics/session-exchange"
|
|
57
|
+
# Refresh the token this many SECONDS before its stated expiry (300s, `runtime-patterns.md`
|
|
58
|
+
# §4). The margin absorbs clock skew and in-flight latency so a live request never carries a
|
|
59
|
+
# token that expires mid-flight.
|
|
60
|
+
REFRESH_MARGIN_SECONDS = 300
|
|
61
|
+
# The fixed `application/x-www-form-urlencoded` body of the client_credentials grant.
|
|
62
|
+
GRANT_BODY = "grant_type=client_credentials"
|
|
63
|
+
# `Content-Type` of the token request.
|
|
64
|
+
FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
|
|
65
|
+
# OAuthError messages for a malformed token payload (kept as constants so the guard clauses
|
|
66
|
+
# stay on one line and read cleanly).
|
|
67
|
+
MISSING_ACCESS_TOKEN = 'OAuth2 token response was missing a valid "access_token".'
|
|
68
|
+
# {Dinie::OAuthError} message for a token payload missing a valid `expires_in`.
|
|
69
|
+
MISSING_EXPIRES_IN = 'OAuth2 token response was missing a valid "expires_in".'
|
|
70
|
+
|
|
71
|
+
# @param client_id [String] OAuth2 client id (the Basic-auth username)
|
|
72
|
+
# @param client_secret [String] OAuth2 client secret (the Basic-auth password)
|
|
73
|
+
# @param base_url [String, nil] API base URL incl. the version prefix (default
|
|
74
|
+
# {HttpClient::DEFAULT_BASE_URL}); the bare {TOKEN_PATH} is appended to it
|
|
75
|
+
# @param connection [Faraday::Connection, nil] injected shared connection (pool-sharing seam);
|
|
76
|
+
# `nil` builds a standalone one. Requests use absolute URLs, so `url_prefix` is irrelevant
|
|
77
|
+
# @param refresh_margin_seconds [Integer] proactive-refresh margin (default
|
|
78
|
+
# {REFRESH_MARGIN_SECONDS}); parameterized for boundary tests
|
|
79
|
+
# @param adapter [Symbol, nil] Faraday adapter for the standalone connection (default
|
|
80
|
+
# `:net_http_persistent`)
|
|
81
|
+
# @param clock [#call, nil] monotonic-ish "seconds now" source (tests inject a controllable
|
|
82
|
+
# one to exercise the margin boundary); defaults to wall-clock seconds
|
|
83
|
+
# @param code [String, nil] session-mode authorization code (from the biometrics flow).
|
|
84
|
+
# When present the manager operates in **session mode**: `#access_token` performs a
|
|
85
|
+
# two-step exchange (cc-credentials → {SESSION_EXCHANGE_PATH}) exactly once. After the
|
|
86
|
+
# session token expires, {Dinie::SessionTokenExpiredError} is raised — the code is
|
|
87
|
+
# single-use and cannot be re-exchanged.
|
|
88
|
+
def initialize(client_id:, client_secret:, base_url: nil, connection: nil, # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
|
|
89
|
+
refresh_margin_seconds: REFRESH_MARGIN_SECONDS, adapter: nil, clock: nil, code: nil)
|
|
90
|
+
@client_id = client_id
|
|
91
|
+
@client_secret = client_secret
|
|
92
|
+
@base_url = (base_url || HttpClient::DEFAULT_BASE_URL).sub(%r{/+\z}, "")
|
|
93
|
+
@refresh_margin_seconds = refresh_margin_seconds
|
|
94
|
+
@clock = clock || -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) }
|
|
95
|
+
@connection = connection || build_connection(adapter)
|
|
96
|
+
@code = code
|
|
97
|
+
@exchanged = false
|
|
98
|
+
|
|
99
|
+
@mutex = Mutex.new
|
|
100
|
+
@condition = ConditionVariable.new
|
|
101
|
+
@refreshing = false
|
|
102
|
+
@access_token = nil
|
|
103
|
+
@expires_at = nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Return a valid Bearer access token, acquiring or refreshing transparently.
|
|
107
|
+
#
|
|
108
|
+
# Fast path: a cached token still inside the margin is returned without a request. Otherwise
|
|
109
|
+
# the FIRST caller claims the refresh under the mutex and runs the POST outside it; concurrent
|
|
110
|
+
# callers park on the condition variable and, after a double-check on wake, return the
|
|
111
|
+
# freshly-cached token. Under N concurrent callers exactly ONE token POST occurs.
|
|
112
|
+
#
|
|
113
|
+
# @return [String] a valid OAuth2 access token
|
|
114
|
+
# @raise [Dinie::OAuthError] the token handshake failed (transport, non-2xx, or malformed body)
|
|
115
|
+
def access_token
|
|
116
|
+
@mutex.synchronize do
|
|
117
|
+
loop do
|
|
118
|
+
return @access_token if token_valid?
|
|
119
|
+
break unless @refreshing
|
|
120
|
+
|
|
121
|
+
# Another thread is already refreshing — park until it broadcasts, then re-check
|
|
122
|
+
# (double-check pattern): it may have populated the cache, or its refresh may have failed.
|
|
123
|
+
@condition.wait(@mutex)
|
|
124
|
+
end
|
|
125
|
+
@refreshing = true # claim the refresh (under the mutex — only one thread can win)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
refresh_and_cache
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Drop the cached token so the next {#access_token} re-authenticates. Called by {HttpClient}
|
|
132
|
+
# on a 401 (the one-shot re-auth is orchestrated there). Takes the mutex so it never races a
|
|
133
|
+
# concurrent validity check.
|
|
134
|
+
#
|
|
135
|
+
# @return [void]
|
|
136
|
+
def invalidate!
|
|
137
|
+
@mutex.synchronize do
|
|
138
|
+
@access_token = nil
|
|
139
|
+
@expires_at = nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
# True when a token is cached and still outside the refresh margin. Only ever called under
|
|
146
|
+
# the mutex, so reading `@access_token`/`@expires_at` together is race-free.
|
|
147
|
+
def token_valid?
|
|
148
|
+
!@access_token.nil? && now < @expires_at - @refresh_margin_seconds
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Run the refresh OUTSIDE the mutex (the POST is I/O — holding the lock would block the
|
|
152
|
+
# validity check for every other caller). Whatever happens, the `ensure` clears the claim and
|
|
153
|
+
# wakes the parked threads, so a failed handshake never leaves a permanently-stuck lock: the
|
|
154
|
+
# next waiter re-checks, finds no token, and retries.
|
|
155
|
+
def refresh_and_cache # rubocop:disable Metrics/MethodLength
|
|
156
|
+
access_token, expires_at = fetch_token
|
|
157
|
+
@mutex.synchronize do
|
|
158
|
+
@access_token = access_token
|
|
159
|
+
@expires_at = expires_at
|
|
160
|
+
end
|
|
161
|
+
access_token
|
|
162
|
+
ensure
|
|
163
|
+
@mutex.synchronize do
|
|
164
|
+
@refreshing = false
|
|
165
|
+
@condition.broadcast
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Dispatch to partner mode or session mode. Partner mode (no `code`) performs the standard
|
|
170
|
+
# client_credentials POST and caches until the margin. Session mode performs the two-step
|
|
171
|
+
# exchange exactly once; subsequent stale-token paths raise {Dinie::SessionTokenExpiredError}.
|
|
172
|
+
def fetch_token
|
|
173
|
+
return fetch_partner_token unless @code
|
|
174
|
+
raise Dinie::SessionTokenExpiredError if @exchanged
|
|
175
|
+
|
|
176
|
+
fetch_session_token
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Session mode step: cc-credentials → SESSION_EXCHANGE_PATH. Returns
|
|
180
|
+
# `[customer_access_token, absolute_expiry_seconds]`. Sets `@exchanged` AFTER the POST
|
|
181
|
+
# succeeds so T9 (exchange failure) leaves `@exchanged = false` and the caller can see
|
|
182
|
+
# the typed API error (no phantom expiry on the next call).
|
|
183
|
+
def fetch_session_token
|
|
184
|
+
cc_token = fetch_client_credentials_token
|
|
185
|
+
access_token, expires_in = exchange(cc_token, @code)
|
|
186
|
+
@exchanged = true
|
|
187
|
+
[access_token, now + expires_in]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Partner mode: a single client_credentials POST, returns
|
|
191
|
+
# `[access_token, absolute_expiry_seconds]`. The original `fetch_token` body, extracted
|
|
192
|
+
# so `fetch_token` stays a clean dispatcher.
|
|
193
|
+
def fetch_partner_token
|
|
194
|
+
response = request_token
|
|
195
|
+
raise Dinie::OAuthError, status_failure(response.status, body_detail(response)) unless success?(response.status)
|
|
196
|
+
|
|
197
|
+
access_token, expires_in = parse_token_response(response.body)
|
|
198
|
+
[access_token, now + expires_in]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Step 1 of the session two-step: obtain a cc-bearer (used as the Authorization header
|
|
202
|
+
# on the subsequent exchange POST). Returns only the access_token string.
|
|
203
|
+
def fetch_client_credentials_token
|
|
204
|
+
response = request_token
|
|
205
|
+
raise Dinie::OAuthError, status_failure(response.status, body_detail(response)) unless success?(response.status)
|
|
206
|
+
|
|
207
|
+
access_token, = parse_token_response(response.body)
|
|
208
|
+
access_token
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Step 2 of the session two-step: POST SESSION_EXCHANGE_PATH with the cc-bearer and
|
|
212
|
+
# the authorization code. Returns `[customer_access_token, expires_in]`. Non-2xx raises
|
|
213
|
+
# the typed API error from {Dinie::Internal::Errors.from_response} (e.g. AuthError on 401).
|
|
214
|
+
def exchange(cc_token, code)
|
|
215
|
+
response = request_exchange(cc_token, code)
|
|
216
|
+
unless success?(response.status)
|
|
217
|
+
raise Dinie::Internal::Errors.from_response(
|
|
218
|
+
status: response.status, headers: response.headers.to_h, body: response.body
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
parse_exchange_response(response.body)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# One Faraday round-trip to SESSION_EXCHANGE_PATH. Absolute URL. Transport failures
|
|
226
|
+
# become {Dinie::OAuthError} (no server response to dispatch on).
|
|
227
|
+
def request_exchange(cc_token, code)
|
|
228
|
+
@connection.run_request(
|
|
229
|
+
:post,
|
|
230
|
+
exchange_url,
|
|
231
|
+
JSON.generate(code: code),
|
|
232
|
+
"authorization" => "Bearer #{cc_token}",
|
|
233
|
+
"content-type" => "application/json",
|
|
234
|
+
"accept" => "application/json"
|
|
235
|
+
)
|
|
236
|
+
rescue Faraday::Error => e
|
|
237
|
+
raise Dinie::OAuthError, "Session exchange request failed before a response was received: #{e.message}"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Validate + extract the exchange response, returning `[access_token, expires_in]`.
|
|
241
|
+
# Raises {Dinie::OAuthError} on structural problems (shape, missing fields).
|
|
242
|
+
def parse_exchange_response(raw_body)
|
|
243
|
+
parsed = parse_json(raw_body)
|
|
244
|
+
raise Dinie::OAuthError, "Session exchange response was not a JSON object." unless parsed.is_a?(Hash)
|
|
245
|
+
|
|
246
|
+
access_token = parsed[:access_token]
|
|
247
|
+
expires_in = parsed[:expires_in]
|
|
248
|
+
raise Dinie::OAuthError, MISSING_ACCESS_TOKEN unless valid_access_token?(access_token)
|
|
249
|
+
raise Dinie::OAuthError, MISSING_EXPIRES_IN unless valid_expires_in?(expires_in)
|
|
250
|
+
|
|
251
|
+
[access_token, expires_in]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def exchange_url
|
|
255
|
+
"#{@base_url}#{SESSION_EXCHANGE_PATH}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# One Faraday round-trip to the token endpoint. Absolute URL ⇒ the connection's `url_prefix`
|
|
259
|
+
# is irrelevant. A transport failure (DNS, refused, timeout) becomes {Dinie::OAuthError}.
|
|
260
|
+
def request_token
|
|
261
|
+
@connection.run_request(:post, token_url, GRANT_BODY, token_request_headers)
|
|
262
|
+
rescue Faraday::Error => e
|
|
263
|
+
raise Dinie::OAuthError, "OAuth2 token request failed before a response was received: #{e.message}"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Validate + extract the wire payload, returning `[access_token, expires_in]`. `token_type` is
|
|
267
|
+
# intentionally ignored: {HttpClient} always sends `Authorization: Bearer …`, so the wire
|
|
268
|
+
# value is informational. Any shape problem raises {Dinie::OAuthError}.
|
|
269
|
+
def parse_token_response(raw_body)
|
|
270
|
+
parsed = parse_json(raw_body)
|
|
271
|
+
raise Dinie::OAuthError, "OAuth2 token response was not a JSON object." unless parsed.is_a?(Hash)
|
|
272
|
+
|
|
273
|
+
access_token = parsed[:access_token]
|
|
274
|
+
expires_in = parsed[:expires_in]
|
|
275
|
+
raise Dinie::OAuthError, MISSING_ACCESS_TOKEN unless valid_access_token?(access_token)
|
|
276
|
+
raise Dinie::OAuthError, MISSING_EXPIRES_IN unless valid_expires_in?(expires_in)
|
|
277
|
+
|
|
278
|
+
[access_token, expires_in]
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def parse_json(raw_body)
|
|
282
|
+
JSON.parse(raw_body.to_s, symbolize_names: true)
|
|
283
|
+
rescue JSON::ParserError
|
|
284
|
+
raise Dinie::OAuthError, "OAuth2 token response body was not valid JSON."
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def valid_access_token?(value)
|
|
288
|
+
value.is_a?(String) && !value.empty?
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def valid_expires_in?(value)
|
|
292
|
+
value.is_a?(Numeric) && value.positive?
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def token_request_headers
|
|
296
|
+
{
|
|
297
|
+
"authorization" => "Basic #{basic_credentials}",
|
|
298
|
+
"content-type" => FORM_CONTENT_TYPE,
|
|
299
|
+
"accept" => "application/json"
|
|
300
|
+
}
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Base64 (RFC 4648) of `client_id:client_secret`, no line breaks (HTTP Basic).
|
|
304
|
+
def basic_credentials
|
|
305
|
+
Base64.strict_encode64("#{@client_id}:#{@client_secret}")
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def token_url
|
|
309
|
+
"#{@base_url}#{TOKEN_PATH}"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def success?(status)
|
|
313
|
+
(200..299).cover?(status)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Best-effort body text for a non-2xx failure message (never raises).
|
|
317
|
+
def body_detail(response)
|
|
318
|
+
response.body.to_s.strip
|
|
319
|
+
rescue StandardError
|
|
320
|
+
""
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def status_failure(status, detail)
|
|
324
|
+
suffix = detail.empty? ? "" : ": #{detail}"
|
|
325
|
+
"OAuth2 token request failed with status #{status}#{suffix}"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def now
|
|
329
|
+
@clock.call
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Standalone fallback connection (production injects the shared one from {Dinie::Client}).
|
|
333
|
+
# No logger seam here: the canonical, logged connection is the Client's shared one.
|
|
334
|
+
def build_connection(adapter)
|
|
335
|
+
Faraday.new(url: @base_url) do |faraday|
|
|
336
|
+
faraday.adapter(adapter || :net_http_persistent)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Dinie
|
|
9
|
+
# `Dinie::Webhooks.extract` — webhook verification + per-type deserialization, a module-function
|
|
10
|
+
# (RB11, architecture §8, §18.2). Implements the Standard Webhooks v1 contract Dinie inherited:
|
|
11
|
+
#
|
|
12
|
+
# signed_payload = "{webhook-id}.{webhook-timestamp}.{body}"
|
|
13
|
+
# expected = base64(HMAC-SHA256(decode_secret(secret), signed_payload))
|
|
14
|
+
# header = "v1,<sig1> v1,<sig2>" # space-separated, rotation-capable
|
|
15
|
+
#
|
|
16
|
+
# `extract` does verification AND deserialization in one call: it reads the three `webhook-*`
|
|
17
|
+
# headers (case-insensitive, symbol/string), enforces a bidirectional timestamp window (replay
|
|
18
|
+
# guard), decodes the secret (`whsec_` → base64), HMAC-SHA256s the signed payload, and compares —
|
|
19
|
+
# in CONSTANT time via {OpenSSL.secure_compare} — against every `v1,<sig>` in the header. Any
|
|
20
|
+
# match → {Dinie::Events::DESERIALIZERS}`[type]` hydrates the snake-native body into the typed
|
|
21
|
+
# {Dinie::Events} member; an unknown `type` → {Dinie::UnknownWebhookEventError}; no match →
|
|
22
|
+
# {Dinie::WebhookSignatureError}. It NEVER returns an unverified event (security by behavior — the
|
|
23
|
+
# name `extract` returns the event, not a boolean).
|
|
24
|
+
#
|
|
25
|
+
# Both `secret` and the signature header accept multiple values, so secret rotation works from
|
|
26
|
+
# either side. Verification needs no OAuth credentials, hence a module-function (not a client
|
|
27
|
+
# method). Mirrors `sdk-js` `src/runtime/webhooks.ts` (@ `19c9bca`), in Ruby (`openssl` stdlib;
|
|
28
|
+
# base64 via `String#unpack1("m")` / `Array#pack("m0")` — no extra dependency).
|
|
29
|
+
#
|
|
30
|
+
# ── runtime ↔ generated boundary (controlled inverse import — openapi-SoT, §4) ──
|
|
31
|
+
# The general rule is "runtime/ never imports generated/". `webhooks.rb` is one of two declared
|
|
32
|
+
# exceptions (the other is `http.rb → ERROR_REGISTRY`). The webhook event catalog's SoT is
|
|
33
|
+
# `openapi.yaml` (`webhooks:`), so the typed members AND the {Dinie::Events::DESERIALIZERS} table
|
|
34
|
+
# live in `generated/events/`. This module references the table at call time; the barrel
|
|
35
|
+
# (`lib/dinie.rb`) loads `generated/events` before any call. The reference is the forcing-function:
|
|
36
|
+
# an event not in openapi is not in `generated/events`, not in the table, so `extract` raises
|
|
37
|
+
# {Dinie::UnknownWebhookEventError} — forcing the contract conversation.
|
|
38
|
+
#
|
|
39
|
+
# ── Header normalization (deferred decision §21.5 — RESOLVED: case-insensitive + symbol/string) ──
|
|
40
|
+
# Standard Webhooks headers are lowercase dashed (`webhook-id`). Inbound frameworks vary in
|
|
41
|
+
# capitalization, so the lookup is case-insensitive and accepts both String and Symbol keys. The
|
|
42
|
+
# partner passes a Hash keyed by the `webhook-*` names; framework env normalization (Rack's
|
|
43
|
+
# `HTTP_WEBHOOK_ID`) is the partner's responsibility (a framework adapter is out of scope, §1):
|
|
44
|
+
#
|
|
45
|
+
# # Rails: Dinie::Webhooks.extract(headers: request.headers.to_h, body: request.raw_post, secret:)
|
|
46
|
+
# # Sinatra: Dinie::Webhooks.extract(headers: { "webhook-id" => request.env["HTTP_WEBHOOK_ID"],
|
|
47
|
+
# # "webhook-timestamp" => request.env["HTTP_WEBHOOK_TIMESTAMP"],
|
|
48
|
+
# # "webhook-signature" => request.env["HTTP_WEBHOOK_SIGNATURE"] }, body: request.body.read, secret:)
|
|
49
|
+
# # Rack: headers = env.select { |k, _| k.start_with?("HTTP_") }
|
|
50
|
+
# # .transform_keys { |k| k.delete_prefix("HTTP_").downcase.tr("_", "-") }
|
|
51
|
+
module Webhooks
|
|
52
|
+
# Default replay tolerance, in seconds (5 minutes), applied in both directions.
|
|
53
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
54
|
+
# Secrets carrying this prefix are base64-encoded; the remainder is decoded before HMAC.
|
|
55
|
+
WHSEC_PREFIX = "whsec_"
|
|
56
|
+
# Only `v1` signature tokens are honored.
|
|
57
|
+
SIGNATURE_VERSION = "v1"
|
|
58
|
+
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
# Verify a Standard Webhooks v1 payload and return the deserialized, typed event — a
|
|
62
|
+
# {Dinie::Events} member straight from openapi (`generated/events/`), hydrated per type so the
|
|
63
|
+
# snake-native surface is honest. It NEVER returns an unverified event.
|
|
64
|
+
#
|
|
65
|
+
# @param headers [Hash] inbound HTTP headers (case-insensitive lookup of the `webhook-*` trio)
|
|
66
|
+
# @param body [String] the RAW request body, exactly as received, BEFORE `JSON.parse`
|
|
67
|
+
# @param secret [String, Array<String>] signing secret(s); `whsec_`-prefixed → base64. An Array
|
|
68
|
+
# tries each secret (rotation)
|
|
69
|
+
# @param tolerance_seconds [Integer] replay window in seconds, applied in both directions
|
|
70
|
+
# @return [Dinie::Events::WebhookEventBase] the verified, typed event member
|
|
71
|
+
# @raise [Dinie::WebhookTimestampError] the timestamp header is missing, malformed, or outside
|
|
72
|
+
# the tolerance window (too old OR too far in the future)
|
|
73
|
+
# @raise [Dinie::WebhookSignatureError] a required header is missing, no usable secret was
|
|
74
|
+
# supplied, or no signature in the header matched
|
|
75
|
+
# @raise [Dinie::UnknownWebhookEventError] the signature verified but the payload's `type` is
|
|
76
|
+
# not in the openapi event catalog
|
|
77
|
+
def extract(headers:, body:, secret:, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS)
|
|
78
|
+
webhook_id = required_header(headers, "webhook-id", Dinie::WebhookSignatureError)
|
|
79
|
+
timestamp = required_header(headers, "webhook-timestamp", Dinie::WebhookTimestampError)
|
|
80
|
+
signature = required_header(headers, "webhook-signature", Dinie::WebhookSignatureError)
|
|
81
|
+
|
|
82
|
+
verify_timestamp!(timestamp, tolerance_seconds)
|
|
83
|
+
verify_signature!(signed_payload(webhook_id, timestamp, body), secret, signature)
|
|
84
|
+
deserialize_verified_event(body)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @api private
|
|
88
|
+
# Build the byte-faithful signed payload `"{id}.{timestamp}.{body}"` (binary-concatenated so a
|
|
89
|
+
# non-UTF-8 body never raises an encoding error).
|
|
90
|
+
def signed_payload(webhook_id, timestamp, body)
|
|
91
|
+
"#{webhook_id}.#{timestamp}.".b + body.to_s.b
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @api private
|
|
95
|
+
# Fetch a required header or raise `error_class`. Missing and empty both fail.
|
|
96
|
+
def required_header(headers, name, error_class)
|
|
97
|
+
value = lookup_header(headers, name)
|
|
98
|
+
return value unless value.nil? || value == ""
|
|
99
|
+
|
|
100
|
+
raise error_class, "Missing required webhook header: #{name}."
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @api private
|
|
104
|
+
# Case-insensitive, symbol/string single-value header lookup (§21.5). A list value (rare) yields
|
|
105
|
+
# its first element.
|
|
106
|
+
def lookup_header(headers, name)
|
|
107
|
+
value = fetch_header(headers, name)
|
|
108
|
+
value.is_a?(Array) ? value.first : value
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @api private
|
|
112
|
+
def fetch_header(headers, name)
|
|
113
|
+
return headers[name] if headers.key?(name)
|
|
114
|
+
return headers[name.to_sym] if headers.key?(name.to_sym)
|
|
115
|
+
|
|
116
|
+
target = name.downcase
|
|
117
|
+
headers.each { |key, value| return value if key.to_s.downcase == target }
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @api private
|
|
122
|
+
# Enforce the bidirectional replay window: reject a malformed, too-old, or too-far-future
|
|
123
|
+
# timestamp.
|
|
124
|
+
def verify_timestamp!(timestamp, tolerance_seconds)
|
|
125
|
+
seconds = Integer(timestamp.to_s.strip, 10, exception: false)
|
|
126
|
+
raise Dinie::WebhookTimestampError, "Invalid webhook timestamp: #{timestamp.inspect}." if seconds.nil?
|
|
127
|
+
|
|
128
|
+
skew = Time.now.to_i - seconds
|
|
129
|
+
raise Dinie::WebhookTimestampError, "Webhook timestamp is too old." if skew > tolerance_seconds
|
|
130
|
+
return unless skew < -tolerance_seconds
|
|
131
|
+
|
|
132
|
+
raise Dinie::WebhookTimestampError, "Webhook timestamp is too far in the future."
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @api private
|
|
136
|
+
# Raise unless any (secret, signature) pair matches (constant-time, multi-sig rotation).
|
|
137
|
+
def verify_signature!(payload, secret, signature_header)
|
|
138
|
+
secrets = normalize_secrets(secret)
|
|
139
|
+
raise Dinie::WebhookSignatureError, "No webhook secret provided." if secrets.empty?
|
|
140
|
+
return if signatures_match?(payload, secrets, parse_signature_header(signature_header))
|
|
141
|
+
|
|
142
|
+
raise Dinie::WebhookSignatureError,
|
|
143
|
+
"No matching webhook signature found; the payload may have been tampered with."
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @api private
|
|
147
|
+
def normalize_secrets(secret)
|
|
148
|
+
Array(secret).select { |candidate| candidate.is_a?(String) && !candidate.empty? }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @api private
|
|
152
|
+
# Split the `webhook-signature` header into decoded `v1` signatures. Space-separated
|
|
153
|
+
# `v1,<base64>` tokens; non-`v1` and malformed tokens are dropped.
|
|
154
|
+
def parse_signature_header(header)
|
|
155
|
+
header.split.filter_map do |token|
|
|
156
|
+
version, separator, value = token.partition(",")
|
|
157
|
+
next if separator.empty? || value.empty? || version != SIGNATURE_VERSION
|
|
158
|
+
|
|
159
|
+
value.unpack1("m")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# @api private
|
|
164
|
+
# True when any (secret, signature) pair matches. {OpenSSL.secure_compare} is constant-time and
|
|
165
|
+
# safe on unequal-length inputs (it digests both first), so no length guard is needed.
|
|
166
|
+
def signatures_match?(payload, secrets, provided_signatures)
|
|
167
|
+
secrets.any? do |secret|
|
|
168
|
+
expected = OpenSSL::HMAC.digest("SHA256", decode_secret(secret), payload)
|
|
169
|
+
provided_signatures.any? { |candidate| OpenSSL.secure_compare(candidate, expected) }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# @api private
|
|
174
|
+
# `whsec_`-prefixed secrets are base64; everything else is treated as raw bytes.
|
|
175
|
+
def decode_secret(secret)
|
|
176
|
+
return secret.delete_prefix(WHSEC_PREFIX).unpack1("m") if secret.start_with?(WHSEC_PREFIX)
|
|
177
|
+
|
|
178
|
+
secret.b
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @api private
|
|
182
|
+
# Deserialize a VERIFIED body per type (reached only after a signature matched, so the bytes are
|
|
183
|
+
# authentic). Dispatch is table-driven through {Dinie::Events::DESERIALIZERS}; a `type` absent
|
|
184
|
+
# from the catalog raises {Dinie::UnknownWebhookEventError} (forces the contract conversation).
|
|
185
|
+
def deserialize_verified_event(body)
|
|
186
|
+
raw = JSON.parse(body, symbolize_names: true)
|
|
187
|
+
event_type = raw[:type]
|
|
188
|
+
deserializer = event_type.is_a?(String) ? Dinie::Events::DESERIALIZERS[event_type] : nil
|
|
189
|
+
raise Dinie::UnknownWebhookEventError, event_type.to_s if deserializer.nil?
|
|
190
|
+
|
|
191
|
+
deserializer.call(raw)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
data/lib/dinie.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Curated public barrel — the single entry point consumers load via `require "dinie"`.
|
|
4
|
+
#
|
|
5
|
+
# It uses explicit `require_relative`s (no Zeitwerk — RB16) so load order is deterministic
|
|
6
|
+
# for the generated error/event registries. This mirrors `src/index.ts` in `sdk-js`:
|
|
7
|
+
# the runtime is required first (hand-written mechanism), then the generated indexes.
|
|
8
|
+
#
|
|
9
|
+
# At bootstrap only `version` exists. The runtime and generated layers land in later
|
|
10
|
+
# stories; their requires stay as commented TODOs so the smoke suite is green before
|
|
11
|
+
# those files exist. Uncomment each line in the story that introduces the file.
|
|
12
|
+
|
|
13
|
+
require_relative "dinie/version"
|
|
14
|
+
|
|
15
|
+
# --- generated api_version constant (required before runtime so http.rb can read it) ---
|
|
16
|
+
require_relative "dinie/generated/api_version"
|
|
17
|
+
|
|
18
|
+
# --- runtime (hand-written — CODEOWNER humans — stories 002–005) ---------------
|
|
19
|
+
require_relative "dinie/runtime/model"
|
|
20
|
+
require_relative "dinie/runtime/errors"
|
|
21
|
+
require_relative "dinie/runtime/request_options"
|
|
22
|
+
require_relative "dinie/runtime/idempotency"
|
|
23
|
+
require_relative "dinie/runtime/rate_limit"
|
|
24
|
+
require_relative "dinie/runtime/retry"
|
|
25
|
+
require_relative "dinie/runtime/http"
|
|
26
|
+
require_relative "dinie/runtime/token_manager"
|
|
27
|
+
require_relative "dinie/runtime/logger"
|
|
28
|
+
require_relative "dinie/runtime/paginator"
|
|
29
|
+
require_relative "dinie/runtime/multipart"
|
|
30
|
+
require_relative "dinie/runtime/webhooks"
|
|
31
|
+
|
|
32
|
+
# --- generated (hand-authored V0.3 — CODEOWNER bot — stories 006–011) ----------
|
|
33
|
+
require_relative "dinie/generated/errors/registry"
|
|
34
|
+
require_relative "dinie/generated/types"
|
|
35
|
+
require_relative "dinie/generated/events"
|
|
36
|
+
require_relative "dinie/generated/resources"
|
|
37
|
+
require_relative "dinie/generated/client"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Vendored minimal Faraday stub (architecture §5.2, story 013 / §21.3).
|
|
2
|
+
#
|
|
3
|
+
# Faraday + net-http-persistent ship no bundled RBS and are not in `rbs collection` at a usable
|
|
4
|
+
# fidelity, so this stub declares only the Faraday surface the SDK references — entry points return
|
|
5
|
+
# `untyped`, so every downstream Faraday interaction (connection, request env, response) type-checks
|
|
6
|
+
# permissively without inventing precise third-party signatures. This is the documented seam that
|
|
7
|
+
# keeps `steep check` informative in v1 (the `# §21.3 decision` in the Steepfile): replace it with a
|
|
8
|
+
# real upstream Faraday RBS to promote the gate to blocking.
|
|
9
|
+
#
|
|
10
|
+
# Scope: only what `lib/dinie/runtime/{http,token_manager,logger,multipart}.rb` touch as Faraday
|
|
11
|
+
# constants. Everything else flows through `untyped`.
|
|
12
|
+
|
|
13
|
+
module Faraday
|
|
14
|
+
# `Faraday.new(url:) { |conn| ... }` — returns an untyped connection so `conn.request` /
|
|
15
|
+
# `conn.use` / `conn.adapter` / `conn.run_request` all resolve permissively.
|
|
16
|
+
def self.new: (*untyped, **untyped) ?{ (untyped) -> void } -> untyped
|
|
17
|
+
|
|
18
|
+
# Base for the custom logging middleware (`Internal::Middleware::Logging < Faraday::Middleware`).
|
|
19
|
+
class Middleware
|
|
20
|
+
def initialize: (untyped app) -> void
|
|
21
|
+
def call: (untyped env) -> untyped
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Transport error hierarchy the retry loop rescues + classifies.
|
|
25
|
+
class Error < StandardError
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class TimeoutError < Error
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class ConnectionFailed < Error
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Multipart parts the KYC upload wrapper builds / type-checks against.
|
|
35
|
+
module Multipart
|
|
36
|
+
class FilePart
|
|
37
|
+
def initialize: (*untyped) -> void
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class ParamPart
|
|
41
|
+
def initialize: (*untyped) -> void
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# `Dinie::Client` — the SDK entry point (architecture §2 RB1, §3.2, §10). Mirrors
|
|
2
|
+
# `lib/dinie/generated/client.rb`. Owns a connection pool + an OAuth2 token cache; construct once.
|
|
3
|
+
|
|
4
|
+
module Dinie
|
|
5
|
+
# Per-call options accepted by every resource method: a normalized `RequestOptions` or a plain
|
|
6
|
+
# Hash (`{ timeout:, idempotency_key:, headers:, max_retries: }`). The methods default it to `{}`.
|
|
7
|
+
type request_opts = Dinie::Internal::RequestOptions | Hash[Symbol, untyped]
|
|
8
|
+
|
|
9
|
+
class Client
|
|
10
|
+
DEFAULT_TIMEOUT_SECONDS: Integer
|
|
11
|
+
DEFAULT_MAX_RETRIES: Integer
|
|
12
|
+
|
|
13
|
+
def initialize: (?client_id: String?, ?client_secret: String?, ?base_url: String?, ?timeout: Numeric, ?max_retries: Integer, ?idempotency: bool, ?log_level: Symbol, ?logger: untyped, ?adapter: Symbol?, ?token_manager: Dinie::Internal::TokenManager?, ?connection: untyped) -> void
|
|
14
|
+
|
|
15
|
+
# The customers resource.
|
|
16
|
+
def customers: () -> Dinie::Resources::Customers
|
|
17
|
+
|
|
18
|
+
# The credit-offers resource.
|
|
19
|
+
def credit_offers: () -> Dinie::Resources::CreditOffers
|
|
20
|
+
|
|
21
|
+
# The loans resource.
|
|
22
|
+
def loans: () -> Dinie::Resources::Loans
|
|
23
|
+
|
|
24
|
+
# The banks resource (flat `Array<Bank>` list).
|
|
25
|
+
def banks: () -> Dinie::Resources::Banks
|
|
26
|
+
|
|
27
|
+
# The credentials resource.
|
|
28
|
+
def credentials: () -> Dinie::Resources::Credentials
|
|
29
|
+
|
|
30
|
+
# The webhook-endpoints resource.
|
|
31
|
+
def webhook_endpoints: () -> Dinie::Resources::WebhookEndpoints
|
|
32
|
+
|
|
33
|
+
# The latest rate-limit snapshot; `nil` before the first call.
|
|
34
|
+
def rate_limit: () -> Dinie::RateLimit?
|
|
35
|
+
|
|
36
|
+
# Clone the client with merged config, sharing the same TokenManager + connection.
|
|
37
|
+
def with_options: (**untyped overrides) -> Dinie::Client
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def build_token_manager: (String? client_id, String? client_secret) -> Dinie::Internal::TokenManager
|
|
42
|
+
def build_connection: (log_level: Symbol, logger: untyped, adapter: Symbol?) -> untyped
|
|
43
|
+
def first_present: (*untyped candidates) -> untyped
|
|
44
|
+
end
|
|
45
|
+
end
|