Ir para o conteúdo

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 o to_dict() do próprio model, que respeita o nome da propriedade no spec — asdict chavearia o payload pelo nome do campo em snake_case);
  • query params viram um params: dict[str, Any] | None anexado à 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 api gera 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.http e levantam ApiError em falha.
  • A saída passa no tempestweb check sem exclusão.
  • Regenere quando a API mudar — nada de tipos à deriva.