from __future__ import annotations import time from collections.abc import Callable from dataclasses import dataclass from typing import Any from urllib.parse import quote import httpx class NexusCRMError(RuntimeError): pass @dataclass(frozen=True) class UpsertResult: id: str success: bool created: bool | None errors: tuple[dict[str, Any], ...] class NexusCRMClient: def __init__( self, *, base_url: str, client_id: str, client_secret: str, api_version: str = "v63.0", timeout_seconds: float = 30, max_attempts: int = 4, transport: httpx.BaseTransport | None = None, sleeper: Callable[[float], None] = time.sleep, ) -> None: self.base_url = base_url.rstrip("/") self.client_id = client_id self.client_secret = client_secret self.api_version = api_version self.max_attempts = max_attempts self.sleeper = sleeper self._access_token: str | None = None self._instance_url: str | None = None self._http = httpx.Client(timeout=timeout_seconds, transport=transport) def close(self) -> None: self._http.close() def __enter__(self) -> NexusCRMClient: return self def __exit__(self, *_args: object) -> None: self.close() def describe_object(self, object_name: str) -> dict[str, Any]: response = self._request( "GET", f"/services/data/{self.api_version}/sobjects/{quote(object_name, safe='')}/describe", ) payload = response.json() if not isinstance(payload, dict): raise NexusCRMError("NexusCRM describe returned a non-object response") return payload def upsert_collection( self, *, object_name: str, external_id_field: str, records: list[dict[str, Any]], ) -> list[UpsertResult]: if not 1 <= len(records) <= 200: raise ValueError("NexusCRM composite upsert requires 1 to 200 records") path = ( f"/services/data/{self.api_version}/composite/sobjects/" f"{quote(object_name, safe='')}/{quote(external_id_field, safe='')}" ) response = self._request("PATCH", path, json={"allOrNone": False, "records": records}) payload = response.json() if not isinstance(payload, list) or len(payload) != len(records): raise NexusCRMError( f"NexusCRM composite upsert returned {len(payload) if isinstance(payload, list) else 'invalid'} " f"results for {len(records)} records" ) return [self._parse_upsert_result(item) for item in payload] def retrieve_collection(self, ids: list[str], *, fields: tuple[str, ...] = ("Id",)) -> list[dict[str, Any] | None]: if not 1 <= len(ids) <= 200: raise ValueError("NexusCRM composite retrieve requires 1 to 200 record ids") response = self._request( "GET", f"/services/data/{self.api_version}/composite/sobjects", params={"ids": ",".join(ids), "fields": ",".join(fields)}, ) payload = response.json() if not isinstance(payload, list) or len(payload) != len(ids): raise NexusCRMError( f"NexusCRM composite retrieve returned {len(payload) if isinstance(payload, list) else 'invalid'} " f"results for {len(ids)} ids" ) if any(item is not None and not isinstance(item, dict) for item in payload): raise NexusCRMError("NexusCRM composite retrieve returned an invalid record result") return payload def resolve_account_ids(self, external_ids: list[str]) -> dict[str, str]: resolved: dict[str, str] = {} for batch in _batches(sorted(set(external_ids)), 100): literals = ", ".join(f"'{_soql_string(value)}'" for value in batch) records = self._query_records( f"SELECT Id, Account_ID__c FROM Account WHERE Account_ID__c IN ({literals})" ) for record in records: external_id = str(record.get("Account_ID__c") or "").strip() record_id = str(record.get("Id") or record.get("id") or "").strip() if not external_id or not record_id: continue if external_id in resolved and resolved[external_id] != record_id: raise NexusCRMError(f"Account_ID__c is not unique in NexusCRM: {external_id}") resolved[external_id] = record_id return resolved def resolve_active_account_matches(self, external_ids: list[str]) -> dict[str, list[str]]: """Return every active Account match for fail-closed caller admission.""" matches: dict[str, list[str]] = {value: [] for value in sorted(set(external_ids)) if value} for batch in _batches(sorted(matches), 100): literals = ", ".join(f"'{_soql_string(value)}'" for value in batch) records = self._query_records( "SELECT Id, Account_ID__c FROM Account " f"WHERE IsDeleted = false AND Account_ID__c IN ({literals})" ) for record in records: external_id = str(record.get("Account_ID__c") or "").strip() record_id = str(record.get("Id") or record.get("id") or "").strip() if external_id in matches and record_id: matches[external_id].append(record_id) return {external_id: sorted(set(ids)) for external_id, ids in matches.items()} def query_records(self, soql: str) -> list[dict[str, Any]]: """Query the authenticated CRM API without exposing transport details.""" return self._query_records(soql) def _query_records(self, soql: str) -> list[dict[str, Any]]: records: list[dict[str, Any]] = [] path = f"/services/data/{self.api_version}/query" params: dict[str, str] | None = {"q": soql} while path: response = self._request("GET", path, params=params) payload = response.json() if not isinstance(payload, dict) or not isinstance(payload.get("records"), list): raise NexusCRMError("NexusCRM query returned an invalid response") for item in payload["records"]: if not isinstance(item, dict): raise NexusCRMError("NexusCRM query returned an invalid record") records.append(item) if payload.get("done", True): break next_path = payload.get("nextRecordsUrl") if not isinstance(next_path, str) or not next_path.startswith("/"): raise NexusCRMError("NexusCRM query pagination returned an invalid nextRecordsUrl") path = next_path params = None return records def _authenticate(self) -> None: response = self._request_with_retry( "POST", f"{self.base_url}/services/oauth2/token", data={ "grant_type": "client_credentials", "client_id": self.client_id, "client_secret": self.client_secret, }, ) payload = response.json() if not isinstance(payload, dict) or not payload.get("access_token"): raise NexusCRMError("NexusCRM OAuth response did not contain access_token") self._access_token = str(payload["access_token"]) self._instance_url = str(payload.get("instance_url") or self.base_url).rstrip("/") def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: if not self._access_token: self._authenticate() headers = dict(kwargs.pop("headers", {})) headers["Authorization"] = f"Bearer {self._access_token}" assert self._instance_url is not None response = self._request_with_retry( method, f"{self._instance_url}{path}", headers=headers, allow_unauthorized=True, **kwargs, ) if response.status_code == 401: self._access_token = None self._authenticate() headers["Authorization"] = f"Bearer {self._access_token}" assert self._instance_url is not None response = self._request_with_retry(method, f"{self._instance_url}{path}", headers=headers, **kwargs) return response def _request_with_retry( self, method: str, url: str, *, allow_unauthorized: bool = False, **kwargs: Any ) -> httpx.Response: for attempt in range(1, self.max_attempts + 1): try: response = self._http.request(method, url, **kwargs) except (httpx.ConnectError, httpx.ReadError, httpx.RemoteProtocolError, httpx.TimeoutException) as error: if attempt == self.max_attempts: message = f"NexusCRM request failed after {attempt} attempts: {type(error).__name__}" raise NexusCRMError(message) from error self.sleeper(2 ** (attempt - 1)) continue if allow_unauthorized and response.status_code == 401: return response if response.status_code not in {429, 500, 502, 503, 504}: if response.is_error: raise NexusCRMError(self._http_error_message(response)) return response if attempt == self.max_attempts: raise NexusCRMError(self._http_error_message(response)) self.sleeper(self._retry_delay(response, attempt)) raise AssertionError("unreachable") @staticmethod def _retry_delay(response: httpx.Response, attempt: int) -> float: retry_after = response.headers.get("Retry-After", "").strip() if retry_after.isdigit(): return min(float(retry_after), 60.0) return float(2 ** (attempt - 1)) @staticmethod def _http_error_message(response: httpx.Response) -> str: request_id = response.headers.get("X-Request-ID") or response.headers.get("X-Correlation-ID") suffix = f" request_id={request_id}" if request_id else "" try: payload = response.json() except ValueError: payload = None if isinstance(payload, dict): detail = payload.get("error_description") or payload.get("message") or payload.get("error") elif isinstance(payload, list) and payload and isinstance(payload[0], dict): detail = payload[0].get("message") or payload[0].get("errorCode") else: detail = None return f"NexusCRM HTTP {response.status_code}: {detail or 'request failed'}{suffix}" @staticmethod def _parse_upsert_result(item: Any) -> UpsertResult: if not isinstance(item, dict): raise NexusCRMError("NexusCRM composite upsert returned an invalid record result") errors = item.get("errors") or [] if not isinstance(errors, list): errors = [{"message": str(errors)}] return UpsertResult( id=str(item.get("id") or item.get("Id") or ""), success=bool(item.get("success")), created=item.get("created") if isinstance(item.get("created"), bool) else None, errors=tuple(error if isinstance(error, dict) else {"message": str(error)} for error in errors), ) def _soql_string(value: str) -> str: return value.replace("\\", "\\\\").replace("'", "\\'") def _batches(values: list[str], size: int) -> list[list[str]]: return [values[start : start + size] for start in range(0, len(values), size)]