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¶
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
@dataclass
class MachineResponseSchema:
"""Generated from the OpenAPI component."""
id: str
name: str
machine_token: str
is_active: bool
created_at: str
updated_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."""
return cls(
id=data.get("id"),
name=data.get("name"),
machine_token=data.get("machine_token"),
is_active=data.get("is_active"),
created_at=data.get("created_at"),
updated_at=data.get("updated_at"),
last_seen_at=data.get("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.
service.py — one class per route group¶
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from tempestweb.native.http import request
from .._runtime import ApiError, encode_query as _encode_query
from .schemas import MachineCreateSchema, MachineResponseSchema
class MachinesService:
"""Generated service for the "machines" routes."""
def __init__(self, base_url: str = "", headers: dict[str, str] | None = None) -> None:
self._base_url = base_url.rstrip("/")
self._headers = headers or {}
async def list_machines(self) -> list[MachineResponseSchema]:
"""GET /api/machines — List Machines"""
url = f"{self._base_url}/api/machines"
response = await request("GET", url, headers=self._headers)
if not response.ok:
raise ApiError(response.status, response.text, response.json_body)
return [MachineResponseSchema.from_dict(item) for item in response.json_body]
async def register_machine(self, body: MachineCreateSchema) -> MachineResponseSchema:
"""POST /api/machines — Register Machine"""
url = f"{self._base_url}/api/machines"
response = await request("POST", url, json=asdict(body), headers=self._headers)
if not response.ok:
raise ApiError(response.status, response.text, 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 withasdict); - 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.
Regenerating¶
The client is generated — do not edit by hand. Every file opens with the
# Generated by tempestweb gen api banner. 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). - Services call
native.httpand raiseApiErroron failure. - Regenerate when the API changes — no drifting types.