4.5 KiB
Use Case 3: The OperationId-Driven Client
OpenAPIClient is the other side of the contract. It reads the same OpenAPI document the server runs on and exposes one callable per operationId — so "client" and "server" are two views of one truth.
🔍 1. Before You Start
The client is httpx-based and returns raw httpx.Response objects — no magic deserialization, no hidden schema inference:
- No response Pydantic models
- No implicit URL construction (path params are explicit)
- No hand-written
requests.get(...)scattered through your code
🧬 2. Constructing the Client
from openapi_first.loader import load_openapi
from openapi_first.client import OpenAPIClient
spec = load_openapi("openapi.yaml")
client = OpenAPIClient(spec)
The base URL comes from the spec's servers list (first entry) unless you pass base_url explicitly:
client = OpenAPIClient(spec, base_url="https://api.internal.myco/v1")
You can also hand over a preconfigured httpx.Client (custom transport, TLS, retries):
import httpx
transport = httpx.HTTPTransport(retries=3)
client = OpenAPIClient(
spec,
client=httpx.Client(transport=transport),
)
💥 3. Fail-Fast at Construction
OpenAPIClient(...) raises immediately if the contract is broken — you find out at startup, not on the first request:
| Violation | Error |
|---|---|
Spec has no servers entry |
OpenAPIClientError |
Spec has no paths |
OpenAPIClientError |
Operation missing operationId |
OpenAPIClientError |
Duplicate operationId |
OpenAPIClientError |
| Operation references unknown parameters | OpenAPIClientError |
Missing required parameters fail at call time — pydoclint-grade strictness on the wire.
📞 4. Calling Operations
Every operationId becomes a method. The call signature is uniform across the whole client:
response = client.<operationId>(
*,
path_params: dict | None = None,
query: dict | None = None,
headers: dict | None = None,
body: Any | None = None,
timeout: float | None = None,
) -> httpx.Response
Concrete examples (from the crud_app / health_app templates):
# No parameters — simplest
response = client.get_health()
assert response.status_code == 200
# Path parameter
response = client.get_item(path_params={"item_id": 3})
# Query parameters
response = client.list_items(query={"limit": 10, "offset": 20})
# JSON request body
response = client.create_item(body={"name": "Orange", "price": 0.8})
# Custom headers / timeout
response = client.get_user(
path_params={"user_id": 1},
headers={"X-Internal-Key": "..."},
timeout=30,
)
Returns: the raw httpx.Response, so status_code, .json(), .headers are all yours to inspect.
🧠 5. How Parameters Are Bound
For each operation the client knows exactly where each parameter belongs:
| OpenAPI location | Client kwarg |
|---|---|
in: path |
path_params[name] |
in: query |
query[name] |
in: header |
headers[name] |
requestBody |
body |
JSON media types are sent as json=; other media types as raw content=.
🔄 6. Server ↔ Client, One Spec
# One directory, two processes
uvicorn main:app --port 8000 # server
python -c "import asyncio; from client_script import run; asyncio.run(run())" # client
Or the same client against a remote environment:
client = OpenAPIClient(
spec,
base_url="https://staging.internal.myco",
)
The URL is the only thing that changes between environments — the contract never does.
✍️ 7. OperationId as the API
Because the client is operationId-driven:
- Adding an operation = adding a method (and vice versa)
- No operation can be "forgotten" by the client — it's constructed from the spec
operationIdis the one name you remember; the HTTP verb/path is an implementation detail
If an operationId you call doesn't exist, you get an AttributeError at construction-scan time — never a silent 404.