Spaces:
Runtime error
Runtime error
| """HTTP transport wrapper for the OpenWA Python SDK. | |
| The client never builds a bare :class:`httpx.Client` with a hard-coded | |
| transport. Instead it accepts an optional ``transport`` (an ``httpx.BaseTransport``) | |
| that overrides the default. This makes the SDK trivially testable — a test | |
| passes ``httpx.MockTransport(handler)`` instead of monkey-patching — and lets | |
| consumers intercept/observability-wrap outbound calls. | |
| """ | |
| from __future__ import annotations | |
| from typing import Any, Mapping | |
| from urllib.parse import quote | |
| import httpx | |
| from .errors import OpenWAApiError, OpenWATimeoutError, classify | |
| HttpMethod = str # "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | |
| def quote_segment(segment: Any) -> str: | |
| """Percent-encode a single path segment so a value containing ``/``, ``#`` or | |
| ``?`` can't break out of its path position. WhatsApp-id characters that are | |
| already path-safe (``@``, ``:``, ``+``) are kept readable. | |
| """ | |
| return quote(str(segment), safe="@:+") | |
| def build_url(base_url: str, path: str, query: Mapping[str, Any] | None = None) -> str: | |
| """Build a URL, serializing query params and skipping ``None`` values.""" | |
| url = f"{base_url.rstrip('/')}{path}" | |
| if not query: | |
| return url | |
| def _serialize(v: Any) -> str: | |
| # Booleans must be lowercase: the backend reads query flags as `=== 'true'`, | |
| # so Python's default str(True) == 'True' would be silently ignored. | |
| if v is True: | |
| return "true" | |
| if v is False: | |
| return "false" | |
| return str(v) | |
| params = {k: _serialize(v) for k, v in query.items() if v is not None} | |
| if not params: | |
| return url | |
| req = httpx.Request("GET", url, params=params) | |
| # httpx.Request already encoded params into the URL string. | |
| return str(req.url) | |
| class HttpExecutor: | |
| """Owns the :class:`httpx.Client` and performs JSON requests. | |
| Constructed once per :class:`OpenWAClient`; the transport is taken from | |
| the client config so all requests share one connection pool. | |
| """ | |
| def __init__( | |
| self, | |
| base_url: str, | |
| api_key: str, | |
| timeout: float = 30.0, | |
| default_headers: Mapping[str, str] | None = None, | |
| transport: httpx.BaseTransport | None = None, | |
| ) -> None: | |
| # Caller-supplied default headers are applied FIRST so the auth/JSON | |
| # headers below always win and can never be clobbered (mirrors the JS SDK). | |
| headers: dict[str, str] = {} | |
| if default_headers: | |
| headers.update(default_headers) | |
| headers["Content-Type"] = "application/json" | |
| headers["X-API-Key"] = api_key | |
| client_kwargs: dict[str, Any] = { | |
| "base_url": base_url.rstrip("/"), | |
| "headers": headers, | |
| "timeout": timeout, | |
| # Never auto-follow redirects: doing so would re-send the X-API-Key | |
| # header to the redirect target (potentially a different origin). | |
| # (This is also httpx's default; set explicitly so it can't regress.) | |
| "follow_redirects": False, | |
| } | |
| if transport is not None: | |
| client_kwargs["transport"] = transport | |
| self._client = httpx.Client(**client_kwargs) | |
| self._timeout = timeout | |
| def close(self) -> None: | |
| self._client.close() | |
| def __enter__(self) -> "HttpExecutor": | |
| return self | |
| def __exit__(self, *exc: Any) -> None: | |
| self.close() | |
| def request(self, method: HttpMethod, path: str, *, query: Mapping[str, Any] | None = None, body: Any = None) -> Any: | |
| """Perform one request and return the parsed JSON (or ``None`` for 204).""" | |
| url = build_url("", path, query) | |
| try: | |
| res = self._client.request(method, url, json=body if body is not None else None) | |
| except httpx.TimeoutException as e: | |
| raise OpenWATimeoutError(self._timeout) from e | |
| # Treat any non-2xx as an error, including 3xx: redirects are deliberately not followed | |
| # (so the API key is never re-sent to the target), which makes an unfollowed 3xx unusable | |
| # rather than a success. Matches the JS transport's `!res.ok`. | |
| if res.status_code >= 300: | |
| context = f"{method} {path}" | |
| raise OpenWAApiError.from_response(res.status_code, res.text, context) | |
| if res.status_code == 204 or not res.content: | |
| return None | |
| try: | |
| return res.json() | |
| except ValueError: | |
| # A 2xx body that isn't JSON surfaces as text rather than a raw | |
| # JSONDecodeError (mirrors the JS and PHP transports). | |
| return res.text | |