Skip to main content

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

httpxgen-0.1.0.tar.gz (96.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

httpxgen-0.1.0-py3-none-any.whl (43.4 kB view details)

Uploaded Python 3

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

Hashes for httpxgen-0.1.0.tar.gz
Algorithm Hash digest
SHA256 f095434b4434b2a92cd65265cf72c7cdb5fa66d456fcc4b814f749d59140315d
MD5 45905c9a7a3ab8522decc9fe7df56f32
BLAKE2b-256 fd97ab0357dff7dc2a5e2fdc7dbf348f8a60af656a1a6d0c40f6d77eca130dd1

See more details on using hashes here.

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

Hashes for httpxgen-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 761e077c2895d2c054bba759c8c349da58f282e1007ddc67a71d5eaedad2d00c
MD5 e92d07c51aae167d11eeda6b42f2cd9c
BLAKE2b-256 33a80b5458ac67b7a94802b7ddc33166fd279a9b238aa66ecdd720531681a3dc

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page