From ebb37520b96df7779018df1441195b51ff19579c Mon Sep 17 00:00:00 2001 From: rzsultan-boop Date: Fri, 24 Apr 2026 17:06:47 +0300 Subject: [PATCH] Fix SSE stream_item_type lost through include_router (#15401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When SSE/JSONL endpoints are defined on an APIRouter and included via include_router(), the stream_item_type and stream_item_field attributes were dropped because the detection in APIRoute.__init__ only runs when response_model is a DefaultPlaceholder — but include_router passes the already-resolved value. After add_api_route, copy both stream_item_type and stream_item_field from the original route, then re-bake self.app so the handler closure captures the correct stream_item_field for runtime validation. Without re-baking, both runtime validation and OpenAPI schema generation remain broken because: - get_route_handler() closes over stream_item_field at __init__ time - openapi/utils.py reads stream_item_field, not stream_item_type --- fastapi/routing.py | 11 +++++ tests/test_sse.py | 105 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/fastapi/routing.py b/fastapi/routing.py index 36acb6b89d..b773675876 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -1794,6 +1794,17 @@ class APIRouter(routing.Router): self.strict_content_type, ), ) + # include_router calls add_api_route with response_model=route.response_model + # (already resolved to None), so the detection block in APIRoute.__init__ + # (lines 847-865) is skipped — both stream_item_type and stream_item_field + # remain None. The handler at __init__ line 976 closes over stream_item_field, + # and OpenAPI utils reads stream_item_field (not stream_item_type), so we + # must restore both and re-bake the handler. + if route.stream_item_type is not None: + new_route = self.routes[-1] + new_route.stream_item_type = route.stream_item_type + new_route.stream_item_field = route.stream_item_field + new_route.app = request_response(new_route.get_route_handler()) elif isinstance(route, routing.Route): methods = list(route.methods or []) self.add_route( diff --git a/tests/test_sse.py b/tests/test_sse.py index 6dfec61838..bb540f57d8 100644 --- a/tests/test_sse.py +++ b/tests/test_sse.py @@ -316,3 +316,108 @@ def test_no_keepalive_when_fast(client: TestClient): assert response.status_code == 200 # KEEPALIVE_COMMENT is ": ping\n\n". assert ": ping\n" not in response.text + + +def test_stream_item_type_preserved_through_include_router(): + """SSE stream_item_type must survive include_router so validation/schema generation works. + + Regression test for https://github.com/fastapi/fastapi/issues/15401 + """ + + class Message(BaseModel): + text: str + + router = APIRouter() + + @router.get("/events-typed", response_class=EventSourceResponse) + async def stream() -> AsyncIterable[Message]: + yield Message(text="hello") + + app = FastAPI() + app.include_router(router, prefix="/api") + + # Find the included route + api_route = None + for route in app.routes: + if isinstance(route, fastapi.routing.APIRoute) and route.path == "/api/events-typed": + api_route = route + break + + assert api_route is not None + assert ( + api_route.stream_item_type is not None + ), "stream_item_type was dropped during include_router" + assert api_route.stream_item_type is Message + # stream_item_field must also be set — OpenAPI schema generation reads + # stream_item_field (not stream_item_type), and the route handler + # closes over stream_item_field at __init__ time. + assert api_route.stream_item_field is not None, ( + "stream_item_field was not restored after include_router" + ) + + +def test_sse_stream_item_field_set_on_direct_route(client: TestClient): + """Routes defined directly on the app should have stream_item_field set. + + Sanity check: verifies the baseline before testing include_router. + """ + api_route = None + for route in app.routes: + if isinstance(route, fastapi.routing.APIRoute) and route.path == "/items/stream": + api_route = route + break + assert api_route is not None + assert api_route.stream_item_field is not None + assert api_route.stream_item_field.field_info.annotation is Item + + +def test_sse_openapi_schema_after_include_router(): + """OpenAPI schema must include the stream item type for SSE endpoints on included routers. + + Regression test for https://github.com/fastapi/fastapi/issues/15401 + """ + + class Message(BaseModel): + text: str + + router = APIRouter() + + @router.get("/events-schema", response_class=EventSourceResponse) + async def stream_schema() -> AsyncIterable[Message]: + yield Message(text="hello") + + app = FastAPI() + app.include_router(router, prefix="/api") + + schema = app.openapi() + # The Message model should appear in the schema components + assert "Message" in schema["components"]["schemas"], ( + "stream_item_type not reflected in OpenAPI schema after include_router" + ) + + +def test_sse_runtime_serialization_after_include_router(): + """SSE endpoint on an included router must serialize items through the + validation path at runtime — proves the handler was re-baked with + stream_item_field. + + Regression test for https://github.com/fastapi/fastapi/issues/15401 + """ + + class Message(BaseModel): + text: str + + router = APIRouter() + + @router.get("/events-runtime", response_class=EventSourceResponse) + async def stream_runtime() -> AsyncIterable[Message]: + yield Message(text="validated") + + app = FastAPI() + app.include_router(router, prefix="/api") + + with TestClient(app) as c: + response = c.get("/api/events-runtime") + assert response.status_code == 200 + # The serialized data should contain the text field from the model + assert '"text": "validated"' in response.text or '"text":"validated"' in response.text