🐛 Propagate stream_item_type through include_router for SSE / JSONL routes (#15401)#15497
Open
jbbqqf wants to merge 1 commit into
Open
🐛 Propagate stream_item_type through include_router for SSE / JSONL routes (#15401)#15497jbbqqf wants to merge 1 commit into
stream_item_type through include_router for SSE / JSONL routes (#15401)#15497jbbqqf wants to merge 1 commit into
Conversation
…NL routes (fastapi#15401) When an SSE route (or JSONL streaming route) is defined on an `APIRouter` and merged onto a `FastAPI` app via `include_router`, the merged route silently lost its `stream_item_type`. As a consequence the emitted OpenAPI schema dropped the `contentSchema` describing the streamed item and downstream tools (`datamodel-codegen`, etc.) could no longer generate frame models from the spec. Root cause: `APIRoute.__init__` only ran stream-item detection inside the `isinstance(response_model, DefaultPlaceholder)` branch. The source route's `__init__` collapses `self.response_model` to `None` after detection, so when `APIRouter.include_router` re-instantiates the route via `add_api_route(response_model=route.response_model, ...)` the new init sees an explicit `None` and skips detection entirely. Fix: also run detection when `response_model is None` on entry, and restrict the "promote return annotation to response_model" fallback to the original `DefaultPlaceholder` case so an explicit `response_model= None` still disables response validation as documented. Adds a regression test asserting both `stream_item_type` propagation and the presence of `contentSchema` in the merged route's OpenAPI. Co-Authored-By: Claude Code <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When an SSE (or JSONL streaming) route is defined on an
APIRouterand merged onto aFastAPIapp viainclude_router, the merged route silently lost itsstream_item_type. The OpenAPI schema then dropped thecontentSchemaunderresponses.200.content["text/event-stream"].itemSchema.properties.data, so tools likedatamodel-codegencould no longer generate frame models from the spec. This PR makesstream_item_typepropagate throughinclude_router, with a regression test.Fixes #15401 — SSE
stream_item_typenot propagated throughAPIRouter+include_routerContext
APIRoute.__init__only ran the stream-item detection inside theisinstance(response_model, DefaultPlaceholder)branch:The source route's
__init__then assignsself.response_model = None. WhenAPIRouter.include_routerrebuilds the route, it callsadd_api_route(response_model=route.response_model, ...)— passing the now-Nonevalue, which is not aDefaultPlaceholder. The detection branch is skipped on the merged route andself.stream_item_typestays at its initialNone. The same gating affects JSONL streaming for the same reason.Changes
fastapi/routing.py— extend the entry condition for stream-item detection to also fire whenresponse_model is Noneon entry. Restrict the "promote return annotation toresponse_model" fallback to the originalDefaultPlaceholdercase so an explicitresponse_model=Nonestill disables response validation as documented. A code comment captures the why so a reviewer reading the diff cold doesn't have to re-derive the chain throughinclude_router.tests/test_sse.py— addstest_stream_item_type_propagated_through_include_routerasserting both (a)stream_item_typeis set on the merged route and (b) the emitted OpenAPI containscontentSchemaunder the SSE response.Reproduce BEFORE/AFTER yourself (copy-paste)
A reviewer can verify this fix in ≤60s by pasting the block below.
What I ran locally
pytest tests/test_sse.py -v→ 19/19 passed (including the new regression test).pytest tests/test_sse.py tests/test_application.py tests/test_router_redirect_slashes.py tests/test_router_events.py tests/test_stream_bare_type.py tests/test_stream_cancellation.py tests/test_stream_json_validation_error.py tests/test_dependency_after_yield_streaming.py tests/test_openapi_examples.py→ 56/56 passed.tests/test_tutorial):pytest tests/ -q→ 1840 passed, 10 skipped (1 skip pre-existing).ruff check fastapi/routing.py tests/test_sse.py→ clean.ruff format --check fastapi/routing.py tests/test_sse.py→ 2 files already formatted.origin/master(assertsmerged_route.stream_item_type is Frame, getsNone) and passes on this branch.Edge cases tested
APIRoutertheninclude_router@router.post(..., response_class=EventSourceResponse)returningAsyncIterable[Frame]stream_item_typeisFrame, OpenAPI carriescontentSchematest_stream_item_type_propagated_through_include_routerFastAPIdirectly (control)@app.post(...)stream_item_typeisFrame, OpenAPI carriescontentSchemaresponse_model=None@app.get(..., response_model=None)returning a modelresponse_modelstaysNone(no validation reintroduced)tests/test_application.pyand the conditional in the patch (theelif isinstance(response_model, DefaultPlaceholder)guard restricts the return-annotation fallback to the default-placeholder case only)include_router)tests/test_sse.pymatrix incl. async/sync generators,ServerSentEventraw data, mixed plain+SSE, keepalive, error pathstests/test_sse.py— 18 pre-existing tests still passRisk / blast radius
response_model is None(previously skipped). The branch is gated onresponse_classbeing eitherDefaultPlaceholder(JSONL) orEventSourceResponse; for any otherresponse_classthe body of the branch is a no-op, so behaviour is unchanged.response_model=SomeModelare untouched (the entry condition does not match).response_model=Noneand a non-streamingresponse_classare untouched (stream_item is None→ no-op).elif isinstance(response_model, DefaultPlaceholder):guard preserves the existing semantics that an explicitresponse_model=Nonedisables response validation; only the original default-placeholder path can promote the return annotation toresponse_model. This guard is the reason the change is non-trivially-conditional rather than a one-line widening.Release note
PR drafted with assistance from Claude Code. The change was reviewed manually against
fastapi/fastapi'smasterbranch and the upstream behaviour cited in the issue. The reproducer block above was used during development; it is the same one a reviewer can paste verbatim.