OpenAPI to
Python.
Turn an OpenAPI 3.0 spec into a typed Python client. httpx.AsyncClient by default, Pydantic v2 models with model_validate and model_dump, modern union syntax like str | None. One file per model. Opt in to sync wrappers when you need them. No signup, no install.
openapi: 3.0.3
info:
title: Payments API
version: 1.2.0
paths:
/payments/{id}:
get:
operationId: getPayment
parameters:
- name: id
in: path
schema: { type: string }
- name: expand
in: query
schema: { type: string }
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Payment'
components:
schemas:
Payment:
type: object
required: [id, amount, method]
properties:
id: { type: string }
amount: { type: number }
currency:
type: string
nullable: true
method:
oneOf:
- $ref: '#/components/schemas/CardPayment'
- $ref: '#/components/schemas/BankPayment'
discriminator:
propertyName: kind# payments/services/default_service.py
import httpx
from models.payment import Payment
class DefaultService:
"""HTTP client for Default operations."""
def __init__(self, base_url: str, client: httpx.AsyncClient | None = None):
self.base_url = base_url
self.client: httpx.AsyncClient = client or httpx.AsyncClient()
async def get_payment(self, id: str, expand: str | None = None) -> Payment:
url = f"{self.base_url}/payments/{id}"
params = {k: str(v) for k, v in {
"expand": expand,
}.items() if v is not None}
response = await self.client.get(url, params=params)
response.raise_for_status()
return Payment.model_validate(response.json())Idiomatic Python. Pydantic v2 all the way down.
Below: models emitted from a small payments spec with a oneOf discriminator. Each model is its own file; the union is a real TypeAlias you can narrow on the discriminator field.
# payments/models/payment.py
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
from union_types import MethodUnion
from models.payment_status import PaymentStatus
class Payment(BaseModel):
model_config = ConfigDict(populate_by_name=True)
status: PaymentStatus
id: str
amount: float
currency: str | None = None
method: MethodUnion
# payments/union_types.py
from typing import TypeAlias
from models.card_payment import CardPayment
from models.bank_payment import BankPayment
MethodUnion: TypeAlias = "CardPayment | BankPayment"
# Narrowing on the discriminator (yours to write — the types enable it):
def label(m: MethodUnion) -> str:
if m.kind == "card":
return f"card {m.last4}"
return f"bank {m.iban}"Grounded in how the spec reads.
Each claim here is something you can verify by running the converter on your own spec. Click a card to see the spec fragment that triggered it and the code that came out.
method:
oneOf:
- $ref: '#/components/schemas/CardPayment'
- $ref: '#/components/schemas/BankPayment'
discriminator:
propertyName: kind# union_types.py
from typing import TypeAlias
from models.card_payment import CardPayment
from models.bank_payment import BankPayment
MethodUnion: TypeAlias = "CardPayment | BankPayment"
# models/card_payment.py
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
from models.card_payment_kind import CardPaymentKind
class CardPayment(BaseModel):
model_config = ConfigDict(populate_by_name=True)
kind: CardPaymentKind
last4: str
# models/card_payment_kind.py
from enum import Enum
class CardPaymentKind(str, Enum):
CARD = "card"Async methods, reusable client.
The default output. Every operation is async, the service takes an httpx.AsyncClient you can inject or let it build its own, and responses are validated through Pydantic v2 before you get them.
# payments/services/default_service.py
import httpx
from models.payment import Payment
from models.create_payment_request import CreatePaymentRequest
class DefaultService:
"""HTTP client for Default operations."""
def __init__(self, base_url: str, client: httpx.AsyncClient | None = None):
self.base_url = base_url
self.client: httpx.AsyncClient = client or httpx.AsyncClient()
async def get_payment(self, id: str, expand: str | None = None) -> Payment:
url = f"{self.base_url}/payments/{id}"
params = {k: str(v) for k, v in {
"expand": expand,
}.items() if v is not None}
response = await self.client.get(url, params=params)
response.raise_for_status()
return Payment.model_validate(response.json())
async def create_payment(self, body: CreatePaymentRequest) -> Payment:
url = f"{self.base_url}/payments"
json_data = body.model_dump(by_alias=True, exclude_none=True)
response = await self.client.post(url, json=json_data)
response.raise_for_status()
return Payment.model_validate(response.json())Or ask Claude to do it.
The same generator ships as a Model Context Protocol server. It calls the same web API this converter uses, so the output is identical. Point Claude Desktop, Cursor, or any MCP-aware agent at a spec and it produces the Python client as a diff — free, no token, no leaving the chat.
// claude-desktop config · .mcp.json
{
"mcpServers": {
"metaengine": {
"command": "npx",
"args": ["-y", "@metaengine/mcp-server"]
}
}
}
// Then in Claude:
// "Load specs/payments.yaml and generate a Python
// FastAPI-style client with async httpx methods."Things people ask.
The same questions we keep getting. If yours is not here, run the converter — the answer is usually in the output.
.py files: one Pydantic v2 model per schema, one str, Enum per enum, a union_types.py for any oneOf discriminated unions, and an async service class per tag that uses httpx.AsyncClient. No runtime framework you did not ask for.Try it on your own spec.
The converter runs in the browser. Your spec never leaves the page.