hayate-openapi 0.1.0__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.
- hayate_openapi-0.1.0/LICENSE +21 -0
- hayate_openapi-0.1.0/PKG-INFO +86 -0
- hayate_openapi-0.1.0/README.md +67 -0
- hayate_openapi-0.1.0/pyproject.toml +48 -0
- hayate_openapi-0.1.0/src/hayate_openapi/__init__.py +25 -0
- hayate_openapi-0.1.0/src/hayate_openapi/__main__.py +44 -0
- hayate_openapi-0.1.0/src/hayate_openapi/generate.py +195 -0
- hayate_openapi-0.1.0/src/hayate_openapi/providers.py +109 -0
- hayate_openapi-0.1.0/src/hayate_openapi/tags.py +66 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yusuke Hayashi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hayate-openapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenAPI 3.1 generation for hayate: routes from app.routes, schemas from your validators
|
|
5
|
+
Keywords: hayate,openapi,json-schema,documentation
|
|
6
|
+
Author: Yusuke Hayashi
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Topic :: Documentation
|
|
15
|
+
Requires-Dist: hayate>=0.8.0
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Project-URL: Repository, https://github.com/hayatepy/hayate-openapi
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# hayate-openapi
|
|
21
|
+
|
|
22
|
+
OpenAPI 3.1 generation for [hayate](https://github.com/hayatepy/hayate) —
|
|
23
|
+
built from what your app already knows: routes from `app.routes`, request
|
|
24
|
+
schemas from your validators, response schemas from one decorator. No magic
|
|
25
|
+
inference, no schema-library lock-in.
|
|
26
|
+
|
|
27
|
+
> **Status: alpha (0.1.x).** The emitted document passes the official
|
|
28
|
+
> `openapi-spec-validator` and feeds `openapi-typescript` for end-to-end
|
|
29
|
+
> TypeScript types. The internal design memo (Japanese, per project
|
|
30
|
+
> convention) lives in [DESIGN.md](DESIGN.md).
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from hayate import Hayate
|
|
34
|
+
from hayate_openapi import OpenApi, describe, validated
|
|
35
|
+
import msgspec
|
|
36
|
+
|
|
37
|
+
class BookIn(msgspec.Struct):
|
|
38
|
+
title: str
|
|
39
|
+
|
|
40
|
+
app = Hayate()
|
|
41
|
+
|
|
42
|
+
@app.post("/books", validated("json", BookIn)) # validator + schema tag in one
|
|
43
|
+
@describe(status=201, summary="Create a book")
|
|
44
|
+
async def create(c):
|
|
45
|
+
book = c.req.valid("json") # BookIn instance — validation still runs
|
|
46
|
+
return c.json({"title": book.title}, status=201)
|
|
47
|
+
|
|
48
|
+
OpenApi(app, title="Bookstore", version="1.0.0").register(app)
|
|
49
|
+
# GET /openapi.json is live; or emit statically:
|
|
50
|
+
# python -m hayate_openapi main:app --title Bookstore --version 1.0.0
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## How it works
|
|
54
|
+
|
|
55
|
+
| Source | What it provides |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `app.routes` (hayate ≥ 0.8) | every method + path, converted to OpenAPI templating (`:id` → `{id}`) |
|
|
58
|
+
| `validated(target, T)` | request body / query / form schemas — a tagging wrapper around the core `validator`, behavior-identical |
|
|
59
|
+
| `@describe(...)` | summary, tags, response schemas, operationId — all optional, all additive |
|
|
60
|
+
|
|
61
|
+
Schema conversion goes through a `SchemaProvider` protocol. msgspec and
|
|
62
|
+
pydantic are auto-detected (guarded imports); a plain dict is taken as
|
|
63
|
+
literal JSON Schema. **The package itself depends only on hayate.**
|
|
64
|
+
|
|
65
|
+
TypeScript types, the recommended recipe:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
python -m hayate_openapi main:app --title API --version 1.0.0 -o openapi.json
|
|
69
|
+
npx openapi-typescript openapi.json -o src/api-types.ts
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Docs UI: serve the JSON and point any renderer at it — e.g. one line of
|
|
73
|
+
[Scalar](https://github.com/scalar/scalar) or Redoc HTML. Nothing is bundled.
|
|
74
|
+
|
|
75
|
+
## What is documented (and what is not)
|
|
76
|
+
|
|
77
|
+
- Routes with real HTTP verbs; WebSocket routes and wildcard mounts
|
|
78
|
+
(`/api/auth/*`) are skipped.
|
|
79
|
+
- Responses you declare. Undeclared operations get a bare 200 — the
|
|
80
|
+
generator never invents schemas.
|
|
81
|
+
- Operations with a validator automatically document the 400
|
|
82
|
+
`application/problem+json` failure the framework actually returns.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# hayate-openapi
|
|
2
|
+
|
|
3
|
+
OpenAPI 3.1 generation for [hayate](https://github.com/hayatepy/hayate) —
|
|
4
|
+
built from what your app already knows: routes from `app.routes`, request
|
|
5
|
+
schemas from your validators, response schemas from one decorator. No magic
|
|
6
|
+
inference, no schema-library lock-in.
|
|
7
|
+
|
|
8
|
+
> **Status: alpha (0.1.x).** The emitted document passes the official
|
|
9
|
+
> `openapi-spec-validator` and feeds `openapi-typescript` for end-to-end
|
|
10
|
+
> TypeScript types. The internal design memo (Japanese, per project
|
|
11
|
+
> convention) lives in [DESIGN.md](DESIGN.md).
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from hayate import Hayate
|
|
15
|
+
from hayate_openapi import OpenApi, describe, validated
|
|
16
|
+
import msgspec
|
|
17
|
+
|
|
18
|
+
class BookIn(msgspec.Struct):
|
|
19
|
+
title: str
|
|
20
|
+
|
|
21
|
+
app = Hayate()
|
|
22
|
+
|
|
23
|
+
@app.post("/books", validated("json", BookIn)) # validator + schema tag in one
|
|
24
|
+
@describe(status=201, summary="Create a book")
|
|
25
|
+
async def create(c):
|
|
26
|
+
book = c.req.valid("json") # BookIn instance — validation still runs
|
|
27
|
+
return c.json({"title": book.title}, status=201)
|
|
28
|
+
|
|
29
|
+
OpenApi(app, title="Bookstore", version="1.0.0").register(app)
|
|
30
|
+
# GET /openapi.json is live; or emit statically:
|
|
31
|
+
# python -m hayate_openapi main:app --title Bookstore --version 1.0.0
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## How it works
|
|
35
|
+
|
|
36
|
+
| Source | What it provides |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `app.routes` (hayate ≥ 0.8) | every method + path, converted to OpenAPI templating (`:id` → `{id}`) |
|
|
39
|
+
| `validated(target, T)` | request body / query / form schemas — a tagging wrapper around the core `validator`, behavior-identical |
|
|
40
|
+
| `@describe(...)` | summary, tags, response schemas, operationId — all optional, all additive |
|
|
41
|
+
|
|
42
|
+
Schema conversion goes through a `SchemaProvider` protocol. msgspec and
|
|
43
|
+
pydantic are auto-detected (guarded imports); a plain dict is taken as
|
|
44
|
+
literal JSON Schema. **The package itself depends only on hayate.**
|
|
45
|
+
|
|
46
|
+
TypeScript types, the recommended recipe:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
python -m hayate_openapi main:app --title API --version 1.0.0 -o openapi.json
|
|
50
|
+
npx openapi-typescript openapi.json -o src/api-types.ts
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Docs UI: serve the JSON and point any renderer at it — e.g. one line of
|
|
54
|
+
[Scalar](https://github.com/scalar/scalar) or Redoc HTML. Nothing is bundled.
|
|
55
|
+
|
|
56
|
+
## What is documented (and what is not)
|
|
57
|
+
|
|
58
|
+
- Routes with real HTTP verbs; WebSocket routes and wildcard mounts
|
|
59
|
+
(`/api/auth/*`) are skipped.
|
|
60
|
+
- Responses you declare. Undeclared operations get a bare 200 — the
|
|
61
|
+
generator never invents schemas.
|
|
62
|
+
- Operations with a validator automatically document the 400
|
|
63
|
+
`application/problem+json` failure the framework actually returns.
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.9,<1"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hayate-openapi"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "OpenAPI 3.1 generation for hayate: routes from app.routes, schemas from your validators"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Yusuke Hayashi" }]
|
|
14
|
+
keywords = ["hayate", "openapi", "json-schema", "documentation"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Topic :: Documentation",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["hayate>=0.8.0"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Repository = "https://github.com/hayatepy/hayate-openapi"
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=8.3",
|
|
31
|
+
"pytest-asyncio>=0.25",
|
|
32
|
+
"ruff>=0.9",
|
|
33
|
+
"msgspec>=0.18",
|
|
34
|
+
"pydantic>=2.9",
|
|
35
|
+
"openapi-spec-validator>=0.7",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
asyncio_mode = "auto"
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 100
|
|
44
|
+
target-version = "py312"
|
|
45
|
+
src = ["src", "tests"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""hayate-openapi: OpenAPI 3.1 from what your app already knows."""
|
|
2
|
+
|
|
3
|
+
from .generate import OpenApi
|
|
4
|
+
from .providers import (
|
|
5
|
+
MsgspecProvider,
|
|
6
|
+
PydanticProvider,
|
|
7
|
+
RawSchemaProvider,
|
|
8
|
+
SchemaProvider,
|
|
9
|
+
default_providers,
|
|
10
|
+
)
|
|
11
|
+
from .tags import describe, validated
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"MsgspecProvider",
|
|
17
|
+
"OpenApi",
|
|
18
|
+
"PydanticProvider",
|
|
19
|
+
"RawSchemaProvider",
|
|
20
|
+
"SchemaProvider",
|
|
21
|
+
"__version__",
|
|
22
|
+
"default_providers",
|
|
23
|
+
"describe",
|
|
24
|
+
"validated",
|
|
25
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""CLI: python -m hayate_openapi app:app --title X --version 1.0 [-o out.json]"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .generate import OpenApi
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv: list[str] | None = None) -> int:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="python -m hayate_openapi",
|
|
17
|
+
description="Emit the OpenAPI 3.1 document for a hayate app.",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument("app", help="import target, e.g. 'main:app'")
|
|
20
|
+
parser.add_argument("--title", required=True)
|
|
21
|
+
parser.add_argument("--version", required=True)
|
|
22
|
+
parser.add_argument("--description")
|
|
23
|
+
parser.add_argument("-o", "--output", help="write here instead of stdout")
|
|
24
|
+
args = parser.parse_args(argv)
|
|
25
|
+
|
|
26
|
+
module_name, _, attribute = args.app.partition(":")
|
|
27
|
+
if not attribute:
|
|
28
|
+
parser.error("app must be 'module:attribute', e.g. 'main:app'")
|
|
29
|
+
sys.path.insert(0, str(Path.cwd()))
|
|
30
|
+
application = getattr(importlib.import_module(module_name), attribute)
|
|
31
|
+
|
|
32
|
+
document = OpenApi(
|
|
33
|
+
application, title=args.title, version=args.version, description=args.description
|
|
34
|
+
).generate()
|
|
35
|
+
text = json.dumps(document, indent=2)
|
|
36
|
+
if args.output:
|
|
37
|
+
Path(args.output).write_text(text + "\n", encoding="utf-8")
|
|
38
|
+
else:
|
|
39
|
+
print(text)
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
sys.exit(main())
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""The generator: walk ``app.routes``, merge the tags, emit OpenAPI 3.1.
|
|
2
|
+
|
|
3
|
+
Everything here is a pure function of the app object — no I/O, no globals —
|
|
4
|
+
so ``generate()`` is trivially testable and the mounted endpoint is just
|
|
5
|
+
``c.json(generate())``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from hayate import Context, Response
|
|
14
|
+
|
|
15
|
+
from .providers import SchemaProvider, default_providers, resolve
|
|
16
|
+
from .tags import OPENAPI_ATTR
|
|
17
|
+
|
|
18
|
+
OPENAPI_VERSION = "3.1.1"
|
|
19
|
+
|
|
20
|
+
_PARAM_RE = re.compile(r":(\w+)(\([^)]*\))?")
|
|
21
|
+
# Only real HTTP verbs become operations; hayate's websocket routes use an
|
|
22
|
+
# internal marker method and are not documentable in OpenAPI.
|
|
23
|
+
_HTTP_METHODS = frozenset({"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _convert_path(pattern: str) -> tuple[str, list[dict[str, Any]]] | None:
|
|
27
|
+
"""URLPattern pathname -> (OpenAPI path, path parameters).
|
|
28
|
+
|
|
29
|
+
Wildcard patterns are not documentable operations; returns None for them.
|
|
30
|
+
"""
|
|
31
|
+
if "*" in pattern:
|
|
32
|
+
return None
|
|
33
|
+
parameters: list[dict[str, Any]] = []
|
|
34
|
+
|
|
35
|
+
def replace(match: re.Match[str]) -> str:
|
|
36
|
+
name, regex = match.group(1), match.group(2)
|
|
37
|
+
schema: dict[str, Any] = {"type": "string"}
|
|
38
|
+
if regex:
|
|
39
|
+
schema["pattern"] = regex[1:-1]
|
|
40
|
+
parameters.append({"name": name, "in": "path", "required": True, "schema": schema})
|
|
41
|
+
return "{" + name + "}"
|
|
42
|
+
|
|
43
|
+
return _PARAM_RE.sub(replace, pattern), parameters
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _operation_id(method: str, path: str) -> str:
|
|
47
|
+
slug = re.sub(r"[^a-zA-Z0-9]+", "_", path).strip("_") or "root"
|
|
48
|
+
return f"{method.lower()}_{slug}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class OpenApi:
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
app: Any,
|
|
55
|
+
*,
|
|
56
|
+
title: str,
|
|
57
|
+
version: str,
|
|
58
|
+
description: str | None = None,
|
|
59
|
+
path: str = "/openapi.json",
|
|
60
|
+
providers: list[SchemaProvider] | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self.app = app
|
|
63
|
+
self.title = title
|
|
64
|
+
self.version = version
|
|
65
|
+
self.description = description
|
|
66
|
+
self.path = path
|
|
67
|
+
self.providers = providers if providers is not None else default_providers()
|
|
68
|
+
|
|
69
|
+
# -- generation --------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def generate(self) -> dict[str, Any]:
|
|
72
|
+
paths: dict[str, dict[str, Any]] = {}
|
|
73
|
+
components: dict[str, Any] = {}
|
|
74
|
+
|
|
75
|
+
for route in self.app.routes:
|
|
76
|
+
if route.method not in _HTTP_METHODS:
|
|
77
|
+
continue
|
|
78
|
+
converted = _convert_path(route.pattern)
|
|
79
|
+
if converted is None:
|
|
80
|
+
continue
|
|
81
|
+
path, path_params = converted
|
|
82
|
+
operation = self._operation(route, path, path_params, components)
|
|
83
|
+
paths.setdefault(path, {})[route.method.lower()] = operation
|
|
84
|
+
|
|
85
|
+
document: dict[str, Any] = {
|
|
86
|
+
"openapi": OPENAPI_VERSION,
|
|
87
|
+
"info": {"title": self.title, "version": self.version},
|
|
88
|
+
"paths": paths,
|
|
89
|
+
}
|
|
90
|
+
if self.description is not None:
|
|
91
|
+
document["info"]["description"] = self.description
|
|
92
|
+
if components:
|
|
93
|
+
document["components"] = {"schemas": components}
|
|
94
|
+
return document
|
|
95
|
+
|
|
96
|
+
def _operation(
|
|
97
|
+
self,
|
|
98
|
+
route: Any,
|
|
99
|
+
path: str,
|
|
100
|
+
path_params: list[dict[str, Any]],
|
|
101
|
+
components: dict[str, Any],
|
|
102
|
+
) -> dict[str, Any]:
|
|
103
|
+
meta = getattr(route.handler, OPENAPI_ATTR, {})
|
|
104
|
+
operation: dict[str, Any] = {
|
|
105
|
+
"operationId": meta.get("operation_id") or _operation_id(route.method, path),
|
|
106
|
+
"responses": {},
|
|
107
|
+
}
|
|
108
|
+
for key in ("summary", "description", "tags"):
|
|
109
|
+
if meta.get(key) is not None:
|
|
110
|
+
operation[key] = meta[key]
|
|
111
|
+
if meta.get("deprecated"):
|
|
112
|
+
operation["deprecated"] = True
|
|
113
|
+
|
|
114
|
+
parameters = list(path_params)
|
|
115
|
+
has_validator = False
|
|
116
|
+
for middleware in route.middleware:
|
|
117
|
+
tag = getattr(middleware, OPENAPI_ATTR, None)
|
|
118
|
+
if tag is None:
|
|
119
|
+
continue
|
|
120
|
+
has_validator = True
|
|
121
|
+
schema = self._register_schema(tag["type"], components)
|
|
122
|
+
target = tag["target"]
|
|
123
|
+
if target == "json":
|
|
124
|
+
operation["requestBody"] = {
|
|
125
|
+
"required": True,
|
|
126
|
+
"content": {"application/json": {"schema": schema}},
|
|
127
|
+
}
|
|
128
|
+
elif target == "form":
|
|
129
|
+
operation["requestBody"] = {
|
|
130
|
+
"required": True,
|
|
131
|
+
"content": {"application/x-www-form-urlencoded": {"schema": schema}},
|
|
132
|
+
}
|
|
133
|
+
else: # query: expand an object schema into individual parameters
|
|
134
|
+
parameters.extend(self._query_parameters(schema, components))
|
|
135
|
+
|
|
136
|
+
if parameters:
|
|
137
|
+
operation["parameters"] = parameters
|
|
138
|
+
|
|
139
|
+
responses: dict[str, Any] = {}
|
|
140
|
+
for status, type_ in (meta.get("responses") or {}).items():
|
|
141
|
+
entry: dict[str, Any] = {"description": _status_text(status)}
|
|
142
|
+
if type_ is not None:
|
|
143
|
+
entry["content"] = {
|
|
144
|
+
"application/json": {"schema": self._register_schema(type_, components)}
|
|
145
|
+
}
|
|
146
|
+
responses[str(status)] = entry
|
|
147
|
+
if not responses:
|
|
148
|
+
responses["200"] = {"description": "Successful response"}
|
|
149
|
+
if has_validator and "400" not in responses:
|
|
150
|
+
responses["400"] = {
|
|
151
|
+
"description": "Validation failed",
|
|
152
|
+
"content": {"application/problem+json": {"schema": {"type": "object"}}},
|
|
153
|
+
}
|
|
154
|
+
operation["responses"] = responses
|
|
155
|
+
return operation
|
|
156
|
+
|
|
157
|
+
def _register_schema(self, type_: Any, components: dict[str, Any]) -> dict[str, Any]:
|
|
158
|
+
provider = resolve(self.providers, type_)
|
|
159
|
+
schema, defs = provider.schema(type_)
|
|
160
|
+
components.update(defs)
|
|
161
|
+
return schema
|
|
162
|
+
|
|
163
|
+
def _query_parameters(
|
|
164
|
+
self, schema: dict[str, Any], components: dict[str, Any]
|
|
165
|
+
) -> list[dict[str, Any]]:
|
|
166
|
+
resolved = self._deref(schema, components)
|
|
167
|
+
required = set(resolved.get("required", ()))
|
|
168
|
+
out = []
|
|
169
|
+
for name, prop in (resolved.get("properties") or {}).items():
|
|
170
|
+
out.append({"name": name, "in": "query", "required": name in required, "schema": prop})
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _deref(schema: dict[str, Any], components: dict[str, Any]) -> dict[str, Any]:
|
|
175
|
+
ref = schema.get("$ref", "")
|
|
176
|
+
if ref.startswith("#/components/schemas/"):
|
|
177
|
+
return components.get(ref.rsplit("/", 1)[1], {})
|
|
178
|
+
return schema
|
|
179
|
+
|
|
180
|
+
# -- mounting ----------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def register(self, app: Any) -> None:
|
|
183
|
+
async def openapi_handler(c: Context) -> Response:
|
|
184
|
+
return c.json(self.generate())
|
|
185
|
+
|
|
186
|
+
app.get(self.path)(openapi_handler)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _status_text(status: int) -> str:
|
|
190
|
+
import http
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
return http.HTTPStatus(status).phrase
|
|
194
|
+
except ValueError:
|
|
195
|
+
return "Response"
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Schema providers: type -> (JSON Schema 2020-12, referenced $defs).
|
|
2
|
+
|
|
3
|
+
The chain is guarded-import autodetection (DESIGN §3.2): msgspec Structs,
|
|
4
|
+
pydantic BaseModels, then raw dicts passed through untouched. Each provider
|
|
5
|
+
also supplies the validation converter, so ``validated()`` needs no separate
|
|
6
|
+
wiring.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SchemaProvider(Protocol):
|
|
17
|
+
def supports(self, type_: Any) -> bool: ...
|
|
18
|
+
|
|
19
|
+
def schema(self, type_: Any) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
20
|
+
"""Return (schema for the type, {name: definition} it references)."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
def converter(self, type_: Any) -> Callable[[Any], Any]:
|
|
24
|
+
"""A callable that validates/converts raw data (raises on failure)."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_REF_PREFIX = "#/components/schemas/"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MsgspecProvider:
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
import msgspec
|
|
34
|
+
|
|
35
|
+
self._msgspec = msgspec
|
|
36
|
+
|
|
37
|
+
def supports(self, type_: Any) -> bool:
|
|
38
|
+
return isinstance(type_, type) and issubclass(type_, self._msgspec.Struct)
|
|
39
|
+
|
|
40
|
+
def schema(self, type_: Any) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
41
|
+
schemas, components = self._msgspec.json.schema_components(
|
|
42
|
+
(type_,), ref_template=_REF_PREFIX + "{name}"
|
|
43
|
+
)
|
|
44
|
+
return schemas[0], dict(components)
|
|
45
|
+
|
|
46
|
+
def converter(self, type_: Any) -> Callable[[Any], Any]:
|
|
47
|
+
return lambda data: self._msgspec.convert(data, type_)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PydanticProvider:
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
from pydantic import TypeAdapter
|
|
53
|
+
|
|
54
|
+
self._adapter = TypeAdapter
|
|
55
|
+
|
|
56
|
+
def supports(self, type_: Any) -> bool:
|
|
57
|
+
try:
|
|
58
|
+
from pydantic import BaseModel
|
|
59
|
+
except ImportError: # pragma: no cover - guarded twice
|
|
60
|
+
return False
|
|
61
|
+
return isinstance(type_, type) and issubclass(type_, BaseModel)
|
|
62
|
+
|
|
63
|
+
def schema(self, type_: Any) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
64
|
+
raw = self._adapter(type_).json_schema(
|
|
65
|
+
ref_template=_REF_PREFIX + "{model}", mode="validation"
|
|
66
|
+
)
|
|
67
|
+
defs = raw.pop("$defs", {})
|
|
68
|
+
# The root model itself lands inline; hoist it so every model ref
|
|
69
|
+
# points into components uniformly.
|
|
70
|
+
name = type_.__name__
|
|
71
|
+
defs[name] = raw
|
|
72
|
+
return {"$ref": _REF_PREFIX + name}, defs
|
|
73
|
+
|
|
74
|
+
def converter(self, type_: Any) -> Callable[[Any], Any]:
|
|
75
|
+
adapter = self._adapter(type_)
|
|
76
|
+
return adapter.validate_python
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RawSchemaProvider:
|
|
80
|
+
"""A plain dict is taken as literal JSON Schema: documented, not enforced."""
|
|
81
|
+
|
|
82
|
+
def supports(self, type_: Any) -> bool:
|
|
83
|
+
return isinstance(type_, dict)
|
|
84
|
+
|
|
85
|
+
def schema(self, type_: Any) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
86
|
+
return dict(type_), {}
|
|
87
|
+
|
|
88
|
+
def converter(self, type_: Any) -> Callable[[Any], Any]:
|
|
89
|
+
return lambda data: data
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def default_providers() -> list[SchemaProvider]:
|
|
93
|
+
chain: list[SchemaProvider] = []
|
|
94
|
+
with contextlib.suppress(ImportError):
|
|
95
|
+
chain.append(MsgspecProvider())
|
|
96
|
+
with contextlib.suppress(ImportError):
|
|
97
|
+
chain.append(PydanticProvider())
|
|
98
|
+
chain.append(RawSchemaProvider())
|
|
99
|
+
return chain
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def resolve(providers: list[SchemaProvider], type_: Any) -> SchemaProvider:
|
|
103
|
+
for provider in providers:
|
|
104
|
+
if provider.supports(type_):
|
|
105
|
+
return provider
|
|
106
|
+
raise TypeError(
|
|
107
|
+
f"no schema provider supports {type_!r}: install msgspec or pydantic, "
|
|
108
|
+
"or pass a raw JSON Schema dict"
|
|
109
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The two annotation surfaces (DESIGN §3.1): ``validated`` wraps the core
|
|
2
|
+
validator and tags the middleware with its type; ``describe`` tags the
|
|
3
|
+
handler. Both are additive — untagged routes still document (thinly).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from hayate import Middleware, validator
|
|
11
|
+
|
|
12
|
+
from .providers import SchemaProvider, default_providers, resolve
|
|
13
|
+
|
|
14
|
+
OPENAPI_ATTR = "__openapi__"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validated(
|
|
18
|
+
target: str,
|
|
19
|
+
type_: Any,
|
|
20
|
+
*,
|
|
21
|
+
providers: list[SchemaProvider] | None = None,
|
|
22
|
+
) -> Middleware:
|
|
23
|
+
"""``hayate.validator`` plus an OpenAPI tag.
|
|
24
|
+
|
|
25
|
+
Validation behavior is identical to wiring the converter yourself; the
|
|
26
|
+
only addition is the ``__openapi__`` attribute the generator reads.
|
|
27
|
+
"""
|
|
28
|
+
chain = providers if providers is not None else default_providers()
|
|
29
|
+
provider = resolve(chain, type_)
|
|
30
|
+
middleware = validator(target, provider.converter(type_))
|
|
31
|
+
middleware.__openapi__ = {"target": target, "type": type_} # type: ignore[attr-defined]
|
|
32
|
+
return middleware
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def describe(
|
|
36
|
+
*,
|
|
37
|
+
summary: str | None = None,
|
|
38
|
+
description: str | None = None,
|
|
39
|
+
tags: list[str] | None = None,
|
|
40
|
+
status: int = 200,
|
|
41
|
+
response: Any | None = None,
|
|
42
|
+
responses: dict[int, Any] | None = None,
|
|
43
|
+
operation_id: str | None = None,
|
|
44
|
+
deprecated: bool = False,
|
|
45
|
+
):
|
|
46
|
+
"""Attach OpenAPI operation metadata to a handler (all fields optional).
|
|
47
|
+
|
|
48
|
+
``response=T, status=201`` is sugar for ``responses={201: T}``; use
|
|
49
|
+
``responses={404: None}`` for a schema-less documented status.
|
|
50
|
+
"""
|
|
51
|
+
merged: dict[int, Any] = dict(responses or {})
|
|
52
|
+
if response is not None or (responses is None and status != 200):
|
|
53
|
+
merged.setdefault(status, response)
|
|
54
|
+
|
|
55
|
+
def wrap(handler):
|
|
56
|
+
handler.__openapi__ = {
|
|
57
|
+
"summary": summary,
|
|
58
|
+
"description": description,
|
|
59
|
+
"tags": tags,
|
|
60
|
+
"responses": merged,
|
|
61
|
+
"operation_id": operation_id,
|
|
62
|
+
"deprecated": deprecated,
|
|
63
|
+
}
|
|
64
|
+
return handler
|
|
65
|
+
|
|
66
|
+
return wrap
|