Skip to main content

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.

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;
  • 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, 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 JSON null.
  • An optional JSON body may be absent; explicit null is accepted only when its schema is nullable. The handler receives None for 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: false on an object without declared properties permits only an empty object.
  • Validation errors return HTTP 422 with a detail list 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.py coordinates document validation and operation parsing. The other parser modules handle references, schemas, parameters, security, bodies, and responses.
  • render/router.py emits explicit request parsing, handler calls and response encoding. render/runtime.py provides the generated HTTP helpers; contracts and authorization are rendered in separate modules. The original OpenAPI document is copied as JSON.
  • ir.py defines 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/snapshots records 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

oapi_gen-0.1.4.tar.gz (84.3 kB view details)

Uploaded Source

Built Distribution

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

oapi_gen-0.1.4-py3-none-any.whl (48.0 kB view details)

Uploaded Python 3

File details

Details for the file oapi_gen-0.1.4.tar.gz.

File metadata

  • Download URL: oapi_gen-0.1.4.tar.gz
  • Upload date:
  • Size: 84.3 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

Hashes for oapi_gen-0.1.4.tar.gz
Algorithm Hash digest
SHA256 a9653f36b89f4cb0ea5fcb0be7b7bf939673d5bdeb4007f7034e8d49f9bfea1c
MD5 e1b1f1d8fd452c8c15cc01c739540cba
BLAKE2b-256 9bea92ea4c7ef7ad75cb7510224abf86124e9e402eb7ba52d90b8f84adcfc5ab

See more details on using hashes here.

File details

Details for the file oapi_gen-0.1.4-py3-none-any.whl.

File metadata

  • Download URL: oapi_gen-0.1.4-py3-none-any.whl
  • Upload date:
  • Size: 48.0 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

Hashes for oapi_gen-0.1.4-py3-none-any.whl
Algorithm Hash digest
SHA256 dda5f480fa20cebc7c4cb88d43a8c34463cebdcca4030161a1002a0f74ecc094
MD5 5b822062589085775f9b66db7648ccdb
BLAKE2b-256 acb8639392d4ff9b5467b92e8d85d759c53005c55c08fbbba3ac35d63c0ec38b

See more details on using hashes here.

Release history Release notifications | RSS feed

0.1.9

2 files

0.1.8

2 files

0.1.7

2 files

0.1.6

2 files

This release

0.1.4 This release

2 files

0.1.3

2 files

0.1.2

2 files

0.1.1

1 file

0.1.0

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