https://github.com/goduni/unihttp-openapi-generator
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
https://github.com/goduni/unihttp-openapi-generator
adaptix api-client async client-generator code-generator codegen http-client msgspec openapi openapi-generator openapi3 pydantic python rest-api sdk-generator swagger type-safe unihttp
Last synced: about 4 hours ago
JSON representation
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
- Host: GitHub
- URL: https://github.com/goduni/unihttp-openapi-generator
- Owner: goduni
- License: mit
- Created: 2026-05-30T04:29:54.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-06-30T19:19:44.000Z (5 days ago)
- Last Synced: 2026-06-30T21:10:24.816Z (5 days ago)
- Topics: adaptix, api-client, async, client-generator, code-generator, codegen, http-client, msgspec, openapi, openapi-generator, openapi3, pydantic, python, rest-api, sdk-generator, swagger, type-safe, unihttp
- Language: Python
- Homepage:
- Size: 237 KB
- Stars: 7
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
- Code of conduct: .github/CODE_OF_CONDUCT.md
- Security: .github/SECURITY.md
Awesome Lists containing this project
README
# unihttp-openapi-generator
[](https://codecov.io/gh/goduni/unihttp-openapi-generator)
[](https://pypi.org/project/unihttp-openapi-generator)




[](https://t.me/+OsmQESHc1xU1MGVi)
Turn an OpenAPI spec into a typed Python API client built on
[unihttp](https://github.com/goduni/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
```bash
pip install unihttp-openapi-generator
# or, with uv:
uv tool install unihttp-openapi-generator
```
## Quick start
```bash
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:
```bash
pip install ./out
```
```python
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/.py # one request class per operation
โโโ client.py # the client(s)
```
A request class and the client constructor (real output):
```python
@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.
```python
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:
```python
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:
```python
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 ` |
| apiKey (header or query) | `: str` | the named header or query parameter |
| http basic | `: tuple[str, str]` | `Authorization: Basic ` |
### 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`, โฆ):
```python
import requests
session = requests.Session()
session.headers["User-Agent"] = "acme/1.0"
client = AcmeClient(session=session)
```
### Errors
Non-2xx responses raise. `.exceptions` defines a base `ApiError` plus a
subclass per status code (`NotFoundError`, `UnprocessableEntityError`, โฆ), with
`4xx`/`5xx` falling back to unihttp's `ClientError`/`ServerError`.
```python
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.
```python
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.Pod` โ `CoreV1Pod`) |
| `--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:
```bash
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](#cli-options) table. Unknown keys are
rejected so typos surface immediately.
A fully annotated `unihttp-openapi-generator.toml`:
```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`:
```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:
```python
client.get_booking(booking_id=...)
client.create_booking(body=...)
```
- `grouped` โ operations are grouped into sub-clients by their OpenAPI tag (nicer for
large APIs):
```python
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/.py` per tag. Fewer, larger
files.
- `per-object` โ one file per model/enum and per request method
(`models/.py`, `methods//.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:
```python
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:
```python
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`.
```python
middle_name: str | None = None
```
- `omitted` โ `Omittable[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:
```python
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
```bash
uv sync
uv run pytest
uv run ruff check src tests
uv run mypy
```
## License
MIT