From eb0f83137c8262f9febf25a454479cc7644dc3e9 Mon Sep 17 00:00:00 2001 From: Alexander Rauhut Date: Sun, 8 Mar 2026 17:25:54 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20stream=20schema=20lost=20w?= =?UTF-8?q?hen=20using=20include=5Frouter()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Propagate stream_item_type through add_api_route so that include_router preserves the already-computed stream item type for SSE and JSONL endpoints. --- fastapi/routing.py | 4 +++- tests/test_sse.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index 48c0c2153..6aa4ff243 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -926,10 +926,11 @@ def _populate_api_route_state( generate_unique_id ), strict_content_type: bool | DefaultPlaceholder = Default(True), + stream_item_type: Any | None = None, ) -> None: route.path = path route.endpoint = endpoint - route.stream_item_type = None + route.stream_item_type = stream_item_type if isinstance(response_model, DefaultPlaceholder): return_annotation = get_typed_return_annotation(endpoint) if lenient_issubclass(return_annotation, Response): @@ -1414,6 +1415,7 @@ class _EffectiveRouteContext: include_context.included_router.strict_content_type, include_context.strict_content_type, ), + stream_item_type=route.stream_item_type, ) return context diff --git a/tests/test_sse.py b/tests/test_sse.py index 86a67f8f9..474ef9499 100644 --- a/tests/test_sse.py +++ b/tests/test_sse.py @@ -96,6 +96,18 @@ async def stream_events(): yield {"msg": "world"} +@router.get("/events-typed", response_class=EventSourceResponse) +async def stream_events_typed() -> AsyncIterable[Item]: + for item in items: + yield item + + +@router.get("/events-jsonl") +async def stream_events_jsonl() -> AsyncIterable[Item]: + for item in items: + yield item + + app.include_router(router, prefix="/api") @@ -274,6 +286,49 @@ def test_sse_on_router_included_in_app(client: TestClient): assert len(data_lines) == 2 +def test_sse_router_typed_openapi_schema(client: TestClient): + """Typed SSE endpoint on a router should preserve itemSchema with contentSchema.""" + response = client.get("/openapi.json") + assert response.status_code == 200 + paths = response.json()["paths"] + sse_response = paths["/api/events-typed"]["get"]["responses"]["200"] + assert sse_response == { + "description": "Successful Response", + "content": { + "text/event-stream": { + "itemSchema": { + "type": "object", + "properties": { + "data": { + "type": "string", + "contentMediaType": "application/json", + "contentSchema": {"$ref": "#/components/schemas/Item"}, + }, + "event": {"type": "string"}, + "id": {"type": "string"}, + "retry": {"type": "integer", "minimum": 0}, + }, + "required": ["data"], + } + } + }, + } + + +def test_jsonl_router_typed_openapi_schema(client: TestClient): + """Typed JSONL endpoint on a router should preserve itemSchema.""" + response = client.get("/openapi.json") + assert response.status_code == 200 + paths = response.json()["paths"] + jsonl_response = paths["/api/events-jsonl"]["get"]["responses"]["200"] + assert jsonl_response == { + "description": "Successful Response", + "content": { + "application/jsonl": {"itemSchema": {"$ref": "#/components/schemas/Item"}} + }, + } + + # Keepalive ping tests