4ce4204b6c
CI / Lint (push) Failing after 2s
CI / Build (push) Has been skipped
SDK CI / PHP SDK (push) Failing after 1s
Split PHP SDK / PHP SDK tests (push) Failing after 1s
CI / Test (push) Failing after 1s
CI / Dashboard (push) Failing after 0s
SDK CI / JavaScript SDK (push) Failing after 0s
SDK CI / Python SDK (push) Failing after 2s
SDK CI / Java SDK (push) Failing after 1s
Split PHP SDK / Mirror sdk/php -> rmyndharis/openwa-php (push) Has been skipped
CI / Test (PostgreSQL migrations) (push) Failing after 7m47s
CI / Docker Build (push) Has been skipped
119 lines
4.5 KiB
Python
119 lines
4.5 KiB
Python
"""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
|