Skip to content

Commit f021f4c

Browse files
authored
Implement AsyncAPI documentation support (#244)
1 parent bc59735 commit f021f4c

40 files changed

+954
-552
lines changed

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ dev-dependencies:
3737

3838
update-dependencies:
3939
uv lock --upgrade
40+
uv sync --all-groups --frozen
4041

4142
migrate:
4243
uv run alembic upgrade heads

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ and [SOLID principles](https://en.wikipedia.org/wiki/SOLID).
2020

2121
This template provides out of the box some commonly used functionalities:
2222

23-
* API Documentation using [FastAPI](https://fastapi.tiangolo.com/)
23+
* Sync and Async API Documentation using [FastAPI](https://fastapi.tiangolo.com/) and [AsyncAPI](https://www.asyncapi.com/en)
2424
* Async tasks execution using [Dramatiq](https://dramatiq.io/index.html)
2525
* Repository pattern for databases using [SQLAlchemy](https://www.sqlalchemy.org/) and [SQLAlchemy bind manager](https://febus982.github.io/sqlalchemy-bind-manager/stable/)
2626
* Database migrations using [Alembic](https://alembic.sqlalchemy.org/en/latest/) (configured supporting both sync and async SQLAlchemy engines)

docs/api-documentation.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
API documentation is rendered by [FastAPI](https://fastapi.tiangolo.com/features/)
44
on `/docs` and `/redoc` paths using OpenAPI format.
55

6+
AsyncAPI documentation is rendered using the
7+
[AsyncAPI react components](https://github.com/asyncapi/asyncapi-react).
8+
It is available on `/docs/ws` path.
9+
610
## API versioning
711

812
Versioning an API at resource level provides a much more

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
authors = [
33
{name = "Federico Busetti", email = "[email protected]"},
44
]
5-
requires-python = "<3.14,>=3.9"
5+
requires-python = "<3.14,>=3.10"
66
name = "bootstrap-fastapi-service"
77
version = "0.1.0"
88
description = ""
@@ -14,7 +14,7 @@ dependencies = [
1414
"cloudevents-pydantic<1.0.0,>=0.0.3",
1515
"dependency-injector[pydantic]<5.0.0,>=4.41.0",
1616
"dramatiq[redis,watch]<2.0.0,>=1.17.1",
17-
"hiredis<4.0.0,>=3.1.0", # Recommended by dramatiq
17+
"hiredis<4.0.0,>=3.1.0", # Recommended by dramatiq
1818
"httpx>=0.23.0",
1919
"opentelemetry-distro[otlp]",
2020
"opentelemetry-instrumentation",
@@ -28,6 +28,7 @@ dependencies = [
2828
"SQLAlchemy[asyncio,mypy]<3.0.0,>=2.0.0",
2929
"sqlalchemy-bind-manager",
3030
"structlog<25.1.1,>=25.1.0",
31+
"pydantic-asyncapi>=0.2.1",
3132
]
3233

3334
[dependency-groups]
@@ -115,6 +116,7 @@ testpaths = [
115116

116117
[tool.ruff]
117118
target-version = "py39"
119+
line-length = 120
118120
extend-exclude = [
119121
"docs",
120122
]

src/common/asyncapi.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
from typing import List, Literal, Optional, Type
2+
3+
import pydantic_asyncapi.v3 as pa
4+
from pydantic import BaseModel
5+
6+
_info: pa.Info = pa.Info(
7+
title="AsyncAPI",
8+
version="1.0.0",
9+
)
10+
11+
12+
_servers = {} # type: ignore
13+
_channels = {} # type: ignore
14+
_operations = {} # type: ignore
15+
_components_schemas = {} # type: ignore
16+
17+
18+
def get_schema() -> pa.AsyncAPI:
19+
"""
20+
Function `get_schema` provides the complete AsyncAPI schema for the application, complying with
21+
version 3.0.0 of the AsyncAPI specification. It includes detailed information about info metadata,
22+
components, servers, channels, and operations required to set up and describe the asynchronous
23+
communication layer.
24+
25+
Returns:
26+
pa.AsyncAPI: A fully constructed AsyncAPI schema object based on predefined configurations.
27+
"""
28+
return pa.AsyncAPI(
29+
asyncapi="3.0.0",
30+
info=_info,
31+
components=pa.Components(
32+
schemas=_components_schemas,
33+
),
34+
servers=_servers,
35+
channels=_channels,
36+
operations=_operations,
37+
)
38+
39+
40+
def init_asyncapi_info(
41+
title: str,
42+
version: str = "1.0.0",
43+
) -> None:
44+
"""
45+
Initializes the AsyncAPI information object with the specified title and version.
46+
47+
This function creates and initializes an AsyncAPI Info object, which includes
48+
mandatory fields such as title and version. The title represents the name of the
49+
AsyncAPI document, and the version represents the version of the API.
50+
51+
Parameters:
52+
title (str): The title of the AsyncAPI document.
53+
version (str): The version of the AsyncAPI document. Defaults to "1.0.0".
54+
55+
Returns:
56+
None
57+
"""
58+
# We can potentially add the other info supported by pa.Info
59+
global _info
60+
_info = pa.Info(
61+
title=title,
62+
version=version,
63+
)
64+
65+
66+
def register_server(
67+
id: str,
68+
host: str,
69+
protocol: str,
70+
pathname: Optional[str] = None,
71+
) -> None:
72+
"""
73+
Registers a server with a unique identifier and its associated properties.
74+
This function accepts information about the server such as its host,
75+
protocol, and optionally its pathname, and stores it in the internal
76+
server registry identified by the unique ID. The parameters must be
77+
provided appropriately for proper registration. The server registry
78+
ensures that server configurations can be retrieved and managed based
79+
on the assigned identifier.
80+
81+
Args:
82+
id: str
83+
A unique identifier for the server being registered. It is used
84+
as the key in the internal server registry.
85+
host: str
86+
The host address of the server. This may be an IP address or
87+
a domain name.
88+
protocol: str
89+
Communication protocol used by the server, such as "http" or "https".
90+
pathname: Optional[str]
91+
The optional pathname of the server. If provided, it will be
92+
associated with the registered server.
93+
94+
Returns:
95+
None
96+
This function does not return a value. It modifies the internal
97+
server registry to include the provided server details.
98+
"""
99+
# TODO: Implement other server parameters
100+
_servers[id] = pa.Server(
101+
host=host,
102+
protocol=protocol,
103+
)
104+
if pathname is not None:
105+
_servers[id].pathname = pathname
106+
107+
108+
def _create_base_channel(address: str, channel_id: str) -> pa.Channel:
109+
"""Create a basic channel with minimum required parameters."""
110+
return pa.Channel(
111+
address=address,
112+
servers=[],
113+
messages={},
114+
)
115+
116+
117+
def _add_channel_metadata(channel: pa.Channel, description: Optional[str], title: Optional[str]) -> None:
118+
"""Add optional metadata to the channel."""
119+
if description is not None:
120+
channel.description = description
121+
if title is not None:
122+
channel.title = title
123+
124+
125+
def _add_server_reference(channel: pa.Channel, server_id: Optional[str]) -> None:
126+
"""Add server reference to the channel if server exists."""
127+
if server_id is not None and server_id in _servers:
128+
channel.servers.append(pa.Reference(ref=f"#/servers/{server_id}")) # type: ignore
129+
130+
131+
def register_channel(
132+
address: str,
133+
id: Optional[str] = None,
134+
description: Optional[str] = None,
135+
title: Optional[str] = None,
136+
server_id: Optional[str] = None,
137+
) -> None:
138+
"""
139+
Registers a communication channel with the specified parameters.
140+
141+
Args:
142+
address (str): The address of the channel.
143+
id (Optional[str]): Unique identifier for the channel. Defaults to None.
144+
description (Optional[str]): Description of the channel. Defaults to None.
145+
title (Optional[str]): Title to be associated with the channel. Defaults to None.
146+
server_id (Optional[str]): Server identifier to link this channel to. Defaults to None.
147+
148+
Returns:
149+
None
150+
"""
151+
channel_id = id or address
152+
channel = _create_base_channel(address, channel_id)
153+
_add_channel_metadata(channel, description, title)
154+
_add_server_reference(channel, server_id)
155+
_channels[channel_id] = channel
156+
157+
158+
def _register_message_schema(message: Type[BaseModel], operation_type: Literal["receive", "send"]) -> None:
159+
"""Register message schema in components schemas."""
160+
message_json_schema = message.model_json_schema(
161+
mode="validation" if operation_type == "receive" else "serialization",
162+
ref_template="#/components/schemas/{model}",
163+
)
164+
165+
_components_schemas[message.__name__] = message_json_schema
166+
167+
if message_json_schema.get("$defs"):
168+
_components_schemas.update(message_json_schema["$defs"])
169+
170+
171+
def _create_channel_message(channel_id: str, message: Type[BaseModel]) -> pa.Reference:
172+
"""Create channel message and return reference to it."""
173+
_channels[channel_id].messages[message.__name__] = pa.Message( # type: ignore
174+
payload=pa.Reference(ref=f"#/components/schemas/{message.__name__}")
175+
)
176+
return pa.Reference(ref=f"#/channels/{channel_id}/messages/{message.__name__}")
177+
178+
179+
def register_channel_operation(
180+
channel_id: str,
181+
operation_type: Literal["receive", "send"],
182+
messages: List[Type[BaseModel]],
183+
operation_name: Optional[str] = None,
184+
) -> None:
185+
"""
186+
Registerm a channel operation with associated messages.
187+
188+
Args:
189+
channel_id: Channel identifier
190+
operation_type: Type of operation ("receive" or "send")
191+
messages: List of message models
192+
operation_name: Optional operation name
193+
194+
Raises:
195+
ValueError: If channel_id doesn't exist
196+
"""
197+
if not _channels.get(channel_id):
198+
raise ValueError(f"Channel {channel_id} does not exist.")
199+
200+
operation_message_refs = []
201+
202+
for message in messages:
203+
_register_message_schema(message, operation_type)
204+
message_ref = _create_channel_message(channel_id, message)
205+
operation_message_refs.append(message_ref)
206+
207+
operation_id = operation_name or f"{channel_id}-{operation_type}"
208+
_operations[operation_id] = pa.Operation(
209+
action=operation_type,
210+
channel=pa.Reference(ref=f"#/channels/{channel_id}"),
211+
messages=operation_message_refs,
212+
traits=[],
213+
)
214+
215+
# TODO: Define operation traits
216+
# if operation_name is not None:
217+
# _operations[operation_name or f"{channel_id}-{operation_type}"].traits.append(
218+
# pa.OperationTrait(
219+
# title=operation_name,
220+
# summary=f"{operation_name} operation summary",
221+
# description=f"{operation_name} operation description",
222+
# )
223+
# )

src/common/bootstrap.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from dependency_injector.providers import Object
55
from pydantic import BaseModel, ConfigDict
66

7+
from .asyncapi import init_asyncapi_info
78
from .config import AppConfig
89
from .di_container import Container
910
from .dramatiq import init_dramatiq
@@ -27,6 +28,7 @@ def application_init(app_config: AppConfig) -> InitReference:
2728
init_logger(app_config)
2829
init_storage()
2930
init_dramatiq(app_config)
31+
init_asyncapi_info(app_config.APP_NAME)
3032

3133
return InitReference(
3234
di_container=container,

src/common/di_container.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,4 @@ def function(
6565
bind=SQLAlchemyBindManager.provided.get_bind.call(),
6666
model_class=BookModel,
6767
)
68-
BookEventGatewayInterface: Factory[BookEventGatewayInterface] = Factory(
69-
NullEventGateway
70-
)
68+
BookEventGatewayInterface: Factory[BookEventGatewayInterface] = Factory(NullEventGateway)

src/common/dramatiq.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@ def decode(self, data: bytes) -> MessageData:
2222
try:
2323
return orjson.loads(data)
2424
except orjson.JSONDecodeError as e:
25-
raise DecodeError(
26-
"failed to decode message %r" % (data,), data, e
27-
) from None
25+
raise DecodeError("failed to decode message %r" % (data,), data, e) from None
2826

2927

3028
def init_dramatiq(config: AppConfig):

src/common/logs/__init__.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,7 @@ def init_logger(config: AppConfig) -> None:
4444

4545
log_level = logging.DEBUG if config.DEBUG else logging.INFO
4646
if config.ENVIRONMENT in ["local", "test"]:
47-
shared_processors.append(
48-
structlog.processors.TimeStamper(fmt="%d-%m-%Y %H:%M:%S", utc=True)
49-
)
47+
shared_processors.append(structlog.processors.TimeStamper(fmt="%d-%m-%Y %H:%M:%S", utc=True))
5048
stdlib_processors.append(structlog.dev.ConsoleRenderer())
5149
else:
5250
shared_processors.append(structlog.processors.TimeStamper(fmt="iso", utc=True))

src/common/utils.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
def apply_decorator_to_methods(
2-
decorator, protected_methods: bool = False, private_methods: bool = False
3-
):
1+
def apply_decorator_to_methods(decorator, protected_methods: bool = False, private_methods: bool = False):
42
"""
53
Class decorator to apply a given function or coroutine decorator
64
to all functions and coroutines within a class.

0 commit comments

Comments
 (0)