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..47a907457c6 --- /dev/null +++ b/.chronus/changes/iscai-msft-add-sse-spector-scenarios-2026-7-10-14-23-58.md @@ -0,0 +1,11 @@ +--- +changeKind: feature +packages: + - "@typespec/http-specs" +--- + +Add SSE protocol coverage for event IDs, retry fields, and reconnection + +```tsp +op reconnect(): SSEStream; +``` diff --git a/packages/http-specs/spec-summary.md b/packages/http-specs/spec-summary.md index 373a6e93f37..9c7f6485dc6 100644 --- a/packages/http-specs/spec-summary.md +++ b/packages/http-specs/spec-summary.md @@ -5385,6 +5385,128 @@ data: [DONE] ``` +### Streaming_Sse_Protocol_Data_withEnvelope + +- Endpoint: `get /streaming/sse/protocol/data/with-envelope` + +SSE event with an explicit `@data` payload. The `withEnvelope` event sends +only the `contents` property in the SSE `data` field. +Expected response body (content type `text/event-stream`): + +``` +event: withEnvelope +data: hello +``` + +### Streaming_Sse_Protocol_Data_withoutEnvelope + +- Endpoint: `get /streaming/sse/protocol/data/without-envelope` + +SSE event without an explicit `@data` payload. The `withoutEnvelope` event +sends the complete model in the SSE `data` field. +Expected response body (content type `text/event-stream`): + +``` +event: withoutEnvelope +data: {"metadata": {"source": "test"}, "contents": "world"} +``` + +### 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 +event: message +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 +event: message +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 +event: message +data: {"message": "hello"} + +``` + +### Streaming_Sse_Protocol_reconnect + +- Endpoint: `get /streaming/sse/protocol/reconnect` + +An SSE stream that resumes after a reconnect. The first response closes after +sending `event-1`. On reconnect, the client sends the most recently received +event ID in the `Last-Event-ID` request header. + +Expected initial response body (content type `text/event-stream`): + +``` +id: event-1 +event: message +data: {"message": "hello"} +``` + +Expected request header on reconnect: + +``` +Last-Event-ID: event-1 +``` + +Expected reconnect response body (content type `text/event-stream`): + +``` +id: event-2 +event: message +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 +event: message +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..2293746ff09 100644 --- a/packages/http-specs/specs/streaming/sse/main.tsp +++ b/packages/http-specs/specs/streaming/sse/main.tsp @@ -151,3 +151,152 @@ 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") + message: Info, + } + + @route("data") + namespace Data { + @events + union DataEvents { + withEnvelope: { + @Events.contentType("text/plain") + @data + contents: string, + }, + withoutEnvelope: { + metadata: Record, + contents: string, + }, + } + + @scenario + @scenarioDoc(""" + SSE event with an explicit `@data` payload. The `withEnvelope` event sends + only the `contents` property in the SSE `data` field. + Expected response body (content type `text/event-stream`): + ``` + event: withEnvelope + data: hello + ``` + """) + @route("with-envelope") + op withEnvelope(): SSEStream; + + @scenario + @scenarioDoc(""" + SSE event without an explicit `@data` payload. The `withoutEnvelope` event + sends the complete model in the SSE `data` field. + Expected response body (content type `text/event-stream`): + ``` + event: withoutEnvelope + data: {"metadata": {"source": "test"}, "contents": "world"} + ``` + """) + @route("without-envelope") + op withoutEnvelope(): SSEStream; + } + + @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 + event: message + 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 + event: message + 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 + event: message + 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 + event: message + data: {"message": "hello"} + + ``` + """) + @route("invalid-retry") + op invalidRetry(): SSEStream; + + @scenario + @scenarioDoc(""" + An SSE stream that resumes after a reconnect. The first response closes after + sending `event-1`. On reconnect, the client sends the most recently received + event ID in the `Last-Event-ID` request header. + + Expected initial response body (content type `text/event-stream`): + ``` + id: event-1 + event: message + data: {"message": "hello"} + ``` + + Expected request header on reconnect: + ``` + Last-Event-ID: event-1 + ``` + + Expected reconnect response body (content type `text/event-stream`): + ``` + id: event-2 + event: message + 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..2ecc6aa6878 100644 --- a/packages/http-specs/specs/streaming/sse/mockapi.ts +++ b/packages/http-specs/specs/streaming/sse/mockapi.ts @@ -1,5 +1,5 @@ -import type { ScenarioMockApi } from "@typespec/spec-api"; -import { passOnSuccess } from "@typespec/spec-api"; +import type { MockRequest, ScenarioMockApi } from "@typespec/spec-api"; +import { passOnSuccess, withServiceKeys } from "@typespec/spec-api"; export const Scenarios: Record = {}; @@ -70,3 +70,140 @@ Scenarios.Streaming_Sse_Retrieve_stream = passOnSuccess({ }, kind: "MockApiDefinition", }); + +const protocolEvent = (fields: string[]) => Buffer.from(`${fields.join("\n")}\n\n`); + +Scenarios.Streaming_Sse_Protocol_Data_withEnvelope = passOnSuccess({ + uri: "/streaming/sse/protocol/data/with-envelope", + method: "get", + request: {}, + response: { + status: 200, + body: { + rawContent: protocolEvent(["event: withEnvelope", "data: hello"]), + contentType: "text/event-stream", + }, + }, + kind: "MockApiDefinition", +}); + +Scenarios.Streaming_Sse_Protocol_Data_withoutEnvelope = passOnSuccess({ + uri: "/streaming/sse/protocol/data/without-envelope", + method: "get", + request: {}, + response: { + status: 200, + body: { + rawContent: protocolEvent([ + "event: withoutEnvelope", + 'data: {"metadata": {"source": "test"}, "contents": "world"}', + ]), + contentType: "text/event-stream", + }, + }, + kind: "MockApiDefinition", +}); + +Scenarios.Streaming_Sse_Protocol_id = passOnSuccess({ + uri: "/streaming/sse/protocol/id", + method: "get", + request: {}, + response: { + status: 200, + body: { + rawContent: protocolEvent(["id: event-1", "event: message", '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", + "event: message", + '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", "event: message", '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", + "event: message", + 'data: {"message": "hello"}', + ]), + contentType: "text/event-stream", + }, + }, + kind: "MockApiDefinition", +}); + +Scenarios.Streaming_Sse_Protocol_reconnect = withServiceKeys(["initial", "reconnect"]).pass({ + uri: "/streaming/sse/protocol/reconnect", + method: "get", + request: {}, + response: { + status: 200, + body: { + rawContent: protocolEvent(["id: event-1", "event: message", 'data: {"message": "hello"}']), + contentType: "text/event-stream", + }, + }, + handler: (req: MockRequest) => { + if (req.headers["last-event-id"] !== undefined) { + req.expect.containsHeader("last-event-id", "event-1"); + return { + pass: "reconnect", + status: 200, + body: { + rawContent: protocolEvent([ + "id: event-2", + "event: message", + 'data: {"message": "world"}', + ]), + contentType: "text/event-stream", + }, + }; + } + + return { + pass: "initial", + status: 200, + body: { + rawContent: protocolEvent(["id: event-1", "event: message", 'data: {"message": "hello"}']), + contentType: "text/event-stream", + }, + }; + }, + kind: "MockApiDefinition", +});