Files
openapi-first/docs/wiki/03_use_cases/03_client.md

4.5 KiB
Raw Permalink Blame History

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
  • operationId is 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.