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.
@@ -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,8 @@
1
+ from __future__ import annotations
2
+
3
+ from .config import OpenAPIConfig
4
+ from .decorator import docs
5
+ from ._builder import build_openapi
6
+
7
+ __all__ = ["docs", "build_openapi", "OpenAPIConfig"]
8
+ __version__ = "0.1.0"
@@ -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("<", "&lt;").replace(">", "&gt;")
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>"""