Generate a client from OpenAPI (tempestweb gen api)¶
What you'll learn
How to get a typed client — models + services — straight from your
backend's openapi.json, with no hand-written fetch and no types drifting
from the API. 🚀
If your backend is FastAPI (or anything exposing OpenAPI 3.x), tempestweb
gen api reads the spec and generates the client for you, the Tempest way:
one package per route group (the operation's tag), each with
@dataclass models and a service class whose methods call
native.http. It is the Python analog of
tempest-react-sdk's tempest gen api (which emits Zod + TS).
Why dataclasses (not pydantic)¶
One client, three modes
The generated client runs in all three tempestweb modes — including Mode C
(transpile), which transcribes your Python to JavaScript. Transpile
supports @dataclass but not pydantic (which needs a Python runtime). So
models are emitted as dataclasses with a from_dict — identical behavior
under WASM, server, and transpile.
Generating¶
Point it at your running backend's /openapi.json, or at a saved file:
# from a running FastAPI server
tempestweb gen api http://127.0.0.1:8000/openapi.json --out api
# or from a file
tempestweb gen api ./openapi.json --out api
The --out flag picks the output directory (default: ./api).
What is generated¶
Each tag produces a package; a root _runtime.py and __init__.py complete
the client:
api/
├── __init__.py # re-exports each group as a submodule
├── _runtime.py # ApiError + encode_query (shared)
├── machines/
│ ├── __init__.py # re-exports schemas + the service
│ ├── schemas.py # one @dataclass per model in the group
│ └── service.py # MachinesService class (one method per route)
└── tunnels/
├── __init__.py
├── schemas.py
└── service.py
schemas.py — one @dataclass per model¶
"""Models for the 'machines' routes.
Generated by `tempestweb gen api` — do not edit.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass
class MachineResponseSchema:
"""Generated from the OpenAPI component."""
id: str
name: str
machine_token: str
is_active: bool
created_at: str
last_seen_at: str | None = None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> MachineResponseSchema:
"""Build the model from a decoded JSON object.
Args:
data: The decoded JSON object.
Returns:
The reconstructed model.
"""
return cls(
id=data["id"],
name=data["name"],
machine_token=data["machine_token"],
is_active=data["is_active"],
created_at=data["created_at"],
last_seen_at=data.get("last_seen_at"),
)
def to_dict(self) -> dict[str, Any]:
"""Return the model as the JSON object the API expects.
Returns:
The payload keyed by the property names the spec uses.
"""
return {
"id": self.id,
"name": self.name,
"machine_token": self.machine_token,
"is_active": self.is_active,
"created_at": self.created_at,
"last_seen_at": self.last_seen_at,
}
Required fields before optional ones
A schema's required fields come first (no default — dataclass rule);
optional ones default to None (or field(default_factory=list) for
arrays). Enums become Literal[...] aliases.
Index for required, .get for optional only
A property the spec marks required is read by key. dict.get returns
Any | None, so every required field of every model used to be an
arg-type error at the call site — and a truncated response built the
dataclass with None inside a str, surfacing far from the cause. With
data["x"] an incomplete response fails loudly, at the boundary.
A property the spec does not mark required is annotated T | None,
never T = None.
service.py — one class per route group¶
"""Service for the 'machines' routes.
Generated by `tempestweb gen api` — do not edit.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from tempestweb.native.http import request
from .._runtime import ApiError
from .schemas import MachineCreateSchema, MachineResponseSchema
@dataclass
class MachinesService:
"""Client for the 'machines' routes.
Attributes:
base_url: Base URL prefix prepended to every route. Give it without a trailing
slash — the routes carry their own.
headers: Default headers sent with every request.
"""
base_url: str = ""
headers: dict[str, str] = field(default_factory=dict)
async def list_machines(self) -> list[MachineResponseSchema]:
"""Call ``GET /api/machines``.
List Machines.
Returns:
The decoded ``list[MachineResponseSchema]`` response body.
Raises:
ApiError: When the API answers with a non-2xx status.
"""
url = f"{self.base_url}/api/machines"
response = await request("GET", url, headers=self.headers)
if not response.ok:
raise ApiError(
f"HTTP {response.status}: {response.text}",
response.status,
response.json_body,
)
return [MachineResponseSchema.from_dict(item) for item in response.json_body]
async def register_machine(
self,
body: MachineCreateSchema,
) -> MachineResponseSchema:
"""Call ``POST /api/machines``.
Register Machine.
Args:
body: The ``MachineCreateSchema`` request body.
Returns:
The decoded ``MachineResponseSchema`` response body.
Raises:
ApiError: When the API answers with a non-2xx status.
"""
url = f"{self.base_url}/api/machines"
response = await request("POST", url, json=body.to_dict(), headers=self.headers)
if not response.ok:
raise ApiError(
f"HTTP {response.status}: {response.text}",
response.status,
response.json_body,
)
return MachineResponseSchema.from_dict(response.json_body)
Each method:
- path params become positional arguments interpolated into the URL;
- a request body that references a model becomes
body: <Model>(serialized with the model's ownto_dict(), which honours the property name the spec declares —asdictwould key the payload by the snake_case field name); - query params become a
params: dict[str, Any] | Noneappended to the URL; - success responses become the typed model (single or
list[...]); - non-2xx responses raise
ApiError.
Using the client¶
Instantiate the service with the backend base URL and use it in your handlers:
from api.machines import MachinesService, MachineCreateSchema
machines = MachinesService(base_url="https://api.example.com")
async def load() -> None:
all_machines = await machines.list_machines()
created = await machines.register_machine(MachineCreateSchema(name="home-1"))
print(created.machine_token)
Per-request headers
Pass headers={"X-Token": "..."} to the constructor to send auth on every
call — ideal for an admin token or Authorization.
The generated client passes tempestweb check¶
Zero lint errors, zero type errors — with no exclusion at all
The gen api output goes through tempestweb check
like any code you write, at all three typing_strictness levels
(lenient/standard/strict), and comes out clean. You do not need
(and should not want) to exclude api/ from ruff.toml or mypy.ini.
That is a contract, not luck: the emitter already writes double-quoted strings,
imports limited to what the file uses and ordered the way isort orders them, a
sorted __all__, no noqa, and wraps at 88 columns exactly the way
ruff format would. A test in the repo generates the client from a fixture spec
and runs the real ruff/mypy over it — if the generator regresses, the suite
fails.
The honest bound
A line only escapes the 88-column budget when a single spec identifier
no longer fits: "<36-char property>": self.<36-char field>, already costs
94 columns and has nothing left to split. ruff format leaves that line
long too — the alternative would be a noqa silencing a real measurement.
Regenerating¶
The client is generated — do not edit by hand. Every file opens with the
Generated by `tempestweb gen api` — do not edit. docstring. When the backend
changes, run the command again against the same --out; the files are
rewritten.
Recap
tempestweb gen api <openapi> --out apigenerates a typed per-tag client.- Models are
@dataclass(work in all three modes, transpile included). - A required property is read with
data["x"]; an optional one isT | None. - Services call
native.httpand raiseApiErroron failure. - The output passes
tempestweb checkwith no exclusion. - Regenerate when the API changes — no drifting types.