used for server-side route binding, and refactor documentation and templates to treat the client as a first-class contract consumer. Key changes: - Add OpenAPI-first client module based on httpx - Document client usage alongside server-side binder usage - Update mkdocs navigation to include client documentation - Refactor CRUD and model app templates to call APIs via operationId instead of hardcoded paths - Align package documentation and public API surface with client support - Clarify server/client dependency split (fastapi vs httpx) This establishes strict symmetry between OpenAPI-driven server binding and OpenAPI-driven client invocation, reinforcing OpenAPI as the single source of truth on both sides of the contract.
177 lines
5.5 KiB
Python
177 lines
5.5 KiB
Python
from typing import Any, Callable, Dict, Optional
|
|
from urllib.parse import urljoin
|
|
|
|
import httpx
|
|
|
|
from .errors import OpenAPIFirstError
|
|
|
|
|
|
class OpenAPIClientError(OpenAPIFirstError):
|
|
"""Raised when an OpenAPI client operation fails."""
|
|
|
|
|
|
class OpenAPIClient:
|
|
"""
|
|
OpenAPI-first HTTP client (httpx-based).
|
|
|
|
- One callable per operationId
|
|
- Explicit parameters (path, query, headers, body)
|
|
- No implicit schema inference or mutation
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
spec: dict[str, Any],
|
|
base_url: Optional[str] = None,
|
|
client: Optional[httpx.Client] = None,
|
|
) -> None:
|
|
self.spec = spec
|
|
self.base_url = base_url or self._resolve_base_url(spec)
|
|
self.client = client or httpx.Client(base_url=self.base_url)
|
|
|
|
self._operations: Dict[str, Callable[..., httpx.Response]] = {}
|
|
self._build_operations()
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Public API
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def __getattr__(self, name: str) -> Callable[..., httpx.Response]:
|
|
try:
|
|
return self._operations[name]
|
|
except KeyError:
|
|
raise AttributeError(f"No such operationId: {name}") from None
|
|
|
|
def operations(self) -> Dict[str, Callable[..., httpx.Response]]:
|
|
return dict(self._operations)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Internal mechanics
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _resolve_base_url(self, spec: dict[str, Any]) -> str:
|
|
servers = spec.get("servers")
|
|
if not servers:
|
|
raise OpenAPIClientError("No servers defined in OpenAPI spec")
|
|
|
|
url = servers[0].get("url")
|
|
if not url:
|
|
raise OpenAPIClientError("Server entry missing 'url'")
|
|
|
|
return url.rstrip("/") + "/"
|
|
|
|
def _build_operations(self) -> None:
|
|
paths = self.spec.get("paths", {})
|
|
if not paths:
|
|
raise OpenAPIClientError("OpenAPI spec contains no paths")
|
|
|
|
for path, path_item in paths.items():
|
|
for method, operation in path_item.items():
|
|
if method.lower() not in {
|
|
"get", "post", "put", "patch", "delete", "head", "options"
|
|
}:
|
|
continue
|
|
|
|
operation_id = operation.get("operationId")
|
|
if not operation_id:
|
|
raise OpenAPIClientError(
|
|
f"Missing operationId for {method.upper()} {path}"
|
|
)
|
|
|
|
if operation_id in self._operations:
|
|
raise OpenAPIClientError(
|
|
f"Duplicate operationId detected: {operation_id}"
|
|
)
|
|
|
|
self._operations[operation_id] = self._make_operation(
|
|
method=method.upper(),
|
|
path=path,
|
|
operation=operation,
|
|
)
|
|
|
|
def _make_operation(
|
|
self,
|
|
*,
|
|
method: str,
|
|
path: str,
|
|
operation: dict[str, Any],
|
|
) -> Callable[..., httpx.Response]:
|
|
request_body = operation.get("requestBody")
|
|
|
|
def call(
|
|
*,
|
|
path_params: Optional[dict[str, Any]] = None,
|
|
query: Optional[dict[str, Any]] = None,
|
|
headers: Optional[dict[str, str]] = None,
|
|
body: Optional[Any] = None,
|
|
timeout: Optional[float] = None,
|
|
) -> httpx.Response:
|
|
url = self._build_url(path, path_params or {})
|
|
|
|
req_headers = headers.copy() if headers else {}
|
|
|
|
json_data = None
|
|
content = None
|
|
|
|
if request_body is not None:
|
|
if body is None:
|
|
raise OpenAPIClientError(
|
|
f"Request body required for operation {operation['operationId']}"
|
|
)
|
|
|
|
media_types = request_body.get("content", {})
|
|
if "application/json" in media_types:
|
|
json_data = body
|
|
req_headers.setdefault("Content-Type", "application/json")
|
|
else:
|
|
content = body
|
|
|
|
response = self.client.request(
|
|
method=method,
|
|
url=url,
|
|
params=query,
|
|
headers=req_headers,
|
|
json=json_data,
|
|
content=content,
|
|
timeout=timeout,
|
|
)
|
|
|
|
return response
|
|
|
|
call.__name__ = operation["operationId"]
|
|
call.__doc__ = self._build_docstring(method, path, operation)
|
|
|
|
return call
|
|
|
|
def _build_url(self, path: str, path_params: dict[str, Any]) -> str:
|
|
try:
|
|
formatted_path = path.format(**path_params)
|
|
except KeyError as exc:
|
|
raise OpenAPIClientError(
|
|
f"Missing path parameter: {exc.args[0]}"
|
|
) from exc
|
|
|
|
return urljoin(self.base_url, formatted_path.lstrip("/"))
|
|
|
|
def _build_docstring(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
operation: dict[str, Any],
|
|
) -> str:
|
|
lines = [
|
|
f"{method} {path}",
|
|
"",
|
|
operation.get("summary", ""),
|
|
"",
|
|
"Parameters:",
|
|
" path_params: dict | None",
|
|
" query: dict | None",
|
|
" headers: dict | None",
|
|
" body: Any | None",
|
|
"",
|
|
"Returns:",
|
|
" httpx.Response",
|
|
]
|
|
return "\n".join(lines)
|