9 changed files with 502 additions and 233 deletions
@ -0,0 +1,2 @@ |
|||||
|
ASYNCAPI_VERSION = "2.6.0" |
||||
|
REF_PREFIX = "#/components/schemas/" |
||||
@ -0,0 +1,127 @@ |
|||||
|
from typing import Annotated |
||||
|
|
||||
|
from annotated_doc import Doc |
||||
|
from starlette.responses import HTMLResponse |
||||
|
|
||||
|
|
||||
|
def get_asyncapi_html( |
||||
|
*, |
||||
|
asyncapi_url: Annotated[ |
||||
|
str, |
||||
|
Doc( |
||||
|
""" |
||||
|
The AsyncAPI URL that AsyncAPI Studio should load and use. |
||||
|
|
||||
|
This is normally done automatically by FastAPI using the default URL |
||||
|
`/asyncapi.json`. |
||||
|
|
||||
|
Read more about it in the |
||||
|
[FastAPI docs for AsyncAPI](https://fastapi.tiangolo.com/advanced/asyncapi/). |
||||
|
""" |
||||
|
), |
||||
|
], |
||||
|
title: Annotated[ |
||||
|
str, |
||||
|
Doc( |
||||
|
""" |
||||
|
The HTML `<title>` content, normally shown in the browser tab. |
||||
|
""" |
||||
|
), |
||||
|
], |
||||
|
asyncapi_js_url: Annotated[ |
||||
|
str, |
||||
|
Doc( |
||||
|
""" |
||||
|
The URL to use to load the AsyncAPI Studio JavaScript. |
||||
|
|
||||
|
It is normally set to a CDN URL. |
||||
|
""" |
||||
|
), |
||||
|
] = "https://unpkg.com/@asyncapi/react-component@latest/browser/standalone/index.js", |
||||
|
asyncapi_favicon_url: Annotated[ |
||||
|
str, |
||||
|
Doc( |
||||
|
""" |
||||
|
The URL of the favicon to use. It is normally shown in the browser tab. |
||||
|
""" |
||||
|
), |
||||
|
] = "https://fastapi.tiangolo.com/img/favicon.png", |
||||
|
docs_url: Annotated[ |
||||
|
str | None, |
||||
|
Doc( |
||||
|
""" |
||||
|
The URL to the OpenAPI docs (Swagger UI) for navigation link. |
||||
|
""" |
||||
|
), |
||||
|
] = None, |
||||
|
) -> HTMLResponse: |
||||
|
""" |
||||
|
Generate and return the HTML that loads AsyncAPI Studio for the interactive |
||||
|
WebSocket API docs (normally served at `/asyncapi-docs`). |
||||
|
|
||||
|
You would only call this function yourself if you needed to override some parts, |
||||
|
for example the URLs to use to load AsyncAPI Studio's JavaScript. |
||||
|
""" |
||||
|
navigation_html = "" |
||||
|
if docs_url: |
||||
|
navigation_html = f""" |
||||
|
<div style="padding: 10px; background-color: #f5f5f5; border-bottom: 1px solid #ddd;"> |
||||
|
<a href="{docs_url}" style="color: #007bff; text-decoration: none; margin-right: 20px;"> |
||||
|
📄 OpenAPI Docs (REST API) |
||||
|
</a> |
||||
|
<span style="color: #666;">WebSocket API Documentation</span> |
||||
|
</div> |
||||
|
""" |
||||
|
|
||||
|
html = f""" |
||||
|
<!DOCTYPE html> |
||||
|
<html> |
||||
|
<head> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
|
<link rel="shortcut icon" href="{asyncapi_favicon_url}"> |
||||
|
<title>{title}</title> |
||||
|
<style> |
||||
|
body {{ |
||||
|
margin: 0; |
||||
|
padding: 0; |
||||
|
}} |
||||
|
#asyncapi {{ |
||||
|
height: 100vh; |
||||
|
width: 100%; |
||||
|
}} |
||||
|
</style> |
||||
|
</head> |
||||
|
<body> |
||||
|
{navigation_html} |
||||
|
<div id="asyncapi"></div> |
||||
|
<script src="{asyncapi_js_url}"></script> |
||||
|
<script> |
||||
|
(async function() {{ |
||||
|
const asyncapiSpec = await fetch('{asyncapi_url}').then(res => res.json()); |
||||
|
const AsyncApiStandalone = window.AsyncApiStandalone || window.AsyncAPIStandalone; |
||||
|
if (AsyncApiStandalone) {{ |
||||
|
AsyncApiStandalone.render({{ |
||||
|
schema: asyncapiSpec, |
||||
|
config: {{ |
||||
|
show: {{ |
||||
|
sidebar: true, |
||||
|
info: true, |
||||
|
servers: true, |
||||
|
operations: true, |
||||
|
messages: true, |
||||
|
}}, |
||||
|
}}, |
||||
|
}}, document.getElementById('asyncapi')); |
||||
|
}} else {{ |
||||
|
document.getElementById('asyncapi').innerHTML = |
||||
|
'<div style="padding: 20px; text-align: center;">' + |
||||
|
'<h2>Failed to load AsyncAPI Studio</h2>' + |
||||
|
'<p>Please check your internet connection and try again.</p>' + |
||||
|
'</div>'; |
||||
|
}} |
||||
|
}})(); |
||||
|
</script> |
||||
|
</body> |
||||
|
</html> |
||||
|
""" |
||||
|
return HTMLResponse(html) |
||||
@ -0,0 +1,224 @@ |
|||||
|
from collections.abc import Sequence |
||||
|
from typing import Any |
||||
|
|
||||
|
from pydantic import BaseModel |
||||
|
|
||||
|
from fastapi import routing |
||||
|
from fastapi.asyncapi.constants import ASYNCAPI_VERSION, REF_PREFIX |
||||
|
from fastapi.encoders import jsonable_encoder |
||||
|
from starlette.routing import BaseRoute |
||||
|
|
||||
|
|
||||
|
def get_asyncapi_channel( |
||||
|
*, |
||||
|
route: routing.APIWebSocketRoute, |
||||
|
subscribe_payload_schema: dict[str, Any] | None = None, |
||||
|
publish_payload_schema: dict[str, Any] | None = None, |
||||
|
) -> dict[str, Any]: |
||||
|
"""Generate AsyncAPI channel definition for a WebSocket route.""" |
||||
|
channel: dict[str, Any] = {} |
||||
|
|
||||
|
# WebSocket channels typically have subscribe operation |
||||
|
# (client subscribes to receive messages from server) |
||||
|
operation: dict[str, Any] = { |
||||
|
"operationId": route.name or f"websocket_{route.path_format}", |
||||
|
} |
||||
|
|
||||
|
# Message schema: contentType and optional payload (schema for message body) |
||||
|
subscribe_message: dict[str, Any] = { |
||||
|
"contentType": "application/json", |
||||
|
} |
||||
|
if subscribe_payload_schema: |
||||
|
subscribe_message["payload"] = subscribe_payload_schema |
||||
|
|
||||
|
operation["message"] = subscribe_message |
||||
|
channel["subscribe"] = operation |
||||
|
|
||||
|
# WebSockets are bidirectional, so we also include publish |
||||
|
# (client can publish messages to server) |
||||
|
publish_operation: dict[str, Any] = { |
||||
|
"operationId": f"{route.name or f'websocket_{route.path_format}'}_publish", |
||||
|
"message": { |
||||
|
"contentType": "application/json", |
||||
|
**({"payload": publish_payload_schema} if publish_payload_schema else {}), |
||||
|
}, |
||||
|
} |
||||
|
channel["publish"] = publish_operation |
||||
|
|
||||
|
return channel |
||||
|
|
||||
|
|
||||
|
def _get_fields_from_websocket_routes( |
||||
|
routes: Sequence[BaseRoute], |
||||
|
) -> list[Any]: |
||||
|
"""Collect body (ModelField) params from WebSocket routes for schema generation.""" |
||||
|
from fastapi.dependencies.utils import get_flat_dependant |
||||
|
|
||||
|
from fastapi._compat import ModelField |
||||
|
from pydantic.fields import FieldInfo |
||||
|
|
||||
|
fields: list[Any] = [] |
||||
|
seen_models: set[type[BaseModel]] = set() |
||||
|
for route in routes or []: |
||||
|
if not isinstance(route, routing.APIWebSocketRoute): |
||||
|
continue |
||||
|
flat_dependant = get_flat_dependant(route.dependant, skip_repeats=True) |
||||
|
fields.extend(flat_dependant.body_params) |
||||
|
# Add explicit subscribe_schema / publish_schema as ModelFields so they get definitions |
||||
|
for model in ( |
||||
|
getattr(route, "subscribe_schema", None), |
||||
|
getattr(route, "publish_schema", None), |
||||
|
): |
||||
|
if model is not None and isinstance(model, type) and issubclass(model, BaseModel) and model not in seen_models: |
||||
|
seen_models.add(model) |
||||
|
fields.append( |
||||
|
ModelField( |
||||
|
field_info=FieldInfo(annotation=model), |
||||
|
name=model.__name__, |
||||
|
mode="validation", |
||||
|
) |
||||
|
) |
||||
|
return fields |
||||
|
|
||||
|
|
||||
|
def get_asyncapi( |
||||
|
*, |
||||
|
title: str, |
||||
|
version: str, |
||||
|
asyncapi_version: str = ASYNCAPI_VERSION, |
||||
|
summary: str | None = None, |
||||
|
description: str | None = None, |
||||
|
routes: Sequence[BaseRoute], |
||||
|
servers: list[dict[str, str | Any]] | None = None, |
||||
|
terms_of_service: str | None = None, |
||||
|
contact: dict[str, str | Any] | None = None, |
||||
|
license_info: dict[str, str | Any] | None = None, |
||||
|
external_docs: dict[str, Any] | None = None, |
||||
|
) -> dict[str, Any]: |
||||
|
""" |
||||
|
Generate AsyncAPI schema from FastAPI application routes. |
||||
|
|
||||
|
Filters for WebSocket routes and generates AsyncAPI 2.6.0 compliant schema. |
||||
|
Includes components/schemas for message payloads when WebSocket routes use |
||||
|
Pydantic models (e.g. via Body() in dependencies). |
||||
|
""" |
||||
|
from fastapi._compat import ( |
||||
|
ModelField, |
||||
|
get_definitions, |
||||
|
get_flat_models_from_fields, |
||||
|
get_model_name_map, |
||||
|
get_schema_from_model_field, |
||||
|
) |
||||
|
|
||||
|
info: dict[str, Any] = {"title": title, "version": version} |
||||
|
if summary: |
||||
|
info["summary"] = summary |
||||
|
if description: |
||||
|
info["description"] = description |
||||
|
if terms_of_service: |
||||
|
info["termsOfService"] = terms_of_service |
||||
|
if contact: |
||||
|
info["contact"] = contact |
||||
|
if license_info: |
||||
|
info["license"] = license_info |
||||
|
|
||||
|
output: dict[str, Any] = {"asyncapi": asyncapi_version, "info": info} |
||||
|
|
||||
|
# Add default WebSocket server if no servers provided and we have WebSocket routes |
||||
|
websocket_routes = [ |
||||
|
route for route in routes or [] if isinstance(route, routing.APIWebSocketRoute) |
||||
|
] |
||||
|
if websocket_routes and not servers: |
||||
|
# Default WebSocket server - can be overridden by providing servers parameter |
||||
|
output["servers"] = [ |
||||
|
{ |
||||
|
"url": "ws://localhost:8000", |
||||
|
"protocol": "ws", |
||||
|
"description": "WebSocket server", |
||||
|
} |
||||
|
] |
||||
|
elif servers: |
||||
|
output["servers"] = servers |
||||
|
|
||||
|
# Build components/schemas from WebSocket body params and explicit subscribe/publish_schema |
||||
|
ws_fields = _get_fields_from_websocket_routes(routes or []) |
||||
|
components: dict[str, Any] = {} |
||||
|
route_subscribe_schemas: dict[str, dict[str, Any] | None] = {} |
||||
|
route_publish_schemas: dict[str, dict[str, Any] | None] = {} |
||||
|
if ws_fields: |
||||
|
flat_models = get_flat_models_from_fields(ws_fields, known_models=set()) |
||||
|
model_name_map = get_model_name_map(flat_models) |
||||
|
field_mapping, definitions = get_definitions( |
||||
|
fields=ws_fields, |
||||
|
model_name_map=model_name_map, |
||||
|
separate_input_output_schemas=True, |
||||
|
) |
||||
|
if definitions: |
||||
|
components["schemas"] = {k: definitions[k] for k in sorted(definitions)} |
||||
|
# For each WebSocket route, resolve subscribe and publish payload schemas |
||||
|
for route in routes or []: |
||||
|
if not isinstance(route, routing.APIWebSocketRoute): |
||||
|
continue |
||||
|
sub_schema: dict[str, Any] | None = None |
||||
|
pub_schema: dict[str, Any] | None = None |
||||
|
# Explicit subscribe_schema / publish_schema (e.g. when route has no Body() in Depends) |
||||
|
subscribe_model = getattr(route, "subscribe_schema", None) |
||||
|
publish_model = getattr(route, "publish_schema", None) |
||||
|
if subscribe_model is not None and isinstance(subscribe_model, type) and issubclass(subscribe_model, BaseModel): |
||||
|
sub_schema = {"$ref": f"{REF_PREFIX}{subscribe_model.__name__}"} |
||||
|
if publish_model is not None and isinstance(publish_model, type) and issubclass(publish_model, BaseModel): |
||||
|
pub_schema = {"$ref": f"{REF_PREFIX}{publish_model.__name__}"} |
||||
|
# Fall back to first body param (Depends with Body()) for both if not set |
||||
|
if sub_schema is None or pub_schema is None: |
||||
|
flat_dependant = route._flat_dependant |
||||
|
if flat_dependant.body_params: |
||||
|
first_body = flat_dependant.body_params[0] |
||||
|
if isinstance(first_body, ModelField): |
||||
|
body_schema = get_schema_from_model_field( |
||||
|
field=first_body, |
||||
|
model_name_map=model_name_map, |
||||
|
field_mapping=field_mapping, |
||||
|
separate_input_output_schemas=True, |
||||
|
) |
||||
|
# Use only $ref for channel payload when schema is in components |
||||
|
if "$ref" in body_schema and body_schema["$ref"].startswith( |
||||
|
REF_PREFIX |
||||
|
): |
||||
|
body_schema = {"$ref": body_schema["$ref"]} |
||||
|
if sub_schema is None: |
||||
|
sub_schema = body_schema |
||||
|
if pub_schema is None: |
||||
|
pub_schema = body_schema |
||||
|
route_subscribe_schemas[route.path_format] = sub_schema |
||||
|
route_publish_schemas[route.path_format] = pub_schema |
||||
|
else: |
||||
|
for route in routes or []: |
||||
|
if not isinstance(route, routing.APIWebSocketRoute): |
||||
|
continue |
||||
|
route_subscribe_schemas[route.path_format] = None |
||||
|
route_publish_schemas[route.path_format] = None |
||||
|
|
||||
|
channels: dict[str, dict[str, Any]] = {} |
||||
|
|
||||
|
# Filter routes to only include WebSocket routes |
||||
|
for route in routes or []: |
||||
|
if isinstance(route, routing.APIWebSocketRoute): |
||||
|
sub_schema = route_subscribe_schemas.get(route.path_format) |
||||
|
pub_schema = route_publish_schemas.get(route.path_format) |
||||
|
channel = get_asyncapi_channel( |
||||
|
route=route, |
||||
|
subscribe_payload_schema=sub_schema, |
||||
|
publish_payload_schema=pub_schema, |
||||
|
) |
||||
|
if channel: |
||||
|
channels[route.path_format] = channel |
||||
|
|
||||
|
output["channels"] = channels |
||||
|
|
||||
|
if components: |
||||
|
output["components"] = components |
||||
|
|
||||
|
if external_docs: |
||||
|
output["externalDocs"] = external_docs |
||||
|
|
||||
|
return jsonable_encoder(output, by_alias=True, exclude_none=True) # type: ignore |
||||
@ -1,105 +0,0 @@ |
|||||
from collections.abc import Sequence |
|
||||
from typing import Any |
|
||||
|
|
||||
from fastapi import routing |
|
||||
from fastapi.encoders import jsonable_encoder |
|
||||
from starlette.routing import BaseRoute |
|
||||
|
|
||||
|
|
||||
def get_asyncapi_channel( |
|
||||
*, |
|
||||
route: routing.APIWebSocketRoute, |
|
||||
) -> dict[str, Any]: |
|
||||
"""Generate AsyncAPI channel definition for a WebSocket route.""" |
|
||||
channel: dict[str, Any] = {} |
|
||||
|
|
||||
# WebSocket channels typically have subscribe operation |
|
||||
# (client subscribes to receive messages from server) |
|
||||
operation: dict[str, Any] = { |
|
||||
"operationId": route.name or f"websocket_{route.path_format}", |
|
||||
} |
|
||||
|
|
||||
# Basic message schema - can be enhanced later with actual message types |
|
||||
# For WebSockets, messages can be sent in both directions |
|
||||
message: dict[str, Any] = { |
|
||||
"contentType": "application/json", |
|
||||
} |
|
||||
|
|
||||
operation["message"] = message |
|
||||
channel["subscribe"] = operation |
|
||||
|
|
||||
# WebSockets are bidirectional, so we also include publish |
|
||||
# (client can publish messages to server) |
|
||||
publish_operation: dict[str, Any] = { |
|
||||
"operationId": f"{route.name or f'websocket_{route.path_format}'}_publish", |
|
||||
"message": message, |
|
||||
} |
|
||||
channel["publish"] = publish_operation |
|
||||
|
|
||||
return channel |
|
||||
|
|
||||
|
|
||||
def get_asyncapi( |
|
||||
*, |
|
||||
title: str, |
|
||||
version: str, |
|
||||
asyncapi_version: str = "2.6.0", |
|
||||
summary: str | None = None, |
|
||||
description: str | None = None, |
|
||||
routes: Sequence[BaseRoute], |
|
||||
servers: list[dict[str, str | Any]] | None = None, |
|
||||
terms_of_service: str | None = None, |
|
||||
contact: dict[str, str | Any] | None = None, |
|
||||
license_info: dict[str, str | Any] | None = None, |
|
||||
external_docs: dict[str, Any] | None = None, |
|
||||
) -> dict[str, Any]: |
|
||||
""" |
|
||||
Generate AsyncAPI schema from FastAPI application routes. |
|
||||
|
|
||||
Filters for WebSocket routes and generates AsyncAPI 2.6.0 compliant schema. |
|
||||
""" |
|
||||
info: dict[str, Any] = {"title": title, "version": version} |
|
||||
if summary: |
|
||||
info["summary"] = summary |
|
||||
if description: |
|
||||
info["description"] = description |
|
||||
if terms_of_service: |
|
||||
info["termsOfService"] = terms_of_service |
|
||||
if contact: |
|
||||
info["contact"] = contact |
|
||||
if license_info: |
|
||||
info["license"] = license_info |
|
||||
|
|
||||
output: dict[str, Any] = {"asyncapi": asyncapi_version, "info": info} |
|
||||
|
|
||||
# Add default WebSocket server if no servers provided and we have WebSocket routes |
|
||||
websocket_routes = [ |
|
||||
route for route in routes or [] if isinstance(route, routing.APIWebSocketRoute) |
|
||||
] |
|
||||
if websocket_routes and not servers: |
|
||||
# Default WebSocket server - can be overridden by providing servers parameter |
|
||||
output["servers"] = [ |
|
||||
{ |
|
||||
"url": "ws://localhost:8000", |
|
||||
"protocol": "ws", |
|
||||
"description": "WebSocket server", |
|
||||
} |
|
||||
] |
|
||||
elif servers: |
|
||||
output["servers"] = servers |
|
||||
|
|
||||
channels: dict[str, dict[str, Any]] = {} |
|
||||
|
|
||||
# Filter routes to only include WebSocket routes |
|
||||
for route in routes or []: |
|
||||
if isinstance(route, routing.APIWebSocketRoute): |
|
||||
channel = get_asyncapi_channel(route=route) |
|
||||
if channel: |
|
||||
channels[route.path_format] = channel |
|
||||
|
|
||||
output["channels"] = channels |
|
||||
|
|
||||
if external_docs: |
|
||||
output["externalDocs"] = external_docs |
|
||||
|
|
||||
return jsonable_encoder(output, by_alias=True, exclude_none=True) # type: ignore |
|
||||
Loading…
Reference in new issue