from __future__ import annotations import base64 import hashlib import hmac import secrets import time from collections.abc import Callable from dataclasses import dataclass from typing import Any from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit import httpx class NetSuiteRestletError(RuntimeError): pass def _oauth_escape(value: object) -> str: return quote(str(value), safe="~-._") def _normalized_parameters(url: str, oauth_parameters: dict[str, str]) -> str: query = parse_qsl(urlsplit(url).query, keep_blank_values=True) pairs = [*query, *oauth_parameters.items()] ordered = sorted(pairs, key=lambda pair: (pair[0], pair[1])) return "&".join(f"{_oauth_escape(key)}={_oauth_escape(value)}" for key, value in ordered) @dataclass(frozen=True) class OAuth1TbaCredentials: realm: str consumer_key: str consumer_secret: str token_key: str token_secret: str class OAuth1TbaSigner: """TBA OAuth 1.0 signer that always signs the complete query-bearing URL.""" def __init__( self, credentials: OAuth1TbaCredentials, *, nonce_factory: Callable[[], str] = lambda: secrets.token_hex(16), timestamp_factory: Callable[[], int] = lambda: int(time.time()), ) -> None: self.credentials = credentials self.nonce_factory = nonce_factory self.timestamp_factory = timestamp_factory def authorization_header(self, method: str, url: str) -> str: split = urlsplit(url) base_url = urlunsplit((split.scheme, split.netloc, split.path, "", "")) parameters = { "oauth_consumer_key": self.credentials.consumer_key, "oauth_token": self.credentials.token_key, "oauth_nonce": self.nonce_factory(), "oauth_timestamp": str(self.timestamp_factory()), "oauth_signature_method": "HMAC-SHA256", "oauth_version": "1.0", } base_string = "&".join( [ method.upper(), _oauth_escape(base_url), _oauth_escape(_normalized_parameters(url, parameters)), ] ) key = f"{_oauth_escape(self.credentials.consumer_secret)}&{_oauth_escape(self.credentials.token_secret)}" signature = base64.b64encode(hmac.new(key.encode(), base_string.encode(), hashlib.sha256).digest()).decode() parameters["oauth_signature"] = signature header_parameters = {"realm": self.credentials.realm, **parameters} return "OAuth " + ", ".join( f'{_oauth_escape(key)}="{_oauth_escape(value)}"' for key, value in sorted(header_parameters.items()) ) class NetSuiteRestletClient: def __init__( self, *, url: str, signer: OAuth1TbaSigner, max_attempts: int = 4, timeout_seconds: float = 30, transport: httpx.BaseTransport | None = None, sleeper: Callable[[float], None] = time.sleep, ) -> None: self.url = url self.signer = signer self.max_attempts = max_attempts self.sleeper = sleeper self._http = httpx.Client(timeout=timeout_seconds, transport=transport) def close(self) -> None: self._http.close() def __enter__(self) -> NetSuiteRestletClient: return self def __exit__(self, *_args: object) -> None: self.close() def fetch_page(self, *, posting_period_id: str, cursor: str | None, page_size: int) -> dict[str, Any]: params: list[tuple[str, str]] = [("postingPeriodId", posting_period_id), ("pageSize", str(page_size))] if cursor is not None: params.append(("cursor", cursor)) separator = "&" if urlsplit(self.url).query else "?" full_url = f"{self.url}{separator}{urlencode(params)}" for attempt in range(1, self.max_attempts + 1): # Signing is deliberately inside the retry loop: NetSuite rejects stale # nonce/timestamp pairs, and the query string must be part of every base string. authorization = self.signer.authorization_header("GET", full_url) response = self._http.get(full_url, headers={"Authorization": authorization}) if response.status_code not in {408, 429, 500, 502, 503, 504}: if response.is_error: raise NetSuiteRestletError(f"NetSuite RESTlet HTTP {response.status_code}") try: payload = response.json() except ValueError as error: raise NetSuiteRestletError("NetSuite RESTlet returned invalid JSON") from error if not isinstance(payload, dict): raise NetSuiteRestletError("NetSuite RESTlet returned a non-object JSON payload") return payload if attempt == self.max_attempts: raise NetSuiteRestletError(f"NetSuite RESTlet HTTP {response.status_code} after {attempt} attempts") self.sleeper(min(float(2 ** (attempt - 1)), 30.0)) raise AssertionError("unreachable")