diff --git a/docs/en/docs/tutorial/server-sent-events.md b/docs/en/docs/tutorial/server-sent-events.md index bbac05bd6..2cf6e352a 100644 --- a/docs/en/docs/tutorial/server-sent-events.md +++ b/docs/en/docs/tutorial/server-sent-events.md @@ -69,6 +69,18 @@ You can also omit the return type. FastAPI will use the [`jsonable_encoder`](./e {* ../../docs_src/server_sent_events/tutorial001_py310.py ln[34:37] hl[35] *} +## Custom Response Headers { #custom-response-headers } + +For normal responses, you can set custom headers using a `Response` parameter, as explained in [Response Headers](../advanced/response-headers.md#use-a-response-parameter). + +With an SSE stream, the *path operation function* is a generator. Calling it creates the generator, but the code inside the generator body doesn't run until the response is already streaming. So setting headers inside the generator body is too late for headers that need to be sent with the response. + +Instead, use a dependency that receives the `Response` parameter and sets the headers before the stream starts: + +{* ../../docs_src/server_sent_events/tutorial006_py310.py ln[1:33] hl[3:4,22:23,26:30] *} + +Dependencies run before FastAPI creates the streaming response, so FastAPI can copy those headers into the final SSE response. + ## `ServerSentEvent` { #serversentevent } If you need to set SSE fields like `event`, `id`, `retry`, or `comment`, you can yield `ServerSentEvent` objects instead of plain data. diff --git a/docs/en/docs/tutorial/stream-json-lines.md b/docs/en/docs/tutorial/stream-json-lines.md index ea1b781a7..79b520f09 100644 --- a/docs/en/docs/tutorial/stream-json-lines.md +++ b/docs/en/docs/tutorial/stream-json-lines.md @@ -106,6 +106,18 @@ You can also omit the return type. FastAPI will then use the [`jsonable_encoder` {* ../../docs_src/stream_json_lines/tutorial001_py310.py ln[33:36] hl[34] *} +## Custom Response Headers { #custom-response-headers } + +For normal responses, you can set custom headers using a `Response` parameter, as explained in [Response Headers](../advanced/response-headers.md#use-a-response-parameter). + +With a JSON Lines stream, the *path operation function* is a generator. Calling it creates the generator, but the code inside the generator body doesn't run until the response is already streaming. So setting headers inside the generator body is too late for headers that need to be sent with the response. + +Instead, use a dependency that receives the `Response` parameter and sets the headers before the stream starts: + +{* ../../docs_src/stream_json_lines/tutorial002_py310.py ln[1:28] hl[3,21:22,25] *} + +Dependencies run before FastAPI creates the streaming response, so FastAPI can copy those headers into the final JSON Lines response. + ## Server-Sent Events (SSE) { #server-sent-events-sse } FastAPI also has first-class support for Server-Sent Events (SSE), which are quite similar but with a couple of extra details. You can learn about them in the next chapter: [Server-Sent Events (SSE)](server-sent-events.md). 🤓 diff --git a/docs_src/server_sent_events/tutorial006_py310.py b/docs_src/server_sent_events/tutorial006_py310.py new file mode 100644 index 000000000..7b9072e22 --- /dev/null +++ b/docs_src/server_sent_events/tutorial006_py310.py @@ -0,0 +1,33 @@ +from collections.abc import AsyncIterable + +from fastapi import Depends, FastAPI, Response +from fastapi.sse import EventSourceResponse +from pydantic import BaseModel + +app = FastAPI() + + +class Item(BaseModel): + name: str + description: str | None + + +items = [ + Item(name="Plumbus", description="A multi-purpose household device."), + Item(name="Portal Gun", description="A portal opening device."), + Item(name="Meeseeks Box", description="A box that summons a Meeseeks."), +] + + +async def add_stream_headers(response: Response) -> None: + response.headers["X-Stream-Source"] = "inventory" + + +@app.get( + "/items/stream", + response_class=EventSourceResponse, + dependencies=[Depends(add_stream_headers)], +) +async def sse_items() -> AsyncIterable[Item]: + for item in items: + yield item diff --git a/docs_src/stream_json_lines/tutorial002_py310.py b/docs_src/stream_json_lines/tutorial002_py310.py new file mode 100644 index 000000000..a0d933b3a --- /dev/null +++ b/docs_src/stream_json_lines/tutorial002_py310.py @@ -0,0 +1,28 @@ +from collections.abc import AsyncIterable + +from fastapi import Depends, FastAPI, Response +from pydantic import BaseModel + +app = FastAPI() + + +class Item(BaseModel): + name: str + description: str | None + + +items = [ + Item(name="Plumbus", description="A multi-purpose household device."), + Item(name="Portal Gun", description="A portal opening device."), + Item(name="Meeseeks Box", description="A box that summons a Meeseeks."), +] + + +async def add_stream_headers(response: Response) -> None: + response.headers["X-Stream-Source"] = "inventory" + + +@app.get("/items/stream", dependencies=[Depends(add_stream_headers)]) +async def stream_items() -> AsyncIterable[Item]: + for item in items: + yield item diff --git a/tests/test_tutorial/test_server_sent_events/test_tutorial006.py b/tests/test_tutorial/test_server_sent_events/test_tutorial006.py new file mode 100644 index 000000000..e3e59d304 --- /dev/null +++ b/tests/test_tutorial/test_server_sent_events/test_tutorial006.py @@ -0,0 +1,96 @@ +import importlib + +import pytest +from fastapi.testclient import TestClient +from inline_snapshot import snapshot + + +@pytest.fixture( + name="client", + params=[ + pytest.param("tutorial006_py310"), + ], +) +def get_client(request: pytest.FixtureRequest): + mod = importlib.import_module(f"docs_src.server_sent_events.{request.param}") + + client = TestClient(mod.app) + return client + + +def test_stream_items_with_custom_header(client: TestClient): + response = client.get("/items/stream") + assert response.status_code == 200, response.text + assert response.headers["content-type"] == "text/event-stream; charset=utf-8" + assert response.headers["x-stream-source"] == "inventory" + data_lines = [ + line for line in response.text.strip().split("\n") if line.startswith("data: ") + ] + assert len(data_lines) == 3 + assert '"name":"Plumbus"' in data_lines[0] or '"name": "Plumbus"' in data_lines[0] + + +def test_openapi_schema(client: TestClient): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == snapshot( + { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/items/stream": { + "get": { + "summary": "Sse Items", + "operationId": "sse_items_items_stream_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/event-stream": { + "itemSchema": { + "type": "object", + "properties": { + "data": { + "type": "string", + "contentMediaType": "application/json", + "contentSchema": { + "$ref": "#/components/schemas/Item" + }, + }, + "event": {"type": "string"}, + "id": {"type": "string"}, + "retry": { + "type": "integer", + "minimum": 0, + }, + }, + "required": ["data"], + } + } + }, + } + }, + } + } + }, + "components": { + "schemas": { + "Item": { + "properties": { + "name": {"type": "string", "title": "Name"}, + "description": { + "anyOf": [ + {"type": "string"}, + {"type": "null"}, + ], + "title": "Description", + }, + }, + "type": "object", + "required": ["name", "description"], + "title": "Item", + } + } + }, + } + ) diff --git a/tests/test_tutorial/test_stream_json_lines/test_tutorial002.py b/tests/test_tutorial/test_stream_json_lines/test_tutorial002.py new file mode 100644 index 000000000..d46419507 --- /dev/null +++ b/tests/test_tutorial/test_stream_json_lines/test_tutorial002.py @@ -0,0 +1,82 @@ +import importlib +import json + +import pytest +from fastapi.testclient import TestClient +from inline_snapshot import snapshot + + +@pytest.fixture( + name="client", + params=[ + pytest.param("tutorial002_py310"), + ], +) +def get_client(request: pytest.FixtureRequest): + mod = importlib.import_module(f"docs_src.stream_json_lines.{request.param}") + + client = TestClient(mod.app) + return client + + +def test_stream_items_with_custom_header(client: TestClient): + response = client.get("/items/stream") + assert response.status_code == 200, response.text + assert response.headers["content-type"] == "application/jsonl" + assert response.headers["x-stream-source"] == "inventory" + lines = [json.loads(line) for line in response.text.strip().splitlines()] + assert lines == [ + {"name": "Plumbus", "description": "A multi-purpose household device."}, + {"name": "Portal Gun", "description": "A portal opening device."}, + {"name": "Meeseeks Box", "description": "A box that summons a Meeseeks."}, + ] + + +def test_openapi_schema(client: TestClient): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == snapshot( + { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/items/stream": { + "get": { + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/jsonl": { + "itemSchema": { + "$ref": "#/components/schemas/Item" + }, + } + }, + } + }, + "summary": "Stream Items", + "operationId": "stream_items_items_stream_get", + } + } + }, + "components": { + "schemas": { + "Item": { + "properties": { + "name": {"type": "string", "title": "Name"}, + "description": { + "anyOf": [ + {"type": "string"}, + {"type": "null"}, + ], + "title": "Description", + }, + }, + "type": "object", + "required": ["name", "description"], + "title": "Item", + } + } + }, + } + )