aiohttp-autodocs 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.
- aiohttp_autodocs-0.1.0/.gitignore +1 -0
- aiohttp_autodocs-0.1.0/LICENSE +21 -0
- aiohttp_autodocs-0.1.0/PKG-INFO +131 -0
- aiohttp_autodocs-0.1.0/README.md +97 -0
- aiohttp_autodocs-0.1.0/pyproject.toml +74 -0
- aiohttp_autodocs-0.1.0/src/aiohttp_autodocs/__init__.py +8 -0
- aiohttp_autodocs-0.1.0/src/aiohttp_autodocs/_builder.py +74 -0
- aiohttp_autodocs-0.1.0/src/aiohttp_autodocs/_routes.py +48 -0
- aiohttp_autodocs-0.1.0/src/aiohttp_autodocs/config.py +62 -0
- aiohttp_autodocs-0.1.0/src/aiohttp_autodocs/decorator.py +131 -0
- aiohttp_autodocs-0.1.0/src/aiohttp_autodocs/scanner.py +231 -0
- aiohttp_autodocs-0.1.0/src/aiohttp_autodocs/schema.py +112 -0
- aiohttp_autodocs-0.1.0/src/aiohttp_autodocs/ui.py +55 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__pycache__/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Karam El labadie
|
|
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,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiohttp-autodocs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Auto-discovering OpenAPI documentation for aiohttp.
|
|
5
|
+
Project-URL: Homepage, https://github.com/kappall/aiohttp-autodocs
|
|
6
|
+
Project-URL: Repository, https://github.com/kappall/aiohttp-autodocs
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/kappall/aiohttp-autodocs/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: aiohttp,api,documentation,openapi,swagger
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
20
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: aiohttp>=3.9
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: aiohttp[speedups]>=3.9; extra == 'dev'
|
|
26
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
27
|
+
Requires-Dist: pydantic>=2.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
31
|
+
Provides-Extra: pydantic
|
|
32
|
+
Requires-Dist: pydantic>=2.0; extra == 'pydantic'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# aiohttp-autodocs
|
|
36
|
+
|
|
37
|
+
A zero-overhead, non-invasive OpenAPI 3.1.0 documentation generator for `aiohttp`.
|
|
38
|
+
|
|
39
|
+
It gives you the beautiful Swagger UI and Pydantic integration of modern frameworks (like FastAPI), but preserves the raw, unhindered speed and stability of bare `aiohttp`.
|
|
40
|
+
|
|
41
|
+
## Design Philosophy
|
|
42
|
+
|
|
43
|
+
This package was built with a specific set of architectural goals in mind to ensure it remains safe and performant in production environments:
|
|
44
|
+
|
|
45
|
+
1. **Absolute Zero Runtime Overhead**
|
|
46
|
+
Many OpenAPI solutions intercept every HTTP request at runtime to perform validation and dependency injection, adding CPU overhead to every API call.
|
|
47
|
+
`aiohttp-autodocs` operates entirely at **boot time**. It builds the OpenAPI spec once, freezes it to raw bytes in memory, and steps out of the way. When a user hits an endpoint, the decorator does absolutely nothing.
|
|
48
|
+
|
|
49
|
+
2. **Native Pydantic v2 Support**
|
|
50
|
+
If your project uses modern `pydantic` (or `sqlmodel`), you shouldn't have to rewrite your schemas into another validation library just to generate documentation. Our package natively understands Pydantic v2 (including nested `$defs`), while degrading gracefully to raw Python dictionaries if Pydantic isn't installed.
|
|
51
|
+
|
|
52
|
+
3. **Non-Invasive Architecture**
|
|
53
|
+
We don't force you to use Class-Based Views or wrap your handlers so heavily that you lose access to the raw `web.Request` object. Your routes remain pure `aiohttp` async functions.
|
|
54
|
+
|
|
55
|
+
4. **No YAML-in-Docstrings**
|
|
56
|
+
Older tools rely on writing OpenAPI YAML directly inside your Python function docstrings. This is error-prone, hard to format, and invisible to IDE type-checkers. We use a strongly typed Python decorator (`@docs()`) so your IDE catches mistakes instantly.
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# If you want Pydantic support
|
|
62
|
+
pip install aiohttp-autodocs[pydantic]
|
|
63
|
+
|
|
64
|
+
# If you only want raw dictionary schemas
|
|
65
|
+
pip install aiohttp-autodocs
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick Start
|
|
69
|
+
|
|
70
|
+
### 1. Decorate your routes
|
|
71
|
+
|
|
72
|
+
The `@docs` decorator attaches metadata to your handler. It **must** be placed above the `aiohttp` route decorator.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from aiohttp import web
|
|
76
|
+
from aiohttp_autodocs import docs
|
|
77
|
+
from pydantic import BaseModel
|
|
78
|
+
|
|
79
|
+
class AlarmSchema(BaseModel):
|
|
80
|
+
id: int
|
|
81
|
+
message: str
|
|
82
|
+
|
|
83
|
+
alarm_routes = web.RouteTableDef()
|
|
84
|
+
|
|
85
|
+
@docs(
|
|
86
|
+
summary="List all alarms",
|
|
87
|
+
tags=["Alarms"],
|
|
88
|
+
response=AlarmSchema,
|
|
89
|
+
response_list=True,
|
|
90
|
+
security=["BearerAuth"]
|
|
91
|
+
)
|
|
92
|
+
@alarm_routes.get("/api/v1/alarms")
|
|
93
|
+
async def get_alarms(request: web.Request) -> web.Response:
|
|
94
|
+
return web.json_response([{"id": 1, "message": "High CPU"}])
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 2. Build the spec at startup
|
|
98
|
+
|
|
99
|
+
In your application factory (e.g., `create_app()`), call `build_openapi` *after* you've added all your routes.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from aiohttp_autodocs import build_openapi, OpenAPIConfig
|
|
103
|
+
|
|
104
|
+
app = web.Application()
|
|
105
|
+
app.add_routes(alarm_routes)
|
|
106
|
+
|
|
107
|
+
build_openapi(
|
|
108
|
+
app,
|
|
109
|
+
OpenAPIConfig(
|
|
110
|
+
title="My API",
|
|
111
|
+
version="1.0.0",
|
|
112
|
+
enabled=True,
|
|
113
|
+
security_schemes={
|
|
114
|
+
"BearerAuth": {
|
|
115
|
+
"type": "http",
|
|
116
|
+
"scheme": "bearer",
|
|
117
|
+
"bearerFormat": "JWT",
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
),
|
|
121
|
+
alarm_routes # Pass all your RouteTableDefs here
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
web.run_app(app)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 3. View your docs
|
|
128
|
+
|
|
129
|
+
Start your server and visit:
|
|
130
|
+
* Interactive Swagger UI: `http://localhost:8080/docs`
|
|
131
|
+
* Raw OpenAPI JSON: `http://localhost:8080/openapi.json`
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# aiohttp-autodocs
|
|
2
|
+
|
|
3
|
+
A zero-overhead, non-invasive OpenAPI 3.1.0 documentation generator for `aiohttp`.
|
|
4
|
+
|
|
5
|
+
It gives you the beautiful Swagger UI and Pydantic integration of modern frameworks (like FastAPI), but preserves the raw, unhindered speed and stability of bare `aiohttp`.
|
|
6
|
+
|
|
7
|
+
## Design Philosophy
|
|
8
|
+
|
|
9
|
+
This package was built with a specific set of architectural goals in mind to ensure it remains safe and performant in production environments:
|
|
10
|
+
|
|
11
|
+
1. **Absolute Zero Runtime Overhead**
|
|
12
|
+
Many OpenAPI solutions intercept every HTTP request at runtime to perform validation and dependency injection, adding CPU overhead to every API call.
|
|
13
|
+
`aiohttp-autodocs` operates entirely at **boot time**. It builds the OpenAPI spec once, freezes it to raw bytes in memory, and steps out of the way. When a user hits an endpoint, the decorator does absolutely nothing.
|
|
14
|
+
|
|
15
|
+
2. **Native Pydantic v2 Support**
|
|
16
|
+
If your project uses modern `pydantic` (or `sqlmodel`), you shouldn't have to rewrite your schemas into another validation library just to generate documentation. Our package natively understands Pydantic v2 (including nested `$defs`), while degrading gracefully to raw Python dictionaries if Pydantic isn't installed.
|
|
17
|
+
|
|
18
|
+
3. **Non-Invasive Architecture**
|
|
19
|
+
We don't force you to use Class-Based Views or wrap your handlers so heavily that you lose access to the raw `web.Request` object. Your routes remain pure `aiohttp` async functions.
|
|
20
|
+
|
|
21
|
+
4. **No YAML-in-Docstrings**
|
|
22
|
+
Older tools rely on writing OpenAPI YAML directly inside your Python function docstrings. This is error-prone, hard to format, and invisible to IDE type-checkers. We use a strongly typed Python decorator (`@docs()`) so your IDE catches mistakes instantly.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# If you want Pydantic support
|
|
28
|
+
pip install aiohttp-autodocs[pydantic]
|
|
29
|
+
|
|
30
|
+
# If you only want raw dictionary schemas
|
|
31
|
+
pip install aiohttp-autodocs
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
### 1. Decorate your routes
|
|
37
|
+
|
|
38
|
+
The `@docs` decorator attaches metadata to your handler. It **must** be placed above the `aiohttp` route decorator.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from aiohttp import web
|
|
42
|
+
from aiohttp_autodocs import docs
|
|
43
|
+
from pydantic import BaseModel
|
|
44
|
+
|
|
45
|
+
class AlarmSchema(BaseModel):
|
|
46
|
+
id: int
|
|
47
|
+
message: str
|
|
48
|
+
|
|
49
|
+
alarm_routes = web.RouteTableDef()
|
|
50
|
+
|
|
51
|
+
@docs(
|
|
52
|
+
summary="List all alarms",
|
|
53
|
+
tags=["Alarms"],
|
|
54
|
+
response=AlarmSchema,
|
|
55
|
+
response_list=True,
|
|
56
|
+
security=["BearerAuth"]
|
|
57
|
+
)
|
|
58
|
+
@alarm_routes.get("/api/v1/alarms")
|
|
59
|
+
async def get_alarms(request: web.Request) -> web.Response:
|
|
60
|
+
return web.json_response([{"id": 1, "message": "High CPU"}])
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 2. Build the spec at startup
|
|
64
|
+
|
|
65
|
+
In your application factory (e.g., `create_app()`), call `build_openapi` *after* you've added all your routes.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from aiohttp_autodocs import build_openapi, OpenAPIConfig
|
|
69
|
+
|
|
70
|
+
app = web.Application()
|
|
71
|
+
app.add_routes(alarm_routes)
|
|
72
|
+
|
|
73
|
+
build_openapi(
|
|
74
|
+
app,
|
|
75
|
+
OpenAPIConfig(
|
|
76
|
+
title="My API",
|
|
77
|
+
version="1.0.0",
|
|
78
|
+
enabled=True,
|
|
79
|
+
security_schemes={
|
|
80
|
+
"BearerAuth": {
|
|
81
|
+
"type": "http",
|
|
82
|
+
"scheme": "bearer",
|
|
83
|
+
"bearerFormat": "JWT",
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
),
|
|
87
|
+
alarm_routes # Pass all your RouteTableDefs here
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
web.run_app(app)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 3. View your docs
|
|
94
|
+
|
|
95
|
+
Start your server and visit:
|
|
96
|
+
* Interactive Swagger UI: `http://localhost:8080/docs`
|
|
97
|
+
* Raw OpenAPI JSON: `http://localhost:8080/openapi.json`
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aiohttp-autodocs"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Auto-discovering OpenAPI documentation for aiohttp."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
keywords = ["aiohttp", "openapi", "swagger", "documentation", "api"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Framework :: AsyncIO",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
23
|
+
"Topic :: Software Development :: Documentation",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
dependencies = [
|
|
28
|
+
"aiohttp>=3.9",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
# Enables model_json_schema() extraction from Pydantic/SQLModel models
|
|
33
|
+
pydantic = [
|
|
34
|
+
"pydantic>=2.0",
|
|
35
|
+
]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=8",
|
|
38
|
+
"pytest-asyncio>=0.23",
|
|
39
|
+
"aiohttp[speedups]>=3.9",
|
|
40
|
+
"pydantic>=2.0",
|
|
41
|
+
"mypy>=1.8",
|
|
42
|
+
"ruff>=0.4",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://github.com/kappall/aiohttp-autodocs"
|
|
47
|
+
Repository = "https://github.com/kappall/aiohttp-autodocs"
|
|
48
|
+
"Bug Tracker" = "https://github.com/kappall/aiohttp-autodocs/issues"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["src/aiohttp_autodocs"]
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.sdist]
|
|
54
|
+
include = [
|
|
55
|
+
"src/",
|
|
56
|
+
"README.md",
|
|
57
|
+
"LICENSE",
|
|
58
|
+
"pyproject.toml",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[tool.ruff]
|
|
62
|
+
line-length = 100
|
|
63
|
+
target-version = "py311"
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint]
|
|
66
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
67
|
+
|
|
68
|
+
[tool.mypy]
|
|
69
|
+
python_version = "3.11"
|
|
70
|
+
strict = true
|
|
71
|
+
ignore_missing_imports = true
|
|
72
|
+
|
|
73
|
+
[tool.pytest.ini_options]
|
|
74
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from aiohttp import web
|
|
6
|
+
|
|
7
|
+
from .config import OpenAPIConfig
|
|
8
|
+
from .scanner import build_spec
|
|
9
|
+
from .ui import render_swagger_ui
|
|
10
|
+
from ._routes import (
|
|
11
|
+
SPEC_KEY,
|
|
12
|
+
HTML_KEY,
|
|
13
|
+
make_spec_handler,
|
|
14
|
+
make_ui_handler,
|
|
15
|
+
make_redirect_handler,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_INITIALIZED_KEY = "_aiohttp_autodocs_initialized"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_openapi(
|
|
24
|
+
app: web.Application,
|
|
25
|
+
config: OpenAPIConfig,
|
|
26
|
+
*route_tables: web.RouteTableDef,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Build the OpenAPI spec from *route_tables* and register /openapi.json
|
|
30
|
+
and /docs on *app*. Call once in create_app(), after all routes are added.
|
|
31
|
+
|
|
32
|
+
When ``config.enabled`` is False this is a no-op — no routes are registered.
|
|
33
|
+
Raises RuntimeError if called more than once on the same app.
|
|
34
|
+
"""
|
|
35
|
+
if app.get(_INITIALIZED_KEY):
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
"build_openapi() has already been called on this application. "
|
|
38
|
+
"It must be called exactly once, after all routes are registered."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if not config.enabled:
|
|
42
|
+
logger.info(
|
|
43
|
+
"aiohttp-autodocs: documentation is disabled "
|
|
44
|
+
"(OpenAPIConfig.enabled=False). Skipping."
|
|
45
|
+
)
|
|
46
|
+
app[_INITIALIZED_KEY] = True
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
spec_bytes = build_spec(config, list(route_tables))
|
|
50
|
+
app[SPEC_KEY] = spec_bytes
|
|
51
|
+
app[_INITIALIZED_KEY] = True
|
|
52
|
+
|
|
53
|
+
html = render_swagger_ui(
|
|
54
|
+
spec_url=config.spec_path,
|
|
55
|
+
title=config.title,
|
|
56
|
+
cdn_base=config.swagger_ui_cdn,
|
|
57
|
+
)
|
|
58
|
+
app[HTML_KEY] = html
|
|
59
|
+
|
|
60
|
+
app.router.add_get(config.spec_path, make_spec_handler())
|
|
61
|
+
app.router.add_get(config.docs_path, make_ui_handler())
|
|
62
|
+
|
|
63
|
+
# Redirect /docs/ to /docs to avoid duplicate content.
|
|
64
|
+
if not config.docs_path.endswith("/"):
|
|
65
|
+
app.router.add_get(
|
|
66
|
+
config.docs_path + "/",
|
|
67
|
+
make_redirect_handler(config.docs_path),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
logger.info(
|
|
71
|
+
"aiohttp-autodocs: docs available at %s | spec at %s",
|
|
72
|
+
config.docs_path,
|
|
73
|
+
config.spec_path,
|
|
74
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
# Internal handlers for /openapi.json and /docs.
|
|
3
|
+
# Registered after spec generation so they never appear in the spec itself.
|
|
4
|
+
|
|
5
|
+
from aiohttp import web
|
|
6
|
+
|
|
7
|
+
SPEC_KEY = "_aiohttp_autodocs_spec"
|
|
8
|
+
HTML_KEY = "_aiohttp_autodocs_html"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def make_spec_handler(spec_key: str = SPEC_KEY):
|
|
12
|
+
|
|
13
|
+
async def openapi_json(request: web.Request) -> web.Response:
|
|
14
|
+
spec_bytes: bytes = request.app[spec_key]
|
|
15
|
+
return web.Response(
|
|
16
|
+
body=spec_bytes,
|
|
17
|
+
content_type="application/json",
|
|
18
|
+
charset="utf-8",
|
|
19
|
+
headers={
|
|
20
|
+
# Allow Swagger UI to call this from any origin during dev
|
|
21
|
+
"Access-Control-Allow-Origin": "*",
|
|
22
|
+
# Encourage browsers to not cache a stale spec
|
|
23
|
+
"Cache-Control": "no-cache",
|
|
24
|
+
},
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return openapi_json
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def make_ui_handler(html_key: str = HTML_KEY):
|
|
31
|
+
|
|
32
|
+
async def swagger_ui(request: web.Request) -> web.Response:
|
|
33
|
+
html: str = request.app[html_key]
|
|
34
|
+
return web.Response(
|
|
35
|
+
text=html,
|
|
36
|
+
content_type="text/html",
|
|
37
|
+
charset="utf-8",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return swagger_ui
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def make_redirect_handler(target: str):
|
|
44
|
+
|
|
45
|
+
async def redirect(_request: web.Request) -> web.Response:
|
|
46
|
+
raise web.HTTPMovedPermanently(target)
|
|
47
|
+
|
|
48
|
+
return redirect
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class OpenAPIConfig:
|
|
8
|
+
"""
|
|
9
|
+
Configuration for the OpenAPI documentation module.
|
|
10
|
+
|
|
11
|
+
Example::
|
|
12
|
+
|
|
13
|
+
OpenAPIConfig(
|
|
14
|
+
title="My API",
|
|
15
|
+
version="1.0.0",
|
|
16
|
+
description="desc.",
|
|
17
|
+
servers=[{"url": "http://localhost:8080", "description": "Dev"}],
|
|
18
|
+
enabled=True,
|
|
19
|
+
security_schemes={
|
|
20
|
+
"BearerAuth": {
|
|
21
|
+
"type": "http",
|
|
22
|
+
"scheme": "bearer",
|
|
23
|
+
"bearerFormat": "JWT",
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
tags=[
|
|
27
|
+
{"name": "Alarms", "description": "Alarm management"},
|
|
28
|
+
],
|
|
29
|
+
)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
title: str
|
|
33
|
+
version: str
|
|
34
|
+
description: str = ""
|
|
35
|
+
"""Markdown description displayed below the title in Swagger UI."""
|
|
36
|
+
|
|
37
|
+
contact: dict | None = None
|
|
38
|
+
"""Contact object, e.g. ``{"name": "Support", "email": "api@example.com"}``."""
|
|
39
|
+
|
|
40
|
+
license_info: dict | None = None
|
|
41
|
+
servers: list[dict] = field(default_factory=list)
|
|
42
|
+
tags: list[dict] = field(default_factory=list)
|
|
43
|
+
security_schemes: dict = field(default_factory=dict)
|
|
44
|
+
docs_path: str = "/docs"
|
|
45
|
+
spec_path: str = "/openapi.json"
|
|
46
|
+
enabled: bool = True
|
|
47
|
+
swagger_ui_cdn: str = "https://unpkg.com/swagger-ui-dist@5"
|
|
48
|
+
openapi_version: str = "3.1.0"
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
if not self.title:
|
|
52
|
+
raise ValueError("OpenAPIConfig.title must not be empty.")
|
|
53
|
+
if not self.version:
|
|
54
|
+
raise ValueError("OpenAPIConfig.version must not be empty.")
|
|
55
|
+
if not self.docs_path.startswith("/"):
|
|
56
|
+
raise ValueError("OpenAPIConfig.docs_path must start with '/'.")
|
|
57
|
+
if not self.spec_path.startswith("/"):
|
|
58
|
+
raise ValueError("OpenAPIConfig.spec_path must start with '/'.")
|
|
59
|
+
if self.docs_path == self.spec_path:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"OpenAPIConfig.docs_path and spec_path must be different paths."
|
|
62
|
+
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""
|
|
3
|
+
@docs() : non-invasive decorator for attaching OpenAPI metadata to handlers.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Callable, TypeVar
|
|
7
|
+
|
|
8
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
9
|
+
|
|
10
|
+
#: Attribute name used to store metadata on the handler function.
|
|
11
|
+
#: Prefixed with the package name to avoid collisions with other libraries.
|
|
12
|
+
OPENAPI_META_ATTR = "__aiohttp_autodocs__"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def docs(
|
|
16
|
+
*,
|
|
17
|
+
summary: str = "",
|
|
18
|
+
description: str = "",
|
|
19
|
+
tags: list[str] | None = None,
|
|
20
|
+
request_body: type | dict | None = None,
|
|
21
|
+
response: type | dict | None = None,
|
|
22
|
+
response_list: bool = False,
|
|
23
|
+
responses: dict[int, type | dict | None] | None = None,
|
|
24
|
+
query_params: list[tuple[str, ...]] | None = None,
|
|
25
|
+
path_params: list[tuple[str, ...]] | None = None,
|
|
26
|
+
security: list[str | dict] | None = None,
|
|
27
|
+
deprecated: bool = False,
|
|
28
|
+
include_in_schema: bool = True,
|
|
29
|
+
operation_id: str | None = None,
|
|
30
|
+
) -> Callable[[F], F]:
|
|
31
|
+
"""
|
|
32
|
+
Decorator that attaches OpenAPI metadata to an aiohttp route handler.
|
|
33
|
+
|
|
34
|
+
**Must be placed ABOVE** the ``@route_table.method()`` decorator so that
|
|
35
|
+
the metadata is visible on the function object that ends up stored in the
|
|
36
|
+
route table.
|
|
37
|
+
|
|
38
|
+
Example::
|
|
39
|
+
|
|
40
|
+
@docs(
|
|
41
|
+
summary="Create alarm",
|
|
42
|
+
description="Creates a new alarm and broadcasts it via WebSocket.",
|
|
43
|
+
tags=["Alarms"],
|
|
44
|
+
request_body=AlarmCreateSchema,
|
|
45
|
+
responses={
|
|
46
|
+
201: Alarm,
|
|
47
|
+
400: None,
|
|
48
|
+
500: None,
|
|
49
|
+
},
|
|
50
|
+
security=["BearerAuth"],
|
|
51
|
+
)
|
|
52
|
+
@alarm_routes.post(f"{PREFIX}/alarms")
|
|
53
|
+
async def create_alarm(request: web.Request) -> web.Response:
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
summary:
|
|
59
|
+
Short, one-line description shown as the route title in Swagger UI.
|
|
60
|
+
description:
|
|
61
|
+
Longer Markdown description shown in the expanded route panel.
|
|
62
|
+
tags:
|
|
63
|
+
Tag names used to group routes in the UI, e.g. ``["Alarms"]``.
|
|
64
|
+
request_body:
|
|
65
|
+
A Pydantic ``BaseModel`` subclass (or SQLModel) whose
|
|
66
|
+
``model_json_schema()`` will be used, **or** a plain dict containing
|
|
67
|
+
a raw JSON Schema object.
|
|
68
|
+
response:
|
|
69
|
+
Shorthand for a single success response model (Pydantic class or
|
|
70
|
+
dict schema). The status code defaults to ``200`` for non-POST
|
|
71
|
+
methods and ``201`` for POST. Use ``responses`` for multiple codes.
|
|
72
|
+
response_list:
|
|
73
|
+
When ``True``, wraps the ``response`` schema in
|
|
74
|
+
``{"type": "array", "items": <schema>}``.
|
|
75
|
+
responses:
|
|
76
|
+
Fine-grained per-status-code responses. Keys are HTTP status code
|
|
77
|
+
integers; values are Pydantic model classes, raw dicts, or ``None``
|
|
78
|
+
(for responses with no body). Takes precedence over ``response``.
|
|
79
|
+
query_params:
|
|
80
|
+
List of tuples describing query parameters::
|
|
81
|
+
|
|
82
|
+
query_params=[
|
|
83
|
+
("severity", "string", "Filter by severity", False),
|
|
84
|
+
("limit", "integer", "Max results", False),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
Each tuple: ``(name, openapi_type, description, required)``.
|
|
88
|
+
``description`` and ``required`` are optional (default ``""`` / ``False``).
|
|
89
|
+
path_params:
|
|
90
|
+
Explicit path parameter declarations::
|
|
91
|
+
|
|
92
|
+
path_params=[("alarm_id", "integer", "The alarm's primary key")]
|
|
93
|
+
|
|
94
|
+
Each tuple: ``(name, openapi_type, description)``.
|
|
95
|
+
If omitted, path params are auto-detected from the route path pattern
|
|
96
|
+
(``{param_name}``) and typed as ``string``.
|
|
97
|
+
security:
|
|
98
|
+
List of security scheme names (strings) or full security requirement
|
|
99
|
+
objects (dicts). Names must match keys in
|
|
100
|
+
:attr:`OpenAPIConfig.security_schemes`::
|
|
101
|
+
|
|
102
|
+
security=["BearerAuth"]
|
|
103
|
+
deprecated:
|
|
104
|
+
When ``True``, marks the operation as deprecated in the spec.
|
|
105
|
+
include_in_schema:
|
|
106
|
+
When ``False``, this route is silently excluded from the spec.
|
|
107
|
+
Useful for internal or diagnostic routes.
|
|
108
|
+
operation_id:
|
|
109
|
+
Explicit ``operationId`` string. Auto-generated from method + path
|
|
110
|
+
if not provided (e.g. ``getApiV1AlarmsAlarmId``).
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def decorator(func: F) -> F:
|
|
114
|
+
setattr(func, OPENAPI_META_ATTR, {
|
|
115
|
+
"summary": summary,
|
|
116
|
+
"description": description,
|
|
117
|
+
"tags": list(tags) if tags else [],
|
|
118
|
+
"request_body": request_body,
|
|
119
|
+
"response": response,
|
|
120
|
+
"response_list": response_list,
|
|
121
|
+
"responses": dict(responses) if responses else {},
|
|
122
|
+
"query_params": list(query_params) if query_params else [],
|
|
123
|
+
"path_params": list(path_params) if path_params else [],
|
|
124
|
+
"security": list(security) if security is not None else None,
|
|
125
|
+
"deprecated": deprecated,
|
|
126
|
+
"include_in_schema": include_in_schema,
|
|
127
|
+
"operation_id": operation_id,
|
|
128
|
+
})
|
|
129
|
+
return func
|
|
130
|
+
|
|
131
|
+
return decorator
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from aiohttp import web
|
|
9
|
+
|
|
10
|
+
from .config import OpenAPIConfig
|
|
11
|
+
from .decorator import OPENAPI_META_ATTR
|
|
12
|
+
from .schema import extract_schema
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_PATH_PARAM_RE = re.compile(r"\{(\w+)\}")
|
|
17
|
+
|
|
18
|
+
_STATUS_DESCRIPTIONS: dict[int, str] = {
|
|
19
|
+
200: "Success",
|
|
20
|
+
201: "Created",
|
|
21
|
+
204: "No content",
|
|
22
|
+
400: "Bad request",
|
|
23
|
+
401: "Unauthorized",
|
|
24
|
+
403: "Forbidden",
|
|
25
|
+
404: "Not found",
|
|
26
|
+
409: "Conflict",
|
|
27
|
+
422: "Unprocessable entity",
|
|
28
|
+
500: "Internal server error",
|
|
29
|
+
503: "Service unavailable",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_ERROR_SCHEMA = {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {"error": {"type": "string"}},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_spec(config: OpenAPIConfig, route_tables: list[web.RouteTableDef]) -> bytes:
|
|
39
|
+
"""Build a frozen OpenAPI spec from *route_tables* and return UTF-8 JSON bytes."""
|
|
40
|
+
components_schemas: dict[str, Any] = {}
|
|
41
|
+
paths: dict[str, Any] = {}
|
|
42
|
+
|
|
43
|
+
for route_table in route_tables:
|
|
44
|
+
for route_def in route_table._items: # noqa: SLF001
|
|
45
|
+
_process_route(route_def, paths, components_schemas)
|
|
46
|
+
|
|
47
|
+
info: dict[str, Any] = {"title": config.title, "version": config.version}
|
|
48
|
+
if config.description:
|
|
49
|
+
info["description"] = config.description
|
|
50
|
+
if config.contact:
|
|
51
|
+
info["contact"] = config.contact
|
|
52
|
+
if config.license_info:
|
|
53
|
+
info["license"] = config.license_info
|
|
54
|
+
|
|
55
|
+
spec: dict[str, Any] = {"openapi": config.openapi_version, "info": info}
|
|
56
|
+
if config.servers:
|
|
57
|
+
spec["servers"] = config.servers
|
|
58
|
+
if config.tags:
|
|
59
|
+
spec["tags"] = config.tags
|
|
60
|
+
|
|
61
|
+
components: dict[str, Any] = {}
|
|
62
|
+
if config.security_schemes:
|
|
63
|
+
components["securitySchemes"] = config.security_schemes
|
|
64
|
+
if components_schemas:
|
|
65
|
+
components["schemas"] = components_schemas
|
|
66
|
+
if components:
|
|
67
|
+
spec["components"] = components
|
|
68
|
+
|
|
69
|
+
spec["paths"] = paths
|
|
70
|
+
|
|
71
|
+
n_ops = sum(len(ops) for ops in paths.values())
|
|
72
|
+
logger.info("aiohttp-autodocs: %d operation(s) across %d path(s).", n_ops, len(paths))
|
|
73
|
+
|
|
74
|
+
return json.dumps(spec, indent=2, default=str).encode("utf-8")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _process_route(
|
|
78
|
+
route_def: Any,
|
|
79
|
+
paths: dict[str, Any],
|
|
80
|
+
components: dict[str, Any],
|
|
81
|
+
) -> None:
|
|
82
|
+
handler = route_def.handler
|
|
83
|
+
meta: dict[str, Any] | None = getattr(handler, OPENAPI_META_ATTR, None)
|
|
84
|
+
|
|
85
|
+
if meta is None or not meta.get("include_in_schema", True):
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
path: str = route_def.path
|
|
89
|
+
method: str = route_def.method.lower()
|
|
90
|
+
|
|
91
|
+
if method == "*" or _is_websocket_route(path, handler):
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
operation = _build_operation(meta, method, path, components)
|
|
95
|
+
|
|
96
|
+
if path not in paths:
|
|
97
|
+
paths[path] = {}
|
|
98
|
+
paths[path][method] = operation
|
|
99
|
+
|
|
100
|
+
logger.debug("Registered %s %s → %s", method.upper(), path, handler.__name__)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _build_operation(
|
|
104
|
+
meta: dict[str, Any],
|
|
105
|
+
method: str,
|
|
106
|
+
path: str,
|
|
107
|
+
components: dict[str, Any],
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
op: dict[str, Any] = {}
|
|
110
|
+
|
|
111
|
+
if meta["summary"]:
|
|
112
|
+
op["summary"] = meta["summary"]
|
|
113
|
+
if meta["description"]:
|
|
114
|
+
op["description"] = meta["description"]
|
|
115
|
+
if meta["tags"]:
|
|
116
|
+
op["tags"] = meta["tags"]
|
|
117
|
+
if meta["deprecated"]:
|
|
118
|
+
op["deprecated"] = True
|
|
119
|
+
|
|
120
|
+
op["operationId"] = meta.get("operation_id") or _make_operation_id(method, path)
|
|
121
|
+
|
|
122
|
+
if meta.get("security") is not None:
|
|
123
|
+
op["security"] = [
|
|
124
|
+
{s: []} if isinstance(s, str) else s for s in meta["security"]
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
parameters = _build_parameters(meta, path)
|
|
128
|
+
if parameters:
|
|
129
|
+
op["parameters"] = parameters
|
|
130
|
+
|
|
131
|
+
request_body_model = meta.get("request_body")
|
|
132
|
+
if request_body_model is not None:
|
|
133
|
+
schema = extract_schema(request_body_model, components)
|
|
134
|
+
if schema:
|
|
135
|
+
op["requestBody"] = {
|
|
136
|
+
"required": True,
|
|
137
|
+
"content": {"application/json": {"schema": schema}},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
op["responses"] = _build_responses(meta, method, components)
|
|
141
|
+
return op
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _build_parameters(meta: dict[str, Any], path: str) -> list[dict[str, Any]]:
|
|
145
|
+
parameters: list[dict[str, Any]] = []
|
|
146
|
+
declared = {p[0]: p for p in meta.get("path_params", [])}
|
|
147
|
+
|
|
148
|
+
for param_name in _PATH_PARAM_RE.findall(path):
|
|
149
|
+
if param_name in declared:
|
|
150
|
+
decl = declared[param_name]
|
|
151
|
+
entry: dict[str, Any] = {
|
|
152
|
+
"name": decl[0],
|
|
153
|
+
"in": "path",
|
|
154
|
+
"required": True,
|
|
155
|
+
"schema": {"type": decl[1] if len(decl) > 1 else "string"},
|
|
156
|
+
}
|
|
157
|
+
if len(decl) > 2 and decl[2]:
|
|
158
|
+
entry["description"] = decl[2]
|
|
159
|
+
else:
|
|
160
|
+
entry = {"name": param_name, "in": "path", "required": True, "schema": {"type": "string"}}
|
|
161
|
+
parameters.append(entry)
|
|
162
|
+
|
|
163
|
+
for qp in meta.get("query_params", []):
|
|
164
|
+
qp_entry: dict[str, Any] = {
|
|
165
|
+
"name": qp[0],
|
|
166
|
+
"in": "query",
|
|
167
|
+
"required": bool(qp[3]) if len(qp) > 3 else False,
|
|
168
|
+
"schema": {"type": qp[1] if len(qp) > 1 else "string"},
|
|
169
|
+
}
|
|
170
|
+
if len(qp) > 2 and qp[2]:
|
|
171
|
+
qp_entry["description"] = qp[2]
|
|
172
|
+
parameters.append(qp_entry)
|
|
173
|
+
|
|
174
|
+
return parameters
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _build_responses(
|
|
178
|
+
meta: dict[str, Any],
|
|
179
|
+
method: str,
|
|
180
|
+
components: dict[str, Any],
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
# Priority: responses dict > response shorthand > bare 200
|
|
183
|
+
responses: dict[str, Any] = {}
|
|
184
|
+
|
|
185
|
+
if meta.get("responses"):
|
|
186
|
+
for status_code, response_model in meta["responses"].items():
|
|
187
|
+
desc = _STATUS_DESCRIPTIONS.get(status_code, "Response")
|
|
188
|
+
if response_model is None:
|
|
189
|
+
responses[str(status_code)] = {"description": desc}
|
|
190
|
+
else:
|
|
191
|
+
schema = extract_schema(response_model, components)
|
|
192
|
+
responses[str(status_code)] = (
|
|
193
|
+
{"description": desc, "content": {"application/json": {"schema": schema}}}
|
|
194
|
+
if schema else {"description": desc}
|
|
195
|
+
)
|
|
196
|
+
elif meta.get("response") is not None:
|
|
197
|
+
schema = extract_schema(meta["response"], components)
|
|
198
|
+
if schema and meta.get("response_list"):
|
|
199
|
+
schema = {"type": "array", "items": schema}
|
|
200
|
+
default_code = "201" if method == "post" else "200"
|
|
201
|
+
responses[default_code] = {
|
|
202
|
+
"description": "Success",
|
|
203
|
+
"content": {"application/json": {"schema": schema}},
|
|
204
|
+
}
|
|
205
|
+
else:
|
|
206
|
+
responses["200"] = {"description": "Success"}
|
|
207
|
+
|
|
208
|
+
if "500" not in responses:
|
|
209
|
+
responses["500"] = {
|
|
210
|
+
"description": "Internal server error",
|
|
211
|
+
"content": {"application/json": {"schema": _ERROR_SCHEMA}},
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return responses
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _is_websocket_route(path: str, handler: Any) -> bool:
|
|
218
|
+
if "/ws" in path.lower() or "websocket" in path.lower():
|
|
219
|
+
return True
|
|
220
|
+
return_hint = getattr(handler, "__annotations__", {}).get("return")
|
|
221
|
+
if return_hint is not None:
|
|
222
|
+
hint_name = getattr(return_hint, "__name__", str(return_hint))
|
|
223
|
+
if "WebSocket" in hint_name:
|
|
224
|
+
return True
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _make_operation_id(method: str, path: str) -> str:
|
|
229
|
+
clean = re.sub(r"[{}]", "", path)
|
|
230
|
+
parts = [p for p in re.split(r"[/_\-]+", clean) if p]
|
|
231
|
+
return method + "".join(p.capitalize() for p in parts)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from pydantic import BaseModel as _PydanticBase
|
|
9
|
+
_PYDANTIC_AVAILABLE = True
|
|
10
|
+
except ImportError: # pragma: no cover
|
|
11
|
+
_PydanticBase = None # type: ignore[assignment,misc]
|
|
12
|
+
_PYDANTIC_AVAILABLE = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Public helpers
|
|
16
|
+
|
|
17
|
+
def is_pydantic_model(obj: Any) -> bool:
|
|
18
|
+
if not _PYDANTIC_AVAILABLE or _PydanticBase is None:
|
|
19
|
+
return False
|
|
20
|
+
try:
|
|
21
|
+
return isinstance(obj, type) and issubclass(obj, _PydanticBase)
|
|
22
|
+
except TypeError:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_schema(
|
|
27
|
+
model: type | dict | None,
|
|
28
|
+
components: dict[str, Any],
|
|
29
|
+
) -> dict[str, Any] | None:
|
|
30
|
+
"""
|
|
31
|
+
Convert *model* to an OpenAPI schema dict.
|
|
32
|
+
|
|
33
|
+
Nested / referenced component schemas are registered in *components*
|
|
34
|
+
(the ``components/schemas`` section of the spec) as a side effect.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
model:
|
|
39
|
+
- ``None`` -> returns ``None`` (no body / no schema).
|
|
40
|
+
- ``dict`` -> treated as a raw JSON Schema object, returned as-is.
|
|
41
|
+
- Pydantic ``BaseModel`` subclass -> ``model_json_schema()`` is called
|
|
42
|
+
and the result is normalised for OpenAPI.
|
|
43
|
+
|
|
44
|
+
components:
|
|
45
|
+
A mutable dict that accumulates component schemas during spec building.
|
|
46
|
+
Pass the same object for every call within one spec-building session.
|
|
47
|
+
|
|
48
|
+
Raises
|
|
49
|
+
------
|
|
50
|
+
TypeError
|
|
51
|
+
If *model* is not ``None``, a ``dict``, or a Pydantic model class.
|
|
52
|
+
ImportError
|
|
53
|
+
If *model* is a Pydantic model class but Pydantic is not installed.
|
|
54
|
+
"""
|
|
55
|
+
if model is None:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
if isinstance(model, dict):
|
|
59
|
+
# use as-is (deep-copy to prevent accidental mutation)
|
|
60
|
+
return copy.deepcopy(model)
|
|
61
|
+
|
|
62
|
+
if is_pydantic_model(model):
|
|
63
|
+
return _pydantic_to_schema(model, components)
|
|
64
|
+
|
|
65
|
+
if not _PYDANTIC_AVAILABLE and isinstance(model, type):
|
|
66
|
+
raise ImportError(
|
|
67
|
+
f"Cannot extract schema from {model!r}: Pydantic is not installed. "
|
|
68
|
+
"Install it with: pip install aiohttp-autodocs[pydantic]"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
raise TypeError(
|
|
72
|
+
f"Cannot extract schema from {model!r}. "
|
|
73
|
+
"Expected a Pydantic BaseModel subclass (or SQLModel) or a plain dict."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _pydantic_to_schema(model: type, components: dict[str, Any]) -> dict[str, Any]:
|
|
78
|
+
"""
|
|
79
|
+
Generate an OpenAPI-compatible ``$ref`` for a Pydantic model, registering
|
|
80
|
+
the model (and all nested models from ``$defs``) in *components*.
|
|
81
|
+
"""
|
|
82
|
+
raw: dict[str, Any] = model.model_json_schema() # type: ignore[attr-defined]
|
|
83
|
+
defs: dict[str, Any] = raw.pop("$defs", {})
|
|
84
|
+
|
|
85
|
+
for def_name, def_schema in defs.items():
|
|
86
|
+
if def_name not in components:
|
|
87
|
+
components[def_name] = _fix_refs(def_schema)
|
|
88
|
+
|
|
89
|
+
name = model.__name__
|
|
90
|
+
if name not in components:
|
|
91
|
+
components[name] = _fix_refs(raw)
|
|
92
|
+
|
|
93
|
+
return {"$ref": f"#/components/schemas/{name}"}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _fix_refs(schema: Any) -> Any:
|
|
97
|
+
"""
|
|
98
|
+
Recursively rewrite Pydantic's internal ``#/$defs/Foo`` references to
|
|
99
|
+
the OpenAPI-standard ``#/components/schemas/Foo`` form.
|
|
100
|
+
"""
|
|
101
|
+
if isinstance(schema, dict):
|
|
102
|
+
return {
|
|
103
|
+
k: (
|
|
104
|
+
f"#/components/schemas/{v[len('#/$defs/'):]}"
|
|
105
|
+
if k == "$ref" and isinstance(v, str) and v.startswith("#/$defs/")
|
|
106
|
+
else _fix_refs(v)
|
|
107
|
+
)
|
|
108
|
+
for k, v in schema.items()
|
|
109
|
+
}
|
|
110
|
+
if isinstance(schema, list):
|
|
111
|
+
return [_fix_refs(item) for item in schema]
|
|
112
|
+
return schema
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def render_swagger_ui(
|
|
5
|
+
spec_url: str,
|
|
6
|
+
title: str = "API Documentation",
|
|
7
|
+
cdn_base: str = "https://unpkg.com/swagger-ui-dist@5",
|
|
8
|
+
) -> str:
|
|
9
|
+
# Escape any single quotes or braces that might break the inline JS
|
|
10
|
+
safe_spec_url = spec_url.replace("'", "\\'")
|
|
11
|
+
safe_title = title.replace("<", "<").replace(">", ">")
|
|
12
|
+
|
|
13
|
+
return f"""\
|
|
14
|
+
<!DOCTYPE html>
|
|
15
|
+
<html lang="en">
|
|
16
|
+
<head>
|
|
17
|
+
<meta charset="UTF-8" />
|
|
18
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
19
|
+
<title>{safe_title}</title>
|
|
20
|
+
<link rel="stylesheet" href="{cdn_base}/swagger-ui.css" crossorigin="anonymous" />
|
|
21
|
+
<style>
|
|
22
|
+
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
23
|
+
html, body {{ height: 100%; margin: 0; padding: 0; }}
|
|
24
|
+
#swagger-ui .topbar-wrapper .link {{ display: none; /* hide the default Swagger "Explore" link */ }}
|
|
25
|
+
</style>
|
|
26
|
+
</head>
|
|
27
|
+
<body>
|
|
28
|
+
<div id="swagger-ui"></div>
|
|
29
|
+
|
|
30
|
+
<script src="{cdn_base}/swagger-ui-bundle.js" crossorigin="anonymous"></script>
|
|
31
|
+
<script src="{cdn_base}/swagger-ui-standalone-preset.js" crossorigin="anonymous"></script>
|
|
32
|
+
<script>
|
|
33
|
+
window.addEventListener("load", function () {{
|
|
34
|
+
window.ui = SwaggerUIBundle({{
|
|
35
|
+
url: "{safe_spec_url}",
|
|
36
|
+
dom_id: "#swagger-ui",
|
|
37
|
+
deepLinking: true,
|
|
38
|
+
tryItOutEnabled: true,
|
|
39
|
+
persistAuthorization: true,
|
|
40
|
+
displayRequestDuration: true,
|
|
41
|
+
filter: true,
|
|
42
|
+
presets: [
|
|
43
|
+
SwaggerUIBundle.presets.apis,
|
|
44
|
+
SwaggerUIStandalonePreset,
|
|
45
|
+
],
|
|
46
|
+
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
|
|
47
|
+
layout: "StandaloneLayout",
|
|
48
|
+
defaultModelsExpandDepth: 1,
|
|
49
|
+
defaultModelExpandDepth: 2,
|
|
50
|
+
docExpansion: "list",
|
|
51
|
+
}});
|
|
52
|
+
}});
|
|
53
|
+
</script>
|
|
54
|
+
</body>
|
|
55
|
+
</html>"""
|