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.
@@ -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