Skip to main content

httpxgen

OpenAPI in. Typed async Python out.

httpxgen turns an OpenAPI 3 document into a readable, fully typed async Python client built for httpx and Pydantic. The generated source looks like code you would write yourself: real method signatures, idiomatic names, explicit request handling, and models your editor understands.

httpxgen openapi.yaml src/payments --package-name payments

There is no runtime framework, reflection, or hidden request machinery. Generate the client, check it into your repository, review it, debug it, and ship it.

Contents

Why httpxgen?

You get Why it matters
Plain, generated Python Read, review, and debug every request without learning a runtime abstraction
Precise async methods operationId becomes an idiomatic method with real path, query, header, and body parameters
Pydantic 2 models Validation, aliases, constraints, recursive types, allOf, and discriminated unions stay useful in Python
Native Python types OpenAPI formats become types such as UUID, datetime, StrEnum, and Literal
Explicit error handling Transport failures, invalid responses, and API errors are separate typed exceptions
Flexible transport Use httpx.AsyncClient or any compatible async client through a structural protocol
Deterministic output Commit generated files and use --check to catch stale clients in CI
Tag-based packages Split a large API into focused clients while generating shared code only once

httpxgen is a build-time tool. Your generated package depends on Pydantic for its models, but it does not depend on httpxgen at runtime.

Generated package

A regular specification becomes a self-contained Python package:

openapi.json  ──▶  httpxgen  ──▶  payments/
                                    ├── __init__.py
                                    ├── client.py         # the public client class
                                    ├── models.py
                                    ├── exceptions.py     # generated client errors
                                    ├── http_methods.py   # HttpMethods
                                    ├── serialization.py  # parameter and auth helpers
                                    ├── transport.py      # structural protocols and file types
                                    └── py.typed

For larger specifications, repeat --tag to create focused subclients. Shared models and support modules are generated once beside 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
                                    │     └── transport.py
                                    ├── payments/
                                    │     ├── client.py
                                    │     └── models.py   # models only payments uses
                                    ├── invoices/
                                    │     ├── client.py
                                    │     └── models.py
                                    └── py.typed

Install

Requires Python 3.12 or newer.

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

Quick start

Generate a package from a local OpenAPI JSON or YAML document:

httpxgen openapi.yaml src/payments --package-name payments
Generated 8 file(s) in package src/payments.
import asyncio

import httpx

from payments import (
    CardPaymentMethod,
    ClientError,
    CreateChargeRequest,
    Money,
    PaymentsClient,
)


async def main() -> None:
    async with httpx.AsyncClient() as http_client:
        client = PaymentsClient(
            http_client,
            "https://payments.example.com/api",
            credentials={"bearerAuth": "your-token"},
        )
        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 ClientError as error:
            print(f"payment request failed: {error}")


asyncio.run(main())

The package root re-exports the client, models, and exceptions, so application imports stay short and stable.

From OpenAPI to Python

OpenAPI input

/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" }

Generated 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, {})

        try:
            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,
            )
        except Exception as error:
            raise TransportError(error) from error

        try:
            if response.status_code == 200:
                return ChargePage.model_validate(response.json())
            raise ApiError(response.status_code, response.text, response=response)
        except ClientError:
            raise
        except Exception as error:
            raise ResponseValidationError(
                response.status_code, response, error
            ) from error

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:
            detail = ApiErrorModel.model_validate(response.json())
            raise ApiError(response.status_code, response.text, detail, response)

        raise ApiError(response.status_code, response.text, response=response)

Request bodies are a single typed body argument, serialized by alias. JSON inputs are validated using the declared Pydantic schema before the HTTP transport runs, including mappings and model instances changed after creation. Invalid inputs raise RequestValidationError; Pydantic's normal accepted conversions apply before serialization. Unsupported runtime content_type choices also raise RequestValidationError before sending a request, with the declared alternatives listed. Explicitly supplied nullable fields are sent as JSON null; unset nullable fields are omitted, including inside nested models:

For application/merge-patch+json, all unset model fields are omitted, including schema defaults. Explicit values equal to a default are still sent. Ordinary JSON create requests continue to include non-null defaults.

Omit an optional JSON body by leaving out body. Passing body=None sends the literal JSON payload null when the schema permits null, with the selected JSON Content-Type. Omission uses a separate generated sentinel.

    async def create_charge(
        self,
        body: CreateChargeRequest,
        *,
        timeout: float | None = None,
    ) -> Charge:
        ...
        json_body = serialize_json_body(body, CreateChargeRequest)
        ...

The same direct shape is used for JSON, form, text, and small binary bodies. An application/octet-stream body accepts bytes, Iterable[bytes], or AsyncIterable[bytes] and is passed directly as content=.

Synchronous binary iterables are adapted lazily to async chunks. Exceptions raised while consuming an upload iterable are reported as TransportError.

Multipart fields are flattened into method arguments. Binary arrays become repeated parts with the same wire name. Parameter and multipart argument names that collide with generated locals (such as path, headers, or files) gain an _arg suffix; their wire names are preserved. UploadFile carries optional per-part metadata without reading an open file eagerly:

Bytes and default UploadFile inputs use the filename upload; bare handles use their basename when available. Set UploadFile(filename=None, ...) explicitly to send a filename-less part.

await client.upload_documents(
    documents=[
        UploadFile(open("a.pdf", "rb"), filename="a.pdf"),
        UploadFile(open("b.pdf", "rb"), filename="b.pdf"),
    ],
    category="contracts",
)

Multipart boundaries remain under the HTTP transport's control. This replaces the old Pydantic multipart body argument; regenerate and update multipart call sites to pass fields directly.

Successful binary responses also get a streaming context-manager variant. The buffered method remains available for small responses:

For endpoints with mixed success representations, buffered methods return the declared bytes/model/text/None union. Streaming variants yield BinaryStream for binary content and the parsed value for JSON, text, or empty successes.

Binary successes with declared headers return a typed result whose body is bytes and whose fields contain validated headers. Their streaming variants yield a corresponding ...Result...Stream model with body: BinaryStream and the same header fields, validated before iteration. Endpoints without declared headers retain the direct bytes/BinaryStream API.

content = await client.download_recording(recording_id)

async with client.stream_download_recording(recording_id) as recording:
    async for chunk in recording.iter_bytes(chunk_size=64 * 1024):
        await destination.write(chunk)

print(recording.media_type, recording.content_length, recording.filename)

The streaming context owns the individual response only. The generated wrapper closes entered responses even when validation or reading is cancelled, and treats its injected HTTP client as borrowed, so closing the wrapper does not close a shared application transport:

http_client = httpx.AsyncClient()
async with PaymentsClient(
    http_client,
    "https://payments.example.com/api",
    credentials={"bearerAuth": "your-token"},
) as client:
    ...

await http_client.aclose()

All operation failures derive from ClientError. TransportError retains its original cause, including failures during binary iteration. ResponseValidationError retains both the response and cause, and ApiError exposes a parsed detail plus convenient code and message properties. body and parsed_body remain compatibility aliases for raw_body and detail. When an error declares headers, detail retains its typed result wrapper and header fields; code/message read its typed payload. Arbitrary user payloads with a property named body are not unwrapped.

Generated 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 reference

httpxgen OPENAPI OUTPUT [--package-name NAME] [--client-name NAME] [--http-client {protocol,httpx,httpx2}] [--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 complete generated import package; defaults to the output directory name
--client-name exact single-client class name; not available for multi-tag workspaces
--http-client HTTP client annotation: protocol supports both implementations (default), or bind generated code directly to httpx or httpx2
--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

For a single client, the default class name comes from the final package segment: generated.browser_worker produces BrowserWorkerClient. Override it verbatim when the API needs a different public identity:

httpxgen openapi.json src/generated/browser_worker \
  --package-name generated.browser_worker \
  --client-name ProvisionerClient \
  --http-client httpx2

Keep the default protocol when the same generated package should accept both httpx.AsyncClient and httpx2.AsyncClient without a runtime dependency on either implementation.

Repeat --tag and you get one client package per tag. The exception hierarchy, transport types, HttpMethods, and 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 AsyncHttpClient, ClientError, HttpMethods, serialize_path
from api.shared.models import ApiErrorModel
from api import ClientError, 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.

Automation and CI

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

Supported scope

HTTP header names merge case-insensitively. Configured values override generated defaults; operation header parameters override configured values; applied security credentials take precedence over both. Each logical header is sent once.

Recursive array and anyOf aliases use lazy Python 3.12+ type aliases, including mutual recursion. Recursive nondiscriminated oneOf and reference-only aliases are rejected with an actionable error; ordinary aliases and recursive object models stay readable.

Exact oneOf membership uses JSON types without string/number/boolean coercion. Formatted date/UUID strings remain valid, and mathematically integral JSON numbers count as integers. Zero or multiple matching branches are rejected.

Query names beginning with _ or model_ receive a public field/field_ prefix in generated signatures and query models. Their wire names and validation constraints remain intact.

Leading-underscore schema properties also receive a field prefix (for example, _id becomes field_id) and keep an explicit wire alias. They are validated ordinary model fields, including when additional properties are forbidden.

Operation names that collide with generated lifecycle methods or streaming companions fail generation before writing files; rename the conflicting operationId in the specification.

Local Path Item references are resolved before schema normalization and tag selection. Authored siblings replace the corresponding inherited fields; omitted siblings preserve inherited parameters and operations. Cycles fail generation with a specific error.

References to nullable object components accept null in response bodies, request arguments, and nested fields while keeping the object model's required fields and constraints. OpenAPI 3.0 nullable requires an explicit type.

URL-encoded forms honor supported per-field style/explode encodings and JSON contentType. Object values use JSON by default; explicit form styles can flatten simple objects. Unsupported encoding options (including form allowReserved) fail generation.

Multipart object fields and object-array items are JSON parts with aliases and the declared encoding.contentType (JSON/+json), or application/json by default. File parts also honor encoding.contentType. Unsupported multipart encoding options fail generation explicitly.

Dot-valued path identifiers (. and ..) are percent-encoded to preserve the route boundary instead of allowing URL dot-segment normalization.

Path simple, label, and matrix styles support scalar, flat array, and flat object values with their style-specific explode delimiters. Data is escaped before joining it, including literal commas, label dots, slashes, and percent signs. allowReserved applies only to query parameters.

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.

allOf supports object-model inheritance. Scalar/array intersections and nullable or union bases fail generation with an unsupported-composition error.

Required object keys without declared property schemas retain presence checks and their additional-property validation, including inherited and directional models. Their values remain unconstrained when additional properties are allowed.

Unsupported constructs fail generation where possible. Important remaining limitations are external references, callbacks/webhooks, automatic pagination, and a synchronous client.

Development

String format: byte values use standard Base64 in JSON (OpenAPI 3.0 and the legacy format in 3.1). Python bytes hold decoded data; Base64 strings are decoded with strict alphabet/padding validation. Invalid response strings raise ResponseValidationError. This does not change format: binary, raw binary bodies, or multipart file uploads.

Parameter content supports one JSON/+json representation for query, header, and path positions. Values are validated and serialized as one compact JSON value with aliases and formatted fields. Required nullable parameters send JSON null; optional None omits the parameter. JSON cookie content and combinations with schema/style/explode/allowReserved are rejected during generation.

Query allowReserved: true preserves :/?@!$'()*,; and valid percent-encoded triples in values. It escapes []#&=+, stray percent signs, and other disallowed characters to preserve query boundaries and form decoding. Parameter names, ordinary parameters, and query API keys retain normal escaping.

Regeneration removes obsolete modules carrying the generator header inside the output package; --check reports them without changing files. Handwritten files and unmanaged __init__.py/py.typed files are preserved. Linked output paths are rejected instead of writing outside the package.

uv sync --all-groups
uv run pytest
uv run ruff check .          # lint
uv run ruff format .         # format

CI also checks generated transport/operation typing with Mypy against HTTPX and HTTPX2. Run that check locally with:

uv run --frozen --with mypy==2.4.0 --with httpx2==2.13.1 \
  pytest --no-cov tests/generator/test_typing.py

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

Metadata

Release files for httpxgen 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for httpxgen 0.3.0
File Size Uploaded Provenance Actions
httpxgen-0.3.0.tar.gz 144.3 kB Without provenance Details for httpxgen-0.3.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for httpxgen 0.3.0
File Interpreter ABI Platform Actions
httpxgen-0.3.0-py3-none-any.whl Python 3 none any Details for httpxgen-0.3.0-py3-none-any.whl

Total release size: 209.9 kB

Release files / httpxgen-0.3.0.tar.gz

Download URL httpxgen-0.3.0.tar.gz
Size 144.3 kB
Tags Source
SHA-256 checksum
How to use checksums
df8ebf6f0260213846032abbc702e29e4dacd87655b271749c1f9e6a950af2d8
BLAKE2b-256 checksum
How to use checksums
836a91dddf8cd2f0f7c96d0e9092debd93f7252e65707573f9b089317eaaf965
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.9.2

Release files / httpxgen-0.3.0-py3-none-any.whl

Download URL httpxgen-0.3.0-py3-none-any.whl
Size 65.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
633cd7e393c2d0a0086234e5de482975a36541835284946eea0f2b2a5c3478c0
BLAKE2b-256 checksum
How to use checksums
ee4dbcd8fbdc3e1af830a1ddb288cee3abc1848240dfe28bea615aa42e0739a0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.9.2

Security for httpxgen 0.3.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

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