Skip to content

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 own to_dict(), which honours the property name the spec declares — asdict would key the payload by the snake_case field name);
  • query params become a params: dict[str, Any] | None appended 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 api generates 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 is T | None.
  • Services call native.http and raise ApiError on failure.
  • The output passes tempestweb check with no exclusion.
  • Regenerate when the API changes — no drifting types.