From c70eb8749198652797a916b55d7d595c22cb15c3 Mon Sep 17 00:00:00 2001 From: Kagami Date: Mon, 8 Jun 2026 21:23:44 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20Fix=20response=20model=20ser?= =?UTF-8?q?ialization=20options=20for=20SSE=20event=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/routing.py | 9 +++- tests/test_sse.py | 121 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index 21a1385a2..f383f995c 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -507,7 +507,14 @@ def get_request_handler( data_str: str | None = item.raw_data elif item.data is not None: if hasattr(item.data, "model_dump_json"): - data_str = item.data.model_dump_json() + data_str = item.data.model_dump_json( + include=response_model_include, + exclude=response_model_exclude, + by_alias=response_model_by_alias, + exclude_unset=response_model_exclude_unset, + exclude_defaults=response_model_exclude_defaults, + exclude_none=response_model_exclude_none, + ) else: data_str = json.dumps(jsonable_encoder(item.data)) else: diff --git a/tests/test_sse.py b/tests/test_sse.py index 86a67f8f9..3b8bd1126 100644 --- a/tests/test_sse.py +++ b/tests/test_sse.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, FastAPI from fastapi.responses import EventSourceResponse from fastapi.sse import ServerSentEvent from fastapi.testclient import TestClient -from pydantic import BaseModel +from pydantic import BaseModel, Field class Item(BaseModel): @@ -16,6 +16,17 @@ class Item(BaseModel): description: str | None = None +class AliasedItem(BaseModel): + name: str = Field(serialization_alias="itemName") + + +class AliasedItemWithDefaults(BaseModel): + name: str = Field(serialization_alias="itemName") + description: str | None = None + hidden: str = "secret" + status: str = "new" + + items = [ Item(name="Plumbus", description="A multi-purpose household device."), Item(name="Portal Gun", description="A portal opening device."), @@ -62,6 +73,65 @@ async def sse_items_event(): yield ServerSentEvent(data="retry-test", retry=5000) +@app.get("/items/stream-sse-event-model-data", response_class=EventSourceResponse) +async def sse_items_event_model_data(): + yield ServerSentEvent(data=AliasedItem(name="Portal Gun")) + + +@app.get( + "/items/stream-sse-event-model-data-no-alias", + response_class=EventSourceResponse, + response_model_by_alias=False, +) +async def sse_items_event_model_data_no_alias(): + yield ServerSentEvent(data=AliasedItem(name="Portal Gun")) + + +@app.get( + "/items/stream-sse-event-model-data-include", + response_class=EventSourceResponse, + response_model_include={"name"}, +) +async def sse_items_event_model_data_include(): + yield ServerSentEvent(data=AliasedItemWithDefaults(name="Portal Gun")) + + +@app.get( + "/items/stream-sse-event-model-data-exclude", + response_class=EventSourceResponse, + response_model_exclude={"hidden"}, +) +async def sse_items_event_model_data_exclude(): + yield ServerSentEvent(data=AliasedItemWithDefaults(name="Portal Gun")) + + +@app.get( + "/items/stream-sse-event-model-data-exclude-none", + response_class=EventSourceResponse, + response_model_exclude_none=True, +) +async def sse_items_event_model_data_exclude_none(): + yield ServerSentEvent(data=AliasedItemWithDefaults(name="Portal Gun")) + + +@app.get( + "/items/stream-sse-event-model-data-exclude-unset", + response_class=EventSourceResponse, + response_model_exclude_unset=True, +) +async def sse_items_event_model_data_exclude_unset(): + yield ServerSentEvent(data=AliasedItemWithDefaults(name="Portal Gun")) + + +@app.get( + "/items/stream-sse-event-model-data-exclude-defaults", + response_class=EventSourceResponse, + response_model_exclude_defaults=True, +) +async def sse_items_event_model_data_exclude_defaults(): + yield ServerSentEvent(data=AliasedItemWithDefaults(name="Portal Gun")) + + @app.get("/items/stream-mixed", response_class=EventSourceResponse) async def sse_items_mixed() -> AsyncIterable[Item]: yield items[0] @@ -199,6 +269,55 @@ def test_sse_events_with_fields(client: TestClient): assert 'data: "retry-test"\n' in text +def test_sse_event_model_data_uses_serialization_alias(client: TestClient): + response = client.get("/items/stream-sse-event-model-data") + assert response.status_code == 200 + assert 'data: {"itemName":"Portal Gun"}\n' in response.text + assert '"name"' not in response.text + + +def test_sse_event_model_data_respects_response_model_by_alias_false( + client: TestClient, +): + response = client.get("/items/stream-sse-event-model-data-no-alias") + assert response.status_code == 200 + assert 'data: {"name":"Portal Gun"}\n' in response.text + assert '"itemName"' not in response.text + + +@pytest.mark.parametrize( + ("path", "expected_data"), + [ + ( + "/items/stream-sse-event-model-data-include", + '{"itemName":"Portal Gun"}', + ), + ( + "/items/stream-sse-event-model-data-exclude", + '{"itemName":"Portal Gun","description":null,"status":"new"}', + ), + ( + "/items/stream-sse-event-model-data-exclude-none", + '{"itemName":"Portal Gun","hidden":"secret","status":"new"}', + ), + ( + "/items/stream-sse-event-model-data-exclude-unset", + '{"itemName":"Portal Gun"}', + ), + ( + "/items/stream-sse-event-model-data-exclude-defaults", + '{"itemName":"Portal Gun"}', + ), + ], +) +def test_sse_event_model_data_respects_response_model_serialization_options( + client: TestClient, path: str, expected_data: str +): + response = client.get(path) + assert response.status_code == 200 + assert response.text == f"data: {expected_data}\n\n" + + def test_mixed_plain_and_sse_events(client: TestClient): response = client.get("/items/stream-mixed") assert response.status_code == 200 From 6843f4e3a3634e154f5bee3b9792444b0b297d08 Mon Sep 17 00:00:00 2001 From: Kagami Date: Tue, 16 Jun 2026 13:52:42 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20Fix=20response=20model=20ser?= =?UTF-8?q?ialization=20options=20for=20SSE=20fallback=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/routing.py | 12 +++++++++++- tests/test_sse.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index f383f995c..c3e3e9899 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -516,7 +516,17 @@ def get_request_handler( exclude_none=response_model_exclude_none, ) else: - data_str = json.dumps(jsonable_encoder(item.data)) + data_str = json.dumps( + jsonable_encoder( + item.data, + include=response_model_include, + exclude=response_model_exclude, + by_alias=response_model_by_alias, + exclude_unset=response_model_exclude_unset, + exclude_defaults=response_model_exclude_defaults, + exclude_none=response_model_exclude_none, + ) + ) else: data_str = None return format_sse_event( diff --git a/tests/test_sse.py b/tests/test_sse.py index 3b8bd1126..3efd43e4f 100644 --- a/tests/test_sse.py +++ b/tests/test_sse.py @@ -132,6 +132,24 @@ async def sse_items_event_model_data_exclude_defaults(): yield ServerSentEvent(data=AliasedItemWithDefaults(name="Portal Gun")) +@app.get( + "/items/stream-sse-event-dict-data-include", + response_class=EventSourceResponse, + response_model_include={"name"}, +) +async def sse_items_event_dict_data_include(): + yield ServerSentEvent(data={"name": "Portal Gun", "hidden": "secret"}) + + +@app.get( + "/items/stream-sse-event-list-model-data-no-alias", + response_class=EventSourceResponse, + response_model_by_alias=False, +) +async def sse_items_event_list_model_data_no_alias(): + yield ServerSentEvent(data=[AliasedItem(name="Portal Gun")]) + + @app.get("/items/stream-mixed", response_class=EventSourceResponse) async def sse_items_mixed() -> AsyncIterable[Item]: yield items[0] @@ -318,6 +336,27 @@ def test_sse_event_model_data_respects_response_model_serialization_options( assert response.text == f"data: {expected_data}\n\n" +@pytest.mark.parametrize( + ("path", "expected_data"), + [ + ( + "/items/stream-sse-event-dict-data-include", + '{"name": "Portal Gun"}', + ), + ( + "/items/stream-sse-event-list-model-data-no-alias", + '[{"name": "Portal Gun"}]', + ), + ], +) +def test_sse_event_jsonable_encoder_data_respects_response_model_serialization_options( + client: TestClient, path: str, expected_data: str +): + response = client.get(path) + assert response.status_code == 200 + assert response.text == f"data: {expected_data}\n\n" + + def test_mixed_plain_and_sse_events(client: TestClient): response = client.get("/items/stream-mixed") assert response.status_code == 200