Browse Source

Fix SSE stream_item_type lost through include_router (#15401)

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
pull/15426/head
rzsultan-boop 2 months ago
parent
commit
ebb37520b9
  1. 11
      fastapi/routing.py
  2. 105
      tests/test_sse.py

11
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(

105
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

Loading…
Cancel
Save