httpxgen
Generate a typed async httpx client from an OpenAPI document — no runtime layer, no reflection, no magic.
httpxgen emits plain Python you could have written by hand: async def methods with real parameter names, Pydantic models for every schema, discriminated unions for oneOf, StrEnum for enums, and UUID / datetime where the spec says so. Check the output into your repo, read it, click through it in your editor.
openapi.json ──▶ httpxgen ──▶ payments/
├── __init__.py
├── client.py # the public client class
├── models.py
├── exceptions.py # ApiError
├── http_methods.py # HttpMethods
├── serialization.py # parameter and auth helpers
└── py.typed
Split a large document along its tags and the support modules are generated once, beside the clients that share them:
openapi.json ──▶ httpxgen ──▶ api/
├── __init__.py # clients, ApiError, all models
├── shared/ # generated once
│ ├── exceptions.py
│ ├── http_methods.py
│ ├── models.py # only models used by both tags
│ └── serialization.py
├── payments/
│ ├── client.py
│ └── models.py # models only payments uses
├── invoices/
│ ├── client.py
│ └── models.py
└── py.typed
Install
With uv:
uv tool install httpxgen # standalone CLI
uv add --dev httpxgen # dev dependency of your project
With pip:
pip install httpxgen
Or run it without installing anything:
uvx httpxgen openapi.json src/payments
httpxgen is only needed at build time — the generated package depends on httpx and pydantic alone.
Quick start
httpxgen openapi.yaml src/payments --package-name payments
Generated 6 file(s) in package src/payments.
import asyncio
import httpx
from payments import ApiError, CardPaymentMethod, CreateChargeRequest, Money, PaymentsClient
async def main() -> None:
async with PaymentsClient(
httpx.AsyncClient(),
"https://payments.example.com/api",
credentials={"bearerAuth": "your-token"},
) as client:
page = await client.list_charges(status="succeeded", page_size=50)
for charge in page.items:
print(charge.id, charge.amount.amount_cents, charge.status)
try:
charge = await client.create_charge(
CreateChargeRequest(
amount=Money(amount_cents=4200, currency="EUR"),
payment_method=CardPaymentMethod(...),
)
)
except ApiError as error:
print(error.status_code, error.body)
if error.parsed_body is not None:
print(error.parsed_body)
asyncio.run(main())
What the generated code looks like
From this spec
/charges:
get:
operationId: listCharges
parameters:
- name: status
in: query
schema: { $ref: "#/components/schemas/ChargeStatus" }
- name: cursor
in: query
schema: { type: string }
- name: page_size
in: query
schema: { type: integer, default: 25, minimum: 1, maximum: 200 }
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/ChargePage" }
You get this client
operationId becomes an idiomatic snake_case method, optional query parameters are only sent when set, and the response is validated into a model:
client.py holds nothing but the imports and the client class — the serialization
helpers live in serialization.py, the error type in exceptions.py:
class ListChargesParams(BaseModel):
status: ChargeStatus | None = None
cursor: str | None = None
page_size: int = Field(25, ge=1, le=200)
class PaymentsClient:
async def list_charges(
self,
status: ChargeStatus | None = None,
cursor: str | None = None,
page_size: int = 25,
*,
timeout: float | None = None,
) -> ChargePage:
path = "/charges"
params = ListChargesParams(
status=status,
cursor=cursor,
page_size=page_size,
)
query: list[tuple[str, str]] = []
if params.status is not None:
query.extend(serialize_query("status", params.status))
if params.cursor is not None:
query.extend(serialize_query("cursor", params.cursor))
query.extend(serialize_query("page_size", params.page_size))
headers = dict(self._headers)
headers.setdefault("Accept", "application/json")
apply_security(self._credentials, [("bearerAuth",)], headers, query, {})
response = await self._client.request(
method=HttpMethods.GET,
url=f"{self._base_url}{path}",
params=query,
headers=headers,
timeout=self._timeout if timeout is None else timeout,
)
if response.status_code == 200:
return ChargePage.model_validate(response.json())
raise ApiError(response.status_code, response.text, response=response)
Path parameters carry their spec format — format: uuid becomes UUID, not str:
async def get_customer(
self,
customer_id: UUID,
*,
timeout: float | None = None,
) -> Customer:
path = "/customers/{customerId}"
path = path.replace("{customerId}", serialize_path("customerId", customer_id))
headers = dict(self._headers)
headers.setdefault("Accept", "application/json")
response = await self._client.request(
method=HttpMethods.GET,
url=f"{self._base_url}{path}",
headers=headers,
timeout=self._timeout if timeout is None else timeout,
)
if response.status_code == 200:
return Customer.model_validate(response.json())
if response.status_code == 404:
parsed_body = ApiErrorModel.model_validate(response.json())
raise ApiError(response.status_code, response.text, parsed_body, response)
raise ApiError(response.status_code, response.text, response=response)
Request bodies are a single typed body argument, serialized by alias and without None noise:
async def create_charge(
self,
body: CreateChargeRequest,
*,
timeout: float | None = None,
) -> Charge:
...
json_body = TypeAdapter(CreateChargeRequest).dump_python(
body, mode="json", by_alias=True, exclude_none=True
)
...
The same direct shape is used for other ordinary body encodings:
application/x-www-form-urlencoded is passed as data=, multipart object
fields are separated into data= and files=, and binary payloads use
content=. Multipart boundaries remain under httpx's control.
The client is an async context manager, so httpx connections are closed for you:
http_client = httpx.AsyncClient()
async with PaymentsClient(
http_client,
"https://payments.example.com/api",
credentials={"bearerAuth": "your-token"},
) as client:
...
And these models
allOf becomes inheritance, oneOf + discriminator becomes a Pydantic discriminated union, string enums become StrEnum, and minimum / maxLength survive as Field(...) constraints:
class ChargeStatus(StrEnum):
PENDING = "pending"
SUCCEEDED = "succeeded"
FAILED = "failed"
REFUNDED = "refunded"
class Money(BaseModel):
amount_cents: int
currency: str = Field(min_length=3, max_length=3)
class CardPaymentMethod(BaseModel):
type: Literal[PaymentMethodType.CARD]
card_number: str
exp_month: int = Field(ge=1, le=12)
exp_year: int
billing_address: Address | None = None
PaymentMethod = Annotated[
CardPaymentMethod | BankTransferPaymentMethod,
Field(discriminator="type"),
]
class BaseEntity(BaseModel):
id: str
created_at: datetime
class Charge(BaseEntity): # allOf: BaseEntity + own properties
amount: Money
status: ChargeStatus
payment_method: PaymentMethod # discriminated at parse time
metadata: dict[str, str] | None = None
Everything is re-exported from the package root, so consumers import from one place:
from payments import ApiError, Charge, ChargeStatus, Money, PaymentsClient
CLI
httpxgen OPENAPI OUTPUT [--package-name NAME] [--tag TAG] [--schema-tag TAG] [--check]
| Argument | Meaning |
|---|---|
OPENAPI |
OpenAPI JSON or YAML file |
OUTPUT |
target package directory, or the root holding one package per tag when several --tag are given (created if missing) |
--package-name |
import name and client class prefix; defaults to the output directory name |
--tag TAG |
generate only operations carrying this tag; repeat it for one package per tag |
--schema-tag TAG |
keep schemas referenced by this tag without generating its operations; repeatable |
--check |
write nothing; exit non-zero when the checked-in output is stale |
Carve a focused client out of a large spec:
httpxgen openapi.json src/billing \
--package-name billing \
--tag charges \
--schema-tag webhooks
Repeat --tag and you get one client package per tag. ApiError, HttpMethods,
and the serialization helpers are generated once in shared/, and every model lands in
the package that uses it — shared/models.py holds only what more than one tag
references. Generated modules import each other absolutely, so the output reads
the same wherever you open it:
httpxgen specs/api.yml src/api --package-name api --tag payments --tag invoices
# src/api/invoices/client.py
from api.invoices.models import CreateInvoiceRequest, Invoice, InvoicePage
from api.shared import ApiError, HttpMethods, apply_security, serialize_path
from api.shared.models import ApiErrorModel
from api import ApiError, InvoicesClient, Money, PaymentsClient
Every managed file starts with a # Generated by httpxgen. DO NOT EDIT. header. Files without it are never overwritten — httpxgen aborts instead.
Use it from a shell script
Generated code is checked in, so a tiny script is usually all the automation you need.
scripts/generate-client.sh:
#!/usr/bin/env sh
set -eu
SPEC_URL="https://payments.example.com/api/openapi.json"
OUT="src/payments"
curl -fsSL "$SPEC_URL" -o openapi.json
uvx httpxgen openapi.json "$OUT" --package-name payments
echo "client regenerated in $OUT"
chmod +x scripts/generate-client.sh
./scripts/generate-client.sh
Use the --check variant in CI so a drifting spec fails the build instead of surprising you at runtime:
# .github/workflows/client.yml
- name: Verify generated client is current
run: |
curl -fsSL "$SPEC_URL" -o openapi.json
uvx httpxgen openapi.json src/payments --package-name payments --check
Generated HTTP client is current.
Or wire it into a Makefile:
.PHONY: client client-check
client:
uvx httpxgen openapi.json src/payments --package-name payments
client-check:
uvx httpxgen openapi.json src/payments --package-name payments --check
Scope
httpxgen targets ordinary OpenAPI 3.0 and 3.1 client specifications, not every
JSON Schema feature. It supports JSON/YAML input, local component references,
path/query/header/cookie serialization, JSON/form/multipart/binary request
bodies, JSON/text/binary responses, numeric/default/status-range responses,
typed error bodies, common
security schemes, directional request/response models, inline and recursive
Pydantic models, enums, nullable values, discriminated unions, and practical
allOf inheritance.
Unsupported constructs fail generation where possible. Important remaining limitations are external references, streaming, callbacks/webhooks, automatic pagination, and a synchronous client.
See MISSING_IMPL.md for the prioritized checklist and the
test requirements for each future step.
Development
uv sync --all-groups
uv run pytest
uv run ruff check . # lint
uv run ruff format . # format
preview/ is generated output, checked in so the effect of a change is visible
in review. Regenerate it whenever generated code changes — CI fails if it is
stale:
./scripts/generate-preview.sh
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file httpxgen-0.1.0.tar.gz.
File metadata
- Download URL: httpxgen-0.1.0.tar.gz
- Upload date:
- Size: 96.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.9.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f095434b4434b2a92cd65265cf72c7cdb5fa66d456fcc4b814f749d59140315d
|
|
| MD5 |
45905c9a7a3ab8522decc9fe7df56f32
|
|
| BLAKE2b-256 |
fd97ab0357dff7dc2a5e2fdc7dbf348f8a60af656a1a6d0c40f6d77eca130dd1
|
File details
Details for the file httpxgen-0.1.0-py3-none-any.whl.
File metadata
- Download URL: httpxgen-0.1.0-py3-none-any.whl
- Upload date:
- Size: 43.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.9.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
761e077c2895d2c054bba759c8c349da58f282e1007ddc67a71d5eaedad2d00c
|
|
| MD5 |
e92d07c51aae167d11eeda6b42f2cd9c
|
|
| BLAKE2b-256 |
33a80b5458ac67b7a94802b7ddc33166fd279a9b238aa66ecdd720531681a3dc
|