Gerar um cliente do OpenAPI (tempestweb gen api)¶
O que você vai aprender
A tirar um cliente tipado — models + serviços — direto do openapi.json
do seu backend, sem escrever fetch na mão nem manter tipos sincronizados
com a API. 🚀
Se o seu backend é FastAPI (ou qualquer API que exponha um OpenAPI 3.x), o
tempestweb gen api lê o spec e gera o cliente pra você, no padrão da
Tempest: um pacote por grupo de rotas (a tag da operação), cada um com
models @dataclass e uma classe de serviço cujos métodos chamam
native.http. É o análogo, em Python, do
tempest gen api do tempest-react-sdk (que emite Zod + TS).
Por que dataclasses (e não pydantic)¶
Um cliente, três modos
O cliente gerado roda nos três modos do tempestweb — inclusive o Modo C
(transpile), que transcreve seu Python pra JavaScript. O transpile suporta
@dataclass, mas não pydantic (que precisa de runtime Python). Por isso
os models saem como dataclasses com um from_dict — funcionam igual em WASM,
server e transpile.
Gerando¶
Aponte pro /openapi.json do backend rodando, ou pra um arquivo salvo:
# a partir de um servidor FastAPI rodando
tempestweb gen api http://127.0.0.1:8000/openapi.json --out api
# ou a partir de um arquivo
tempestweb gen api ./openapi.json --out api
A flag --out escolhe o diretório de saída (default: ./api).
O que é gerado¶
Para cada tag sai um pacote; um _runtime.py e um __init__.py na raiz
completam o cliente:
api/
├── __init__.py # re-exporta cada grupo como submódulo
├── _runtime.py # ApiError + encode_query (compartilhados)
├── machines/
│ ├── __init__.py # re-exporta schemas + o serviço
│ ├── schemas.py # @dataclass de cada model do grupo
│ └── service.py # classe MachinesService (1 método por rota)
└── tunnels/
├── __init__.py
├── schemas.py
└── service.py
schemas.py — um @dataclass por 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,
}
Campos obrigatórios antes dos opcionais
Os campos required do schema vêm primeiro (sem default — regra de
dataclass); os opcionais recebem None (ou field(default_factory=list)
para arrays). Enums viram aliases Literal[...].
Índice para obrigatório, .get só para opcional
Um campo que o spec marca required é lido por índice. dict.get
devolve Any | None, então todo campo obrigatório de todo model virava um
erro arg-type em quem chamava — e uma resposta truncada montava o
dataclass com None dentro de um str, com o erro aparecendo longe da
causa. Com data["x"] a resposta incompleta falha alto, na fronteira.
Campo que o spec não marca required sai anotado T | None, nunca
T = None.
service.py — uma classe por grupo de rotas¶
"""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)
Cada método:
- path params viram argumentos posicionais interpolados na URL;
- request body que referencia um model vira
body: <Model>(serializado com oto_dict()do próprio model, que respeita o nome da propriedade no spec —asdictchavearia o payload pelo nome do campo em snake_case); - query params viram um
params: dict[str, Any] | Noneanexado à URL; - respostas de sucesso viram o model tipado (single ou
list[...]); - respostas fora de 2xx levantam
ApiError.
Usando o cliente¶
Instancie o serviço com a base do backend e use dentro dos seus handlers:
from api.machines import MachinesService, MachineCreateSchema
machines = MachinesService(base_url="https://api.exemplo.com")
async def carregar() -> None:
todas = await machines.list_machines()
nova = await machines.register_machine(MachineCreateSchema(name="casa-1"))
print(nova.machine_token)
Headers por requisição
Passe headers={"X-Token": "..."} no construtor pra enviar autenticação em
toda chamada — ideal pra um token de admin ou Authorization.
O cliente gerado passa no tempestweb check¶
Zero erro de lint, zero erro de tipo — sem exclusão nenhuma
A saída do gen api entra no tempestweb check como
qualquer código seu, nos três níveis de typing_strictness
(lenient/standard/strict), e sai limpa. Você não precisa (nem
deve) excluir api/ do ruff.toml ou do mypy.ini.
Isso é contrato, não sorte: o emissor já escreve string em aspas duplas, import
só do que o arquivo usa e na ordem do isort, __all__ ordenado, nenhum noqa,
e quebra em 88 colunas exatamente como o ruff format quebraria. Um teste do
repo gera o cliente de uma spec de fixture e roda ruff/mypy de verdade sobre
ele — se o gerador regredir, a suíte reprova.
O limite honesto
Uma linha só escapa das 88 colunas quando um identificador do spec já
não cabe: "<propriedade de 36 letras>": self.<campo de 36 letras>, custa
94 colunas e não tem onde quebrar. O ruff format deixa essa linha longa
também — a alternativa seria um noqa calando uma medição real.
Regenerando¶
O cliente é gerado — não edite à mão. Cada arquivo abre com a docstring
Generated by `tempestweb gen api` — do not edit.. Quando o backend muda,
rode o comando de novo apontando pro mesmo --out; os arquivos são reescritos.
Recapitulando
tempestweb gen api <openapi> --out apigera um cliente tipado por tag.- Models são
@dataclass(funcionam nos três modos, inclusive transpile). - Campo obrigatório é lido com
data["x"]; opcional éT | None. - Serviços chamam
native.httpe levantamApiErrorem falha. - A saída passa no
tempestweb checksem exclusão. - Regenere quando a API mudar — nada de tipos à deriva.