fast-asyncapidoc 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.
- fast_asyncapidoc-0.1.0/LICENSE +21 -0
- fast_asyncapidoc-0.1.0/PKG-INFO +211 -0
- fast_asyncapidoc-0.1.0/README.md +179 -0
- fast_asyncapidoc-0.1.0/README.zh-CN.md +179 -0
- fast_asyncapidoc-0.1.0/pyproject.toml +61 -0
- fast_asyncapidoc-0.1.0/src/fast_asyncapidoc/__init__.py +20 -0
- fast_asyncapidoc-0.1.0/src/fast_asyncapidoc/decorator.py +200 -0
- fast_asyncapidoc-0.1.0/src/fast_asyncapidoc/document.py +349 -0
- fast_asyncapidoc-0.1.0/src/fast_asyncapidoc/errors.py +17 -0
- fast_asyncapidoc-0.1.0/src/fast_asyncapidoc/py.typed +0 -0
- fast_asyncapidoc-0.1.0/src/fast_asyncapidoc/scanner.py +104 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 qingyiwebt
|
|
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,211 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fast-asyncapidoc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate AsyncAPI 3 documentation for FastAPI WebSocket endpoints
|
|
5
|
+
Keywords: asyncapi,documentation,fastapi,pydantic,websocket
|
|
6
|
+
Author: qingyiwebt
|
|
7
|
+
Author-email: qingyiwebt <qingyiweb@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Documentation
|
|
20
|
+
Classifier: Topic :: Internet
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Dist: fastapi>=0.100,<1
|
|
23
|
+
Requires-Dist: pydantic>=2,<3
|
|
24
|
+
Requires-Dist: pyyaml>=6 ; extra == 'yaml'
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Project-URL: Documentation, https://github.com/qingyiwebt/fast-asyncapidoc#readme
|
|
27
|
+
Project-URL: Homepage, https://github.com/qingyiwebt/fast-asyncapidoc
|
|
28
|
+
Project-URL: Issues, https://github.com/qingyiwebt/fast-asyncapidoc/issues
|
|
29
|
+
Project-URL: Repository, https://github.com/qingyiwebt/fast-asyncapidoc
|
|
30
|
+
Provides-Extra: yaml
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# fast-asyncapidoc
|
|
34
|
+
|
|
35
|
+
[简体中文](https://github.com/qingyiwebt/fast-asyncapidoc/blob/main/README.zh-CN.md)
|
|
36
|
+
|
|
37
|
+
Generate [AsyncAPI 3.0](https://www.asyncapi.com/docs/reference/specification/v3.0.0)
|
|
38
|
+
documentation for [FastAPI](https://fastapi.tiangolo.com/) WebSocket endpoints
|
|
39
|
+
using [Pydantic v2](https://docs.pydantic.dev/latest/).
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- Describe WebSocket messages with repeatable `@asyncapi` decorators.
|
|
44
|
+
- Generate AsyncAPI channels, operations, messages, and Pydantic schemas.
|
|
45
|
+
- Document request/reply, receive-only, and server-push interactions.
|
|
46
|
+
- Discover routes from direct FastAPI routes, routers, prefixes, and nested routers.
|
|
47
|
+
- Mount the generated document at a JSON endpoint or export it as a Python `dict`.
|
|
48
|
+
- Optionally serialize the document as YAML.
|
|
49
|
+
- Keep endpoint functions unchanged, including their FastAPI dependency signatures.
|
|
50
|
+
|
|
51
|
+
## Requirements
|
|
52
|
+
|
|
53
|
+
- Python 3.10 or newer
|
|
54
|
+
- FastAPI `>=0.100,<1`
|
|
55
|
+
- Pydantic `>=2,<3`
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
Install the package with uv:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uv add fast-asyncapidoc
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Install optional YAML support when needed:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
uv add "fast-asyncapidoc[yaml]"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Quick start
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from fastapi import APIRouter, FastAPI, WebSocket
|
|
75
|
+
from pydantic import BaseModel
|
|
76
|
+
|
|
77
|
+
from fast_asyncapidoc import AsyncAPIDoc, asyncapi
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ControlRequest(BaseModel):
|
|
81
|
+
command: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ControlSuccess(BaseModel):
|
|
85
|
+
accepted: bool
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ControlFailure(BaseModel):
|
|
89
|
+
error: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ServerNotice(BaseModel):
|
|
93
|
+
message: str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
router = APIRouter()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@router.websocket("/control")
|
|
100
|
+
@asyncapi(
|
|
101
|
+
input=ControlRequest,
|
|
102
|
+
output=ControlSuccess,
|
|
103
|
+
operation_id="control.request_reply",
|
|
104
|
+
summary="Handle a control request",
|
|
105
|
+
)
|
|
106
|
+
@asyncapi(
|
|
107
|
+
input=ControlRequest,
|
|
108
|
+
outputs=[ControlSuccess, ControlFailure],
|
|
109
|
+
operation_id="control.result",
|
|
110
|
+
)
|
|
111
|
+
@asyncapi(
|
|
112
|
+
input=ControlRequest,
|
|
113
|
+
operation_id="control.receive_only",
|
|
114
|
+
)
|
|
115
|
+
@asyncapi(
|
|
116
|
+
output=ServerNotice,
|
|
117
|
+
operation_id="control.server_notice",
|
|
118
|
+
)
|
|
119
|
+
async def control(websocket: WebSocket) -> None:
|
|
120
|
+
await websocket.accept()
|
|
121
|
+
await websocket.close()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
app = FastAPI()
|
|
125
|
+
app.include_router(router, prefix="/api")
|
|
126
|
+
|
|
127
|
+
asyncapi_doc = AsyncAPIDoc(
|
|
128
|
+
app,
|
|
129
|
+
title="Control API",
|
|
130
|
+
version="1.0.0",
|
|
131
|
+
description="WebSocket API documentation",
|
|
132
|
+
servers={
|
|
133
|
+
"development": {
|
|
134
|
+
"host": "localhost:8000",
|
|
135
|
+
"protocol": "ws",
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
asyncapi_doc.mount("/asyncapi.json")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The generated document is available at `GET /asyncapi.json` and can also be
|
|
143
|
+
obtained directly:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
document: dict = asyncapi_doc.generate()
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Decorator semantics
|
|
150
|
+
|
|
151
|
+
`@asyncapi` accepts either a singular or plural form for each direction:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
@asyncapi(input=Request)
|
|
155
|
+
@asyncapi(inputs=[RequestA, RequestB])
|
|
156
|
+
@asyncapi(output=Response)
|
|
157
|
+
@asyncapi(outputs=[Success, Failure])
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The supported combinations are:
|
|
161
|
+
|
|
162
|
+
| Inputs | Outputs | AsyncAPI operation |
|
|
163
|
+
| --- | --- | --- |
|
|
164
|
+
| One or more | None | `action: receive`, without `reply` |
|
|
165
|
+
| One or more | One or more | `action: receive`, with `reply` |
|
|
166
|
+
| None | One or more | `action: send`, for server push |
|
|
167
|
+
|
|
168
|
+
An empty declaration is invalid. `input` and `inputs` cannot be used together,
|
|
169
|
+
and the same applies to `output` and `outputs`.
|
|
170
|
+
|
|
171
|
+
The decorator supports the following operation metadata:
|
|
172
|
+
|
|
173
|
+
- `operation_id`: explicit key in the AsyncAPI `operations` map.
|
|
174
|
+
- `title`, `summary`, `description`: operation documentation.
|
|
175
|
+
- `tags`: operation grouping tags.
|
|
176
|
+
|
|
177
|
+
The message payload schema is generated from each Pydantic model's
|
|
178
|
+
`model_json_schema()` output. The package documents message contracts; it does
|
|
179
|
+
not parse, validate, or transform runtime WebSocket frames.
|
|
180
|
+
|
|
181
|
+
## YAML export
|
|
182
|
+
|
|
183
|
+
After installing the `yaml` extra:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
yaml_document = asyncapi_doc.to_yaml()
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
This repository uses uv for dependency management and builds:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
uv sync
|
|
195
|
+
uv run pytest
|
|
196
|
+
uv run ruff check .
|
|
197
|
+
uv run ruff format --check .
|
|
198
|
+
uv build
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Links
|
|
202
|
+
|
|
203
|
+
- [AsyncAPI 3.0 specification](https://www.asyncapi.com/docs/reference/specification/v3.0.0)
|
|
204
|
+
- [FastAPI WebSockets](https://fastapi.tiangolo.com/advanced/websockets/)
|
|
205
|
+
- [Pydantic](https://docs.pydantic.dev/latest/)
|
|
206
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
207
|
+
- [Repository](https://github.com/qingyiwebt/fast-asyncapidoc)
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
[MIT License](LICENSE)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# fast-asyncapidoc
|
|
2
|
+
|
|
3
|
+
[简体中文](https://github.com/qingyiwebt/fast-asyncapidoc/blob/main/README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
Generate [AsyncAPI 3.0](https://www.asyncapi.com/docs/reference/specification/v3.0.0)
|
|
6
|
+
documentation for [FastAPI](https://fastapi.tiangolo.com/) WebSocket endpoints
|
|
7
|
+
using [Pydantic v2](https://docs.pydantic.dev/latest/).
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- Describe WebSocket messages with repeatable `@asyncapi` decorators.
|
|
12
|
+
- Generate AsyncAPI channels, operations, messages, and Pydantic schemas.
|
|
13
|
+
- Document request/reply, receive-only, and server-push interactions.
|
|
14
|
+
- Discover routes from direct FastAPI routes, routers, prefixes, and nested routers.
|
|
15
|
+
- Mount the generated document at a JSON endpoint or export it as a Python `dict`.
|
|
16
|
+
- Optionally serialize the document as YAML.
|
|
17
|
+
- Keep endpoint functions unchanged, including their FastAPI dependency signatures.
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- Python 3.10 or newer
|
|
22
|
+
- FastAPI `>=0.100,<1`
|
|
23
|
+
- Pydantic `>=2,<3`
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
Install the package with uv:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv add fast-asyncapidoc
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Install optional YAML support when needed:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv add "fast-asyncapidoc[yaml]"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from fastapi import APIRouter, FastAPI, WebSocket
|
|
43
|
+
from pydantic import BaseModel
|
|
44
|
+
|
|
45
|
+
from fast_asyncapidoc import AsyncAPIDoc, asyncapi
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ControlRequest(BaseModel):
|
|
49
|
+
command: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ControlSuccess(BaseModel):
|
|
53
|
+
accepted: bool
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ControlFailure(BaseModel):
|
|
57
|
+
error: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ServerNotice(BaseModel):
|
|
61
|
+
message: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
router = APIRouter()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.websocket("/control")
|
|
68
|
+
@asyncapi(
|
|
69
|
+
input=ControlRequest,
|
|
70
|
+
output=ControlSuccess,
|
|
71
|
+
operation_id="control.request_reply",
|
|
72
|
+
summary="Handle a control request",
|
|
73
|
+
)
|
|
74
|
+
@asyncapi(
|
|
75
|
+
input=ControlRequest,
|
|
76
|
+
outputs=[ControlSuccess, ControlFailure],
|
|
77
|
+
operation_id="control.result",
|
|
78
|
+
)
|
|
79
|
+
@asyncapi(
|
|
80
|
+
input=ControlRequest,
|
|
81
|
+
operation_id="control.receive_only",
|
|
82
|
+
)
|
|
83
|
+
@asyncapi(
|
|
84
|
+
output=ServerNotice,
|
|
85
|
+
operation_id="control.server_notice",
|
|
86
|
+
)
|
|
87
|
+
async def control(websocket: WebSocket) -> None:
|
|
88
|
+
await websocket.accept()
|
|
89
|
+
await websocket.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
app = FastAPI()
|
|
93
|
+
app.include_router(router, prefix="/api")
|
|
94
|
+
|
|
95
|
+
asyncapi_doc = AsyncAPIDoc(
|
|
96
|
+
app,
|
|
97
|
+
title="Control API",
|
|
98
|
+
version="1.0.0",
|
|
99
|
+
description="WebSocket API documentation",
|
|
100
|
+
servers={
|
|
101
|
+
"development": {
|
|
102
|
+
"host": "localhost:8000",
|
|
103
|
+
"protocol": "ws",
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
asyncapi_doc.mount("/asyncapi.json")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The generated document is available at `GET /asyncapi.json` and can also be
|
|
111
|
+
obtained directly:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
document: dict = asyncapi_doc.generate()
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Decorator semantics
|
|
118
|
+
|
|
119
|
+
`@asyncapi` accepts either a singular or plural form for each direction:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
@asyncapi(input=Request)
|
|
123
|
+
@asyncapi(inputs=[RequestA, RequestB])
|
|
124
|
+
@asyncapi(output=Response)
|
|
125
|
+
@asyncapi(outputs=[Success, Failure])
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The supported combinations are:
|
|
129
|
+
|
|
130
|
+
| Inputs | Outputs | AsyncAPI operation |
|
|
131
|
+
| --- | --- | --- |
|
|
132
|
+
| One or more | None | `action: receive`, without `reply` |
|
|
133
|
+
| One or more | One or more | `action: receive`, with `reply` |
|
|
134
|
+
| None | One or more | `action: send`, for server push |
|
|
135
|
+
|
|
136
|
+
An empty declaration is invalid. `input` and `inputs` cannot be used together,
|
|
137
|
+
and the same applies to `output` and `outputs`.
|
|
138
|
+
|
|
139
|
+
The decorator supports the following operation metadata:
|
|
140
|
+
|
|
141
|
+
- `operation_id`: explicit key in the AsyncAPI `operations` map.
|
|
142
|
+
- `title`, `summary`, `description`: operation documentation.
|
|
143
|
+
- `tags`: operation grouping tags.
|
|
144
|
+
|
|
145
|
+
The message payload schema is generated from each Pydantic model's
|
|
146
|
+
`model_json_schema()` output. The package documents message contracts; it does
|
|
147
|
+
not parse, validate, or transform runtime WebSocket frames.
|
|
148
|
+
|
|
149
|
+
## YAML export
|
|
150
|
+
|
|
151
|
+
After installing the `yaml` extra:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
yaml_document = asyncapi_doc.to_yaml()
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Development
|
|
158
|
+
|
|
159
|
+
This repository uses uv for dependency management and builds:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
uv sync
|
|
163
|
+
uv run pytest
|
|
164
|
+
uv run ruff check .
|
|
165
|
+
uv run ruff format --check .
|
|
166
|
+
uv build
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Links
|
|
170
|
+
|
|
171
|
+
- [AsyncAPI 3.0 specification](https://www.asyncapi.com/docs/reference/specification/v3.0.0)
|
|
172
|
+
- [FastAPI WebSockets](https://fastapi.tiangolo.com/advanced/websockets/)
|
|
173
|
+
- [Pydantic](https://docs.pydantic.dev/latest/)
|
|
174
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
175
|
+
- [Repository](https://github.com/qingyiwebt/fast-asyncapidoc)
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
[MIT License](LICENSE)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# fast-asyncapidoc
|
|
2
|
+
|
|
3
|
+
[English](README.md)
|
|
4
|
+
|
|
5
|
+
使用 [Pydantic v2](https://docs.pydantic.dev/latest/) 模型,为
|
|
6
|
+
[FastAPI](https://fastapi.tiangolo.com/) WebSocket endpoint 生成
|
|
7
|
+
[AsyncAPI 3.0](https://www.asyncapi.com/docs/reference/specification/v3.0.0)
|
|
8
|
+
文档。
|
|
9
|
+
|
|
10
|
+
## 特性
|
|
11
|
+
|
|
12
|
+
- 使用可重复的 `@asyncapi` 装饰器描述 WebSocket 消息。
|
|
13
|
+
- 自动生成 AsyncAPI channels、operations、messages 和 Pydantic schemas。
|
|
14
|
+
- 支持请求/响应、只接收消息和服务器主动推送。
|
|
15
|
+
- 支持直接注册的 WebSocket、APIRouter、路由前缀和嵌套路由。
|
|
16
|
+
- 可挂载 JSON 文档路由,也可直接导出 Python `dict`。
|
|
17
|
+
- 可选导出 YAML 文档。
|
|
18
|
+
- 不包装 endpoint,不影响 FastAPI 依赖注入和函数签名。
|
|
19
|
+
|
|
20
|
+
## 环境要求
|
|
21
|
+
|
|
22
|
+
- Python 3.10 或更高版本
|
|
23
|
+
- FastAPI `>=0.100,<1`
|
|
24
|
+
- Pydantic `>=2,<3`
|
|
25
|
+
|
|
26
|
+
## 安装
|
|
27
|
+
|
|
28
|
+
使用 uv 安装:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv add fast-asyncapidoc
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
需要 YAML 支持时安装可选依赖:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv add "fast-asyncapidoc[yaml]"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 快速开始
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from fastapi import APIRouter, FastAPI, WebSocket
|
|
44
|
+
from pydantic import BaseModel
|
|
45
|
+
|
|
46
|
+
from fast_asyncapidoc import AsyncAPIDoc, asyncapi
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ControlRequest(BaseModel):
|
|
50
|
+
command: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ControlSuccess(BaseModel):
|
|
54
|
+
accepted: bool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ControlFailure(BaseModel):
|
|
58
|
+
error: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ServerNotice(BaseModel):
|
|
62
|
+
message: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
router = APIRouter()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.websocket("/control")
|
|
69
|
+
@asyncapi(
|
|
70
|
+
input=ControlRequest,
|
|
71
|
+
output=ControlSuccess,
|
|
72
|
+
operation_id="control.request_reply",
|
|
73
|
+
summary="处理控制请求",
|
|
74
|
+
)
|
|
75
|
+
@asyncapi(
|
|
76
|
+
input=ControlRequest,
|
|
77
|
+
outputs=[ControlSuccess, ControlFailure],
|
|
78
|
+
operation_id="control.result",
|
|
79
|
+
)
|
|
80
|
+
@asyncapi(
|
|
81
|
+
input=ControlRequest,
|
|
82
|
+
operation_id="control.receive_only",
|
|
83
|
+
)
|
|
84
|
+
@asyncapi(
|
|
85
|
+
output=ServerNotice,
|
|
86
|
+
operation_id="control.server_notice",
|
|
87
|
+
)
|
|
88
|
+
async def control(websocket: WebSocket) -> None:
|
|
89
|
+
await websocket.accept()
|
|
90
|
+
await websocket.close()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
app = FastAPI()
|
|
94
|
+
app.include_router(router, prefix="/api")
|
|
95
|
+
|
|
96
|
+
asyncapi_doc = AsyncAPIDoc(
|
|
97
|
+
app,
|
|
98
|
+
title="Control API",
|
|
99
|
+
version="1.0.0",
|
|
100
|
+
description="WebSocket API 文档",
|
|
101
|
+
servers={
|
|
102
|
+
"development": {
|
|
103
|
+
"host": "localhost:8000",
|
|
104
|
+
"protocol": "ws",
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
asyncapi_doc.mount("/asyncapi.json")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
生成的文档可以通过 `GET /asyncapi.json` 访问,也可以直接获取:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
document: dict = asyncapi_doc.generate()
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## 装饰器语义
|
|
118
|
+
|
|
119
|
+
输入和输出都支持单数或复数形式:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
@asyncapi(input=Request)
|
|
123
|
+
@asyncapi(inputs=[RequestA, RequestB])
|
|
124
|
+
@asyncapi(output=Response)
|
|
125
|
+
@asyncapi(outputs=[Success, Failure])
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
支持以下组合:
|
|
129
|
+
|
|
130
|
+
| 输入 | 输出 | AsyncAPI operation |
|
|
131
|
+
| --- | --- | --- |
|
|
132
|
+
| 一个或多个 | 无 | `action: receive`,不生成 `reply` |
|
|
133
|
+
| 一个或多个 | 一个或多个 | `action: receive`,生成 `reply` |
|
|
134
|
+
| 无 | 一个或多个 | `action: send`,表示服务器主动推送 |
|
|
135
|
+
|
|
136
|
+
输入和输出同时为空的声明无效。`input` 和 `inputs` 不能同时使用,
|
|
137
|
+
`output` 和 `outputs` 也不能同时使用。
|
|
138
|
+
|
|
139
|
+
装饰器支持以下 operation 元数据:
|
|
140
|
+
|
|
141
|
+
- `operation_id`:AsyncAPI `operations` map 中的显式 key。
|
|
142
|
+
- `title`、`summary`、`description`:operation 文档信息。
|
|
143
|
+
- `tags`:operation 分组标签。
|
|
144
|
+
|
|
145
|
+
消息 payload schema 直接来自 Pydantic 模型的
|
|
146
|
+
`model_json_schema()`。本项目只负责生成消息契约,不会解析、校验或转换
|
|
147
|
+
运行时 WebSocket frame。
|
|
148
|
+
|
|
149
|
+
## YAML 导出
|
|
150
|
+
|
|
151
|
+
安装 `yaml` 可选依赖后:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
yaml_document = asyncapi_doc.to_yaml()
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## 开发
|
|
158
|
+
|
|
159
|
+
本项目使用 uv 管理依赖和构建:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
uv sync
|
|
163
|
+
uv run pytest
|
|
164
|
+
uv run ruff check .
|
|
165
|
+
uv run ruff format --check .
|
|
166
|
+
uv build
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## 相关链接
|
|
170
|
+
|
|
171
|
+
- [AsyncAPI 3.0 规范](https://www.asyncapi.com/docs/reference/specification/v3.0.0)
|
|
172
|
+
- [FastAPI WebSockets](https://fastapi.tiangolo.com/advanced/websockets/)
|
|
173
|
+
- [Pydantic](https://docs.pydantic.dev/latest/)
|
|
174
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
175
|
+
- [项目仓库](https://github.com/qingyiwebt/fast-asyncapidoc)
|
|
176
|
+
|
|
177
|
+
## 许可证
|
|
178
|
+
|
|
179
|
+
[MIT License](LICENSE)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fast-asyncapidoc"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Generate AsyncAPI 3 documentation for FastAPI WebSocket endpoints"
|
|
5
|
+
readme = { file = "README.md", content-type = "text/markdown" }
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "qingyiwebt", email = "qingyiweb@gmail.com" },
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
keywords = [
|
|
12
|
+
"asyncapi",
|
|
13
|
+
"documentation",
|
|
14
|
+
"fastapi",
|
|
15
|
+
"pydantic",
|
|
16
|
+
"websocket",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Topic :: Documentation",
|
|
29
|
+
"Topic :: Internet",
|
|
30
|
+
"Topic :: Software Development :: Libraries",
|
|
31
|
+
]
|
|
32
|
+
requires-python = ">=3.10"
|
|
33
|
+
dependencies = [
|
|
34
|
+
"fastapi>=0.100,<1",
|
|
35
|
+
"pydantic>=2,<3",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/qingyiwebt/fast-asyncapidoc"
|
|
40
|
+
Repository = "https://github.com/qingyiwebt/fast-asyncapidoc"
|
|
41
|
+
Documentation = "https://github.com/qingyiwebt/fast-asyncapidoc#readme"
|
|
42
|
+
Issues = "https://github.com/qingyiwebt/fast-asyncapidoc/issues"
|
|
43
|
+
|
|
44
|
+
[project.optional-dependencies]
|
|
45
|
+
yaml = [
|
|
46
|
+
"pyyaml>=6",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["uv_build>=0.9.11,<0.10.0"]
|
|
51
|
+
build-backend = "uv_build"
|
|
52
|
+
|
|
53
|
+
[tool.uv.build-backend]
|
|
54
|
+
source-include = ["README.zh-CN.md"]
|
|
55
|
+
|
|
56
|
+
[dependency-groups]
|
|
57
|
+
dev = [
|
|
58
|
+
"httpx>=0.28.1",
|
|
59
|
+
"pytest>=9.2.0",
|
|
60
|
+
"ruff>=0.17.4",
|
|
61
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Generate AsyncAPI 3 documentation for FastAPI WebSocket endpoints."""
|
|
2
|
+
|
|
3
|
+
from .decorator import OperationSpec, asyncapi
|
|
4
|
+
from .document import AsyncAPIDoc
|
|
5
|
+
from .errors import (
|
|
6
|
+
AsyncAPIDocError,
|
|
7
|
+
DuplicateOperationIdError,
|
|
8
|
+
InvalidAsyncAPIDecoratorError,
|
|
9
|
+
MissingYamlDependencyError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AsyncAPIDoc",
|
|
14
|
+
"AsyncAPIDocError",
|
|
15
|
+
"DuplicateOperationIdError",
|
|
16
|
+
"InvalidAsyncAPIDecoratorError",
|
|
17
|
+
"MissingYamlDependencyError",
|
|
18
|
+
"OperationSpec",
|
|
19
|
+
"asyncapi",
|
|
20
|
+
]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""The endpoint decorator and its normalized metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from .errors import InvalidAsyncAPIDecoratorError
|
|
13
|
+
|
|
14
|
+
EndpointT = TypeVar("EndpointT", bound=Callable[..., Any])
|
|
15
|
+
ModelType = type[BaseModel]
|
|
16
|
+
|
|
17
|
+
_SPECS_ATTRIBUTE = "__fast_asyncapidoc_specs__"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class OperationSpec:
|
|
22
|
+
"""One documented input/output interaction for a WebSocket endpoint."""
|
|
23
|
+
|
|
24
|
+
inputs: tuple[ModelType, ...]
|
|
25
|
+
outputs: tuple[ModelType, ...]
|
|
26
|
+
operation_id: str | None = None
|
|
27
|
+
title: str | None = None
|
|
28
|
+
summary: str | None = None
|
|
29
|
+
description: str | None = None
|
|
30
|
+
tags: tuple[str, ...] = ()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def asyncapi(
|
|
34
|
+
*,
|
|
35
|
+
input: ModelType | None = None,
|
|
36
|
+
inputs: Sequence[ModelType] | None = None,
|
|
37
|
+
output: ModelType | None = None,
|
|
38
|
+
outputs: Sequence[ModelType] | None = None,
|
|
39
|
+
operation_id: str | None = None,
|
|
40
|
+
title: str | None = None,
|
|
41
|
+
summary: str | None = None,
|
|
42
|
+
description: str | None = None,
|
|
43
|
+
tags: Sequence[str] | None = None,
|
|
44
|
+
) -> Callable[[EndpointT], EndpointT]:
|
|
45
|
+
"""Attach an AsyncAPI interaction declaration to a WebSocket endpoint.
|
|
46
|
+
|
|
47
|
+
``input`` and ``inputs`` are alternate spellings for a single or multiple
|
|
48
|
+
input models. ``output`` and ``outputs`` behave the same way. Either
|
|
49
|
+
side may be omitted for receive-only or server-push documentation, but
|
|
50
|
+
both sides may not be empty. The decorator intentionally returns the
|
|
51
|
+
original callable instead of wrapping it, so FastAPI sees the original
|
|
52
|
+
signature and dependency metadata.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
normalized_inputs = _normalize_models(
|
|
56
|
+
singular=input,
|
|
57
|
+
plural=inputs,
|
|
58
|
+
singular_name="input",
|
|
59
|
+
plural_name="inputs",
|
|
60
|
+
required=False,
|
|
61
|
+
)
|
|
62
|
+
normalized_outputs = _normalize_models(
|
|
63
|
+
singular=output,
|
|
64
|
+
plural=outputs,
|
|
65
|
+
singular_name="output",
|
|
66
|
+
plural_name="outputs",
|
|
67
|
+
required=False,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if not normalized_inputs and not normalized_outputs:
|
|
71
|
+
raise InvalidAsyncAPIDecoratorError(
|
|
72
|
+
"at least one input or output model is required"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
normalized_operation_id = _normalize_optional_string(operation_id, "operation_id")
|
|
76
|
+
normalized_title = _normalize_optional_string(title, "title")
|
|
77
|
+
normalized_summary = _normalize_optional_string(summary, "summary")
|
|
78
|
+
normalized_description = _normalize_optional_string(description, "description")
|
|
79
|
+
normalized_tags = _normalize_tags(tags)
|
|
80
|
+
|
|
81
|
+
spec = OperationSpec(
|
|
82
|
+
inputs=normalized_inputs,
|
|
83
|
+
outputs=normalized_outputs,
|
|
84
|
+
operation_id=normalized_operation_id,
|
|
85
|
+
title=normalized_title,
|
|
86
|
+
summary=normalized_summary,
|
|
87
|
+
description=normalized_description,
|
|
88
|
+
tags=normalized_tags,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def decorate(endpoint: EndpointT) -> EndpointT:
|
|
92
|
+
if not callable(endpoint):
|
|
93
|
+
raise InvalidAsyncAPIDecoratorError(
|
|
94
|
+
"@asyncapi can only decorate a callable endpoint"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
existing = list(getattr(endpoint, _SPECS_ATTRIBUTE, ()))
|
|
98
|
+
# Python applies stacked decorators from bottom to top. Prepending
|
|
99
|
+
# makes generated documentation follow the order visible in source.
|
|
100
|
+
setattr(endpoint, _SPECS_ATTRIBUTE, (spec, *existing))
|
|
101
|
+
return endpoint
|
|
102
|
+
|
|
103
|
+
return decorate
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_operation_specs(endpoint: Any) -> tuple[OperationSpec, ...]:
|
|
107
|
+
"""Return declarations attached to an endpoint, if any."""
|
|
108
|
+
|
|
109
|
+
candidates = [endpoint]
|
|
110
|
+
try:
|
|
111
|
+
unwrapped = inspect.unwrap(endpoint)
|
|
112
|
+
except (ValueError, TypeError):
|
|
113
|
+
unwrapped = None
|
|
114
|
+
if unwrapped is not None and unwrapped is not endpoint:
|
|
115
|
+
candidates.append(unwrapped)
|
|
116
|
+
|
|
117
|
+
for candidate in candidates:
|
|
118
|
+
specs = getattr(candidate, _SPECS_ATTRIBUTE, None)
|
|
119
|
+
if specs is not None:
|
|
120
|
+
return tuple(specs)
|
|
121
|
+
return ()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _normalize_models(
|
|
125
|
+
*,
|
|
126
|
+
singular: ModelType | None,
|
|
127
|
+
plural: Sequence[ModelType] | None,
|
|
128
|
+
singular_name: str,
|
|
129
|
+
plural_name: str,
|
|
130
|
+
required: bool,
|
|
131
|
+
) -> tuple[ModelType, ...]:
|
|
132
|
+
if singular is not None and plural is not None:
|
|
133
|
+
raise InvalidAsyncAPIDecoratorError(
|
|
134
|
+
f"{singular_name!r} and {plural_name!r} are mutually exclusive"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if singular is not None:
|
|
138
|
+
values: tuple[Any, ...] = (singular,)
|
|
139
|
+
elif plural is not None:
|
|
140
|
+
if isinstance(plural, (str, bytes)):
|
|
141
|
+
raise InvalidAsyncAPIDecoratorError(
|
|
142
|
+
f"{plural_name!r} must be a sequence of Pydantic model classes"
|
|
143
|
+
)
|
|
144
|
+
try:
|
|
145
|
+
values = tuple(plural)
|
|
146
|
+
except TypeError as exc:
|
|
147
|
+
raise InvalidAsyncAPIDecoratorError(
|
|
148
|
+
f"{plural_name!r} must be a sequence of Pydantic model classes"
|
|
149
|
+
) from exc
|
|
150
|
+
else:
|
|
151
|
+
values = ()
|
|
152
|
+
|
|
153
|
+
if required and not values:
|
|
154
|
+
raise InvalidAsyncAPIDecoratorError(
|
|
155
|
+
f"one of {singular_name!r} or {plural_name!r} is required"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
normalized: list[ModelType] = []
|
|
159
|
+
seen: set[ModelType] = set()
|
|
160
|
+
for index, value in enumerate(values):
|
|
161
|
+
if not _is_model_type(value):
|
|
162
|
+
raise InvalidAsyncAPIDecoratorError(
|
|
163
|
+
f"{singular_name if singular is not None else plural_name}[{index}] "
|
|
164
|
+
"must be a Pydantic v2 BaseModel subclass"
|
|
165
|
+
)
|
|
166
|
+
if value not in seen:
|
|
167
|
+
seen.add(value)
|
|
168
|
+
normalized.append(value)
|
|
169
|
+
return tuple(normalized)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _is_model_type(value: Any) -> bool:
|
|
173
|
+
return isinstance(value, type) and issubclass(value, BaseModel)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _normalize_optional_string(value: str | None, name: str) -> str | None:
|
|
177
|
+
if value is None:
|
|
178
|
+
return None
|
|
179
|
+
if not isinstance(value, str) or not value.strip():
|
|
180
|
+
raise InvalidAsyncAPIDecoratorError(f"{name} must be a non-empty string")
|
|
181
|
+
return value
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _normalize_tags(tags: Sequence[str] | None) -> tuple[str, ...]:
|
|
185
|
+
if tags is None:
|
|
186
|
+
return ()
|
|
187
|
+
if isinstance(tags, (str, bytes)):
|
|
188
|
+
raise InvalidAsyncAPIDecoratorError("tags must be a sequence of strings")
|
|
189
|
+
try:
|
|
190
|
+
values = tuple(tags)
|
|
191
|
+
except TypeError as exc:
|
|
192
|
+
raise InvalidAsyncAPIDecoratorError(
|
|
193
|
+
"tags must be a sequence of strings"
|
|
194
|
+
) from exc
|
|
195
|
+
for index, tag in enumerate(values):
|
|
196
|
+
if not isinstance(tag, str) or not tag.strip():
|
|
197
|
+
raise InvalidAsyncAPIDecoratorError(
|
|
198
|
+
f"tags[{index}] must be a non-empty string"
|
|
199
|
+
)
|
|
200
|
+
return values
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""AsyncAPI 3 document generation and optional FastAPI mounting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
from fastapi.responses import JSONResponse
|
|
14
|
+
|
|
15
|
+
from .decorator import OperationSpec, get_operation_specs
|
|
16
|
+
from .errors import DuplicateOperationIdError, MissingYamlDependencyError
|
|
17
|
+
from .scanner import WebSocketRouteInfo, iter_websocket_routes
|
|
18
|
+
|
|
19
|
+
_ASYNCAPI_VERSION = "3.0.0"
|
|
20
|
+
_JSON_CONTENT_TYPE = "application/json"
|
|
21
|
+
_WEBSOCKET_BINDING_VERSION = "0.1.0"
|
|
22
|
+
_KEY_PATTERN = re.compile(r"[^A-Za-z0-9._-]+")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncAPIDoc:
|
|
26
|
+
"""Generate AsyncAPI 3 documentation from a FastAPI application."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
app: FastAPI,
|
|
31
|
+
*,
|
|
32
|
+
title: str,
|
|
33
|
+
version: str,
|
|
34
|
+
description: str | None = None,
|
|
35
|
+
servers: Mapping[str, Mapping[str, Any]] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
if not hasattr(app, "routes") or not hasattr(app, "add_api_route"):
|
|
38
|
+
raise TypeError("app must be a FastAPI application")
|
|
39
|
+
if not isinstance(title, str) or not title.strip():
|
|
40
|
+
raise ValueError("title must be a non-empty string")
|
|
41
|
+
if not isinstance(version, str) or not version.strip():
|
|
42
|
+
raise ValueError("version must be a non-empty string")
|
|
43
|
+
if description is not None and not isinstance(description, str):
|
|
44
|
+
raise TypeError("description must be a string or None")
|
|
45
|
+
|
|
46
|
+
self.app = app
|
|
47
|
+
self.title = title
|
|
48
|
+
self.version = version
|
|
49
|
+
self.description = description
|
|
50
|
+
self.servers = copy.deepcopy(dict(servers)) if servers is not None else None
|
|
51
|
+
self._mounted_paths: set[str] = set()
|
|
52
|
+
|
|
53
|
+
def generate(self) -> dict[str, Any]:
|
|
54
|
+
"""Build and return a fresh AsyncAPI document dictionary."""
|
|
55
|
+
|
|
56
|
+
document: dict[str, Any] = {
|
|
57
|
+
"asyncapi": _ASYNCAPI_VERSION,
|
|
58
|
+
"info": {
|
|
59
|
+
"title": self.title,
|
|
60
|
+
"version": self.version,
|
|
61
|
+
},
|
|
62
|
+
"defaultContentType": _JSON_CONTENT_TYPE,
|
|
63
|
+
"channels": {},
|
|
64
|
+
"operations": {},
|
|
65
|
+
"components": {
|
|
66
|
+
"schemas": {},
|
|
67
|
+
"messages": {},
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
if self.description is not None:
|
|
71
|
+
document["info"]["description"] = self.description
|
|
72
|
+
if self.servers is not None:
|
|
73
|
+
document["servers"] = copy.deepcopy(self.servers)
|
|
74
|
+
|
|
75
|
+
context = _GenerationContext(document)
|
|
76
|
+
for route in iter_websocket_routes(self.app.routes):
|
|
77
|
+
context.add_route(route)
|
|
78
|
+
return document
|
|
79
|
+
|
|
80
|
+
def mount(self, path: str = "/asyncapi.json", *, name: str = "asyncapi") -> None:
|
|
81
|
+
"""Mount a JSON document endpoint on the FastAPI application."""
|
|
82
|
+
|
|
83
|
+
if not isinstance(path, str) or not path.startswith("/"):
|
|
84
|
+
raise ValueError("path must be an absolute route path")
|
|
85
|
+
if not isinstance(name, str) or not name.strip():
|
|
86
|
+
raise ValueError("name must be a non-empty string")
|
|
87
|
+
if path in self._mounted_paths:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
async def asyncapi_json() -> dict[str, Any]:
|
|
91
|
+
return self.generate()
|
|
92
|
+
|
|
93
|
+
self.app.add_api_route(
|
|
94
|
+
path,
|
|
95
|
+
asyncapi_json,
|
|
96
|
+
methods=["GET"],
|
|
97
|
+
name=name,
|
|
98
|
+
include_in_schema=False,
|
|
99
|
+
response_class=JSONResponse,
|
|
100
|
+
)
|
|
101
|
+
self._mounted_paths.add(path)
|
|
102
|
+
|
|
103
|
+
def to_yaml(self, *, sort_keys: bool = False) -> str:
|
|
104
|
+
"""Serialize the generated document as YAML using the optional extra."""
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
import yaml
|
|
108
|
+
except ImportError as exc: # pragma: no cover - depends on installation
|
|
109
|
+
raise MissingYamlDependencyError(
|
|
110
|
+
"YAML support requires the optional dependency; install "
|
|
111
|
+
"fast-asyncapidoc[yaml]"
|
|
112
|
+
) from exc
|
|
113
|
+
|
|
114
|
+
return yaml.safe_dump(
|
|
115
|
+
self.generate(),
|
|
116
|
+
allow_unicode=True,
|
|
117
|
+
sort_keys=sort_keys,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class _GenerationContext:
|
|
122
|
+
def __init__(self, document: dict[str, Any]) -> None:
|
|
123
|
+
self.document = document
|
|
124
|
+
self.channels: dict[str, Any] = document["channels"]
|
|
125
|
+
self.operations: dict[str, Any] = document["operations"]
|
|
126
|
+
self.schemas: dict[str, Any] = document["components"]["schemas"]
|
|
127
|
+
self.messages: dict[str, Any] = document["components"]["messages"]
|
|
128
|
+
self.schema_keys_by_model: dict[type[Any], str] = {}
|
|
129
|
+
self.message_keys: dict[tuple[str, str, str], str] = {}
|
|
130
|
+
|
|
131
|
+
def add_route(self, route: WebSocketRouteInfo) -> None:
|
|
132
|
+
specs = get_operation_specs(route.endpoint)
|
|
133
|
+
if not specs:
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
channel_id = self._channel_id(route.path)
|
|
137
|
+
channel = self.channels.setdefault(
|
|
138
|
+
channel_id,
|
|
139
|
+
{
|
|
140
|
+
"address": route.path,
|
|
141
|
+
"bindings": {"ws": {"bindingVersion": _WEBSOCKET_BINDING_VERSION}},
|
|
142
|
+
"messages": {},
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
if channel["address"] != route.path:
|
|
146
|
+
channel_id = self._unique_key(channel_id, route.path, self.channels)
|
|
147
|
+
channel = self.channels.setdefault(
|
|
148
|
+
channel_id,
|
|
149
|
+
{
|
|
150
|
+
"address": route.path,
|
|
151
|
+
"bindings": {"ws": {"bindingVersion": _WEBSOCKET_BINDING_VERSION}},
|
|
152
|
+
"messages": {},
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
for index, spec in enumerate(specs):
|
|
157
|
+
operation_id = self._operation_id(route, spec, index)
|
|
158
|
+
has_inputs = bool(spec.inputs)
|
|
159
|
+
has_outputs = bool(spec.outputs)
|
|
160
|
+
operation: dict[str, Any] = {
|
|
161
|
+
"action": "receive" if has_inputs else "send",
|
|
162
|
+
"channel": {"$ref": f"#/channels/{channel_id}"},
|
|
163
|
+
"messages": [],
|
|
164
|
+
}
|
|
165
|
+
self._add_operation_metadata(operation, spec)
|
|
166
|
+
|
|
167
|
+
if has_inputs:
|
|
168
|
+
input_refs = [
|
|
169
|
+
self._register_message(
|
|
170
|
+
channel=channel,
|
|
171
|
+
channel_id=channel_id,
|
|
172
|
+
direction="input",
|
|
173
|
+
model=model,
|
|
174
|
+
)
|
|
175
|
+
for model in spec.inputs
|
|
176
|
+
]
|
|
177
|
+
operation["messages"] = self._message_refs(channel_id, input_refs)
|
|
178
|
+
|
|
179
|
+
if has_inputs and has_outputs:
|
|
180
|
+
output_ids = [
|
|
181
|
+
self._register_message(
|
|
182
|
+
channel=channel,
|
|
183
|
+
channel_id=channel_id,
|
|
184
|
+
direction="output",
|
|
185
|
+
model=model,
|
|
186
|
+
)
|
|
187
|
+
for model in spec.outputs
|
|
188
|
+
]
|
|
189
|
+
operation["reply"] = {
|
|
190
|
+
"channel": {"$ref": f"#/channels/{channel_id}"},
|
|
191
|
+
"messages": self._message_refs(channel_id, output_ids),
|
|
192
|
+
}
|
|
193
|
+
elif has_outputs:
|
|
194
|
+
output_ids = [
|
|
195
|
+
self._register_message(
|
|
196
|
+
channel=channel,
|
|
197
|
+
channel_id=channel_id,
|
|
198
|
+
direction="output",
|
|
199
|
+
model=model,
|
|
200
|
+
)
|
|
201
|
+
for model in spec.outputs
|
|
202
|
+
]
|
|
203
|
+
operation["messages"] = self._message_refs(channel_id, output_ids)
|
|
204
|
+
|
|
205
|
+
self.operations[operation_id] = operation
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _message_refs(channel_id: str, message_ids: list[str]) -> list[dict[str, str]]:
|
|
209
|
+
return [
|
|
210
|
+
{"$ref": f"#/channels/{channel_id}/messages/{message_id}"}
|
|
211
|
+
for message_id in message_ids
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
def _operation_id(
|
|
215
|
+
self, route: WebSocketRouteInfo, spec: OperationSpec, index: int
|
|
216
|
+
) -> str:
|
|
217
|
+
if spec.operation_id is not None:
|
|
218
|
+
if spec.operation_id in self.operations:
|
|
219
|
+
raise DuplicateOperationIdError(
|
|
220
|
+
f"duplicate operation_id: {spec.operation_id!r}"
|
|
221
|
+
)
|
|
222
|
+
return spec.operation_id
|
|
223
|
+
|
|
224
|
+
endpoint_name = getattr(route.endpoint, "__name__", None)
|
|
225
|
+
base = route.name or endpoint_name or "websocket"
|
|
226
|
+
if len(get_operation_specs(route.endpoint)) > 1:
|
|
227
|
+
base = f"{base}__{index + 1}"
|
|
228
|
+
return self._unique_key(base, f"{route.path}:{index}", self.operations)
|
|
229
|
+
|
|
230
|
+
def _add_operation_metadata(
|
|
231
|
+
self, operation: dict[str, Any], spec: OperationSpec
|
|
232
|
+
) -> None:
|
|
233
|
+
if spec.title is not None:
|
|
234
|
+
operation["title"] = spec.title
|
|
235
|
+
if spec.summary is not None:
|
|
236
|
+
operation["summary"] = spec.summary
|
|
237
|
+
if spec.description is not None:
|
|
238
|
+
operation["description"] = spec.description
|
|
239
|
+
if spec.tags:
|
|
240
|
+
operation["tags"] = [{"name": tag} for tag in spec.tags]
|
|
241
|
+
|
|
242
|
+
def _register_message(
|
|
243
|
+
self,
|
|
244
|
+
*,
|
|
245
|
+
channel: dict[str, Any],
|
|
246
|
+
channel_id: str,
|
|
247
|
+
direction: str,
|
|
248
|
+
model: type[Any],
|
|
249
|
+
) -> str:
|
|
250
|
+
schema_key = self._register_model_schema(model)
|
|
251
|
+
message_key = (channel_id, direction, schema_key)
|
|
252
|
+
message_id = self.message_keys.get(message_key)
|
|
253
|
+
if message_id is None:
|
|
254
|
+
base_message_id = _safe_key(f"{channel_id}_{direction}_{schema_key}")
|
|
255
|
+
message_id = self._unique_key(
|
|
256
|
+
base_message_id,
|
|
257
|
+
f"{channel_id}:{direction}:{schema_key}",
|
|
258
|
+
self.messages,
|
|
259
|
+
)
|
|
260
|
+
self.message_keys[message_key] = message_id
|
|
261
|
+
|
|
262
|
+
if message_id not in self.messages:
|
|
263
|
+
schema = self.schemas[schema_key]
|
|
264
|
+
message: dict[str, Any] = {
|
|
265
|
+
"name": message_id,
|
|
266
|
+
"contentType": _JSON_CONTENT_TYPE,
|
|
267
|
+
"payload": {"$ref": f"#/components/schemas/{schema_key}"},
|
|
268
|
+
}
|
|
269
|
+
if isinstance(schema.get("title"), str):
|
|
270
|
+
message["title"] = schema["title"]
|
|
271
|
+
if isinstance(schema.get("description"), str):
|
|
272
|
+
message["description"] = schema["description"]
|
|
273
|
+
self.messages[message_id] = message
|
|
274
|
+
|
|
275
|
+
channel["messages"][message_id] = {
|
|
276
|
+
"$ref": f"#/components/messages/{message_id}"
|
|
277
|
+
}
|
|
278
|
+
return message_id
|
|
279
|
+
|
|
280
|
+
def _register_model_schema(self, model: type[Any]) -> str:
|
|
281
|
+
existing_key = self.schema_keys_by_model.get(model)
|
|
282
|
+
if existing_key is not None:
|
|
283
|
+
return existing_key
|
|
284
|
+
|
|
285
|
+
raw_schema = copy.deepcopy(
|
|
286
|
+
model.model_json_schema(ref_template="#/components/schemas/{model}")
|
|
287
|
+
)
|
|
288
|
+
definitions = raw_schema.pop("$defs", {})
|
|
289
|
+
local_keys: dict[str, str] = {}
|
|
290
|
+
for name, schema in definitions.items():
|
|
291
|
+
local_keys[name] = self._schema_key(name, schema)
|
|
292
|
+
root_key = self._schema_key(model.__name__, raw_schema)
|
|
293
|
+
self.schema_keys_by_model[model] = root_key
|
|
294
|
+
|
|
295
|
+
for name, schema in definitions.items():
|
|
296
|
+
key = local_keys[name]
|
|
297
|
+
rewritten = _rewrite_schema_refs(schema, local_keys)
|
|
298
|
+
if key not in self.schemas:
|
|
299
|
+
self.schemas[key] = rewritten
|
|
300
|
+
self.schemas[root_key] = _rewrite_schema_refs(raw_schema, local_keys)
|
|
301
|
+
return root_key
|
|
302
|
+
|
|
303
|
+
def _schema_key(self, name: str, schema: Any) -> str:
|
|
304
|
+
base = _safe_key(name) or "Model"
|
|
305
|
+
return self._unique_key(base, json.dumps(schema, sort_keys=True), self.schemas)
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
def _channel_id(path: str) -> str:
|
|
309
|
+
stripped = path.strip("/")
|
|
310
|
+
if not stripped:
|
|
311
|
+
return "root"
|
|
312
|
+
return _safe_key(stripped.replace("/", "_")) or "channel"
|
|
313
|
+
|
|
314
|
+
@staticmethod
|
|
315
|
+
def _unique_key(base: str, identity: str, existing: Mapping[str, Any]) -> str:
|
|
316
|
+
if base not in existing:
|
|
317
|
+
return base
|
|
318
|
+
if identity == base:
|
|
319
|
+
return base
|
|
320
|
+
digest = hashlib.sha1(identity.encode("utf-8")).hexdigest()[:8]
|
|
321
|
+
candidate = f"{base}__{digest}"
|
|
322
|
+
counter = 2
|
|
323
|
+
while candidate in existing:
|
|
324
|
+
candidate = f"{base}__{digest}_{counter}"
|
|
325
|
+
counter += 1
|
|
326
|
+
return candidate
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _safe_key(value: str) -> str:
|
|
330
|
+
value = _KEY_PATTERN.sub("_", value).strip("._-")
|
|
331
|
+
return value or "item"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _rewrite_schema_refs(value: Any, local_keys: Mapping[str, str]) -> Any:
|
|
335
|
+
if isinstance(value, dict):
|
|
336
|
+
rewritten: dict[str, Any] = {}
|
|
337
|
+
for key, child in value.items():
|
|
338
|
+
if key == "$ref" and isinstance(child, str):
|
|
339
|
+
if child.startswith("#/$defs/"):
|
|
340
|
+
name = child.removeprefix("#/$defs/")
|
|
341
|
+
child = f"#/components/schemas/{local_keys.get(name, name)}"
|
|
342
|
+
elif child.startswith("#/components/schemas/"):
|
|
343
|
+
name = child.removeprefix("#/components/schemas/")
|
|
344
|
+
child = f"#/components/schemas/{local_keys.get(name, name)}"
|
|
345
|
+
rewritten[key] = _rewrite_schema_refs(child, local_keys)
|
|
346
|
+
return rewritten
|
|
347
|
+
if isinstance(value, list):
|
|
348
|
+
return [_rewrite_schema_refs(item, local_keys) for item in value]
|
|
349
|
+
return value
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Public exceptions raised by :mod:`fast_asyncapidoc`."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AsyncAPIDocError(Exception):
|
|
5
|
+
"""Base exception for fast-asyncapidoc errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidAsyncAPIDecoratorError(AsyncAPIDocError, ValueError):
|
|
9
|
+
"""Raised when an ``@asyncapi`` declaration is invalid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DuplicateOperationIdError(AsyncAPIDocError, ValueError):
|
|
13
|
+
"""Raised when two operations use the same explicit operation ID."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MissingYamlDependencyError(AsyncAPIDocError, RuntimeError):
|
|
17
|
+
"""Raised when YAML serialization is requested without PyYAML installed."""
|
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""FastAPI/Starlette WebSocket route discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi.routing import APIWebSocketRoute
|
|
10
|
+
from starlette.routing import Mount, WebSocketRoute
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class WebSocketRouteInfo:
|
|
15
|
+
"""The route information needed by the document generator."""
|
|
16
|
+
|
|
17
|
+
path: str
|
|
18
|
+
endpoint: Any
|
|
19
|
+
name: str | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_WEBSOCKET_ROUTE_TYPES = (APIWebSocketRoute, WebSocketRoute)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def iter_websocket_routes(routes: Iterable[Any]) -> Iterator[WebSocketRouteInfo]:
|
|
26
|
+
"""Yield WebSocket routes with their effective mounted paths.
|
|
27
|
+
|
|
28
|
+
FastAPI releases that use lazy router inclusion expose an internal
|
|
29
|
+
``_IncludedRouter`` object rather than flattening routes into
|
|
30
|
+
``app.routes``. Its capability-based ``effective_route_contexts`` method
|
|
31
|
+
gives us the final Starlette route and therefore preserves include
|
|
32
|
+
prefixes. Older FastAPI releases continue to work through the direct
|
|
33
|
+
route and Mount branches below.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
for route in routes:
|
|
37
|
+
if isinstance(route, _WEBSOCKET_ROUTE_TYPES):
|
|
38
|
+
yield _route_info(route)
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
effective_contexts = getattr(route, "effective_route_contexts", None)
|
|
42
|
+
if callable(effective_contexts):
|
|
43
|
+
for context in effective_contexts():
|
|
44
|
+
effective_route = getattr(context, "starlette_route", None)
|
|
45
|
+
original_route = getattr(context, "original_route", None)
|
|
46
|
+
if isinstance(effective_route, _WEBSOCKET_ROUTE_TYPES):
|
|
47
|
+
yield _route_info(
|
|
48
|
+
effective_route,
|
|
49
|
+
endpoint=getattr(original_route, "endpoint", None),
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
nested_routes = getattr(effective_route, "routes", None)
|
|
53
|
+
if nested_routes is not None:
|
|
54
|
+
yield from iter_websocket_routes(nested_routes)
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
if isinstance(route, Mount):
|
|
58
|
+
nested_routes = getattr(route, "routes", None)
|
|
59
|
+
if nested_routes is not None:
|
|
60
|
+
prefix = getattr(route, "path", "")
|
|
61
|
+
yield from _with_prefix(iter_websocket_routes(nested_routes), prefix)
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
nested_routes = getattr(route, "routes", None)
|
|
65
|
+
if nested_routes is not None:
|
|
66
|
+
yield from iter_websocket_routes(nested_routes)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _route_info(route: Any, *, endpoint: Any | None = None) -> WebSocketRouteInfo:
|
|
70
|
+
actual_endpoint = (
|
|
71
|
+
endpoint if endpoint is not None else getattr(route, "endpoint", None)
|
|
72
|
+
)
|
|
73
|
+
return WebSocketRouteInfo(
|
|
74
|
+
path=getattr(route, "path", "/") or "/",
|
|
75
|
+
endpoint=actual_endpoint,
|
|
76
|
+
name=getattr(route, "name", None),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _with_prefix(
|
|
81
|
+
routes: Iterable[WebSocketRouteInfo], prefix: str
|
|
82
|
+
) -> Iterator[WebSocketRouteInfo]:
|
|
83
|
+
for route in routes:
|
|
84
|
+
yield WebSocketRouteInfo(
|
|
85
|
+
path=join_paths(prefix, route.path),
|
|
86
|
+
endpoint=route.endpoint,
|
|
87
|
+
name=route.name,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def join_paths(prefix: str, path: str) -> str:
|
|
92
|
+
"""Join mounted paths while preserving a route's trailing slash."""
|
|
93
|
+
|
|
94
|
+
prefix = prefix or ""
|
|
95
|
+
path = path or "/"
|
|
96
|
+
if not prefix or prefix == "/":
|
|
97
|
+
return path if path.startswith("/") else f"/{path}"
|
|
98
|
+
if path == "/":
|
|
99
|
+
return prefix if prefix.startswith("/") else f"/{prefix}"
|
|
100
|
+
|
|
101
|
+
result = f"/{prefix.strip('/')}/{path.strip('/')}"
|
|
102
|
+
if path.endswith("/") and not result.endswith("/"):
|
|
103
|
+
result += "/"
|
|
104
|
+
return result
|