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.
Files changed (72) hide show
  1. oapi_gen-0.1.2/MANIFEST.in +3 -0
  2. oapi_gen-0.1.2/PKG-INFO +308 -0
  3. oapi_gen-0.1.2/README.md +295 -0
  4. oapi_gen-0.1.2/packages/oapi_gen/__init__.py +14 -0
  5. oapi_gen-0.1.2/packages/oapi_gen/__main__.py +3 -0
  6. oapi_gen-0.1.2/packages/oapi_gen/_version.py +1 -0
  7. oapi_gen-0.1.2/packages/oapi_gen/errors.py +10 -0
  8. oapi_gen-0.1.2/packages/oapi_gen/formatter.py +16 -0
  9. oapi_gen-0.1.2/packages/oapi_gen/generator.py +200 -0
  10. oapi_gen-0.1.2/packages/oapi_gen/ir.py +206 -0
  11. oapi_gen-0.1.2/packages/oapi_gen/main.py +63 -0
  12. oapi_gen-0.1.2/packages/oapi_gen/models.py +54 -0
  13. oapi_gen-0.1.2/packages/oapi_gen/naming.py +49 -0
  14. oapi_gen-0.1.2/packages/oapi_gen/parser/__init__.py +5 -0
  15. oapi_gen-0.1.2/packages/oapi_gen/parser/bodies.py +193 -0
  16. oapi_gen-0.1.2/packages/oapi_gen/parser/document.py +134 -0
  17. oapi_gen-0.1.2/packages/oapi_gen/parser/operations.py +166 -0
  18. oapi_gen-0.1.2/packages/oapi_gen/parser/parameters.py +168 -0
  19. oapi_gen-0.1.2/packages/oapi_gen/parser/references.py +47 -0
  20. oapi_gen-0.1.2/packages/oapi_gen/parser/responses.py +144 -0
  21. oapi_gen-0.1.2/packages/oapi_gen/parser/schemas.py +514 -0
  22. oapi_gen-0.1.2/packages/oapi_gen/parser/security.py +157 -0
  23. oapi_gen-0.1.2/packages/oapi_gen/parser/serialization.py +31 -0
  24. oapi_gen-0.1.2/packages/oapi_gen/parser/values.py +35 -0
  25. oapi_gen-0.1.2/packages/oapi_gen/render/__init__.py +8 -0
  26. oapi_gen-0.1.2/packages/oapi_gen/render/contracts.py +210 -0
  27. oapi_gen-0.1.2/packages/oapi_gen/render/inspection.py +66 -0
  28. oapi_gen-0.1.2/packages/oapi_gen/render/package.py +42 -0
  29. oapi_gen-0.1.2/packages/oapi_gen/render/router.py +299 -0
  30. oapi_gen-0.1.2/packages/oapi_gen/render/runtime.py +321 -0
  31. oapi_gen-0.1.2/packages/oapi_gen/render/security.py +88 -0
  32. oapi_gen-0.1.2/packages/oapi_gen/render/writer.py +68 -0
  33. oapi_gen-0.1.2/packages/oapi_gen.egg-info/PKG-INFO +308 -0
  34. oapi_gen-0.1.2/packages/oapi_gen.egg-info/SOURCES.txt +70 -0
  35. oapi_gen-0.1.2/packages/oapi_gen.egg-info/dependency_links.txt +1 -0
  36. oapi_gen-0.1.2/packages/oapi_gen.egg-info/entry_points.txt +2 -0
  37. oapi_gen-0.1.2/packages/oapi_gen.egg-info/requires.txt +6 -0
  38. oapi_gen-0.1.2/packages/oapi_gen.egg-info/top_level.txt +1 -0
  39. oapi_gen-0.1.2/pyproject.toml +57 -0
  40. oapi_gen-0.1.2/setup.cfg +4 -0
  41. oapi_gen-0.1.2/tests/__init__.py +0 -0
  42. oapi_gen-0.1.2/tests/conftest.py +83 -0
  43. oapi_gen-0.1.2/tests/fixtures/advanced.openapi.yaml +633 -0
  44. oapi_gen-0.1.2/tests/fixtures/cats.openapi.yaml +101 -0
  45. oapi_gen-0.1.2/tests/fixtures/security.openapi.yaml +123 -0
  46. oapi_gen-0.1.2/tests/snapshots/README.md +13 -0
  47. oapi_gen-0.1.2/tests/snapshots/advanced/.oapi-gen-manifest.json.txt +13 -0
  48. oapi_gen-0.1.2/tests/snapshots/advanced/__init__.py.txt +29 -0
  49. oapi_gen-0.1.2/tests/snapshots/advanced/_runtime.py.txt +323 -0
  50. oapi_gen-0.1.2/tests/snapshots/advanced/contracts.py.txt +507 -0
  51. oapi_gen-0.1.2/tests/snapshots/advanced/models.py.txt +104 -0
  52. oapi_gen-0.1.2/tests/snapshots/advanced/openapi.json.txt +932 -0
  53. oapi_gen-0.1.2/tests/snapshots/advanced/router.py.txt +693 -0
  54. oapi_gen-0.1.2/tests/snapshots/cats/.oapi-gen-manifest.json.txt +13 -0
  55. oapi_gen-0.1.2/tests/snapshots/cats/__init__.py.txt +22 -0
  56. oapi_gen-0.1.2/tests/snapshots/cats/_runtime.py.txt +323 -0
  57. oapi_gen-0.1.2/tests/snapshots/cats/contracts.py.txt +90 -0
  58. oapi_gen-0.1.2/tests/snapshots/cats/models.py.txt +24 -0
  59. oapi_gen-0.1.2/tests/snapshots/cats/openapi.json.txt +170 -0
  60. oapi_gen-0.1.2/tests/snapshots/cats/router.py.txt +123 -0
  61. oapi_gen-0.1.2/tests/support.py +37 -0
  62. oapi_gen-0.1.2/tests/test_generator.py +91 -0
  63. oapi_gen-0.1.2/tests/test_imports.py +107 -0
  64. oapi_gen-0.1.2/tests/test_multipart.py +252 -0
  65. oapi_gen-0.1.2/tests/test_parameters.py +223 -0
  66. oapi_gen-0.1.2/tests/test_parser.py +272 -0
  67. oapi_gen-0.1.2/tests/test_responses.py +170 -0
  68. oapi_gen-0.1.2/tests/test_router.py +193 -0
  69. oapi_gen-0.1.2/tests/test_schemas.py +647 -0
  70. oapi_gen-0.1.2/tests/test_security.py +339 -0
  71. oapi_gen-0.1.2/tests/test_snapshots.py +18 -0
  72. oapi_gen-0.1.2/tests/test_starlette.py +263 -0
@@ -0,0 +1,3 @@
1
+ recursive-include tests *.py
2
+ recursive-include tests/fixtures *.yaml
3
+ recursive-include tests/snapshots *.txt *.md
@@ -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.
@@ -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,3 @@
1
+ from .main import main
2
+
3
+ raise SystemExit(main())
@@ -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
+ )