4 changed files with 626 additions and 0 deletions
@ -0,0 +1,106 @@ |
|||||
|
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) |
||||
|
|
||||
@ -0,0 +1,262 @@ |
|||||
|
from fastapi import FastAPI, WebSocket |
||||
|
from fastapi.testclient import TestClient |
||||
|
|
||||
|
|
||||
|
def test_asyncapi_schema(): |
||||
|
"""Test AsyncAPI schema endpoint with WebSocket routes.""" |
||||
|
app = FastAPI(title="Test API", version="1.0.0") |
||||
|
|
||||
|
@app.websocket("/ws") |
||||
|
async def websocket_endpoint(websocket: WebSocket): |
||||
|
await websocket.accept() |
||||
|
await websocket.close() |
||||
|
|
||||
|
@app.websocket("/ws/{item_id}") |
||||
|
async def websocket_with_param(websocket: WebSocket, item_id: str): |
||||
|
await websocket.accept() |
||||
|
await websocket.close() |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
response = client.get("/asyncapi.json") |
||||
|
assert response.status_code == 200, response.text |
||||
|
schema = response.json() |
||||
|
assert schema["asyncapi"] == "2.6.0" |
||||
|
assert schema["info"]["title"] == "Test API" |
||||
|
assert schema["info"]["version"] == "1.0.0" |
||||
|
assert "channels" in schema |
||||
|
assert "/ws" in schema["channels"] |
||||
|
assert "/ws/{item_id}" in schema["channels"] |
||||
|
|
||||
|
|
||||
|
def test_asyncapi_no_websockets(): |
||||
|
"""Test AsyncAPI schema with no WebSocket routes.""" |
||||
|
app = FastAPI(title="Test API", version="1.0.0") |
||||
|
|
||||
|
@app.get("/") |
||||
|
def read_root(): |
||||
|
return {"message": "Hello World"} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
response = client.get("/asyncapi.json") |
||||
|
assert response.status_code == 200, response.text |
||||
|
schema = response.json() |
||||
|
assert schema["asyncapi"] == "2.6.0" |
||||
|
assert schema["info"]["title"] == "Test API" |
||||
|
assert schema["channels"] == {} |
||||
|
|
||||
|
|
||||
|
def test_asyncapi_caching(): |
||||
|
"""Test that AsyncAPI schema is cached.""" |
||||
|
app = FastAPI(title="Test API", version="1.0.0") |
||||
|
|
||||
|
@app.websocket("/ws") |
||||
|
async def websocket_endpoint(websocket: WebSocket): |
||||
|
await websocket.accept() |
||||
|
await websocket.close() |
||||
|
|
||||
|
schema1 = app.asyncapi() |
||||
|
schema2 = app.asyncapi() |
||||
|
# Should return the same object (identity check) |
||||
|
assert schema1 is schema2 |
||||
|
|
||||
|
|
||||
|
def test_asyncapi_ui(): |
||||
|
"""Test AsyncAPI UI endpoint.""" |
||||
|
app = FastAPI(title="Test API", version="1.0.0") |
||||
|
|
||||
|
@app.websocket("/ws") |
||||
|
async def websocket_endpoint(websocket: WebSocket): |
||||
|
await websocket.accept() |
||||
|
await websocket.close() |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
response = client.get("/asyncapi-docs") |
||||
|
assert response.status_code == 200, response.text |
||||
|
assert response.headers["content-type"] == "text/html; charset=utf-8" |
||||
|
assert "@asyncapi/react-component" in response.text |
||||
|
assert "/asyncapi.json" in response.text |
||||
|
|
||||
|
|
||||
|
def test_asyncapi_ui_navigation(): |
||||
|
"""Test navigation links in AsyncAPI UI.""" |
||||
|
app = FastAPI(title="Test API", version="1.0.0") |
||||
|
|
||||
|
@app.websocket("/ws") |
||||
|
async def websocket_endpoint(websocket: WebSocket): |
||||
|
await websocket.accept() |
||||
|
await websocket.close() |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
response = client.get("/asyncapi-docs") |
||||
|
assert response.status_code == 200, response.text |
||||
|
# Should contain link to OpenAPI docs |
||||
|
assert "/docs" in response.text |
||||
|
assert "OpenAPI Docs" in response.text |
||||
|
|
||||
|
|
||||
|
def test_swagger_ui_asyncapi_navigation(): |
||||
|
"""Test navigation link to AsyncAPI in Swagger UI.""" |
||||
|
app = FastAPI(title="Test API", version="1.0.0") |
||||
|
|
||||
|
@app.get("/") |
||||
|
def read_root(): |
||||
|
return {"message": "Hello World"} |
||||
|
|
||||
|
@app.websocket("/ws") |
||||
|
async def websocket_endpoint(websocket: WebSocket): |
||||
|
await websocket.accept() |
||||
|
await websocket.close() |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
response = client.get("/docs") |
||||
|
assert response.status_code == 200, response.text |
||||
|
# Should contain link to AsyncAPI docs |
||||
|
assert "/asyncapi-docs" in response.text |
||||
|
assert "AsyncAPI Docs" in response.text |
||||
|
|
||||
|
|
||||
|
def test_asyncapi_custom_urls(): |
||||
|
"""Test custom AsyncAPI URLs.""" |
||||
|
app = FastAPI( |
||||
|
title="Test API", |
||||
|
version="1.0.0", |
||||
|
asyncapi_url="/custom/asyncapi.json", |
||||
|
asyncapi_docs_url="/custom/asyncapi-docs", |
||||
|
) |
||||
|
|
||||
|
@app.websocket("/ws") |
||||
|
async def websocket_endpoint(websocket: WebSocket): |
||||
|
await websocket.accept() |
||||
|
await websocket.close() |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
# Test custom JSON endpoint |
||||
|
response = client.get("/custom/asyncapi.json") |
||||
|
assert response.status_code == 200, response.text |
||||
|
schema = response.json() |
||||
|
assert schema["asyncapi"] == "2.6.0" |
||||
|
|
||||
|
# Test custom UI endpoint |
||||
|
response = client.get("/custom/asyncapi-docs") |
||||
|
assert response.status_code == 200, response.text |
||||
|
assert "/custom/asyncapi.json" in response.text |
||||
|
|
||||
|
# Default endpoints should not exist |
||||
|
response = client.get("/asyncapi.json") |
||||
|
assert response.status_code == 404 |
||||
|
response = client.get("/asyncapi-docs") |
||||
|
assert response.status_code == 404 |
||||
|
|
||||
|
|
||||
|
def test_asyncapi_disabled(): |
||||
|
"""Test when AsyncAPI is disabled.""" |
||||
|
app = FastAPI( |
||||
|
title="Test API", |
||||
|
version="1.0.0", |
||||
|
asyncapi_url=None, |
||||
|
) |
||||
|
|
||||
|
@app.websocket("/ws") |
||||
|
async def websocket_endpoint(websocket: WebSocket): |
||||
|
await websocket.accept() |
||||
|
await websocket.close() |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
# Endpoints should return 404 |
||||
|
response = client.get("/asyncapi.json") |
||||
|
assert response.status_code == 404 |
||||
|
response = client.get("/asyncapi-docs") |
||||
|
assert response.status_code == 404 |
||||
|
|
||||
|
|
||||
|
def test_asyncapi_channel_structure(): |
||||
|
"""Test AsyncAPI channel structure.""" |
||||
|
app = FastAPI(title="Test API", version="1.0.0") |
||||
|
|
||||
|
@app.websocket("/ws") |
||||
|
async def websocket_endpoint(websocket: WebSocket): |
||||
|
await websocket.accept() |
||||
|
await websocket.close() |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
response = client.get("/asyncapi.json") |
||||
|
assert response.status_code == 200, response.text |
||||
|
schema = response.json() |
||||
|
channel = schema["channels"]["/ws"] |
||||
|
assert "subscribe" in channel |
||||
|
assert "operationId" in channel["subscribe"] |
||||
|
assert "message" in channel["subscribe"] |
||||
|
|
||||
|
|
||||
|
def test_asyncapi_multiple_websockets(): |
||||
|
"""Test AsyncAPI with multiple WebSocket routes.""" |
||||
|
app = FastAPI(title="Test API", version="1.0.0") |
||||
|
|
||||
|
@app.websocket("/ws1") |
||||
|
async def websocket1(websocket: WebSocket): |
||||
|
await websocket.accept() |
||||
|
await websocket.close() |
||||
|
|
||||
|
@app.websocket("/ws2") |
||||
|
async def websocket2(websocket: WebSocket): |
||||
|
await websocket.accept() |
||||
|
await websocket.close() |
||||
|
|
||||
|
@app.websocket("/ws3/{param}") |
||||
|
async def websocket3(websocket: WebSocket, param: str): |
||||
|
await websocket.accept() |
||||
|
await websocket.close() |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
response = client.get("/asyncapi.json") |
||||
|
assert response.status_code == 200, response.text |
||||
|
schema = response.json() |
||||
|
assert len(schema["channels"]) == 3 |
||||
|
assert "/ws1" in schema["channels"] |
||||
|
assert "/ws2" in schema["channels"] |
||||
|
assert "/ws3/{param}" in schema["channels"] |
||||
|
|
||||
|
|
||||
|
def test_asyncapi_with_metadata(): |
||||
|
"""Test AsyncAPI schema includes app metadata.""" |
||||
|
app = FastAPI( |
||||
|
title="My API", |
||||
|
version="2.0.0", |
||||
|
summary="Test summary", |
||||
|
description="Test description", |
||||
|
) |
||||
|
|
||||
|
@app.websocket("/ws") |
||||
|
async def websocket_endpoint(websocket: WebSocket): |
||||
|
await websocket.accept() |
||||
|
await websocket.close() |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
response = client.get("/asyncapi.json") |
||||
|
assert response.status_code == 200, response.text |
||||
|
schema = response.json() |
||||
|
assert schema["info"]["title"] == "My API" |
||||
|
assert schema["info"]["version"] == "2.0.0" |
||||
|
assert schema["info"]["summary"] == "Test summary" |
||||
|
assert schema["info"]["description"] == "Test description" |
||||
|
|
||||
|
|
||||
|
def test_asyncapi_ui_no_docs_url(): |
||||
|
"""Test AsyncAPI UI when docs_url is None.""" |
||||
|
app = FastAPI( |
||||
|
title="Test API", |
||||
|
version="1.0.0", |
||||
|
docs_url=None, |
||||
|
) |
||||
|
|
||||
|
@app.websocket("/ws") |
||||
|
async def websocket_endpoint(websocket: WebSocket): |
||||
|
await websocket.accept() |
||||
|
await websocket.close() |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
response = client.get("/asyncapi-docs") |
||||
|
assert response.status_code == 200, response.text |
||||
|
# Should not contain link to /docs if docs_url is None |
||||
|
# But navigation should still work (just won't show the link) |
||||
|
assert "/asyncapi.json" in response.text |
||||
Loading…
Reference in new issue