diff --git a/fastapi/routing.py b/fastapi/routing.py index 21a1385a27..f383f995c8 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 86a67f8f9f..3b8bd11269 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