diff --git a/.chronus/changes/iscai-msft-add-sse-spector-scenarios-2026-7-10-14-23-58.md b/.chronus/changes/iscai-msft-add-sse-spector-scenarios-2026-7-10-14-23-58.md new file mode 100644 index 00000000000..0cfa45d7805 --- /dev/null +++ b/.chronus/changes/iscai-msft-add-sse-spector-scenarios-2026-7-10-14-23-58.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http-specs" +--- + +Add SSE protocol coverage for event IDs, retry fields, and reconnection \ No newline at end of file diff --git a/packages/http-specs/spec-summary.md b/packages/http-specs/spec-summary.md index 77e08a1490f..2ae36494582 100644 --- a/packages/http-specs/spec-summary.md +++ b/packages/http-specs/spec-summary.md @@ -5356,6 +5356,88 @@ data: [DONE] ``` +### Streaming_Sse_Protocol_id + +- Endpoint: `get /streaming/sse/protocol/id` + +An SSE event with an `id` field. The event ID is envelope metadata and is +not part of the typed event data. + +Expected response body (content type `text/event-stream`): + +``` +id: event-1 +data: {"message": "hello"} + +``` + +### Streaming_Sse_Protocol_invalidId + +- Endpoint: `get /streaming/sse/protocol/invalid-id` + +An SSE event with an `id` field containing U+0000 NULL. The field is +ignored according to the SSE parsing rules. + +Expected response body (content type `text/event-stream`): + +``` +id: invalidid +data: {"message": "hello"} + +``` + +### Streaming_Sse_Protocol_invalidRetry + +- Endpoint: `get /streaming/sse/protocol/invalid-retry` + +An SSE event with an invalid `retry` field. Since the value contains +non-ASCII-digit characters, the field is ignored. + +Expected response body (content type `text/event-stream`): + +``` +retry: not-a-number +data: {"message": "hello"} + +``` + +### Streaming_Sse_Protocol_reconnect + +- Endpoint: `get /streaming/sse/protocol/reconnect` + +An SSE stream that resumes after a reconnect. The client sends the most +recently received event ID in the `Last-Event-ID` request header. + +Expected request header: + +``` +Last-Event-ID: event-1 +``` + +Expected response body (content type `text/event-stream`): + +``` +id: event-2 +data: {"message": "world"} + +``` + +### Streaming_Sse_Protocol_retry + +- Endpoint: `get /streaming/sse/protocol/retry` + +An SSE event with a valid `retry` field containing only ASCII digits. The +field sets the client's reconnection delay and is not part of the typed +event data. + +Expected response body (content type `text/event-stream`): + +``` +retry: 1000 +data: {"message": "hello"} + +``` + ### Streaming_Sse_Retrieve_stream - Endpoint: `post /streaming/sse/retrieve/stream` diff --git a/packages/http-specs/specs/streaming/sse/main.tsp b/packages/http-specs/specs/streaming/sse/main.tsp index 16e81a33deb..66f314f9e94 100644 --- a/packages/http-specs/specs/streaming/sse/main.tsp +++ b/packages/http-specs/specs/streaming/sse/main.tsp @@ -151,3 +151,97 @@ namespace Retrieve { @route("stream") op stream(@body request: RetrievalRequest): SSEStream; } + +@route("protocol") +namespace Protocol { + model Info { + message: string; + } + + @events + union ProtocolEvents { + @Events.contentType("application/json") + Info, + } + + @scenario + @scenarioDoc(""" + An SSE event with an `id` field. The event ID is envelope metadata and is + not part of the typed event data. + + Expected response body (content type `text/event-stream`): + ``` + id: event-1 + data: {"message": "hello"} + + ``` + """) + @route("id") + op id(): SSEStream; + + @scenario + @scenarioDoc(""" + An SSE event with an `id` field containing U+0000 NULL. The field is + ignored according to the SSE parsing rules. + + Expected response body (content type `text/event-stream`): + ``` + id: invalidid + data: {"message": "hello"} + + ``` + """) + @route("invalid-id") + op invalidId(): SSEStream; + + @scenario + @scenarioDoc(""" + An SSE event with a valid `retry` field containing only ASCII digits. The + field sets the client's reconnection delay and is not part of the typed + event data. + + Expected response body (content type `text/event-stream`): + ``` + retry: 1000 + data: {"message": "hello"} + + ``` + """) + @route("retry") + op retry(): SSEStream; + + @scenario + @scenarioDoc(""" + An SSE event with an invalid `retry` field. Since the value contains + non-ASCII-digit characters, the field is ignored. + + Expected response body (content type `text/event-stream`): + ``` + retry: not-a-number + data: {"message": "hello"} + + ``` + """) + @route("invalid-retry") + op invalidRetry(): SSEStream; + + @scenario + @scenarioDoc(""" + An SSE stream that resumes after a reconnect. The client sends the most + recently received event ID in the `Last-Event-ID` request header. + + Expected request header: + ``` + Last-Event-ID: event-1 + ``` + + Expected response body (content type `text/event-stream`): + ``` + id: event-2 + data: {"message": "world"} + + ``` + """) + @route("reconnect") + op reconnect(): SSEStream; +} diff --git a/packages/http-specs/specs/streaming/sse/mockapi.ts b/packages/http-specs/specs/streaming/sse/mockapi.ts index d74f321d927..3bab7b4598b 100644 --- a/packages/http-specs/specs/streaming/sse/mockapi.ts +++ b/packages/http-specs/specs/streaming/sse/mockapi.ts @@ -70,3 +70,79 @@ Scenarios.Streaming_Sse_Retrieve_stream = passOnSuccess({ }, kind: "MockApiDefinition", }); + +const protocolEvent = (fields: string[]) => Buffer.from(`${fields.join("\n")}\n\n`); + +Scenarios.Streaming_Sse_Protocol_id = passOnSuccess({ + uri: "/streaming/sse/protocol/id", + method: "get", + request: {}, + response: { + status: 200, + body: { + rawContent: protocolEvent(["id: event-1", 'data: {"message": "hello"}']), + contentType: "text/event-stream", + }, + }, + kind: "MockApiDefinition", +}); + +Scenarios.Streaming_Sse_Protocol_invalidId = passOnSuccess({ + uri: "/streaming/sse/protocol/invalid-id", + method: "get", + request: {}, + response: { + status: 200, + body: { + rawContent: protocolEvent(["id: invalid\u0000id", 'data: {"message": "hello"}']), + contentType: "text/event-stream", + }, + }, + kind: "MockApiDefinition", +}); + +Scenarios.Streaming_Sse_Protocol_retry = passOnSuccess({ + uri: "/streaming/sse/protocol/retry", + method: "get", + request: {}, + response: { + status: 200, + body: { + rawContent: protocolEvent(["retry: 1000", 'data: {"message": "hello"}']), + contentType: "text/event-stream", + }, + }, + kind: "MockApiDefinition", +}); + +Scenarios.Streaming_Sse_Protocol_invalidRetry = passOnSuccess({ + uri: "/streaming/sse/protocol/invalid-retry", + method: "get", + request: {}, + response: { + status: 200, + body: { + rawContent: protocolEvent(["retry: not-a-number", 'data: {"message": "hello"}']), + contentType: "text/event-stream", + }, + }, + kind: "MockApiDefinition", +}); + +Scenarios.Streaming_Sse_Protocol_reconnect = passOnSuccess({ + uri: "/streaming/sse/protocol/reconnect", + method: "get", + request: { + headers: { + "last-event-id": "event-1", + }, + }, + response: { + status: 200, + body: { + rawContent: protocolEvent(["id: event-2", 'data: {"message": "world"}']), + contentType: "text/event-stream", + }, + }, + kind: "MockApiDefinition", +});