Skip to content

goduni/unihttp-openapi-generator

Repository files navigation

unihttp-openapi-generator

codecov PyPI version PyPI - Python Version PyPI - Downloads GitHub License GitHub Repo stars Telegram

Turn an OpenAPI spec into a typed Python API client built on unihttp.

Point it at a spec; get back an installable package with data models, request classes, a sync and/or async client, an exception hierarchy, and authentication wiring. The output is formatted with ruff and type-checks clean under mypy --strict.

Why

  • Actually typed. Models, parameters, and return values carry real annotations; the generated code passes mypy --strict. Your editor knows the shape of every request and response.
  • Three model backends. Choose adaptix (default), pydantic, or msgspec for the generated models — same client, your serializer.
  • Sync, async, or both. Backed by httpx, aiohttp, requests, niquests, or zapros, chosen per client.
  • Faithful to the spec. allOf/oneOf/anyOf, discriminated unions, enums, formats, nullable, defaults, multipart uploads, query array styles, security schemes, and error responses are all carried through.
  • Proven on large specs. The Stripe, GitHub, OpenAI, and Kubernetes specs each generate clean, importable code that passes ruff and mypy --strict on every serializer.
  • Readable, regenerable output. Deterministic, ruff-formatted, organized by tag.

Install

pip install unihttp-openapi-generator
# or, with uv:
uv tool install unihttp-openapi-generator

Quick start

unihttp-openapi-generator generate openapi.yaml \
  --output-dir ./out --package-name acme_client

The spec can be a local path or a URL, in JSON or YAML. Install the result and use it:

pip install ./out
from acme_client import AcmeClient

with AcmeClient(base_url="https://api.example.com", token="...") as client:
    pet = client.pets.get_pet(pet_id=1)   # -> a typed model
    print(pet.name)

What you get

out/
├── pyproject.toml          # installable; pins unihttp + your serializer + backend
├── README.md
└── acme_client/
    ├── __init__.py         # exports the client(s), DEFAULT_BASE_URL, SERVERS
    ├── py.typed
    ├── models.py           # dataclass / BaseModel / msgspec.Struct
    ├── _serialization.py    # request/response (de)serialization wiring
    ├── exceptions.py       # ApiError hierarchy + status -> exception map
    ├── auth.py             # credential middlewares (when the spec defines security)
    ├── methods/<tag>.py    # one request class per operation
    └── client.py           # the client(s)

A request class and the client constructor (real output):

@dataclass
class GetBooking(BaseMethod[GetBookingResponse]):
    """Get a booking

    Returns the details of a specific booking.
    """
    __url__ = "/bookings/{bookingId}"
    __method__ = "GET"

    booking_id: Path[UUID]


class TrainTravelAPIClient(RequestsSyncClient):
    def __init__(self, base_url: str = DEFAULT_BASE_URL, *,
                 session: Any = None, middleware: list[Any] | None = None,
                 token: str | None = None) -> None:
        ...

Using the client

Clients are context managers and close their transport on exit.

from acme_client import AcmeClient

with AcmeClient(base_url="https://api.example.com", token="secret") as client:
    booking = client.bookings.get_booking(booking_id=some_uuid)   # grouped layout
    # client.get_booking(...)   # flat layout

Async clients expose the same surface; their methods are awaitables:

import asyncio
from acme_client import AsyncAcmeClient

async def main() -> None:
    async with AsyncAcmeClient(token="secret") as client:
        trips = await client.trips.get_trips(origin=a, destination=b, date=when)

asyncio.run(main())

Base URL and servers

The default base URL is taken from the spec's servers (preferring a production entry). Every server is also exported:

from acme_client import DEFAULT_BASE_URL, SERVERS

client = AcmeClient(base_url=SERVERS["Production"])

Authentication

Each security scheme becomes a constructor keyword that is injected via middleware:

Scheme Keyword Sent as
http bearer / oauth2 / openIdConnect token: str Authorization: Bearer <token>
apiKey (header or query) <scheme>: str the named header or query parameter
http basic <scheme>: tuple[str, str] Authorization: Basic <base64>

Custom headers, cookies, timeouts

Build the underlying HTTP client yourself and pass it as session= (its type matches the chosen backend — requests.Session by default, httpx.Client, aiohttp.ClientSession, …):

import requests

session = requests.Session()
session.headers["User-Agent"] = "acme/1.0"
client = AcmeClient(session=session)

Errors

Non-2xx responses raise. <package>.exceptions defines a base ApiError plus a subclass per status code (NotFoundError, UnprocessableEntityError, …), with 4xx/5xx falling back to unihttp's ClientError/ServerError.

from acme_client.exceptions import ApiError, NotFoundError

try:
    booking = client.bookings.get_booking(booking_id=bad_id)
except NotFoundError as exc:
    print(exc.status_code, exc.response.data)
except ApiError:
    ...

Middleware

Pass any unihttp middleware; auth and error mapping are composed around it.

from unihttp.middlewares.retry import RetryMiddleware
client = AcmeClient(middleware=[RetryMiddleware(retries=3)])

CLI options

unihttp-openapi-generator generate SPEC [options]
Option Values (default)
-o, --output-dir path (required)
--package-name identifier (required)
--serializer adaptix · pydantic · msgspec (adaptix)
--client both · sync · async (both)
--sync-backend httpx · requests · niquests · zapros (requests)
--async-backend httpx · aiohttp · niquests · zapros (aiohttp)
--layout auto · flat · grouped (auto)
--file-layout single · per-object (single)
--style declarative · imperative (declarative)
--optional none · omitted (none) — omitted distinguishes absent from null (adaptix)
--strip-prefix auto or a dotted prefix to drop from schema names (e.g. io.k8s.api.core.v1.PodCoreV1Pod)
--check run ruff and mypy --strict on the output
--config TOML config file

Config file

Keep your generation settings in a TOML file so a regenerate is a single command and the configuration lives in version control.

Precedence. For every setting: an explicit CLI flag wins, otherwise the config file, otherwise the built-in default. So you can pin a project's settings in the file and still override one of them ad hoc on the command line:

unihttp-openapi-generator generate                       # use the discovered config
unihttp-openapi-generator generate --serializer msgspec  # override just this one

Discovery order (the first that exists is used):

  1. the file passed to --config FILE,
  2. unihttp-openapi-generator.toml in the current directory,
  3. a [tool.unihttp-openapi-generator] table in pyproject.toml.

Keys mirror the CLI options exactly. spec, output_dir, and package_name are required (from the file or the command line); everything else is optional and falls back to the default shown in the CLI options table. Unknown keys are rejected so typos surface immediately.

A fully annotated unihttp-openapi-generator.toml:

spec = "https://api.example.com/openapi.json"  # path or URL; JSON or YAML
output_dir = "out"                             # where the package is written
package_name = "acme_client"                   # importable package name

serializer = "adaptix"        # adaptix | pydantic | msgspec
client = "both"               # both | sync | async
sync_backend = "requests"     # httpx | requests | niquests | zapros
async_backend = "aiohttp"     # httpx | aiohttp | niquests | zapros
layout = "auto"               # auto | flat | grouped     (client shape)
file_layout = "single"        # single | per-object       (files on disk)
style = "declarative"         # declarative | imperative  (method style)
optional = "none"             # none | omitted            (optional model fields)
strip_prefix = "auto"         # "auto" or a dotted prefix to drop from schema names
check = true                  # run ruff + mypy --strict on the output

Or, to keep it inside an existing project, drop the same keys under a table in pyproject.toml:

[tool.unihttp-openapi-generator]
spec = "openapi.yaml"
output_dir = "out"
package_name = "acme_client"
serializer = "pydantic"
client = "async"

Serializers

adaptix (default) pydantic msgspec
Model type @dataclass BaseModel msgspec.Struct
Field aliasing full (retort name mapping) Field(alias=…) field(name=…)
Query array styles full explode only explode only
Runtime validation yes yes

adaptix gives the highest fidelity (parameter aliases and all query array styles). pydantic adds runtime validation; msgspec is the fastest.

Generation options

These shape the surface and style of the generated code. All have sensible defaults; reach for them to match an existing codebase or taste.

Client layout — --layout

How methods are exposed on the client.

  • flat — every operation is a method on one client class:
    client.get_booking(booking_id=...)
    client.create_booking(body=...)
  • grouped — operations are grouped into sub-clients by their OpenAPI tag (nicer for large APIs):
    client.bookings.get_booking(booking_id=...)
    client.payments.create_payment(...)
  • auto (default) — flat when the spec has at most one tag, grouped otherwise.

File layout — --file-layout

How the package is split on disk. The import surface is identical either way.

  • single (default) — one models.py and one methods/<tag>.py per tag. Fewer, larger files.
  • per-object — one file per model/enum and per request method (models/<name>.py, methods/<tag>/<method>.py). Easier to navigate and gives small, focused diffs on regeneration, at the cost of many files. Cross-references between modules are resolved automatically without circular imports.

Method style — --style

How client methods are written.

  • declarative (default) — methods are bound from the request classes. Compact; the call signature comes from the request dataclass:
    class BookingsClient:
        get_booking = bind_method(GetBooking)
  • imperative — an explicit, fully-typed wrapper per operation. More generated code, but the signature is spelled out for the best editor experience:
    def get_trips(self, *, origin: UUID, destination: UUID, date: datetime,
                  page: int = 1, limit: int = 10) -> GetTripsResponse:
        return self.call_method(GetTrips(origin=origin, destination=destination,
                                         date=date, page=page, limit=limit))

Optional fields — --optional

How optional model fields are represented (adaptix only).

  • none (default) — T | None = None. Simple, but "field absent" and "field is null" both read as None.
    middle_name: str | None = None
  • omittedOmittable[T] = Omitted(). Distinguishes a field you never set from one set to null; unset fields are dropped from the request body entirely. Useful for PATCH-style APIs where sending null clears a value:
    middle_name: Omittable[str | None] = Omitted()

OpenAPI coverage

  • 3.0 and 3.1; JSON or YAML; file or URL; internal and external $ref.
  • Schemas: objects, allOf merge, oneOf/anyOf, discriminator (including polymorphic bases), enums and const, formats, nullable, additionalProperties, constraints, recursion, and readOnly (excluded from request bodies).
  • Operations: path/query/header parameters with defaults, JSON/form/multipart bodies, file uploads, typed responses, and deprecated.
  • Security: apiKey, http bearer/basic, oauth2, openIdConnect.

Limitations

  • Response headers are not exposed; methods return the response body.
  • deepObject query parameters and full parameter aliasing work on adaptix; on pydantic and msgspec they are limited.
  • Swagger / OpenAPI 2.0 is not supported (use the OpenAPI 3 description if a service publishes both, as Kubernetes does).

Development

uv sync
uv run pytest
uv run ruff check src tests
uv run mypy

License

MIT

About

Generate typed Python API clients from OpenAPI 3.0/3.1 specs, built on unihttp. adaptix/pydantic/msgspec models, sync & async clients, mypy --strict-clean output

Topics

Resources

License

Code of conduct

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors