4 changed files with 389 additions and 35 deletions
@ -0,0 +1,222 @@ |
|||
from typing import Annotated, Any |
|||
|
|||
from annotated_doc import Doc |
|||
from pydantic import AfterValidator, BaseModel, Field, model_validator |
|||
from starlette.responses import StreamingResponse |
|||
|
|||
# Canonical SSE event schema matching the OpenAPI 3.2 spec |
|||
# (Section 4.14.4 "Special Considerations for Server-Sent Events") |
|||
_SSE_EVENT_SCHEMA: dict[str, Any] = { |
|||
"type": "object", |
|||
"properties": { |
|||
"data": {"type": "string"}, |
|||
"event": {"type": "string"}, |
|||
"id": {"type": "string"}, |
|||
"retry": {"type": "integer", "minimum": 0}, |
|||
}, |
|||
} |
|||
|
|||
|
|||
class EventSourceResponse(StreamingResponse): |
|||
"""Streaming response with `text/event-stream` media type. |
|||
|
|||
Use as `response_class=EventSourceResponse` on a *path operation* that uses `yield` |
|||
to enable Server Sent Events (SSE) responses. |
|||
|
|||
Works with **any HTTP method** (`GET`, `POST`, etc.), which makes it compatible |
|||
with protocols like MCP that stream SSE over `POST`. |
|||
|
|||
The actual encoding logic lives in the FastAPI routing layer. This class |
|||
serves mainly as a marker and sets the correct `Content-Type`. |
|||
""" |
|||
|
|||
media_type = "text/event-stream" |
|||
|
|||
|
|||
def _check_id_no_null(v: int | str | None) -> int | str | None: |
|||
if v is not None and "\0" in str(v): |
|||
raise ValueError("SSE 'id' must not contain null characters") |
|||
return v |
|||
|
|||
|
|||
class ServerSentEvent(BaseModel): |
|||
"""Represents a single Server-Sent Event. |
|||
|
|||
When `yield`ed from a *path operation function* that uses |
|||
`response_class=EventSourceResponse`, each `ServerSentEvent` is encoded |
|||
into the [SSE wire format](https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream) |
|||
(`text/event-stream`). |
|||
|
|||
If you yield a plain object (dict, Pydantic model, etc.) instead, it is |
|||
automatically JSON-encoded and sent as the `data:` field. |
|||
|
|||
All `data` values **including plain strings** are JSON-serialized. |
|||
|
|||
For example, `data="hello"` produces `data: "hello"` on the wire (with |
|||
quotes). |
|||
""" |
|||
|
|||
data: Annotated[ |
|||
Any, |
|||
Doc( |
|||
""" |
|||
The event payload. |
|||
|
|||
Can be any JSON-serializable value: a Pydantic model, dict, list, |
|||
string, number, etc. It is **always** serialized to JSON: strings |
|||
are quoted (`"hello"` becomes `data: "hello"` on the wire). |
|||
|
|||
Mutually exclusive with `raw_data`. |
|||
""" |
|||
), |
|||
] = None |
|||
raw_data: Annotated[ |
|||
str | None, |
|||
Doc( |
|||
""" |
|||
Raw string to send as the `data:` field **without** JSON encoding. |
|||
|
|||
Use this when you need to send pre-formatted text, HTML fragments, |
|||
CSV lines, or any non-JSON payload. The string is placed directly |
|||
into the `data:` field as-is. |
|||
|
|||
Mutually exclusive with `data`. |
|||
""" |
|||
), |
|||
] = None |
|||
event: Annotated[ |
|||
str | None, |
|||
Doc( |
|||
""" |
|||
Optional event type name. |
|||
|
|||
Maps to `addEventListener(event, ...)` on the browser. When omitted, |
|||
the browser dispatches on the generic `message` event. |
|||
""" |
|||
), |
|||
] = None |
|||
id: Annotated[ |
|||
int | str | None, |
|||
AfterValidator(_check_id_no_null), |
|||
Doc( |
|||
""" |
|||
Optional event ID. |
|||
|
|||
The browser sends this value back as the `Last-Event-ID` header on |
|||
automatic reconnection. **Must not contain null (`\\0`) characters.** |
|||
""" |
|||
), |
|||
] = None |
|||
retry: Annotated[ |
|||
int | None, |
|||
Field(ge=0), |
|||
Doc( |
|||
""" |
|||
Optional reconnection time in **milliseconds**. |
|||
|
|||
Tells the browser how long to wait before reconnecting after the |
|||
connection is lost. Must be a non-negative integer. |
|||
""" |
|||
), |
|||
] = None |
|||
comment: Annotated[ |
|||
str | None, |
|||
Doc( |
|||
""" |
|||
Optional comment line(s). |
|||
|
|||
Comment lines start with `:` in the SSE wire format and are ignored by |
|||
`EventSource` clients. Useful for keep-alive pings to prevent |
|||
proxy/load-balancer timeouts. |
|||
""" |
|||
), |
|||
] = None |
|||
|
|||
@model_validator(mode="after") |
|||
def _check_data_exclusive(self) -> "ServerSentEvent": |
|||
if self.data is not None and self.raw_data is not None: |
|||
raise ValueError( |
|||
"Cannot set both 'data' and 'raw_data' on the same " |
|||
"ServerSentEvent. Use 'data' for JSON-serialized payloads " |
|||
"or 'raw_data' for pre-formatted strings." |
|||
) |
|||
return self |
|||
|
|||
|
|||
def format_sse_event( |
|||
*, |
|||
data_str: Annotated[ |
|||
str | None, |
|||
Doc( |
|||
""" |
|||
Pre-serialized data string to use as the `data:` field. |
|||
""" |
|||
), |
|||
] = None, |
|||
event: Annotated[ |
|||
str | None, |
|||
Doc( |
|||
""" |
|||
Optional event type name (`event:` field). |
|||
""" |
|||
), |
|||
] = None, |
|||
id: Annotated[ |
|||
int | str | None, |
|||
Doc( |
|||
""" |
|||
Optional event ID (`id:` field). |
|||
""" |
|||
), |
|||
] = None, |
|||
retry: Annotated[ |
|||
int | None, |
|||
Doc( |
|||
""" |
|||
Optional reconnection time in milliseconds (`retry:` field). |
|||
""" |
|||
), |
|||
] = None, |
|||
comment: Annotated[ |
|||
str | None, |
|||
Doc( |
|||
""" |
|||
Optional comment line(s) (`:` prefix). |
|||
""" |
|||
), |
|||
] = None, |
|||
) -> bytes: |
|||
"""Build SSE wire-format bytes from **pre-serialized** data. |
|||
|
|||
The result always ends with `\n\n` (the event terminator). |
|||
""" |
|||
lines: list[str] = [] |
|||
|
|||
if comment is not None: |
|||
for line in comment.splitlines(): |
|||
lines.append(f": {line}") |
|||
|
|||
if event is not None: |
|||
lines.append(f"event: {event}") |
|||
|
|||
if data_str is not None: |
|||
for line in data_str.splitlines(): |
|||
lines.append(f"data: {line}") |
|||
|
|||
if id is not None: |
|||
lines.append(f"id: {id}") |
|||
|
|||
if retry is not None: |
|||
lines.append(f"retry: {retry}") |
|||
|
|||
lines.append("") |
|||
lines.append("") |
|||
return "\n".join(lines).encode("utf-8") |
|||
|
|||
|
|||
# Keep-alive comment, per the SSE spec recommendation |
|||
KEEPALIVE_COMMENT = b": ping\n\n" |
|||
|
|||
# Seconds between keep-alive pings when a generator is idle. |
|||
# Private but importable so tests can monkeypatch it. |
|||
_PING_INTERVAL: float = 15.0 |
|||
Loading…
Reference in new issue