oapi-gen
oapi-gen generates implementation-facing Python 3.12+ contracts and HTTP adapters
from an OpenAPI document using Starlette + msgspec. Handler
protocols and request/response envelopes remain independent of the HTTP framework
and dependency injection. Handler implementations are bound explicitly when the
router is created.
Installation
uv add --dev oapi-gen
uv add starlette msgspec 'uvicorn[standard]'
The generator checks msgspec codec compatibility before writing output files.
Generated applications need Starlette and msgspec at runtime; they do not import
the generator or Pydantic. APIs that use multipart/form-data must also install
python-multipart.
Usage
Save this minimal API as openapi.yaml in your application directory:
openapi: 3.1.0
info: {title: Cats API, version: 1.0.0}
paths:
/cats:
get:
operationId: listCats
tags: [Cats]
summary: List cats
parameters:
- name: limit
in: query
description: Maximum number of cats
schema: {type: integer, minimum: 1, maximum: 100, default: 20}
responses:
'200':
description: Cat list
content:
application/json:
schema:
type: array
items: {$ref: '#/components/schemas/Cat'}
components:
schemas:
Cat:
type: object
required: [id, name]
properties:
id: {type: integer}
name: {type: string}
Generate the package; the command creates its parent directories:
oapi-gen generate openapi.yaml --output app/http/generated
Save the following as app/main.py. Implement the generated protocol without
inheriting from it, then bind each handler group explicitly:
from starlette.applications import Starlette
from app.http.generated import Handlers, create_router, models
from app.http.generated.contracts import ListCats
class CatsController:
async def list_cats(self, request: ListCats.Request) -> ListCats.Response:
cats = [models.Cat(id=1, name="Mittens"), models.Cat(id=2, name="Luna")]
return ListCats.Ok(body=cats[: request.limit])
handlers = Handlers(cats=CatsController())
router = create_router(handlers, prefix="/api")
app = Starlette(routes=router.routes)
Start the server from the same application directory:
uv run uvicorn app.main:app
In another terminal:
curl 'http://127.0.0.1:8000/api/cats?limit=1'
# [{"id":1,"name":"Mittens"}]
curl 'http://127.0.0.1:8000/api/openapi.json'
For CI, commit the generated directory and run:
oapi-gen check openapi.yaml --output app/http/generated
check exits with status 1 when the generated files are missing or stale.
Operation and field descriptions are included in generated contract docstrings,
so they remain available while implementing handlers in an IDE.
Handler protocols use the group name with a Handler suffix, for example
AuthHandler and CatsHandler. Operations without a group use DefaultHandler.
The security group uses SecurityHandler_2 because SecurityHandler is reserved
for authorization. Regenerate existing packages and update imports from *Api
to *Handler.
API_INFO_TITLE and API_INFO_VERSION contain the corresponding values from the
OpenAPI info object.
For Dishka, the separate oapi-gen-dishka package
adds @inject and FromDishka[T] injection to handler methods using the native
Starlette request scope.
Each operation has a namespace in contracts: Login.Request is its request
envelope, Login.Ok is a response variant, and Login.Unauthorized is an exception.
Return responses below 400 and raise declared 4xx/5xx errors:
async def login(self, request: contracts.Login.Request) -> contracts.Login.Response:
if not accepted:
raise contracts.Login.Unauthorized(body="Invalid credentials")
return contracts.Login.Ok(body=session)
Login.Response includes only the returnable variants (Ok in this example).
For an operation with only error statuses, Response is typing.Never.
Status names follow HTTP names such as
Ok (200), Created (201), NoContent (204), NotFound (404), and
UnprocessableEntity (422). Custom statuses use names such as Status499.
The router catches only error variants declared for that operation, including when
raised by a nested service call. Other exceptions propagate normally. Error bodies
and headers use the same serialization and validation as returned responses.
Returning an error instead of raising it, or returning an undeclared variant such
as another operation's Ok, raises TypeError. Declared response headers remain
typed fields on each variant:
return contracts.CreateUpload.Created(
body=upload,
x_request_id=request_id,
)
Header fields that collide with body or exception attributes receive a _header
suffix, for example the Args header on an error becomes args_header.
To set multiple cookies, declare Set-Cookie as an array of strings in the
response headers:
responses:
'200':
description: Logged in
headers:
Set-Cookie:
schema:
type: array
items: {type: string}
Pass one complete cookie string per element, including any attributes:
return contracts.Login.Ok(
set_cookie=[
"session=abc; Path=/; HttpOnly; Secure; SameSite=Lax",
"refresh=xyz; Path=/auth; HttpOnly; Secure; SameSite=Lax",
],
)
Each element becomes a separate Set-Cookie header. An empty list or an omitted
optional value emits no cookie headers. This also works for raised error variants.
A Set-Cookie declared as a string still sets one cookie; arrays in other response
headers keep their comma-separated serialization. Regenerate the package after
changing the schema.
When migrating generated handlers, replace LoginRequest with Login.Request,
LoginResponse with Login.Response, and LoginResponse200 with Login.Ok.
Replace return Login.Unauthorized(...) (and other 4xx/5xx variants) with
raise Login.Unauthorized(...).
Regenerate the package and update all callers together.
Operation names that collide with contract infrastructure receive an Operation
suffix, for example handlers becomes HandlersOperation because Handlers
is the handler container. If that name is also occupied, a numeric suffix is added.
When an API declares security requirements, implement the generated
SecurityHandler protocol and bind it separately from the operation handlers. The
generated router extracts credentials, passes the operation ID and declared
scopes/roles to the security handler, and enforces OpenAPI's OR/AND semantics.
Each security method receives the previous context and returns the context exposed
to the operation handler as request.security_context. Schemes combined in one
requirement are evaluated in declaration order and share that context. Alternatives
are evaluated independently; raise the generated SecurityRejected exception to
reject one alternative and allow the router to try the next one. Other exceptions,
including starlette.exceptions.HTTPException, abort authorization immediately.
Missing or malformed credentials reject only their own alternative. Authorization
runs before parameters and request bodies are read or validated; denied requests
therefore do not parse JSON or spool uploaded files.
router = create_router(handlers, security=security_handler)
app = Starlette(routes=router.routes)
Current scope
Version 0.1 intentionally supports a strict subset:
- OpenAPI 3.0.x and 3.1.x;
- internal references to schemas, response headers, and security schemes in
components; - scalar path, query, header, and cookie parameters with their default serialization;
- arrays of scalars in query parameters (repeated values) and path/header parameters (comma-separated values, including repeated header lines);
- API key (header, query, or cookie), HTTP basic/bearer, and OAuth2 security;
- security requirement alternatives (OR), combined schemes (AND), and operation overrides;
- one JSON or multipart request media type and one JSON response media type per status;
- multipart object bodies with scalar form fields and binary file uploads;
- typed response headers with default simple serialization, plus separate
Set-Cookieheaders for arrays of cookie strings; - fixed numeric response status codes;
- grouping by
x-handler-group, falling back to the first tag.
External references, OpenID Connect/mTLS, callbacks, webhooks, custom parameter or multipart serialization, streaming, multipart responses, and wildcard/default response codes fail generation with an actionable error. They are not silently ignored.
Object parameters, request cookie arrays, nested arrays, and composed array
parameters are also rejected during generation. JSON schema constraints for numeric bounds,
multipleOf, string length and patterns, and array length are preserved in requests
and validated responses, including nested values and referenced scalar/array schemas.
Unsupported constraints such as uniqueItems, contains, conditional schemas, and
propertyNames produce a generation error, including on multipart roots.
The HTTP adapter enforces minProperties and maxProperties on JSON objects,
including nested models, dictionaries, and references, and on multipart roots.
Requests count supplied keys (including extra keys); validated responses count
serialized keys (including model defaults). Multipart counts unique field names,
so repeated parts of an array count as one property. Bounds must be non-negative integers.
File arrays enforce minItems and maxItems; other file constraints fail generation.
Common authoring rules and supported alternatives:
| Construct | Rule / alternative |
|---|---|
operationId |
Required for every operation; must remain unique after Python name normalization. |
Inline object with properties |
Move JSON objects to components.schemas and use $ref. Multipart bodies may declare fields inline. |
Inline allOf |
Only a single member is supported; move multi-member object composition to components. |
oneOf |
Branches must have disjoint explicit JSON types, or object branches must declare a discriminator with required, disjoint string const/enum values. Overlapping or unproven alternatives fail generation. Use anyOf when overlap is intended. |
readOnly: true / writeOnly: true |
Rejected. Define separate input/output components such as CreateUser and UserResponse; put passwords only in the input model and server IDs only in the output model. |
$ref with bounds |
Bounds accumulate across aliases; a sibling bound cannot weaken the referenced schema. Nested collection values preserve the same constraints. |
| Path parameter names | Wire names such as item-id are mapped to Python names for routing. Validation errors and served OpenAPI retain the original names. |
Both oneOf and anyOf must also satisfy msgspec codec restrictions; in particular,
multiple object variants require a supported discriminator.
Responses serialize model fields using their OpenAPI names, including aliases in
nested models. Importing generated models or contracts does not load Starlette; the
existing create_router export loads the HTTP adapter when first accessed.
Runtime
The adapter explicitly reads query/path/header/cookie parameters, decodes JSON
bytes directly into msgspec.Struct models, and encodes responses directly to
bytes. Codecs are created once. Multipart uploads close when the handler finishes,
including after validation failures or exceptions. Authorization follows the declared
security alternatives and scopes.
The original OpenAPI document is emitted as openapi.json and served at
<prefix>/openapi.json; route prefixes are applied when the router is created.
Include this JSON file as package data when distributing the generated package.
Pass include_schema=False to omit the schema route.
Response validation and maximum throughput
Responses are validated by default. For trusted handler implementations, generate a faster adapter with:
oapi-gen generate spec/openapi.yaml --output app/http/generated \
--no-validate-responses
oapi-gen check spec/openapi.yaml --output app/http/generated \
--no-validate-responses
This keeps input validation and the check for declared response variants. It omits runtime validation of response bodies and headers. Static checking remains available through generated protocols, dataclasses and Struct models, but cannot prove numeric bounds, lengths, patterns, or the validity of values obtained from untyped code. Struct constructors themselves do not validate field values.
In checked mode, msgspec responses are converted to builtins and validated before encoding. This also checks existing Struct instances, applies defaults and filters undeclared fields where the schema ignores them. In trusted mode, encoding operates directly on the handler result.
Validation semantics
- JSON decoding is strict: a string such as
"2"does not satisfy an integer field. Textual HTTP parameters and form values use explicit coercion. - Model field names use
msgspec.field(name=...)aliases; pass Python field names when constructing models. Non-object root schemas become type aliases. - Missing optional non-nullable model fields can be
msgspec.UNSET, distinct from an explicit JSONnull. - An optional JSON body may be absent; explicit
nullis accepted only when its schema is nullable. The handler receivesNonefor an absent body. - Multipart bodies require
multipart/form-data. An absent required body is an error even if every field is optional. A present empty multipart object is valid when no fields are required. Form-encoded requests are not accepted as multipart. additionalProperties: falseon an object without declared properties permits only an empty object.- Validation errors return HTTP 422 with a
detaillist and the input location. msgspec reports the first error. - Multiple object types in a decoded union need a supported tagged discriminator. Incompatible codecs fail generation before existing output files are updated.
See the reproducible Uvicorn benchmark for measured throughput, latency, memory, and the distinction between checked/trusted responses.
Development
uv sync
uv run pytest
uv run ruff check .
uv run basedpyright
uv build
The parser and renderer are organized by responsibility:
parser/document.pycoordinates document validation and operation parsing. The other parser modules handle references, schemas, parameters, security, bodies, and responses.render/router.pyemits explicit request parsing, handler calls and response encoding.render/runtime.pyprovides the generated HTTP helpers; contracts and authorization are rendered in separate modules. The original OpenAPI document is copied as JSON.ir.pydefines the shared contract between parsing and rendering. Parser modules do not depend on the renderer; renderer modules consume the IR without parsing documents.- Tests are grouped by behavior. Shared fixtures create isolated generated packages;
tests/snapshotsrecords the complete output for both example specifications.
The existing oapi_gen.parser and oapi_gen.render entry points remain available.
Snapshot tests compare generated files and the manifest byte for byte. Update the
snapshots only when an intentional output change has been reviewed, including changes
to the generator version or example specifications.
The operation parsing and naming behavior is partly adapted from the MIT-licensed
fastapi-code-generator; its copyright notice is included in the package.
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 oapi_gen-0.1.6.tar.gz.
File metadata
- Download URL: oapi_gen-0.1.6.tar.gz
- Upload date:
- Size: 85.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bb6c0545345389a93c646587a13d299a538c96fa00dc17b9996df99b605f621c
|
|
| MD5 |
5f087eb1ec7df905dfd431ca8b24ae24
|
|
| BLAKE2b-256 |
2882c02ab6cd5c526ee8eb09d8202e401ffaeeddc63083fcbf87f095ce5df462
|
File details
Details for the file oapi_gen-0.1.6-py3-none-any.whl.
File metadata
- Download URL: oapi_gen-0.1.6-py3-none-any.whl
- Upload date:
- Size: 48.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
219d902b4a80f7786d618bdf16097a755776e18814fa16f1879090912328d2d1
|
|
| MD5 |
496b135d0e7306352cb24700a78efc4e
|
|
| BLAKE2b-256 |
94f0b441cff52b1c56d4fbd7ca83ea560be3b18184ed7a5e94a11c76a38104f4
|