From aa5c5b513260f919c00d076b7e9b77a566f7bce7 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 10 Nov 2025 14:01:48 -0500 Subject: [PATCH 1/6] fix: a bug where SSE imports would only be added for get operations Signed-off-by: Vincent Biret --- .../actions/convert/transforms/transforms.ts | 36 ++++++++++++++----- .../output/sse-import-scenarios/main.tsp | 7 ++++ .../specs/sse-import-scenarios/service.yml | 20 +++++++++++ 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts b/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts index 9c2ad3bdd91..e643a55d15e 100644 --- a/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts +++ b/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts @@ -81,13 +81,12 @@ function scanForSSESchemas(openapi: SupportedOpenAPIDocuments, context: Context) scanPathForSSESchemas(path, context); } } +const methods = ["get", "post", "put", "patch", "delete", "head"] as const; function scanPathForMultipartSchemas( path: OpenAPI3PathItem | OpenAPIPathItem3_2, context: Context, ): void { - const methods = ["get", "post", "put", "patch", "delete", "head"] as const; - for (const method of methods) { const operation = path[method]; if (!operation?.requestBody) continue; @@ -131,16 +130,35 @@ function scanPathForSSESchemas( path: OpenAPI3PathItem | OpenAPIPathItem3_2, context: Context, ): void { - if (!("get" in path && path.get && path.get.responses)) { - // even though text/event-stream can be defined with other methods, it's only usable with GET because of the WHATWG specification - return; + for (const method of methods) { + const operation = path[method]; + if (!operation?.responses) continue; + + // Handle responses which could be a reference or actual responses + const responses = resolveReference(operation.responses, context); + if (!responses) return; + + scanOperationForSSESchemas(responses, context); + } + if ("query" in path && path.query && path.query.responses) { + // Handle responses which could be a reference or actual responses + const responses = resolveReference(path.query.responses, context); + if (!responses) return; + + scanOperationForSSESchemas(responses, context); } - // Handle responses which could be a reference or actual responses - const responses = resolveReference(path.get.responses, context); - if (!responses) return; + if ("additionalOperations" in path && path.additionalOperations) { + for (const additionalOperation of Object.values(path.additionalOperations)) { + if (additionalOperation.responses) { + // Handle responses which could be a reference or actual responses + const responses = resolveReference(additionalOperation.responses, context); + if (!responses) return; - scanOperationForSSESchemas(responses, context); + scanOperationForSSESchemas(responses, context); + } + } + } } function scanOperationForSSESchemas( diff --git a/packages/openapi3/test/tsp-openapi3/output/sse-import-scenarios/main.tsp b/packages/openapi3/test/tsp-openapi3/output/sse-import-scenarios/main.tsp index eea0a198d61..7e57df27b27 100644 --- a/packages/openapi3/test/tsp-openapi3/output/sse-import-scenarios/main.tsp +++ b/packages/openapi3/test/tsp-openapi3/output/sse-import-scenarios/main.tsp @@ -47,6 +47,13 @@ union ChannelEventWithCustomContentType { @route("/channel/no-terminal") @get op subscribeToChannelNoTerminal( ): SSEStream; +@route("/channel/no-terminal-post") @post op subscribeToChannelNoTerminal( + /** Request body for subscribing to a channel without terminal event. */ + @body body: { + channelId?: string; + }, +): SSEStream; + @route("/channel/with-terminal") @get op subscribeToChannelWithTerminal( ): SSEStream; diff --git a/packages/openapi3/test/tsp-openapi3/specs/sse-import-scenarios/service.yml b/packages/openapi3/test/tsp-openapi3/specs/sse-import-scenarios/service.yml index 02e1b70b037..d47b869026a 100644 --- a/packages/openapi3/test/tsp-openapi3/specs/sse-import-scenarios/service.yml +++ b/packages/openapi3/test/tsp-openapi3/specs/sse-import-scenarios/service.yml @@ -101,6 +101,26 @@ paths: text/event-stream: itemSchema: $ref: "#/components/schemas/ChannelEventsNoTerminal" + /channel/no-terminal-post: + post: + operationId: subscribeToChannelNoTerminal + requestBody: + description: Request body for subscribing to a channel without terminal event. + required: true + content: + application/json: + schema: + type: object + properties: + channelId: + type: string + responses: + "200": + description: A request body to add a stream of typed data. + content: + text/event-stream: + itemSchema: + $ref: "#/components/schemas/ChannelEventsNoTerminal" /channel/with-terminal: get: operationId: subscribeToChannelWithTerminal From f0ebb2407468526a6f0683ee7cf5a3a4f18bbea1 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 10 Nov 2025 14:07:27 -0500 Subject: [PATCH 2/6] fix: do not fall back on schema for sse events Signed-off-by: Vincent Biret --- .../generate-response-expressions.ts | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts index f12eebfb787..fc658a8db09 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts @@ -221,25 +221,20 @@ function generateResponseExpressions({ return contents.map(([mediaType, content]) => { // Special handling for Server-Sent Events - if (mediaType === "text/event-stream") { - context.markSSEUsage(); - + if ( + mediaType === "text/event-stream" && + "itemSchema" in content && + content.itemSchema && + typeof content.itemSchema === "object" && + "$ref" in content.itemSchema + ) { // Check for itemSchema (OpenAPI 3.2 extension) - if ("itemSchema" in content && content.itemSchema) { - const itemSchema = content.itemSchema; - if (itemSchema && typeof itemSchema === "object" && "$ref" in itemSchema) { - const eventUnionType = context.generateTypeFromRefableSchema(itemSchema, operationScope); - return `SSEStream<${eventUnionType}>`; - } - } else if (content.schema && "$ref" in content.schema) { - // Fallback: use schema directly if no itemSchema - const eventUnionType = context.generateTypeFromRefableSchema( - content.schema, - operationScope, - ); - return `SSEStream<${eventUnionType}>`; - } - + const eventUnionType = context.generateTypeFromRefableSchema( + content.itemSchema, + operationScope, + ); + context.markSSEUsage(); + return `SSEStream<${eventUnionType}>`; // If no proper schema reference, fall through to regular handling } From b0e2006ec9e745fedb703062abcf275bcd492dda Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 10 Nov 2025 14:11:26 -0500 Subject: [PATCH 3/6] docs: adds the chronus entry for operations SSE import fix --- .../fix-sse-import-and-operations-2025-10-10-14-9-41.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .chronus/changes/fix-sse-import-and-operations-2025-10-10-14-9-41.md diff --git a/.chronus/changes/fix-sse-import-and-operations-2025-10-10-14-9-41.md b/.chronus/changes/fix-sse-import-and-operations-2025-10-10-14-9-41.md new file mode 100644 index 00000000000..5286e26154f --- /dev/null +++ b/.chronus/changes/fix-sse-import-and-operations-2025-10-10-14-9-41.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +fixed a bug for SSE import where imports would be missing for other operations than get \ No newline at end of file From edc965023e5dbe562042584e8168d6b49d69504a Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 10 Nov 2025 14:17:22 -0500 Subject: [PATCH 4/6] fix: do not import SSE events for earlier document versions Signed-off-by: Vincent Biret --- .../convert/generators/generate-response-expressions.ts | 2 ++ .../src/cli/actions/convert/transforms/transforms.ts | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts index fc658a8db09..cd9b16ea290 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts @@ -222,6 +222,8 @@ function generateResponseExpressions({ return contents.map(([mediaType, content]) => { // Special handling for Server-Sent Events if ( + !context.openApi3Doc.openapi.startsWith("3.0") && + !context.openApi3Doc.openapi.startsWith("3.1") && mediaType === "text/event-stream" && "itemSchema" in content && content.itemSchema && diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts b/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts index e643a55d15e..4dc652fa662 100644 --- a/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts +++ b/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts @@ -42,7 +42,12 @@ export function transform(context: Context): TypeSpecProgram { scanForMultipartSchemas(openapi, context); // Pre-scan for SSE event schemas before generating models - scanForSSESchemas(openapi, context); + if ( + !context.openApi3Doc.openapi.startsWith("3.0") && + !context.openApi3Doc.openapi.startsWith("3.1") + ) { + scanForSSESchemas(openapi, context); + } const models = collectDataTypes(context); const operations = transformPaths(openapi.paths, context); From 35dbb9d5d73f9f3de2d711d271d6fca05c46c339 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 10 Nov 2025 14:22:07 -0500 Subject: [PATCH 5/6] tests: adds 3.1 tests Signed-off-by: Vincent Biret --- .../output/sse-import-scenarios-3-1/main.tsp | 74 +++++++++ .../sse-import-scenarios-3-1/service.yml | 143 ++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 packages/openapi3/test/tsp-openapi3/output/sse-import-scenarios-3-1/main.tsp create mode 100644 packages/openapi3/test/tsp-openapi3/specs/sse-import-scenarios-3-1/service.yml diff --git a/packages/openapi3/test/tsp-openapi3/output/sse-import-scenarios-3-1/main.tsp b/packages/openapi3/test/tsp-openapi3/output/sse-import-scenarios-3-1/main.tsp new file mode 100644 index 00000000000..6b604a569a2 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/output/sse-import-scenarios-3-1/main.tsp @@ -0,0 +1,74 @@ +import "@typespec/http"; +import "@typespec/openapi"; +import "@typespec/openapi3"; + +using Http; +using OpenAPI; + +@service(#{ title: "SSE Import Scenarios" }) +@info(#{ version: "0.0.0" }) +namespace SSEImportScenarios; + +model UserConnect { + username: string; +} + +model UserMessage { + text: string; +} + +@oneOf +union ChannelEventsNoTerminal { + { + event?: "userconnect", + data?: unknown, + }, + { + event?: "usermessage", + data?: unknown, + }, +} + +@oneOf +union ChannelEventsWithTerminal { + { + data?: "[done]", + }, + { + event?: "userconnect", + data?: unknown, + }, + { + event?: "usermessage", + data?: unknown, + }, +} + +@oneOf +union ChannelEventWithCustomContentType { + { + event?: "binary", + data?: unknown, + }, +} + +@route("/channel/no-terminal") @get op subscribeToChannelNoTerminal(): { + @header contentType: "text/event-stream"; +}; + +@route("/channel/no-terminal-post") @post op subscribeToChannelNoTerminal( + /** Request body for subscribing to a channel without terminal event. */ + @body body: { + channelId?: string; + }, +): { + @header contentType: "text/event-stream"; +}; + +@route("/channel/with-terminal") @get op subscribeToChannelWithTerminal(): { + @header contentType: "text/event-stream"; +}; + +@route("/data/custom-content-type") @get op subscribeToDataStream(): { + @header contentType: "text/event-stream"; +}; diff --git a/packages/openapi3/test/tsp-openapi3/specs/sse-import-scenarios-3-1/service.yml b/packages/openapi3/test/tsp-openapi3/specs/sse-import-scenarios-3-1/service.yml new file mode 100644 index 00000000000..37862e70368 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/specs/sse-import-scenarios-3-1/service.yml @@ -0,0 +1,143 @@ +openapi: 3.1.0 +info: + title: SSE Import Scenarios + version: 0.0.0 +components: + schemas: + UserConnect: + type: object + required: [username] + properties: + username: + type: string + UserMessage: + type: object + required: [text] + properties: + text: + type: string + ChannelEventsNoTerminal: + type: object + properties: + event: + type: string + data: + type: string + required: [event] + # Define event types and specific schemas for the corresponding data + oneOf: + - properties: + event: + const: userconnect + data: + contentMediaType: application/json + contentSchema: + $ref: "#/components/schemas/UserConnect" + - properties: + event: + const: usermessage + data: + contentMediaType: application/json + contentSchema: + $ref: "#/components/schemas/UserMessage" + ChannelEventsWithTerminal: + type: object + properties: + event: + type: string + data: + type: string + required: [event] + # Define event types and specific schemas for the corresponding data + oneOf: + - properties: + data: + contentMediaType: text/plain + const: "[done]" + x-ms-sse-terminal-event: true + - properties: + event: + const: userconnect + data: + contentMediaType: application/json + contentSchema: + $ref: "#/components/schemas/UserConnect" + - properties: + event: + const: usermessage + data: + contentMediaType: application/json + contentSchema: + $ref: "#/components/schemas/UserMessage" + ChannelEventWithCustomContentType: + type: object + properties: + event: + type: string + data: + type: string + required: [event] + oneOf: + - properties: + event: + const: binary + data: + contentMediaType: application/octet-stream + contentSchema: + type: object + required: [data] + properties: + data: + type: string + format: byte +paths: + /channel/no-terminal: + get: + operationId: subscribeToChannelNoTerminal + responses: + "200": + description: A request body to add a stream of typed data. + content: + text/event-stream: + itemSchema: + $ref: "#/components/schemas/ChannelEventsNoTerminal" + /channel/no-terminal-post: + post: + operationId: subscribeToChannelNoTerminal + requestBody: + description: Request body for subscribing to a channel without terminal event. + required: true + content: + application/json: + schema: + type: object + properties: + channelId: + type: string + responses: + "200": + description: A request body to add a stream of typed data. + content: + text/event-stream: + itemSchema: + $ref: "#/components/schemas/ChannelEventsNoTerminal" + /channel/with-terminal: + get: + operationId: subscribeToChannelWithTerminal + responses: + "200": + description: A request body to add a stream of typed data. + content: + text/event-stream: + itemSchema: + $ref: "#/components/schemas/ChannelEventsWithTerminal" + /data/custom-content-type: + get: + operationId: subscribeToDataStream + responses: + "200": + description: A data stream with custom content types. + content: + text/event-stream: + itemSchema: + $ref: "#/components/schemas/ChannelEventWithCustomContentType" From 67e38b6458883a274d15a87a6f4a6b5cd8a71b98 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 10 Nov 2025 14:43:29 -0500 Subject: [PATCH 6/6] Update .chronus/changes/fix-sse-import-and-operations-2025-10-10-14-9-41.md Co-authored-by: Timothee Guerin --- .../changes/fix-sse-import-and-operations-2025-10-10-14-9-41.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.chronus/changes/fix-sse-import-and-operations-2025-10-10-14-9-41.md b/.chronus/changes/fix-sse-import-and-operations-2025-10-10-14-9-41.md index 5286e26154f..4d76569f997 100644 --- a/.chronus/changes/fix-sse-import-and-operations-2025-10-10-14-9-41.md +++ b/.chronus/changes/fix-sse-import-and-operations-2025-10-10-14-9-41.md @@ -1,5 +1,5 @@ --- -changeKind: fix +changeKind: internal packages: - "@typespec/openapi3" ---