Skip to content

Commit b637347

Browse files
multiple fixes for SSE imports (#8963)
follow up to #8888 --------- Signed-off-by: Vincent Biret <[email protected]> Co-authored-by: Timothee Guerin <[email protected]>
1 parent bb883df commit b637347

File tree

7 files changed

+299
-28
lines changed

7 files changed

+299
-28
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: internal
3+
packages:
4+
- "@typespec/openapi3"
5+
---
6+
7+
fixed a bug for SSE import where imports would be missing for other operations than get

packages/openapi3/src/cli/actions/convert/generators/generate-response-expressions.ts

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -221,25 +221,22 @@ function generateResponseExpressions({
221221

222222
return contents.map(([mediaType, content]) => {
223223
// Special handling for Server-Sent Events
224-
if (mediaType === "text/event-stream") {
225-
context.markSSEUsage();
226-
224+
if (
225+
!context.openApi3Doc.openapi.startsWith("3.0") &&
226+
!context.openApi3Doc.openapi.startsWith("3.1") &&
227+
mediaType === "text/event-stream" &&
228+
"itemSchema" in content &&
229+
content.itemSchema &&
230+
typeof content.itemSchema === "object" &&
231+
"$ref" in content.itemSchema
232+
) {
227233
// Check for itemSchema (OpenAPI 3.2 extension)
228-
if ("itemSchema" in content && content.itemSchema) {
229-
const itemSchema = content.itemSchema;
230-
if (itemSchema && typeof itemSchema === "object" && "$ref" in itemSchema) {
231-
const eventUnionType = context.generateTypeFromRefableSchema(itemSchema, operationScope);
232-
return `SSEStream<${eventUnionType}>`;
233-
}
234-
} else if (content.schema && "$ref" in content.schema) {
235-
// Fallback: use schema directly if no itemSchema
236-
const eventUnionType = context.generateTypeFromRefableSchema(
237-
content.schema,
238-
operationScope,
239-
);
240-
return `SSEStream<${eventUnionType}>`;
241-
}
242-
234+
const eventUnionType = context.generateTypeFromRefableSchema(
235+
content.itemSchema,
236+
operationScope,
237+
);
238+
context.markSSEUsage();
239+
return `SSEStream<${eventUnionType}>`;
243240
// If no proper schema reference, fall through to regular handling
244241
}
245242

packages/openapi3/src/cli/actions/convert/transforms/transforms.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,12 @@ export function transform(context: Context): TypeSpecProgram {
4242
scanForMultipartSchemas(openapi, context);
4343

4444
// Pre-scan for SSE event schemas before generating models
45-
scanForSSESchemas(openapi, context);
45+
if (
46+
!context.openApi3Doc.openapi.startsWith("3.0") &&
47+
!context.openApi3Doc.openapi.startsWith("3.1")
48+
) {
49+
scanForSSESchemas(openapi, context);
50+
}
4651

4752
const models = collectDataTypes(context);
4853
const operations = transformPaths(openapi.paths, context);
@@ -81,13 +86,12 @@ function scanForSSESchemas(openapi: SupportedOpenAPIDocuments, context: Context)
8186
scanPathForSSESchemas(path, context);
8287
}
8388
}
89+
const methods = ["get", "post", "put", "patch", "delete", "head"] as const;
8490

8591
function scanPathForMultipartSchemas(
8692
path: OpenAPI3PathItem | OpenAPIPathItem3_2,
8793
context: Context,
8894
): void {
89-
const methods = ["get", "post", "put", "patch", "delete", "head"] as const;
90-
9195
for (const method of methods) {
9296
const operation = path[method];
9397
if (!operation?.requestBody) continue;
@@ -131,16 +135,35 @@ function scanPathForSSESchemas(
131135
path: OpenAPI3PathItem | OpenAPIPathItem3_2,
132136
context: Context,
133137
): void {
134-
if (!("get" in path && path.get && path.get.responses)) {
135-
// even though text/event-stream can be defined with other methods, it's only usable with GET because of the WHATWG specification
136-
return;
138+
for (const method of methods) {
139+
const operation = path[method];
140+
if (!operation?.responses) continue;
141+
142+
// Handle responses which could be a reference or actual responses
143+
const responses = resolveReference(operation.responses, context);
144+
if (!responses) return;
145+
146+
scanOperationForSSESchemas(responses, context);
137147
}
148+
if ("query" in path && path.query && path.query.responses) {
149+
// Handle responses which could be a reference or actual responses
150+
const responses = resolveReference(path.query.responses, context);
151+
if (!responses) return;
138152

139-
// Handle responses which could be a reference or actual responses
140-
const responses = resolveReference(path.get.responses, context);
141-
if (!responses) return;
153+
scanOperationForSSESchemas(responses, context);
154+
}
142155

143-
scanOperationForSSESchemas(responses, context);
156+
if ("additionalOperations" in path && path.additionalOperations) {
157+
for (const additionalOperation of Object.values(path.additionalOperations)) {
158+
if (additionalOperation.responses) {
159+
// Handle responses which could be a reference or actual responses
160+
const responses = resolveReference(additionalOperation.responses, context);
161+
if (!responses) return;
162+
163+
scanOperationForSSESchemas(responses, context);
164+
}
165+
}
166+
}
144167
}
145168

146169
function scanOperationForSSESchemas(
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import "@typespec/http";
2+
import "@typespec/openapi";
3+
import "@typespec/openapi3";
4+
5+
using Http;
6+
using OpenAPI;
7+
8+
@service(#{ title: "SSE Import Scenarios" })
9+
@info(#{ version: "0.0.0" })
10+
namespace SSEImportScenarios;
11+
12+
model UserConnect {
13+
username: string;
14+
}
15+
16+
model UserMessage {
17+
text: string;
18+
}
19+
20+
@oneOf
21+
union ChannelEventsNoTerminal {
22+
{
23+
event?: "userconnect",
24+
data?: unknown,
25+
},
26+
{
27+
event?: "usermessage",
28+
data?: unknown,
29+
},
30+
}
31+
32+
@oneOf
33+
union ChannelEventsWithTerminal {
34+
{
35+
data?: "[done]",
36+
},
37+
{
38+
event?: "userconnect",
39+
data?: unknown,
40+
},
41+
{
42+
event?: "usermessage",
43+
data?: unknown,
44+
},
45+
}
46+
47+
@oneOf
48+
union ChannelEventWithCustomContentType {
49+
{
50+
event?: "binary",
51+
data?: unknown,
52+
},
53+
}
54+
55+
@route("/channel/no-terminal") @get op subscribeToChannelNoTerminal(): {
56+
@header contentType: "text/event-stream";
57+
};
58+
59+
@route("/channel/no-terminal-post") @post op subscribeToChannelNoTerminal(
60+
/** Request body for subscribing to a channel without terminal event. */
61+
@body body: {
62+
channelId?: string;
63+
},
64+
): {
65+
@header contentType: "text/event-stream";
66+
};
67+
68+
@route("/channel/with-terminal") @get op subscribeToChannelWithTerminal(): {
69+
@header contentType: "text/event-stream";
70+
};
71+
72+
@route("/data/custom-content-type") @get op subscribeToDataStream(): {
73+
@header contentType: "text/event-stream";
74+
};

packages/openapi3/test/tsp-openapi3/output/sse-import-scenarios/main.tsp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ union ChannelEventWithCustomContentType {
4747
@route("/channel/no-terminal") @get op subscribeToChannelNoTerminal(
4848
): SSEStream<ChannelEventsNoTerminal>;
4949

50+
@route("/channel/no-terminal-post") @post op subscribeToChannelNoTerminal(
51+
/** Request body for subscribing to a channel without terminal event. */
52+
@body body: {
53+
channelId?: string;
54+
},
55+
): SSEStream<ChannelEventsNoTerminal>;
56+
5057
@route("/channel/with-terminal") @get op subscribeToChannelWithTerminal(
5158
): SSEStream<ChannelEventsWithTerminal>;
5259

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
openapi: 3.1.0
2+
info:
3+
title: SSE Import Scenarios
4+
version: 0.0.0
5+
components:
6+
schemas:
7+
UserConnect:
8+
type: object
9+
required: [username]
10+
properties:
11+
username:
12+
type: string
13+
UserMessage:
14+
type: object
15+
required: [text]
16+
properties:
17+
text:
18+
type: string
19+
ChannelEventsNoTerminal:
20+
type: object
21+
properties:
22+
event:
23+
type: string
24+
data:
25+
type: string
26+
required: [event]
27+
# Define event types and specific schemas for the corresponding data
28+
oneOf:
29+
- properties:
30+
event:
31+
const: userconnect
32+
data:
33+
contentMediaType: application/json
34+
contentSchema:
35+
$ref: "#/components/schemas/UserConnect"
36+
- properties:
37+
event:
38+
const: usermessage
39+
data:
40+
contentMediaType: application/json
41+
contentSchema:
42+
$ref: "#/components/schemas/UserMessage"
43+
ChannelEventsWithTerminal:
44+
type: object
45+
properties:
46+
event:
47+
type: string
48+
data:
49+
type: string
50+
required: [event]
51+
# Define event types and specific schemas for the corresponding data
52+
oneOf:
53+
- properties:
54+
data:
55+
contentMediaType: text/plain
56+
const: "[done]"
57+
x-ms-sse-terminal-event: true
58+
- properties:
59+
event:
60+
const: userconnect
61+
data:
62+
contentMediaType: application/json
63+
contentSchema:
64+
$ref: "#/components/schemas/UserConnect"
65+
- properties:
66+
event:
67+
const: usermessage
68+
data:
69+
contentMediaType: application/json
70+
contentSchema:
71+
$ref: "#/components/schemas/UserMessage"
72+
ChannelEventWithCustomContentType:
73+
type: object
74+
properties:
75+
event:
76+
type: string
77+
data:
78+
type: string
79+
required: [event]
80+
oneOf:
81+
- properties:
82+
event:
83+
const: binary
84+
data:
85+
contentMediaType: application/octet-stream
86+
contentSchema:
87+
type: object
88+
required: [data]
89+
properties:
90+
data:
91+
type: string
92+
format: byte
93+
paths:
94+
/channel/no-terminal:
95+
get:
96+
operationId: subscribeToChannelNoTerminal
97+
responses:
98+
"200":
99+
description: A request body to add a stream of typed data.
100+
content:
101+
text/event-stream:
102+
itemSchema:
103+
$ref: "#/components/schemas/ChannelEventsNoTerminal"
104+
/channel/no-terminal-post:
105+
post:
106+
operationId: subscribeToChannelNoTerminal
107+
requestBody:
108+
description: Request body for subscribing to a channel without terminal event.
109+
required: true
110+
content:
111+
application/json:
112+
schema:
113+
type: object
114+
properties:
115+
channelId:
116+
type: string
117+
responses:
118+
"200":
119+
description: A request body to add a stream of typed data.
120+
content:
121+
text/event-stream:
122+
itemSchema:
123+
$ref: "#/components/schemas/ChannelEventsNoTerminal"
124+
/channel/with-terminal:
125+
get:
126+
operationId: subscribeToChannelWithTerminal
127+
responses:
128+
"200":
129+
description: A request body to add a stream of typed data.
130+
content:
131+
text/event-stream:
132+
itemSchema:
133+
$ref: "#/components/schemas/ChannelEventsWithTerminal"
134+
/data/custom-content-type:
135+
get:
136+
operationId: subscribeToDataStream
137+
responses:
138+
"200":
139+
description: A data stream with custom content types.
140+
content:
141+
text/event-stream:
142+
itemSchema:
143+
$ref: "#/components/schemas/ChannelEventWithCustomContentType"

packages/openapi3/test/tsp-openapi3/specs/sse-import-scenarios/service.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,26 @@ paths:
101101
text/event-stream:
102102
itemSchema:
103103
$ref: "#/components/schemas/ChannelEventsNoTerminal"
104+
/channel/no-terminal-post:
105+
post:
106+
operationId: subscribeToChannelNoTerminal
107+
requestBody:
108+
description: Request body for subscribing to a channel without terminal event.
109+
required: true
110+
content:
111+
application/json:
112+
schema:
113+
type: object
114+
properties:
115+
channelId:
116+
type: string
117+
responses:
118+
"200":
119+
description: A request body to add a stream of typed data.
120+
content:
121+
text/event-stream:
122+
itemSchema:
123+
$ref: "#/components/schemas/ChannelEventsNoTerminal"
104124
/channel/with-terminal:
105125
get:
106126
operationId: subscribeToChannelWithTerminal

0 commit comments

Comments
 (0)