nomba-python 0.1.0__py3-none-any.whl
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.
- nomba_python/__init__.py +40 -0
- nomba_python/client.py +161 -0
- nomba_python/concurrency.py +54 -0
- nomba_python/data/__init__.py +0 -0
- nomba_python/data/nomba_openapi.json +13321 -0
- nomba_python/exceptions.py +49 -0
- nomba_python/flows/__init__.py +3 -0
- nomba_python/flows/card_payment.py +204 -0
- nomba_python/http.py +418 -0
- nomba_python/models.py +749 -0
- nomba_python/pagination.py +111 -0
- nomba_python/py.typed +0 -0
- nomba_python/resources/__init__.py +33 -0
- nomba_python/resources/accounts.py +379 -0
- nomba_python/resources/airtime_data.py +252 -0
- nomba_python/resources/cabletv.py +173 -0
- nomba_python/resources/charge.py +410 -0
- nomba_python/resources/checkout.py +239 -0
- nomba_python/resources/electricity.py +204 -0
- nomba_python/resources/terminals.py +184 -0
- nomba_python/resources/transactions.py +460 -0
- nomba_python/resources/transfers.py +298 -0
- nomba_python/resources/virtual_accounts.py +230 -0
- nomba_python/validation.py +97 -0
- nomba_python/webhooks.py +190 -0
- nomba_python-0.1.0.dist-info/METADATA +312 -0
- nomba_python-0.1.0.dist-info/RECORD +29 -0
- nomba_python-0.1.0.dist-info/WHEEL +4 -0
- nomba_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NombaError(Exception):
|
|
7
|
+
"""Base exception for all Nomba SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NombaAPIError(NombaError):
|
|
11
|
+
"""Raised when the Nomba API returns a non-success response."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
*,
|
|
17
|
+
status_code: int | None = None,
|
|
18
|
+
code: str | None = None,
|
|
19
|
+
response_body: Any = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
self.code = code
|
|
24
|
+
self.response_body = response_body
|
|
25
|
+
|
|
26
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
27
|
+
parts = [super().__str__()]
|
|
28
|
+
if self.status_code is not None:
|
|
29
|
+
parts.append(f"(status={self.status_code})")
|
|
30
|
+
if self.code is not None:
|
|
31
|
+
parts.append(f"(code={self.code})")
|
|
32
|
+
return " ".join(parts)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NombaAuthError(NombaAPIError):
|
|
36
|
+
"""Raised when authentication with Nomba fails."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NombaValidationError(NombaError):
|
|
40
|
+
"""
|
|
41
|
+
Raised locally (before any network call) when a request body is missing
|
|
42
|
+
required nested fields per Nomba's own OpenAPI spec. This catches
|
|
43
|
+
mistakes in nested objects (e.g. a missing field inside `order={...}`)
|
|
44
|
+
that the generated method's flat signature can't enforce on its own.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, message: str, *, missing: list[str] | None = None) -> None:
|
|
48
|
+
super().__init__(message)
|
|
49
|
+
self.missing = missing or []
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Guided helper for Nomba's card-payment flow.
|
|
3
|
+
|
|
4
|
+
Nomba's card checkout is a multi-step sequence that otherwise requires
|
|
5
|
+
reading their docs to understand:
|
|
6
|
+
|
|
7
|
+
1. Create an order -> orderReference
|
|
8
|
+
2. Submit card details -> responseCode tells you what's next:
|
|
9
|
+
"00" = done, payment completed
|
|
10
|
+
"T0" = OTP required, call submit_otp()
|
|
11
|
+
"S0" = 3D Secure required, redirect the user using secureAuthenticationData
|
|
12
|
+
3. (if "T0") submit_otp(otp) -> or resend_otp() if it didn't arrive
|
|
13
|
+
4. confirm() -> final transaction status/details
|
|
14
|
+
|
|
15
|
+
This module wraps that sequence in a small stateful object so callers don't
|
|
16
|
+
need to track orderReference/transactionId by hand or look up what each
|
|
17
|
+
responseCode means.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from ..resources.charge import AsyncCharge, Charge
|
|
26
|
+
from .. import models as _models
|
|
27
|
+
# Response codes Nomba documents on the card-details submission response.
|
|
28
|
+
RESPONSE_CODE_SUCCESS = "00"
|
|
29
|
+
RESPONSE_CODE_OTP_REQUIRED = "T0"
|
|
30
|
+
RESPONSE_CODE_3DS_REQUIRED = "S0"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class CardPaymentStep:
|
|
35
|
+
"""Outcome of a single step in the card-payment flow."""
|
|
36
|
+
|
|
37
|
+
raw: _models.SubmitCustomerCardDetailsResponse | _models.SubmitCustomerPaymentOtpResponse | dict[str, Any]
|
|
38
|
+
response_code: str | None
|
|
39
|
+
status: Any
|
|
40
|
+
message: str | None
|
|
41
|
+
transaction_id: str | None
|
|
42
|
+
requires_otp: bool
|
|
43
|
+
requires_3ds: bool
|
|
44
|
+
secure_authentication_data: dict[str, Any] | None
|
|
45
|
+
completed: bool
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _interpret(raw: _models.SubmitCustomerCardDetailsResponse | _models.SubmitCustomerPaymentOtpResponse, transaction_id_fallback: str | None = None) -> CardPaymentStep:
|
|
49
|
+
data = raw.get("data", raw) if isinstance(raw.get("data"), dict) else raw
|
|
50
|
+
response_code = data.get("responseCode")
|
|
51
|
+
transaction_id = data.get("transactionId") or transaction_id_fallback
|
|
52
|
+
return CardPaymentStep(
|
|
53
|
+
raw=raw,
|
|
54
|
+
response_code=response_code,
|
|
55
|
+
status=data.get("status"),
|
|
56
|
+
message=data.get("message"),
|
|
57
|
+
transaction_id=transaction_id,
|
|
58
|
+
requires_otp=response_code == RESPONSE_CODE_OTP_REQUIRED,
|
|
59
|
+
requires_3ds=response_code == RESPONSE_CODE_3DS_REQUIRED,
|
|
60
|
+
secure_authentication_data=data.get("secureAuthenticationData"),
|
|
61
|
+
completed=response_code == RESPONSE_CODE_SUCCESS,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CardPaymentFlow:
|
|
66
|
+
"""
|
|
67
|
+
Stateful, guided wrapper around Nomba's card-payment + OTP sequence.
|
|
68
|
+
|
|
69
|
+
Example:
|
|
70
|
+
from nomba import Nomba
|
|
71
|
+
from nomba.flows import CardPaymentFlow
|
|
72
|
+
|
|
73
|
+
nomba = Nomba(...)
|
|
74
|
+
order = nomba.checkout.create_an_online_checkout_order(
|
|
75
|
+
order={"orderReference": "order-001", "amount": "1000", ...}
|
|
76
|
+
)
|
|
77
|
+
order_ref = order["data"]["orderReference"]
|
|
78
|
+
|
|
79
|
+
flow = CardPaymentFlow(nomba.charge, order_reference=order_ref)
|
|
80
|
+
step = flow.submit_card(card_details="...", key="")
|
|
81
|
+
|
|
82
|
+
if step.requires_otp:
|
|
83
|
+
step = flow.submit_otp(otp="123456")
|
|
84
|
+
elif step.requires_3ds:
|
|
85
|
+
# redirect the user using step.secure_authentication_data
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
if step.completed:
|
|
89
|
+
result = flow.confirm()
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, charge: "Charge", *, order_reference: str) -> None:
|
|
93
|
+
self._charge = charge
|
|
94
|
+
self.order_reference = order_reference
|
|
95
|
+
self.transaction_id: str | None = None
|
|
96
|
+
|
|
97
|
+
def submit_card(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
card_details: str,
|
|
101
|
+
key: str = "",
|
|
102
|
+
save_card: bool | None = None,
|
|
103
|
+
device_information: object | None = None,
|
|
104
|
+
) -> CardPaymentStep:
|
|
105
|
+
raw = self._charge.submit_customer_card_details(
|
|
106
|
+
card_details=card_details,
|
|
107
|
+
key=key,
|
|
108
|
+
order_reference=self.order_reference,
|
|
109
|
+
save_card=save_card,
|
|
110
|
+
device_information=device_information,
|
|
111
|
+
)
|
|
112
|
+
step = _interpret(raw)
|
|
113
|
+
self.transaction_id = step.transaction_id
|
|
114
|
+
return step
|
|
115
|
+
|
|
116
|
+
def submit_otp(self, otp: str) -> CardPaymentStep:
|
|
117
|
+
if not self.transaction_id:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
"No transaction_id on this flow yet — call submit_card() first."
|
|
120
|
+
)
|
|
121
|
+
raw = self._charge.submit_customer_payment_otp(
|
|
122
|
+
otp=otp,
|
|
123
|
+
order_reference=self.order_reference,
|
|
124
|
+
transaction_id=self.transaction_id,
|
|
125
|
+
)
|
|
126
|
+
return _interpret(raw, transaction_id_fallback=self.transaction_id)
|
|
127
|
+
|
|
128
|
+
def resend_otp(self) -> _models.ResendCustomerPaymentOtpResponse:
|
|
129
|
+
return self._charge.resend_customer_payment_otp(
|
|
130
|
+
order_reference=self.order_reference
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def confirm(self) -> _models.FetchCheckoutTransactionDetailsResponse:
|
|
134
|
+
return self._charge.fetch_checkout_transaction_details(
|
|
135
|
+
order_reference=self.order_reference
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def cancel(self, *, force: bool = False) -> _models.CancelCheckoutTransactionResponse:
|
|
139
|
+
if not self.transaction_id:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
"No transaction_id on this flow yet — call submit_card() first."
|
|
142
|
+
)
|
|
143
|
+
return self._charge.cancel_checkout_transaction(
|
|
144
|
+
transaction_id=self.transaction_id, force_cancel=force
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class AsyncCardPaymentFlow:
|
|
149
|
+
"""Async version of `CardPaymentFlow` — same steps, all coroutines."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, charge: "AsyncCharge", *, order_reference: str) -> None:
|
|
152
|
+
self._charge = charge
|
|
153
|
+
self.order_reference = order_reference
|
|
154
|
+
self.transaction_id: str | None = None
|
|
155
|
+
|
|
156
|
+
async def submit_card(
|
|
157
|
+
self,
|
|
158
|
+
*,
|
|
159
|
+
card_details: str,
|
|
160
|
+
key: str = "",
|
|
161
|
+
save_card: bool | None = None,
|
|
162
|
+
device_information: object | None = None,
|
|
163
|
+
) -> CardPaymentStep:
|
|
164
|
+
raw = await self._charge.submit_customer_card_details(
|
|
165
|
+
card_details=card_details,
|
|
166
|
+
key=key,
|
|
167
|
+
order_reference=self.order_reference,
|
|
168
|
+
save_card=save_card,
|
|
169
|
+
device_information=device_information,
|
|
170
|
+
)
|
|
171
|
+
step = _interpret(raw)
|
|
172
|
+
self.transaction_id = step.transaction_id
|
|
173
|
+
return step
|
|
174
|
+
|
|
175
|
+
async def submit_otp(self, otp: str) -> CardPaymentStep:
|
|
176
|
+
if not self.transaction_id:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
"No transaction_id on this flow yet — call submit_card() first."
|
|
179
|
+
)
|
|
180
|
+
raw = await self._charge.submit_customer_payment_otp(
|
|
181
|
+
otp=otp,
|
|
182
|
+
order_reference=self.order_reference,
|
|
183
|
+
transaction_id=self.transaction_id,
|
|
184
|
+
)
|
|
185
|
+
return _interpret(raw, transaction_id_fallback=self.transaction_id)
|
|
186
|
+
|
|
187
|
+
async def resend_otp(self)-> _models.ResendCustomerPaymentOtpResponse:
|
|
188
|
+
return await self._charge.resend_customer_payment_otp(
|
|
189
|
+
order_reference=self.order_reference
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
async def confirm(self)-> _models.FetchCheckoutTransactionDetailsResponse:
|
|
193
|
+
return await self._charge.fetch_checkout_transaction_details(
|
|
194
|
+
order_reference=self.order_reference
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
async def cancel(self, *, force: bool = False)-> _models.CancelCheckoutTransactionResponse:
|
|
198
|
+
if not self.transaction_id:
|
|
199
|
+
raise ValueError(
|
|
200
|
+
"No transaction_id on this flow yet — call submit_card() first."
|
|
201
|
+
)
|
|
202
|
+
return await self._charge.cancel_checkout_transaction(
|
|
203
|
+
transaction_id=self.transaction_id, force_cancel=force
|
|
204
|
+
)
|
nomba_python/http.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import random
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .exceptions import NombaAPIError, NombaAuthError
|
|
12
|
+
|
|
13
|
+
LIVE_BASE_URL = "https://api.nomba.com"
|
|
14
|
+
SANDBOX_BASE_URL = "https://sandbox.nomba.com"
|
|
15
|
+
|
|
16
|
+
# Status codes worth retrying: rate limit + transient server-side errors.
|
|
17
|
+
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _compute_backoff(attempt: int, retry_after: str | None, backoff_factor: float) -> float:
|
|
21
|
+
if retry_after:
|
|
22
|
+
try:
|
|
23
|
+
return float(retry_after)
|
|
24
|
+
except ValueError:
|
|
25
|
+
pass
|
|
26
|
+
# exponential backoff with jitter: backoff_factor * 2^attempt, +/- 20%
|
|
27
|
+
base = backoff_factor * (2 ** attempt)
|
|
28
|
+
jitter = base * random.uniform(-0.2, 0.2)
|
|
29
|
+
return max(0.0, base + jitter)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NombaClient:
|
|
33
|
+
"""
|
|
34
|
+
Low-level HTTP client for the Nomba API.
|
|
35
|
+
|
|
36
|
+
Handles OAuth2 client-credentials authentication, token caching/refresh
|
|
37
|
+
(with a lock so concurrent requests don't race to re-fetch a token),
|
|
38
|
+
the `accountId` header that most endpoints require, and automatic
|
|
39
|
+
retry-with-backoff for 429/5xx responses.
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
client = NombaClient(
|
|
43
|
+
client_id="...",
|
|
44
|
+
client_secret="...",
|
|
45
|
+
account_id="...",
|
|
46
|
+
)
|
|
47
|
+
account = client.get(f"/v1/accounts/virtual/{account_ref}")
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
client_id: str,
|
|
53
|
+
client_secret: str,
|
|
54
|
+
account_id: str,
|
|
55
|
+
*,
|
|
56
|
+
sandbox: bool = False,
|
|
57
|
+
timeout: float = 30.0,
|
|
58
|
+
max_retries: int = 3,
|
|
59
|
+
backoff_factor: float = 0.5,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.client_id = client_id
|
|
62
|
+
self.client_secret = client_secret
|
|
63
|
+
self.account_id = account_id
|
|
64
|
+
self.base_url = SANDBOX_BASE_URL if sandbox else LIVE_BASE_URL
|
|
65
|
+
self.max_retries = max_retries
|
|
66
|
+
self.backoff_factor = backoff_factor
|
|
67
|
+
|
|
68
|
+
self._access_token: str | None = None
|
|
69
|
+
self._token_expires_at: float = 0.0
|
|
70
|
+
self._token_lock = threading.Lock()
|
|
71
|
+
|
|
72
|
+
self._http = httpx.Client(base_url=self.base_url, timeout=timeout)
|
|
73
|
+
|
|
74
|
+
# -- auth -----------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def _fetch_token_locked(self) -> None:
|
|
77
|
+
try:
|
|
78
|
+
response = self._http.post(
|
|
79
|
+
"/v1/auth/token/issue",
|
|
80
|
+
headers={
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
"accountId": self.account_id,
|
|
83
|
+
},
|
|
84
|
+
json={
|
|
85
|
+
"grant_type": "client_credentials",
|
|
86
|
+
"client_id": self.client_id,
|
|
87
|
+
"client_secret": self.client_secret,
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
except httpx.HTTPError as exc:
|
|
91
|
+
raise NombaAuthError(f"Failed to reach Nomba auth endpoint: {exc}") from exc
|
|
92
|
+
|
|
93
|
+
if response.status_code >= 400:
|
|
94
|
+
raise NombaAuthError(
|
|
95
|
+
"Failed to obtain access token",
|
|
96
|
+
status_code=response.status_code,
|
|
97
|
+
response_body=_safe_json(response),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
body = _safe_json(response) or {}
|
|
101
|
+
data = body.get("data", body)
|
|
102
|
+
token = data.get("access_token")
|
|
103
|
+
if not token:
|
|
104
|
+
raise NombaAuthError(
|
|
105
|
+
"Nomba auth response did not include an access_token",
|
|
106
|
+
response_body=body,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
expires_in = data.get("expires_in", 3600)
|
|
110
|
+
self._access_token = token
|
|
111
|
+
# refresh a little early to avoid edge-of-expiry failures
|
|
112
|
+
self._token_expires_at = time.monotonic() + max(int(expires_in) - 60, 0)
|
|
113
|
+
|
|
114
|
+
def _ensure_token(self) -> str:
|
|
115
|
+
# fast path: token already valid, no lock needed
|
|
116
|
+
if self._access_token is not None and time.monotonic() < self._token_expires_at:
|
|
117
|
+
return self._access_token
|
|
118
|
+
# slow path: only one thread should actually fetch; others wait then
|
|
119
|
+
# re-check (the lock serializes them, avoiding a fetch stampede).
|
|
120
|
+
with self._token_lock:
|
|
121
|
+
if self._access_token is None or time.monotonic() >= self._token_expires_at:
|
|
122
|
+
self._fetch_token_locked()
|
|
123
|
+
assert self._access_token is not None
|
|
124
|
+
return self._access_token
|
|
125
|
+
|
|
126
|
+
def _invalidate_token(self) -> None:
|
|
127
|
+
with self._token_lock:
|
|
128
|
+
self._access_token = None
|
|
129
|
+
|
|
130
|
+
# -- request helpers --------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def request(
|
|
133
|
+
self,
|
|
134
|
+
method: str,
|
|
135
|
+
path: str,
|
|
136
|
+
*,
|
|
137
|
+
json: dict[str, Any] | None = None,
|
|
138
|
+
params: dict[str, Any] | None = None,
|
|
139
|
+
extra_headers: dict[str, str] | None = None,
|
|
140
|
+
_attempt: int = 0,
|
|
141
|
+
_retry_on_auth_failure: bool = True,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
token = self._ensure_token()
|
|
144
|
+
headers = {
|
|
145
|
+
"Authorization": f"Bearer {token}",
|
|
146
|
+
"accountId": self.account_id,
|
|
147
|
+
"Content-Type": "application/json",
|
|
148
|
+
}
|
|
149
|
+
if extra_headers:
|
|
150
|
+
headers.update(extra_headers)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
response = self._http.request(
|
|
154
|
+
method, path, json=json, params=params, headers=headers
|
|
155
|
+
)
|
|
156
|
+
except httpx.HTTPError as exc:
|
|
157
|
+
raise NombaAPIError(f"Request to {path} failed: {exc}") from exc
|
|
158
|
+
|
|
159
|
+
if response.status_code == 401 and _retry_on_auth_failure:
|
|
160
|
+
# token may have been invalidated server-side; force refresh once
|
|
161
|
+
self._invalidate_token()
|
|
162
|
+
return self.request(
|
|
163
|
+
method,
|
|
164
|
+
path,
|
|
165
|
+
json=json,
|
|
166
|
+
params=params,
|
|
167
|
+
extra_headers=extra_headers,
|
|
168
|
+
_attempt=_attempt,
|
|
169
|
+
_retry_on_auth_failure=False,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if response.status_code in RETRYABLE_STATUS_CODES and _attempt < self.max_retries:
|
|
173
|
+
delay = _compute_backoff(
|
|
174
|
+
_attempt, response.headers.get("Retry-After"), self.backoff_factor
|
|
175
|
+
)
|
|
176
|
+
time.sleep(delay)
|
|
177
|
+
return self.request(
|
|
178
|
+
method,
|
|
179
|
+
path,
|
|
180
|
+
json=json,
|
|
181
|
+
params=params,
|
|
182
|
+
extra_headers=extra_headers,
|
|
183
|
+
_attempt=_attempt + 1,
|
|
184
|
+
_retry_on_auth_failure=_retry_on_auth_failure,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
body = _safe_json(response)
|
|
188
|
+
|
|
189
|
+
if response.status_code >= 400:
|
|
190
|
+
message = "Nomba API request failed"
|
|
191
|
+
code = None
|
|
192
|
+
if isinstance(body, dict):
|
|
193
|
+
message = body.get("description", message)
|
|
194
|
+
code = body.get("code")
|
|
195
|
+
raise NombaAPIError(
|
|
196
|
+
message,
|
|
197
|
+
status_code=response.status_code,
|
|
198
|
+
code=code,
|
|
199
|
+
response_body=body,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return body if isinstance(body, dict) else {"data": body}
|
|
203
|
+
|
|
204
|
+
def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
205
|
+
return self.request("GET", path, **kwargs)
|
|
206
|
+
|
|
207
|
+
def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
208
|
+
return self.request("POST", path, **kwargs)
|
|
209
|
+
|
|
210
|
+
def put(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
211
|
+
return self.request("PUT", path, **kwargs)
|
|
212
|
+
|
|
213
|
+
def delete(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
214
|
+
return self.request("DELETE", path, **kwargs)
|
|
215
|
+
|
|
216
|
+
def close(self) -> None:
|
|
217
|
+
self._http.close()
|
|
218
|
+
|
|
219
|
+
def __enter__(self) -> "NombaClient":
|
|
220
|
+
return self
|
|
221
|
+
|
|
222
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
223
|
+
self.close()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _safe_json(response: httpx.Response) -> Any:
|
|
227
|
+
try:
|
|
228
|
+
return response.json()
|
|
229
|
+
except ValueError:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class AsyncNombaClient:
|
|
234
|
+
"""
|
|
235
|
+
Async low-level HTTP client for the Nomba API (httpx.AsyncClient based).
|
|
236
|
+
|
|
237
|
+
Mirrors NombaClient's behavior: OAuth2 client-credentials auth, token
|
|
238
|
+
caching/refresh guarded by an asyncio.Lock, the `accountId` header, and
|
|
239
|
+
automatic retry-with-backoff for 429/5xx responses.
|
|
240
|
+
|
|
241
|
+
Example:
|
|
242
|
+
client = AsyncNombaClient(
|
|
243
|
+
client_id="...",
|
|
244
|
+
client_secret="...",
|
|
245
|
+
account_id="...",
|
|
246
|
+
)
|
|
247
|
+
account = await client.get(f"/v1/accounts/virtual/{account_ref}")
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
def __init__(
|
|
251
|
+
self,
|
|
252
|
+
client_id: str,
|
|
253
|
+
client_secret: str,
|
|
254
|
+
account_id: str,
|
|
255
|
+
*,
|
|
256
|
+
sandbox: bool = False,
|
|
257
|
+
timeout: float = 30.0,
|
|
258
|
+
max_retries: int = 3,
|
|
259
|
+
backoff_factor: float = 0.5,
|
|
260
|
+
) -> None:
|
|
261
|
+
self.client_id = client_id
|
|
262
|
+
self.client_secret = client_secret
|
|
263
|
+
self.account_id = account_id
|
|
264
|
+
self.base_url = SANDBOX_BASE_URL if sandbox else LIVE_BASE_URL
|
|
265
|
+
self.max_retries = max_retries
|
|
266
|
+
self.backoff_factor = backoff_factor
|
|
267
|
+
|
|
268
|
+
self._access_token: str | None = None
|
|
269
|
+
self._token_expires_at: float = 0.0
|
|
270
|
+
self._token_lock = asyncio.Lock()
|
|
271
|
+
|
|
272
|
+
self._http = httpx.AsyncClient(base_url=self.base_url, timeout=timeout)
|
|
273
|
+
|
|
274
|
+
# -- auth -----------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
async def _fetch_token_locked(self) -> None:
|
|
277
|
+
try:
|
|
278
|
+
response = await self._http.post(
|
|
279
|
+
"/v1/auth/token/issue",
|
|
280
|
+
headers={
|
|
281
|
+
"Content-Type": "application/json",
|
|
282
|
+
"accountId": self.account_id,
|
|
283
|
+
},
|
|
284
|
+
json={
|
|
285
|
+
"grant_type": "client_credentials",
|
|
286
|
+
"client_id": self.client_id,
|
|
287
|
+
"client_secret": self.client_secret,
|
|
288
|
+
},
|
|
289
|
+
)
|
|
290
|
+
except httpx.HTTPError as exc:
|
|
291
|
+
raise NombaAuthError(f"Failed to reach Nomba auth endpoint: {exc}") from exc
|
|
292
|
+
|
|
293
|
+
if response.status_code >= 400:
|
|
294
|
+
raise NombaAuthError(
|
|
295
|
+
"Failed to obtain access token",
|
|
296
|
+
status_code=response.status_code,
|
|
297
|
+
response_body=_safe_json(response),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
body = _safe_json(response) or {}
|
|
301
|
+
data = body.get("data", body)
|
|
302
|
+
token = data.get("access_token")
|
|
303
|
+
if not token:
|
|
304
|
+
raise NombaAuthError(
|
|
305
|
+
"Nomba auth response did not include an access_token",
|
|
306
|
+
response_body=body,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
expires_in = data.get("expires_in", 3600)
|
|
310
|
+
self._access_token = token
|
|
311
|
+
self._token_expires_at = time.monotonic() + max(int(expires_in) - 60, 0)
|
|
312
|
+
|
|
313
|
+
async def _ensure_token(self) -> str:
|
|
314
|
+
if self._access_token is not None and time.monotonic() < self._token_expires_at:
|
|
315
|
+
return self._access_token
|
|
316
|
+
async with self._token_lock:
|
|
317
|
+
if self._access_token is None or time.monotonic() >= self._token_expires_at:
|
|
318
|
+
await self._fetch_token_locked()
|
|
319
|
+
assert self._access_token is not None
|
|
320
|
+
return self._access_token
|
|
321
|
+
|
|
322
|
+
async def _invalidate_token(self) -> None:
|
|
323
|
+
async with self._token_lock:
|
|
324
|
+
self._access_token = None
|
|
325
|
+
|
|
326
|
+
# -- request helpers --------------------------------------------------
|
|
327
|
+
|
|
328
|
+
async def request(
|
|
329
|
+
self,
|
|
330
|
+
method: str,
|
|
331
|
+
path: str,
|
|
332
|
+
*,
|
|
333
|
+
json: dict[str, Any] | None = None,
|
|
334
|
+
params: dict[str, Any] | None = None,
|
|
335
|
+
extra_headers: dict[str, str] | None = None,
|
|
336
|
+
_attempt: int = 0,
|
|
337
|
+
_retry_on_auth_failure: bool = True,
|
|
338
|
+
) -> dict[str, Any]:
|
|
339
|
+
token = await self._ensure_token()
|
|
340
|
+
headers = {
|
|
341
|
+
"Authorization": f"Bearer {token}",
|
|
342
|
+
"accountId": self.account_id,
|
|
343
|
+
"Content-Type": "application/json",
|
|
344
|
+
}
|
|
345
|
+
if extra_headers:
|
|
346
|
+
headers.update(extra_headers)
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
response = await self._http.request(
|
|
350
|
+
method, path, json=json, params=params, headers=headers
|
|
351
|
+
)
|
|
352
|
+
except httpx.HTTPError as exc:
|
|
353
|
+
raise NombaAPIError(f"Request to {path} failed: {exc}") from exc
|
|
354
|
+
|
|
355
|
+
if response.status_code == 401 and _retry_on_auth_failure:
|
|
356
|
+
await self._invalidate_token()
|
|
357
|
+
return await self.request(
|
|
358
|
+
method,
|
|
359
|
+
path,
|
|
360
|
+
json=json,
|
|
361
|
+
params=params,
|
|
362
|
+
extra_headers=extra_headers,
|
|
363
|
+
_attempt=_attempt,
|
|
364
|
+
_retry_on_auth_failure=False,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
if response.status_code in RETRYABLE_STATUS_CODES and _attempt < self.max_retries:
|
|
368
|
+
delay = _compute_backoff(
|
|
369
|
+
_attempt, response.headers.get("Retry-After"), self.backoff_factor
|
|
370
|
+
)
|
|
371
|
+
await asyncio.sleep(delay)
|
|
372
|
+
return await self.request(
|
|
373
|
+
method,
|
|
374
|
+
path,
|
|
375
|
+
json=json,
|
|
376
|
+
params=params,
|
|
377
|
+
extra_headers=extra_headers,
|
|
378
|
+
_attempt=_attempt + 1,
|
|
379
|
+
_retry_on_auth_failure=_retry_on_auth_failure,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
body = _safe_json(response)
|
|
383
|
+
|
|
384
|
+
if response.status_code >= 400:
|
|
385
|
+
message = "Nomba API request failed"
|
|
386
|
+
code = None
|
|
387
|
+
if isinstance(body, dict):
|
|
388
|
+
message = body.get("description", message)
|
|
389
|
+
code = body.get("code")
|
|
390
|
+
raise NombaAPIError(
|
|
391
|
+
message,
|
|
392
|
+
status_code=response.status_code,
|
|
393
|
+
code=code,
|
|
394
|
+
response_body=body,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
return body if isinstance(body, dict) else {"data": body}
|
|
398
|
+
|
|
399
|
+
async def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
400
|
+
return await self.request("GET", path, **kwargs)
|
|
401
|
+
|
|
402
|
+
async def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
403
|
+
return await self.request("POST", path, **kwargs)
|
|
404
|
+
|
|
405
|
+
async def put(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
406
|
+
return await self.request("PUT", path, **kwargs)
|
|
407
|
+
|
|
408
|
+
async def delete(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
409
|
+
return await self.request("DELETE", path, **kwargs)
|
|
410
|
+
|
|
411
|
+
async def close(self) -> None:
|
|
412
|
+
await self._http.aclose()
|
|
413
|
+
|
|
414
|
+
async def __aenter__(self) -> "AsyncNombaClient":
|
|
415
|
+
return self
|
|
416
|
+
|
|
417
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
418
|
+
await self.close()
|