Introduce an OpenAPI-first HTTP client driven by the same specification
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.
This commit is contained in:
176
openapi_first/client.py
Normal file
176
openapi_first/client.py
Normal file
@@ -0,0 +1,176 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user