Skip to content

🐛 Propagate stream_item_type through include_router for SSE / JSONL routes (#15401)#15497

Open
jbbqqf wants to merge 1 commit into
fastapi:masterfrom
jbbqqf:feat/15401-sse-include-router
Open

🐛 Propagate stream_item_type through include_router for SSE / JSONL routes (#15401)#15497
jbbqqf wants to merge 1 commit into
fastapi:masterfrom
jbbqqf:feat/15401-sse-include-router

Conversation

@jbbqqf
Copy link
Copy Markdown

@jbbqqf jbbqqf commented May 9, 2026

Summary

When an SSE (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. The OpenAPI schema then dropped the contentSchema under responses.200.content["text/event-stream"].itemSchema.properties.data, so tools like datamodel-codegen could no longer generate frame models from the spec. This PR makes stream_item_type propagate through include_router, with a regression test.

Fixes #15401SSE stream_item_type not propagated through APIRouter + include_router

Context

APIRoute.__init__ only ran the stream-item detection inside the isinstance(response_model, DefaultPlaceholder) branch:

if isinstance(response_model, DefaultPlaceholder):
    return_annotation = get_typed_return_annotation(endpoint)
    ...
    stream_item = get_stream_item_type(return_annotation)
    if stream_item is not None:
        if (... or lenient_issubclass(response_class, EventSourceResponse)) ...:
            self.stream_item_type = stream_item
        response_model = None

The source route's __init__ then assigns self.response_model = None. When APIRouter.include_router rebuilds the route, it calls add_api_route(response_model=route.response_model, ...) — passing the now-None value, which is not a DefaultPlaceholder. The detection branch is skipped on the merged route and self.stream_item_type stays at its initial None. 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 when response_model is None on entry. 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. A code comment captures the why so a reviewer reading the diff cold doesn't have to re-derive the chain through include_router.
  • tests/test_sse.py — adds test_stream_item_type_propagated_through_include_router asserting both (a) stream_item_type is set on the merged route and (b) the emitted OpenAPI contains contentSchema under the SSE response.

Reproduce BEFORE/AFTER yourself (copy-paste)

A reviewer can verify this fix in ≤60s by pasting the block below.

# --- one-time setup ---
git clone https://github.com/fastapi/fastapi.git /tmp/repro && cd /tmp/repro
python3 -m venv .venv && source .venv/bin/activate
pip install -q -e ".[all]" pytest

cat > /tmp/repro/check.py <<'PY'
from collections.abc import AsyncIterator
from fastapi import APIRouter, FastAPI
from fastapi.sse import EventSourceResponse
from pydantic import BaseModel

class Frame(BaseModel):
    kind: str

router = APIRouter()

@router.post("/s", response_class=EventSourceResponse)
async def b() -> AsyncIterator[Frame]:
    yield Frame(kind="x")

app = FastAPI()
app.include_router(router)

print("APP (post-include) stream_item_type:", app.routes[-1].stream_item_type)

sse = app.openapi()["paths"]["/s"]["post"]["responses"]["200"]["content"]["text/event-stream"]
has_cs = "contentSchema" in sse.get("itemSchema", {}).get("properties", {}).get("data", {})
print("OpenAPI has contentSchema:", has_cs)
PY

# --- BEFORE (origin/master) ---
git checkout origin/master
python /tmp/repro/check.py
# Expected (BUG):
#   APP (post-include) stream_item_type: None
#   OpenAPI has contentSchema: False

# --- AFTER (this PR) ---
git fetch https://github.com/jbbqqf/fastapi.git feat/15401-sse-include-router
git checkout FETCH_HEAD
python /tmp/repro/check.py
# Expected (FIXED):
#   APP (post-include) stream_item_type: <class '__main__.Frame'>
#   OpenAPI has contentSchema: True

# --- Regression test (also imported into the suite) ---
pytest tests/test_sse.py::test_stream_item_type_propagated_through_include_router -v
# Expected: 1 passed

What I ran locally

  • pytest tests/test_sse.py -v19/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.py56/56 passed.
  • Full suite (excluding tests/test_tutorial): pytest tests/ -q1840 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.
  • Confirmed the new regression test fails on origin/master (asserts merged_route.stream_item_type is Frame, gets None) and passes on this branch.

Edge cases tested

# Scenario Input Expected Verified by
1 SSE route on APIRouter then include_router @router.post(..., response_class=EventSourceResponse) returning AsyncIterable[Frame] merged route's stream_item_type is Frame, OpenAPI carries contentSchema test_stream_item_type_propagated_through_include_router
2 SSE route on FastAPI directly (control) same endpoint registered with @app.post(...) stream_item_type is Frame, OpenAPI carries contentSchema same test, control case
3 Non-streaming routes still respect explicit response_model=None regular @app.get(..., response_model=None) returning a model response_model stays None (no validation reintroduced) covered by existing tests/test_application.py and the conditional in the patch (the elif isinstance(response_model, DefaultPlaceholder) guard restricts the return-annotation fallback to the default-placeholder case only)
4 Existing SSE tests on the app (no include_router) full tests/test_sse.py matrix incl. async/sync generators, ServerSentEvent raw data, mixed plain+SSE, keepalive, error paths unchanged tests/test_sse.py — 18 pre-existing tests still pass

Risk / blast radius

  • The detection branch now also fires on response_model is None (previously skipped). The branch is gated on response_class being either DefaultPlaceholder (JSONL) or EventSourceResponse; for any other response_class the body of the branch is a no-op, so behaviour is unchanged.
  • Routes registered with an explicit response_model=SomeModel are untouched (the entry condition does not match).
  • Routes registered with an explicit response_model=None and a non-streaming response_class are untouched (stream_item is None → no-op).
  • The elif isinstance(response_model, DefaultPlaceholder): guard preserves the existing semantics that an explicit response_model=None disables response validation; only the original default-placeholder path can promote the return annotation to response_model. This guard is the reason the change is non-trivially-conditional rather than a one-line widening.

Release note

Fix SSE / JSONL `stream_item_type` not being propagated through `APIRouter.include_router`, which previously caused the merged route's OpenAPI schema to drop the `contentSchema` for the streamed item.

PR drafted with assistance from Claude Code. The change was reviewed manually against fastapi/fastapi's master branch 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.

…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>
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 9, 2026

Merging this PR will not alter performance

✅ 20 untouched benchmarks


Comparing jbbqqf:feat/15401-sse-include-router (eca7bc9) with master (fb74293)1

Open in CodSpeed

Footnotes

  1. No successful run was found on master (622b635) during the generation of this report, so fb74293 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSE stream_item_type not propagated through APIRouter + include_router

1 participant