oapi-gen 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oapi_gen-0.1.2/MANIFEST.in +3 -0
- oapi_gen-0.1.2/PKG-INFO +308 -0
- oapi_gen-0.1.2/README.md +295 -0
- oapi_gen-0.1.2/packages/oapi_gen/__init__.py +14 -0
- oapi_gen-0.1.2/packages/oapi_gen/__main__.py +3 -0
- oapi_gen-0.1.2/packages/oapi_gen/_version.py +1 -0
- oapi_gen-0.1.2/packages/oapi_gen/errors.py +10 -0
- oapi_gen-0.1.2/packages/oapi_gen/formatter.py +16 -0
- oapi_gen-0.1.2/packages/oapi_gen/generator.py +200 -0
- oapi_gen-0.1.2/packages/oapi_gen/ir.py +206 -0
- oapi_gen-0.1.2/packages/oapi_gen/main.py +63 -0
- oapi_gen-0.1.2/packages/oapi_gen/models.py +54 -0
- oapi_gen-0.1.2/packages/oapi_gen/naming.py +49 -0
- oapi_gen-0.1.2/packages/oapi_gen/parser/__init__.py +5 -0
- oapi_gen-0.1.2/packages/oapi_gen/parser/bodies.py +193 -0
- oapi_gen-0.1.2/packages/oapi_gen/parser/document.py +134 -0
- oapi_gen-0.1.2/packages/oapi_gen/parser/operations.py +166 -0
- oapi_gen-0.1.2/packages/oapi_gen/parser/parameters.py +168 -0
- oapi_gen-0.1.2/packages/oapi_gen/parser/references.py +47 -0
- oapi_gen-0.1.2/packages/oapi_gen/parser/responses.py +144 -0
- oapi_gen-0.1.2/packages/oapi_gen/parser/schemas.py +514 -0
- oapi_gen-0.1.2/packages/oapi_gen/parser/security.py +157 -0
- oapi_gen-0.1.2/packages/oapi_gen/parser/serialization.py +31 -0
- oapi_gen-0.1.2/packages/oapi_gen/parser/values.py +35 -0
- oapi_gen-0.1.2/packages/oapi_gen/render/__init__.py +8 -0
- oapi_gen-0.1.2/packages/oapi_gen/render/contracts.py +210 -0
- oapi_gen-0.1.2/packages/oapi_gen/render/inspection.py +66 -0
- oapi_gen-0.1.2/packages/oapi_gen/render/package.py +42 -0
- oapi_gen-0.1.2/packages/oapi_gen/render/router.py +299 -0
- oapi_gen-0.1.2/packages/oapi_gen/render/runtime.py +321 -0
- oapi_gen-0.1.2/packages/oapi_gen/render/security.py +88 -0
- oapi_gen-0.1.2/packages/oapi_gen/render/writer.py +68 -0
- oapi_gen-0.1.2/packages/oapi_gen.egg-info/PKG-INFO +308 -0
- oapi_gen-0.1.2/packages/oapi_gen.egg-info/SOURCES.txt +70 -0
- oapi_gen-0.1.2/packages/oapi_gen.egg-info/dependency_links.txt +1 -0
- oapi_gen-0.1.2/packages/oapi_gen.egg-info/entry_points.txt +2 -0
- oapi_gen-0.1.2/packages/oapi_gen.egg-info/requires.txt +6 -0
- oapi_gen-0.1.2/packages/oapi_gen.egg-info/top_level.txt +1 -0
- oapi_gen-0.1.2/pyproject.toml +57 -0
- oapi_gen-0.1.2/setup.cfg +4 -0
- oapi_gen-0.1.2/tests/__init__.py +0 -0
- oapi_gen-0.1.2/tests/conftest.py +83 -0
- oapi_gen-0.1.2/tests/fixtures/advanced.openapi.yaml +633 -0
- oapi_gen-0.1.2/tests/fixtures/cats.openapi.yaml +101 -0
- oapi_gen-0.1.2/tests/fixtures/security.openapi.yaml +123 -0
- oapi_gen-0.1.2/tests/snapshots/README.md +13 -0
- oapi_gen-0.1.2/tests/snapshots/advanced/.oapi-gen-manifest.json.txt +13 -0
- oapi_gen-0.1.2/tests/snapshots/advanced/__init__.py.txt +29 -0
- oapi_gen-0.1.2/tests/snapshots/advanced/_runtime.py.txt +323 -0
- oapi_gen-0.1.2/tests/snapshots/advanced/contracts.py.txt +507 -0
- oapi_gen-0.1.2/tests/snapshots/advanced/models.py.txt +104 -0
- oapi_gen-0.1.2/tests/snapshots/advanced/openapi.json.txt +932 -0
- oapi_gen-0.1.2/tests/snapshots/advanced/router.py.txt +693 -0
- oapi_gen-0.1.2/tests/snapshots/cats/.oapi-gen-manifest.json.txt +13 -0
- oapi_gen-0.1.2/tests/snapshots/cats/__init__.py.txt +22 -0
- oapi_gen-0.1.2/tests/snapshots/cats/_runtime.py.txt +323 -0
- oapi_gen-0.1.2/tests/snapshots/cats/contracts.py.txt +90 -0
- oapi_gen-0.1.2/tests/snapshots/cats/models.py.txt +24 -0
- oapi_gen-0.1.2/tests/snapshots/cats/openapi.json.txt +170 -0
- oapi_gen-0.1.2/tests/snapshots/cats/router.py.txt +123 -0
- oapi_gen-0.1.2/tests/support.py +37 -0
- oapi_gen-0.1.2/tests/test_generator.py +91 -0
- oapi_gen-0.1.2/tests/test_imports.py +107 -0
- oapi_gen-0.1.2/tests/test_multipart.py +252 -0
- oapi_gen-0.1.2/tests/test_parameters.py +223 -0
- oapi_gen-0.1.2/tests/test_parser.py +272 -0
- oapi_gen-0.1.2/tests/test_responses.py +170 -0
- oapi_gen-0.1.2/tests/test_router.py +193 -0
- oapi_gen-0.1.2/tests/test_schemas.py +647 -0
- oapi_gen-0.1.2/tests/test_security.py +339 -0
- oapi_gen-0.1.2/tests/test_snapshots.py +18 -0
- oapi_gen-0.1.2/tests/test_starlette.py +263 -0
oapi_gen-0.1.2/PKG-INFO
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oapi-gen
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Strict OpenAPI-first server contract generator for Starlette and msgspec
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: black==26.5.1
|
|
8
|
+
Requires-Dist: datamodel-code-generator==0.65.1
|
|
9
|
+
Requires-Dist: isort==8.0.1
|
|
10
|
+
Requires-Dist: msgspec==0.21.1
|
|
11
|
+
Requires-Dist: starlette==1.6.0
|
|
12
|
+
Requires-Dist: PyYAML==6.0.3
|
|
13
|
+
|
|
14
|
+
# oapi-gen
|
|
15
|
+
|
|
16
|
+
`oapi-gen` generates implementation-facing Python 3.12+ contracts and HTTP adapters
|
|
17
|
+
from an OpenAPI document using Starlette + msgspec. Handler
|
|
18
|
+
protocols and request/response envelopes remain independent of the HTTP framework
|
|
19
|
+
and dependency injection. Handler implementations are bound explicitly when the
|
|
20
|
+
router is created.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv add --dev oapi-gen
|
|
26
|
+
uv add starlette msgspec 'uvicorn[standard]'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The generator checks msgspec codec compatibility before writing output files.
|
|
30
|
+
Generated applications need Starlette and msgspec at runtime; they do not import
|
|
31
|
+
the generator or Pydantic. APIs that use `multipart/form-data` must also install
|
|
32
|
+
`python-multipart`.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
Save this minimal API as `openapi.yaml` in your application directory:
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
openapi: 3.1.0
|
|
40
|
+
info: {title: Cats API, version: 1.0.0}
|
|
41
|
+
paths:
|
|
42
|
+
/cats:
|
|
43
|
+
get:
|
|
44
|
+
operationId: listCats
|
|
45
|
+
tags: [Cats]
|
|
46
|
+
summary: List cats
|
|
47
|
+
parameters:
|
|
48
|
+
- name: limit
|
|
49
|
+
in: query
|
|
50
|
+
description: Maximum number of cats
|
|
51
|
+
schema: {type: integer, minimum: 1, maximum: 100, default: 20}
|
|
52
|
+
responses:
|
|
53
|
+
'200':
|
|
54
|
+
description: Cat list
|
|
55
|
+
content:
|
|
56
|
+
application/json:
|
|
57
|
+
schema:
|
|
58
|
+
type: array
|
|
59
|
+
items: {$ref: '#/components/schemas/Cat'}
|
|
60
|
+
components:
|
|
61
|
+
schemas:
|
|
62
|
+
Cat:
|
|
63
|
+
type: object
|
|
64
|
+
required: [id, name]
|
|
65
|
+
properties:
|
|
66
|
+
id: {type: integer}
|
|
67
|
+
name: {type: string}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Generate the package; the command creates its parent directories:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
oapi-gen generate openapi.yaml --output app/http/generated
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Save the following as `app/main.py`. Implement the generated protocol without
|
|
77
|
+
inheriting from it, then bind each handler group explicitly:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from starlette.applications import Starlette
|
|
81
|
+
from app.http.generated import Handlers, create_router, models
|
|
82
|
+
from app.http.generated.contracts import ListCats
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CatsController:
|
|
86
|
+
async def list_cats(self, request: ListCats.Request) -> ListCats.Response:
|
|
87
|
+
cats = [models.Cat(id=1, name="Mittens"), models.Cat(id=2, name="Luna")]
|
|
88
|
+
return ListCats.Ok(body=cats[: request.limit])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
handlers = Handlers(cats=CatsController())
|
|
92
|
+
router = create_router(handlers, prefix="/api")
|
|
93
|
+
app = Starlette(routes=router.routes)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Start the server from the same application directory:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
uv run uvicorn app.main:app
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
In another terminal:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
curl 'http://127.0.0.1:8000/api/cats?limit=1'
|
|
106
|
+
# [{"id":1,"name":"Mittens"}]
|
|
107
|
+
curl 'http://127.0.0.1:8000/api/openapi.json'
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
For CI, commit the generated directory and run:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
oapi-gen check openapi.yaml --output app/http/generated
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`check` exits with status 1 when the generated files are missing or stale.
|
|
117
|
+
Operation and field descriptions are included in generated contract docstrings,
|
|
118
|
+
so they remain available while implementing handlers in an IDE.
|
|
119
|
+
|
|
120
|
+
`API_INFO_TITLE` and `API_INFO_VERSION` contain the corresponding values from the
|
|
121
|
+
OpenAPI `info` object.
|
|
122
|
+
|
|
123
|
+
For Dishka, the separate [`oapi-gen-dishka`](integrations/dishka/README.md) package
|
|
124
|
+
adds `@inject` and `FromDishka[T]` injection to handler methods using the native
|
|
125
|
+
Starlette request scope.
|
|
126
|
+
|
|
127
|
+
Each operation has a namespace in `contracts`: `Login.Request` is its request
|
|
128
|
+
envelope, `Login.Ok` and `Login.Unauthorized` are its declared response variants,
|
|
129
|
+
and `Login.Response` is their exact union. Status names follow HTTP names such as
|
|
130
|
+
`Ok` (200), `Created` (201), `NoContent` (204), `NotFound` (404), and
|
|
131
|
+
`UnprocessableEntity` (422). Custom statuses use names such as `Status499`.
|
|
132
|
+
Returning an undeclared variant, including another operation's `Ok`, is a server
|
|
133
|
+
error. Declared response headers remain typed fields on each variant:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
return contracts.CreateUpload.Created(
|
|
137
|
+
body=upload,
|
|
138
|
+
x_request_id=request_id,
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
When migrating generated handlers, replace `LoginRequest` with `Login.Request`,
|
|
143
|
+
`LoginResponse` with `Login.Response`, and `LoginResponse200` with `Login.Ok`.
|
|
144
|
+
Regenerate the package and update all callers together.
|
|
145
|
+
Operation names that collide with contract infrastructure receive an `Operation`
|
|
146
|
+
suffix, for example `handlers` becomes `HandlersOperation` because `Handlers`
|
|
147
|
+
is the handler container. If that name is also occupied, a numeric suffix is added.
|
|
148
|
+
|
|
149
|
+
When an API declares security requirements, implement the generated
|
|
150
|
+
`SecurityHandler` protocol and bind it separately from the operation handlers. The
|
|
151
|
+
generated router extracts credentials, passes the operation ID and declared
|
|
152
|
+
scopes/roles to the security handler, and enforces OpenAPI's OR/AND semantics.
|
|
153
|
+
Each security method receives the previous context and returns the context exposed
|
|
154
|
+
to the operation handler as `request.security_context`. Schemes combined in one
|
|
155
|
+
requirement are evaluated in declaration order and share that context. Alternatives
|
|
156
|
+
are evaluated independently; raise the generated `SecurityRejected` exception to
|
|
157
|
+
reject one alternative and allow the router to try the next one. Other exceptions,
|
|
158
|
+
including `starlette.exceptions.HTTPException`, abort authorization immediately.
|
|
159
|
+
Missing or malformed credentials reject only their own alternative. Authorization
|
|
160
|
+
runs before parameters and request bodies are read or validated; denied requests
|
|
161
|
+
therefore do not parse JSON or spool uploaded files.
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
router = create_router(handlers, security=security_handler)
|
|
165
|
+
app = Starlette(routes=router.routes)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Current scope
|
|
169
|
+
|
|
170
|
+
Version 0.1 intentionally supports a strict subset:
|
|
171
|
+
|
|
172
|
+
- OpenAPI 3.0.x and 3.1.x;
|
|
173
|
+
- internal references to schemas, response headers, and security schemes in `components`;
|
|
174
|
+
- scalar path, query, header, and cookie parameters with their default serialization;
|
|
175
|
+
- arrays of scalars in query parameters (repeated values) and path/header parameters
|
|
176
|
+
(comma-separated values, including repeated header lines);
|
|
177
|
+
- API key (header, query, or cookie), HTTP basic/bearer, and OAuth2 security;
|
|
178
|
+
- security requirement alternatives (OR), combined schemes (AND), and operation overrides;
|
|
179
|
+
- one JSON or multipart request media type and one JSON response media type per status;
|
|
180
|
+
- multipart object bodies with scalar form fields and binary file uploads;
|
|
181
|
+
- typed response headers with default simple serialization;
|
|
182
|
+
- fixed numeric response status codes;
|
|
183
|
+
- grouping by `x-handler-group`, falling back to the first tag.
|
|
184
|
+
|
|
185
|
+
External references, OpenID Connect/mTLS, callbacks, webhooks, custom parameter or
|
|
186
|
+
multipart serialization, streaming, multipart responses, and wildcard/default
|
|
187
|
+
response codes fail generation with an actionable error. They are not silently ignored.
|
|
188
|
+
|
|
189
|
+
Object parameters, cookie arrays, nested arrays, and composed array parameters are
|
|
190
|
+
also rejected during generation. JSON schema constraints for numeric bounds,
|
|
191
|
+
`multipleOf`, string length and patterns, and array length are preserved in requests
|
|
192
|
+
and validated responses, including nested values and referenced scalar/array schemas.
|
|
193
|
+
Unsupported constraints such as `uniqueItems`, `contains`, conditional schemas, and
|
|
194
|
+
`propertyNames` produce a generation error, including on multipart roots.
|
|
195
|
+
The HTTP adapter enforces `minProperties` and `maxProperties` on JSON objects,
|
|
196
|
+
including nested models, dictionaries, and references, and on multipart roots.
|
|
197
|
+
Requests count supplied keys (including extra keys); validated responses count
|
|
198
|
+
serialized keys (including model defaults). Multipart counts unique field names,
|
|
199
|
+
so repeated parts of an array count as one property. Bounds must be non-negative integers.
|
|
200
|
+
File arrays enforce `minItems` and `maxItems`; other file constraints fail generation.
|
|
201
|
+
|
|
202
|
+
Common authoring rules and supported alternatives:
|
|
203
|
+
|
|
204
|
+
| Construct | Rule / alternative |
|
|
205
|
+
| --- | --- |
|
|
206
|
+
| `operationId` | Required for every operation; must remain unique after Python name normalization. |
|
|
207
|
+
| Inline object with `properties` | Move JSON objects to `components.schemas` and use `$ref`. Multipart bodies may declare fields inline. |
|
|
208
|
+
| Inline `allOf` | Only a single member is supported; move multi-member object composition to components. |
|
|
209
|
+
| `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. |
|
|
210
|
+
| `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. |
|
|
211
|
+
| `$ref` with bounds | Bounds accumulate across aliases; a sibling bound cannot weaken the referenced schema. Nested collection values preserve the same constraints. |
|
|
212
|
+
| 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. |
|
|
213
|
+
|
|
214
|
+
Both `oneOf` and `anyOf` must also satisfy msgspec codec restrictions; in particular,
|
|
215
|
+
multiple object variants require a supported discriminator.
|
|
216
|
+
|
|
217
|
+
Responses serialize model fields using their OpenAPI names, including aliases in
|
|
218
|
+
nested models. Importing generated models or contracts does not load Starlette; the
|
|
219
|
+
existing `create_router` export loads the HTTP adapter when first accessed.
|
|
220
|
+
|
|
221
|
+
## Runtime
|
|
222
|
+
|
|
223
|
+
The adapter explicitly reads query/path/header/cookie parameters, decodes JSON
|
|
224
|
+
bytes directly into `msgspec.Struct` models, and encodes responses directly to
|
|
225
|
+
bytes. Codecs are created once. Multipart uploads close when the handler finishes,
|
|
226
|
+
including after validation failures or exceptions. Authorization follows the declared
|
|
227
|
+
security alternatives and scopes.
|
|
228
|
+
|
|
229
|
+
The original OpenAPI document is emitted as `openapi.json` and served at
|
|
230
|
+
`<prefix>/openapi.json`; route prefixes are applied when the router is created.
|
|
231
|
+
Include this JSON file as package data when distributing the generated package.
|
|
232
|
+
Pass `include_schema=False` to omit the schema route.
|
|
233
|
+
|
|
234
|
+
### Response validation and maximum throughput
|
|
235
|
+
|
|
236
|
+
Responses are validated by default. For trusted handler implementations,
|
|
237
|
+
generate a faster adapter with:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
oapi-gen generate spec/openapi.yaml --output app/http/generated \
|
|
241
|
+
--no-validate-responses
|
|
242
|
+
oapi-gen check spec/openapi.yaml --output app/http/generated \
|
|
243
|
+
--no-validate-responses
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
This keeps input validation and the check for declared response variants. It
|
|
247
|
+
omits runtime validation of response bodies and headers. Static checking remains
|
|
248
|
+
available through generated protocols, dataclasses and Struct models, but cannot
|
|
249
|
+
prove numeric bounds, lengths, patterns, or the validity of values obtained from
|
|
250
|
+
untyped code. Struct constructors themselves do not validate field values.
|
|
251
|
+
|
|
252
|
+
In checked mode, msgspec responses are converted to builtins and validated before
|
|
253
|
+
encoding. This also checks existing Struct instances, applies defaults and filters
|
|
254
|
+
undeclared fields where the schema ignores them. In trusted mode, encoding operates
|
|
255
|
+
directly on the handler result.
|
|
256
|
+
|
|
257
|
+
### Validation semantics
|
|
258
|
+
|
|
259
|
+
- JSON decoding is strict: a string such as `"2"` does not satisfy an integer field.
|
|
260
|
+
Textual HTTP parameters and form values use explicit coercion.
|
|
261
|
+
- Model field names use `msgspec.field(name=...)` aliases; pass Python field names
|
|
262
|
+
when constructing models. Non-object root schemas become type aliases.
|
|
263
|
+
- Missing optional non-nullable model fields can be `msgspec.UNSET`, distinct from
|
|
264
|
+
an explicit JSON `null`.
|
|
265
|
+
- An optional JSON body may be absent; explicit `null` is accepted only when its
|
|
266
|
+
schema is nullable. The handler receives `None` for an absent body.
|
|
267
|
+
- Multipart bodies require `multipart/form-data`. An absent required body is an
|
|
268
|
+
error even if every field is optional. A present empty multipart object is valid
|
|
269
|
+
when no fields are required. Form-encoded requests are not accepted as multipart.
|
|
270
|
+
- `additionalProperties: false` on an object without declared properties permits
|
|
271
|
+
only an empty object.
|
|
272
|
+
- Validation errors return HTTP 422 with a `detail` list and the input location.
|
|
273
|
+
msgspec reports the first error.
|
|
274
|
+
- Multiple object types in a decoded union need a supported tagged discriminator.
|
|
275
|
+
Incompatible codecs fail generation before existing output files are updated.
|
|
276
|
+
|
|
277
|
+
See [the reproducible Uvicorn benchmark](benchmarks/README.md) for measured
|
|
278
|
+
throughput, latency, memory, and the distinction between checked/trusted responses.
|
|
279
|
+
|
|
280
|
+
## Development
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
uv sync
|
|
284
|
+
uv run pytest
|
|
285
|
+
uv run ruff check .
|
|
286
|
+
uv run basedpyright
|
|
287
|
+
uv build
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
The parser and renderer are organized by responsibility:
|
|
291
|
+
|
|
292
|
+
- `parser/document.py` coordinates document validation and operation parsing. The other
|
|
293
|
+
parser modules handle references, schemas, parameters, security, bodies, and responses.
|
|
294
|
+
- `render/router.py` emits explicit request parsing, handler calls and response encoding.
|
|
295
|
+
`render/runtime.py` provides the generated HTTP helpers; contracts and authorization
|
|
296
|
+
are rendered in separate modules. The original OpenAPI document is copied as JSON.
|
|
297
|
+
- `ir.py` defines the shared contract between parsing and rendering. Parser modules do
|
|
298
|
+
not depend on the renderer; renderer modules consume the IR without parsing documents.
|
|
299
|
+
- Tests are grouped by behavior. Shared fixtures create isolated generated packages;
|
|
300
|
+
`tests/snapshots` records the complete output for both example specifications.
|
|
301
|
+
|
|
302
|
+
The existing `oapi_gen.parser` and `oapi_gen.render` entry points remain available.
|
|
303
|
+
Snapshot tests compare generated files and the manifest byte for byte. Update the
|
|
304
|
+
snapshots only when an intentional output change has been reviewed, including changes
|
|
305
|
+
to the generator version or example specifications.
|
|
306
|
+
|
|
307
|
+
The operation parsing and naming behavior is partly adapted from the MIT-licensed
|
|
308
|
+
`fastapi-code-generator`; its copyright notice is included in the package.
|
oapi_gen-0.1.2/README.md
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# oapi-gen
|
|
2
|
+
|
|
3
|
+
`oapi-gen` generates implementation-facing Python 3.12+ contracts and HTTP adapters
|
|
4
|
+
from an OpenAPI document using Starlette + msgspec. Handler
|
|
5
|
+
protocols and request/response envelopes remain independent of the HTTP framework
|
|
6
|
+
and dependency injection. Handler implementations are bound explicitly when the
|
|
7
|
+
router is created.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv add --dev oapi-gen
|
|
13
|
+
uv add starlette msgspec 'uvicorn[standard]'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The generator checks msgspec codec compatibility before writing output files.
|
|
17
|
+
Generated applications need Starlette and msgspec at runtime; they do not import
|
|
18
|
+
the generator or Pydantic. APIs that use `multipart/form-data` must also install
|
|
19
|
+
`python-multipart`.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
Save this minimal API as `openapi.yaml` in your application directory:
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
openapi: 3.1.0
|
|
27
|
+
info: {title: Cats API, version: 1.0.0}
|
|
28
|
+
paths:
|
|
29
|
+
/cats:
|
|
30
|
+
get:
|
|
31
|
+
operationId: listCats
|
|
32
|
+
tags: [Cats]
|
|
33
|
+
summary: List cats
|
|
34
|
+
parameters:
|
|
35
|
+
- name: limit
|
|
36
|
+
in: query
|
|
37
|
+
description: Maximum number of cats
|
|
38
|
+
schema: {type: integer, minimum: 1, maximum: 100, default: 20}
|
|
39
|
+
responses:
|
|
40
|
+
'200':
|
|
41
|
+
description: Cat list
|
|
42
|
+
content:
|
|
43
|
+
application/json:
|
|
44
|
+
schema:
|
|
45
|
+
type: array
|
|
46
|
+
items: {$ref: '#/components/schemas/Cat'}
|
|
47
|
+
components:
|
|
48
|
+
schemas:
|
|
49
|
+
Cat:
|
|
50
|
+
type: object
|
|
51
|
+
required: [id, name]
|
|
52
|
+
properties:
|
|
53
|
+
id: {type: integer}
|
|
54
|
+
name: {type: string}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Generate the package; the command creates its parent directories:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
oapi-gen generate openapi.yaml --output app/http/generated
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Save the following as `app/main.py`. Implement the generated protocol without
|
|
64
|
+
inheriting from it, then bind each handler group explicitly:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from starlette.applications import Starlette
|
|
68
|
+
from app.http.generated import Handlers, create_router, models
|
|
69
|
+
from app.http.generated.contracts import ListCats
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CatsController:
|
|
73
|
+
async def list_cats(self, request: ListCats.Request) -> ListCats.Response:
|
|
74
|
+
cats = [models.Cat(id=1, name="Mittens"), models.Cat(id=2, name="Luna")]
|
|
75
|
+
return ListCats.Ok(body=cats[: request.limit])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
handlers = Handlers(cats=CatsController())
|
|
79
|
+
router = create_router(handlers, prefix="/api")
|
|
80
|
+
app = Starlette(routes=router.routes)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Start the server from the same application directory:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
uv run uvicorn app.main:app
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
In another terminal:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
curl 'http://127.0.0.1:8000/api/cats?limit=1'
|
|
93
|
+
# [{"id":1,"name":"Mittens"}]
|
|
94
|
+
curl 'http://127.0.0.1:8000/api/openapi.json'
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
For CI, commit the generated directory and run:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
oapi-gen check openapi.yaml --output app/http/generated
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`check` exits with status 1 when the generated files are missing or stale.
|
|
104
|
+
Operation and field descriptions are included in generated contract docstrings,
|
|
105
|
+
so they remain available while implementing handlers in an IDE.
|
|
106
|
+
|
|
107
|
+
`API_INFO_TITLE` and `API_INFO_VERSION` contain the corresponding values from the
|
|
108
|
+
OpenAPI `info` object.
|
|
109
|
+
|
|
110
|
+
For Dishka, the separate [`oapi-gen-dishka`](integrations/dishka/README.md) package
|
|
111
|
+
adds `@inject` and `FromDishka[T]` injection to handler methods using the native
|
|
112
|
+
Starlette request scope.
|
|
113
|
+
|
|
114
|
+
Each operation has a namespace in `contracts`: `Login.Request` is its request
|
|
115
|
+
envelope, `Login.Ok` and `Login.Unauthorized` are its declared response variants,
|
|
116
|
+
and `Login.Response` is their exact union. Status names follow HTTP names such as
|
|
117
|
+
`Ok` (200), `Created` (201), `NoContent` (204), `NotFound` (404), and
|
|
118
|
+
`UnprocessableEntity` (422). Custom statuses use names such as `Status499`.
|
|
119
|
+
Returning an undeclared variant, including another operation's `Ok`, is a server
|
|
120
|
+
error. Declared response headers remain typed fields on each variant:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
return contracts.CreateUpload.Created(
|
|
124
|
+
body=upload,
|
|
125
|
+
x_request_id=request_id,
|
|
126
|
+
)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
When migrating generated handlers, replace `LoginRequest` with `Login.Request`,
|
|
130
|
+
`LoginResponse` with `Login.Response`, and `LoginResponse200` with `Login.Ok`.
|
|
131
|
+
Regenerate the package and update all callers together.
|
|
132
|
+
Operation names that collide with contract infrastructure receive an `Operation`
|
|
133
|
+
suffix, for example `handlers` becomes `HandlersOperation` because `Handlers`
|
|
134
|
+
is the handler container. If that name is also occupied, a numeric suffix is added.
|
|
135
|
+
|
|
136
|
+
When an API declares security requirements, implement the generated
|
|
137
|
+
`SecurityHandler` protocol and bind it separately from the operation handlers. The
|
|
138
|
+
generated router extracts credentials, passes the operation ID and declared
|
|
139
|
+
scopes/roles to the security handler, and enforces OpenAPI's OR/AND semantics.
|
|
140
|
+
Each security method receives the previous context and returns the context exposed
|
|
141
|
+
to the operation handler as `request.security_context`. Schemes combined in one
|
|
142
|
+
requirement are evaluated in declaration order and share that context. Alternatives
|
|
143
|
+
are evaluated independently; raise the generated `SecurityRejected` exception to
|
|
144
|
+
reject one alternative and allow the router to try the next one. Other exceptions,
|
|
145
|
+
including `starlette.exceptions.HTTPException`, abort authorization immediately.
|
|
146
|
+
Missing or malformed credentials reject only their own alternative. Authorization
|
|
147
|
+
runs before parameters and request bodies are read or validated; denied requests
|
|
148
|
+
therefore do not parse JSON or spool uploaded files.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
router = create_router(handlers, security=security_handler)
|
|
152
|
+
app = Starlette(routes=router.routes)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Current scope
|
|
156
|
+
|
|
157
|
+
Version 0.1 intentionally supports a strict subset:
|
|
158
|
+
|
|
159
|
+
- OpenAPI 3.0.x and 3.1.x;
|
|
160
|
+
- internal references to schemas, response headers, and security schemes in `components`;
|
|
161
|
+
- scalar path, query, header, and cookie parameters with their default serialization;
|
|
162
|
+
- arrays of scalars in query parameters (repeated values) and path/header parameters
|
|
163
|
+
(comma-separated values, including repeated header lines);
|
|
164
|
+
- API key (header, query, or cookie), HTTP basic/bearer, and OAuth2 security;
|
|
165
|
+
- security requirement alternatives (OR), combined schemes (AND), and operation overrides;
|
|
166
|
+
- one JSON or multipart request media type and one JSON response media type per status;
|
|
167
|
+
- multipart object bodies with scalar form fields and binary file uploads;
|
|
168
|
+
- typed response headers with default simple serialization;
|
|
169
|
+
- fixed numeric response status codes;
|
|
170
|
+
- grouping by `x-handler-group`, falling back to the first tag.
|
|
171
|
+
|
|
172
|
+
External references, OpenID Connect/mTLS, callbacks, webhooks, custom parameter or
|
|
173
|
+
multipart serialization, streaming, multipart responses, and wildcard/default
|
|
174
|
+
response codes fail generation with an actionable error. They are not silently ignored.
|
|
175
|
+
|
|
176
|
+
Object parameters, cookie arrays, nested arrays, and composed array parameters are
|
|
177
|
+
also rejected during generation. JSON schema constraints for numeric bounds,
|
|
178
|
+
`multipleOf`, string length and patterns, and array length are preserved in requests
|
|
179
|
+
and validated responses, including nested values and referenced scalar/array schemas.
|
|
180
|
+
Unsupported constraints such as `uniqueItems`, `contains`, conditional schemas, and
|
|
181
|
+
`propertyNames` produce a generation error, including on multipart roots.
|
|
182
|
+
The HTTP adapter enforces `minProperties` and `maxProperties` on JSON objects,
|
|
183
|
+
including nested models, dictionaries, and references, and on multipart roots.
|
|
184
|
+
Requests count supplied keys (including extra keys); validated responses count
|
|
185
|
+
serialized keys (including model defaults). Multipart counts unique field names,
|
|
186
|
+
so repeated parts of an array count as one property. Bounds must be non-negative integers.
|
|
187
|
+
File arrays enforce `minItems` and `maxItems`; other file constraints fail generation.
|
|
188
|
+
|
|
189
|
+
Common authoring rules and supported alternatives:
|
|
190
|
+
|
|
191
|
+
| Construct | Rule / alternative |
|
|
192
|
+
| --- | --- |
|
|
193
|
+
| `operationId` | Required for every operation; must remain unique after Python name normalization. |
|
|
194
|
+
| Inline object with `properties` | Move JSON objects to `components.schemas` and use `$ref`. Multipart bodies may declare fields inline. |
|
|
195
|
+
| Inline `allOf` | Only a single member is supported; move multi-member object composition to components. |
|
|
196
|
+
| `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. |
|
|
197
|
+
| `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. |
|
|
198
|
+
| `$ref` with bounds | Bounds accumulate across aliases; a sibling bound cannot weaken the referenced schema. Nested collection values preserve the same constraints. |
|
|
199
|
+
| 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. |
|
|
200
|
+
|
|
201
|
+
Both `oneOf` and `anyOf` must also satisfy msgspec codec restrictions; in particular,
|
|
202
|
+
multiple object variants require a supported discriminator.
|
|
203
|
+
|
|
204
|
+
Responses serialize model fields using their OpenAPI names, including aliases in
|
|
205
|
+
nested models. Importing generated models or contracts does not load Starlette; the
|
|
206
|
+
existing `create_router` export loads the HTTP adapter when first accessed.
|
|
207
|
+
|
|
208
|
+
## Runtime
|
|
209
|
+
|
|
210
|
+
The adapter explicitly reads query/path/header/cookie parameters, decodes JSON
|
|
211
|
+
bytes directly into `msgspec.Struct` models, and encodes responses directly to
|
|
212
|
+
bytes. Codecs are created once. Multipart uploads close when the handler finishes,
|
|
213
|
+
including after validation failures or exceptions. Authorization follows the declared
|
|
214
|
+
security alternatives and scopes.
|
|
215
|
+
|
|
216
|
+
The original OpenAPI document is emitted as `openapi.json` and served at
|
|
217
|
+
`<prefix>/openapi.json`; route prefixes are applied when the router is created.
|
|
218
|
+
Include this JSON file as package data when distributing the generated package.
|
|
219
|
+
Pass `include_schema=False` to omit the schema route.
|
|
220
|
+
|
|
221
|
+
### Response validation and maximum throughput
|
|
222
|
+
|
|
223
|
+
Responses are validated by default. For trusted handler implementations,
|
|
224
|
+
generate a faster adapter with:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
oapi-gen generate spec/openapi.yaml --output app/http/generated \
|
|
228
|
+
--no-validate-responses
|
|
229
|
+
oapi-gen check spec/openapi.yaml --output app/http/generated \
|
|
230
|
+
--no-validate-responses
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
This keeps input validation and the check for declared response variants. It
|
|
234
|
+
omits runtime validation of response bodies and headers. Static checking remains
|
|
235
|
+
available through generated protocols, dataclasses and Struct models, but cannot
|
|
236
|
+
prove numeric bounds, lengths, patterns, or the validity of values obtained from
|
|
237
|
+
untyped code. Struct constructors themselves do not validate field values.
|
|
238
|
+
|
|
239
|
+
In checked mode, msgspec responses are converted to builtins and validated before
|
|
240
|
+
encoding. This also checks existing Struct instances, applies defaults and filters
|
|
241
|
+
undeclared fields where the schema ignores them. In trusted mode, encoding operates
|
|
242
|
+
directly on the handler result.
|
|
243
|
+
|
|
244
|
+
### Validation semantics
|
|
245
|
+
|
|
246
|
+
- JSON decoding is strict: a string such as `"2"` does not satisfy an integer field.
|
|
247
|
+
Textual HTTP parameters and form values use explicit coercion.
|
|
248
|
+
- Model field names use `msgspec.field(name=...)` aliases; pass Python field names
|
|
249
|
+
when constructing models. Non-object root schemas become type aliases.
|
|
250
|
+
- Missing optional non-nullable model fields can be `msgspec.UNSET`, distinct from
|
|
251
|
+
an explicit JSON `null`.
|
|
252
|
+
- An optional JSON body may be absent; explicit `null` is accepted only when its
|
|
253
|
+
schema is nullable. The handler receives `None` for an absent body.
|
|
254
|
+
- Multipart bodies require `multipart/form-data`. An absent required body is an
|
|
255
|
+
error even if every field is optional. A present empty multipart object is valid
|
|
256
|
+
when no fields are required. Form-encoded requests are not accepted as multipart.
|
|
257
|
+
- `additionalProperties: false` on an object without declared properties permits
|
|
258
|
+
only an empty object.
|
|
259
|
+
- Validation errors return HTTP 422 with a `detail` list and the input location.
|
|
260
|
+
msgspec reports the first error.
|
|
261
|
+
- Multiple object types in a decoded union need a supported tagged discriminator.
|
|
262
|
+
Incompatible codecs fail generation before existing output files are updated.
|
|
263
|
+
|
|
264
|
+
See [the reproducible Uvicorn benchmark](benchmarks/README.md) for measured
|
|
265
|
+
throughput, latency, memory, and the distinction between checked/trusted responses.
|
|
266
|
+
|
|
267
|
+
## Development
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
uv sync
|
|
271
|
+
uv run pytest
|
|
272
|
+
uv run ruff check .
|
|
273
|
+
uv run basedpyright
|
|
274
|
+
uv build
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
The parser and renderer are organized by responsibility:
|
|
278
|
+
|
|
279
|
+
- `parser/document.py` coordinates document validation and operation parsing. The other
|
|
280
|
+
parser modules handle references, schemas, parameters, security, bodies, and responses.
|
|
281
|
+
- `render/router.py` emits explicit request parsing, handler calls and response encoding.
|
|
282
|
+
`render/runtime.py` provides the generated HTTP helpers; contracts and authorization
|
|
283
|
+
are rendered in separate modules. The original OpenAPI document is copied as JSON.
|
|
284
|
+
- `ir.py` defines the shared contract between parsing and rendering. Parser modules do
|
|
285
|
+
not depend on the renderer; renderer modules consume the IR without parsing documents.
|
|
286
|
+
- Tests are grouped by behavior. Shared fixtures create isolated generated packages;
|
|
287
|
+
`tests/snapshots` records the complete output for both example specifications.
|
|
288
|
+
|
|
289
|
+
The existing `oapi_gen.parser` and `oapi_gen.render` entry points remain available.
|
|
290
|
+
Snapshot tests compare generated files and the manifest byte for byte. Update the
|
|
291
|
+
snapshots only when an intentional output change has been reviewed, including changes
|
|
292
|
+
to the generator version or example specifications.
|
|
293
|
+
|
|
294
|
+
The operation parsing and naming behavior is partly adapted from the MIT-licensed
|
|
295
|
+
`fastapi-code-generator`; its copyright notice is included in the package.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Public API for oapi-gen."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .errors import CheckFailedError, GenerationError
|
|
5
|
+
from .generator import check_package, generate_package, render_package
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CheckFailedError",
|
|
9
|
+
"GenerationError",
|
|
10
|
+
"__version__",
|
|
11
|
+
"check_package",
|
|
12
|
+
"generate_package",
|
|
13
|
+
"render_package",
|
|
14
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class GenerationError(Exception):
|
|
2
|
+
"""Raised when an OpenAPI document cannot be generated safely."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CheckFailedError(GenerationError):
|
|
6
|
+
"""Raised when generated files are missing or stale."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, paths: list[str]) -> None:
|
|
9
|
+
self.paths = paths
|
|
10
|
+
super().__init__("generated files are stale: " + ", ".join(paths))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import black
|
|
4
|
+
import isort
|
|
5
|
+
from black.mode import TargetVersion
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def format_python(source: str) -> str:
|
|
9
|
+
sorted_source = isort.code(source, profile="black", line_length=100)
|
|
10
|
+
return black.format_str(
|
|
11
|
+
sorted_source,
|
|
12
|
+
mode=black.Mode(
|
|
13
|
+
line_length=100,
|
|
14
|
+
target_versions={TargetVersion.PY312},
|
|
15
|
+
),
|
|
16
|
+
)
|