Skip to content
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2b81e2f
Initial plan
Copilot Oct 31, 2025
28788d5
Add SSE support infrastructure to OpenAPI 3.2
Copilot Oct 31, 2025
9df9947
WIP: Add SSE support with debugging - stream detection in progress
Copilot Oct 31, 2025
691b8b3
Merge branch 'main' into copilot/add-sse-support-to-openapi
baywet Nov 3, 2025
ac730e6
tests: fixes version requested for emission of streaming events
baywet Nov 3, 2025
9d8fa9a
docs: adds the chronus entry for SSE support
baywet Nov 3, 2025
c64d3a4
chore: formatting
baywet Nov 3, 2025
1caa061
chore: typo fix
baywet Nov 3, 2025
559641d
Remove debug console.log statements
Copilot Nov 3, 2025
ca5cc53
chore: linting
baywet Nov 3, 2025
e6b39f7
fix: extension decorator should be used for terminal event
baywet Nov 3, 2025
63c2f9d
tests: adds SEE import tests
baywet Nov 3, 2025
b949e79
Merge branch 'main' into copilot/add-sse-support-to-openapi
baywet Nov 5, 2025
4ce7033
tests: fixes test definition for sse support
baywet Nov 5, 2025
8ce3759
fix: extension is not getting emitted
baywet Nov 5, 2025
0cb0f0c
chore: formatting
baywet Nov 5, 2025
b1f5ea6
feat; emit a warning when trying to emit sse on lower openapi versions
baywet Nov 5, 2025
4154e85
tests: updates tests for see unsupported to also cover version 3.0.0
baywet Nov 5, 2025
28c5cbc
Update .chronus/changes/copilot-add-sse-support-to-openapi-2025-10-3-…
baywet Nov 6, 2025
dfe7f1b
Refactor SSE tests and generalize stream support
Copilot Nov 6, 2025
dfc5338
tests: fixes badly indented extension in import definition
baywet Nov 6, 2025
9766532
Merge branch 'main' into copilot/add-sse-support-to-openapi
baywet Nov 7, 2025
3f5e1de
feat: import of sse
baywet Nov 7, 2025
fbd52ee
tests: linting
baywet Nov 7, 2025
df0c5de
Merge branch 'main' into copilot/add-sse-support-to-openapi
baywet Nov 7, 2025
e0ae77d
fix: use short name for events decorator when importing
baywet Nov 7, 2025
3700ed7
fix: adds import for multipart request body in query and additional o…
baywet Nov 7, 2025
4dde6a8
chore: refactoring
baywet Nov 7, 2025
609f631
chore: refactor duplicated implementation of reference resolution
baywet Nov 7, 2025
f4db16f
fix: regression after events name change
baywet Nov 7, 2025
5fa331b
chore: refactor duplicated constant
baywet Nov 7, 2025
9e1f0b2
chore: refactoring to its own method
baywet Nov 7, 2025
cd74d5d
dev: adds a quick build script to make the local dev loop faster
baywet Nov 7, 2025
2688d07
chore: refactoring to remove casts to any
baywet Nov 7, 2025
1adfa34
Merge branch 'main' into copilot/add-sse-support-to-openapi
baywet Nov 7, 2025
e190414
chore: adds acronym to dictionary
baywet Nov 7, 2025
c2e5c76
chore: better types for the unit test definition
baywet Nov 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/openapi3"
---

adds support for emission and import of SSE for OpenAPI 3.2
7 changes: 4 additions & 3 deletions cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ dictionaries:
- typescript
words:
- Adoptium
- Arize
- arizeaiobservabilityeval
- agentic
- aiohttp
- alzimmer
- amqp
- AQID
- Arize
- arizeaiobservabilityeval
- arraya
- astimezone
- astro
Expand Down Expand Up @@ -230,8 +230,8 @@ words:
- rtype
- rushx
- safeint
- sdkcore
- SCME
- sdkcore
- segmentof
- serde
- setuppy
Expand Down Expand Up @@ -293,6 +293,7 @@ words:
- wday
- weidxu
- westus
- WHATWG
- WINDOWSARMVMIMAGE
- WINDOWSVMIMAGE
- xiangyan
Expand Down
20 changes: 18 additions & 2 deletions packages/openapi3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
},
"scripts": {
"clean": "rimraf ./dist ./temp",
"build": "pnpm gen-version && pnpm gen-extern-signature && tsc -p . && pnpm lint-typespec-library",
"build": "pnpm gen-version && pnpm gen-extern-signature && pnpm quickbuild && pnpm lint-typespec-library",
"quickbuild": "tsc -p .",
"watch": "tsc -p . --watch",
"gen-extern-signature": "tspd --enable-experimental gen-extern-signature .",
"lint-typespec-library": "tsp compile . --warn-as-error --import @typespec/library-linter --no-emit",
Expand Down Expand Up @@ -73,7 +74,10 @@
"@typespec/http": "workspace:^",
"@typespec/json-schema": "workspace:^",
"@typespec/openapi": "workspace:^",
"@typespec/versioning": "workspace:^"
"@typespec/versioning": "workspace:^",
"@typespec/streams": "workspace:^",
"@typespec/events": "workspace:^",
"@typespec/sse": "workspace:^"
},
"peerDependenciesMeta": {
"@typespec/json-schema": {
Expand All @@ -84,6 +88,15 @@
},
"@typespec/versioning": {
"optional": true
},
"@typespec/streams": {
"optional": true
},
"@typespec/events": {
"optional": true
},
"@typespec/sse": {
"optional": true
}
},
"devDependencies": {
Expand All @@ -98,6 +111,9 @@
"@typespec/tspd": "workspace:^",
"@typespec/versioning": "workspace:^",
"@typespec/xml": "workspace:^",
"@typespec/streams": "workspace:^",
"@typespec/events": "workspace:^",
"@typespec/sse": "workspace:^",
"@vitest/coverage-v8": "^4.0.4",
"@vitest/ui": "^4.0.4",
"c8": "^10.1.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,21 @@ import { generateOperation } from "./generate-operation.js";
import { generateServiceInformation } from "./generate-service-info.js";

export function generateMain(program: TypeSpecProgram, context: Context): string {
const sseImports = context.isSSEUsed()
? ` import "@typespec/streams";
import "@typespec/sse";
import "@typespec/events";`
: "";

const sseUsings = context.isSSEUsed() ? "\n using SSE;" : "";

return `
import "@typespec/http";
import "@typespec/openapi";
import "@typespec/openapi3";
import "@typespec/openapi3";${sseImports}

using Http;
using OpenAPI;
using OpenAPI;${sseUsings}

${generateServiceInformation(program.serviceInfo, program.servers, program.tags, context.rootNamespace)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
SchemaToExpressionGenerator,
} from "./generate-types.js";

const SSE_TERMINAL_EVENT_EXTENSION = "x-ms-sse-terminal-event";

export function generateDataType(type: TypeSpecDataTypes, context: Context): string {
switch (type.kind) {
case "alias":
Expand Down Expand Up @@ -83,6 +85,145 @@ function generateScalar(scalar: TypeSpecScalar, context: Context): string {
return definitions.join("\n");
}

function generateSSEEventVariants(
members: Refable<SupportedOpenAPISchema>[],
union: TypeSpecUnion,
context: Context,
getVariantName: (member: Refable<SupportedOpenAPISchema>) => string,
): string[] {
// Generate SSE event variants: eventName: DataType,
// Sort so terminal events come last
const sortedMembers = [...members].sort((a, b) => {
const aSchema = "$ref" in a ? context.getSchemaByRef(a.$ref) : a;
const bSchema = "$ref" in b ? context.getSchemaByRef(b.$ref) : b;

const aIsTerminal = !!aSchema?.[SSE_TERMINAL_EVENT_EXTENSION];
const bIsTerminal = !!bSchema?.[SSE_TERMINAL_EVENT_EXTENSION];

if (aIsTerminal && !bIsTerminal) return 1; // a comes after b
if (!aIsTerminal && bIsTerminal) return -1; // a comes before b
return 0; // maintain original order for same type
});

return sortedMembers.map((member) => {
try {
const memberSchema = "$ref" in member ? context.getSchemaByRef(member.$ref) : member;
if (!memberSchema || typeof memberSchema !== "object" || !memberSchema.properties) {
// Fallback to regular generation if we can't parse the event structure
return (
getVariantName(member) + context.generateTypeFromRefableSchema(member, union.scope) + ","
);
}

// Use any to access properties since the types are complex
const props = memberSchema.properties;

// Extract event name from event.const
let eventName: string | undefined;
if (props.event) {
const eventProp = props.event;
if ("const" in eventProp && eventProp.const && typeof eventProp.const === "string") {
eventName = eventProp.const;
} else if (
"enum" in eventProp &&
eventProp.enum?.[0] &&
typeof eventProp.enum[0] === "string"
) {
eventName = eventProp.enum[0];
}
}

// Check for terminal events or special cases
if (!eventName) {
// Check if this is a terminal event (no event name, just data)
if ("const" in props.data && props.data.const) {
const terminalValue = props.data.const;
const isTerminal = memberSchema[SSE_TERMINAL_EVENT_EXTENSION];
const contentType = "contentMediaType" in props.data && props.data.contentMediaType;

let decorators = "";
if (contentType) {
decorators += `\n @TypeSpec.Events.contentType("${contentType}")`;
}
if (isTerminal) {
decorators += `\n @TypeSpec.SSE.terminalEvent`;
decorators += `\n @extension("${SSE_TERMINAL_EVENT_EXTENSION}", true)`;
}

return `${decorators}\n "${terminalValue}",`;
}
// Fallback to regular generation
return (
getVariantName(member) + context.generateTypeFromRefableSchema(member, union.scope) + ","
);
}

// Extract data type and content type from data.contentSchema
let dataType = "unknown";
let contentType: string | undefined;

if (props.data) {
const dataProp = props.data;

// Get content type if specified
if ("contentMediaType" in dataProp && dataProp.contentMediaType) {
contentType = dataProp.contentMediaType;
}

// Check for contentSchema (OpenAPI extension)
if ("contentSchema" in dataProp && dataProp.contentSchema) {
const contentSchema = dataProp.contentSchema;
// Special handling for byte data which should map to Base64/bytes
if (
contentSchema &&
typeof contentSchema === "object" &&
"type" in contentSchema &&
contentSchema.type === "object" &&
contentSchema.properties?.data
) {
const dataProperty = contentSchema.properties.data;
if (
"type" in dataProperty &&
dataProperty.type === "string" &&
"format" in dataProperty &&
dataProperty.format === "byte"
) {
dataType = "Base64";
} else {
dataType = context.generateTypeFromRefableSchema(dataProp.contentSchema, union.scope);
}
} else {
dataType = context.generateTypeFromRefableSchema(dataProp.contentSchema, union.scope);
}
} else if ("type" in dataProp && dataProp.type && typeof dataProp.type === "string") {
// Simple type like string
dataType = dataProp.type;
}
}

// Build decorators for this event variant
let decorators = "";
if (contentType && contentType !== "application/json") {
decorators += `\n @TypeSpec.Events.contentType("${contentType}")`;
}

// Check if this is a terminal event
const isTerminal = memberSchema[SSE_TERMINAL_EVENT_EXTENSION];
if (isTerminal) {
decorators += `\n @TypeSpec.SSE.terminalEvent`;
decorators += `\n @extension("${SSE_TERMINAL_EVENT_EXTENSION}", true)`;
}

return `${decorators}\n ${eventName}: ${dataType},`;
} catch (error) {
// If any error occurs, fall back to regular generation
return (
getVariantName(member) + context.generateTypeFromRefableSchema(member, union.scope) + ","
);
}
});
}

function generateUnion(union: TypeSpecUnion, context: Context): string {
const definitions: string[] = [];

Expand All @@ -103,24 +244,38 @@ function generateUnion(union: TypeSpecUnion, context: Context): string {

const memberSchema = "$ref" in member ? context.getSchemaByRef(member.$ref)! : member;

const propertySchema =
memberSchema.properties && memberSchema.properties[union.schema.discriminator.propertyName];
const value =
(union.schema.discriminator?.mapping && "$ref" in member
? Object.entries(union.schema.discriminator.mapping).find((x) => x[1] === member.$ref)?.[0]
: undefined) ??
(memberSchema.properties?.[union.schema.discriminator.propertyName] as any)?.enum?.[0];
(propertySchema && "enum" in propertySchema && propertySchema.enum?.[0]);
// checking whether the value is using an invalid character as an identifier
const valueIdentifier = value ? printIdentifier(value, "disallow-reserved") : "";
const valueIdentifier = value ? printIdentifier(`${value}`, "disallow-reserved") : "";
return value ? `${value === valueIdentifier ? value : valueIdentifier}: ` : "";
};
if (schema.enum) {
definitions.push(...schema.enum.map((e) => `${JSON.stringify(e)},`));
} else if (schema.oneOf) {
definitions.push(
...schema.oneOf.map(
(member) =>
getVariantName(member) + context.generateTypeFromRefableSchema(member, union.scope) + ",",
),
// Check if this is an SSE event union
const isSSEEventUnion = union.decorators.some(
(d) => d.name === "TypeSpec.Events.events" || d.name === "events",
);

if (isSSEEventUnion) {
definitions.push(...generateSSEEventVariants(schema.oneOf, union, context, getVariantName));
} else {
// Regular union generation
definitions.push(
...schema.oneOf.map(
(member) =>
getVariantName(member) +
context.generateTypeFromRefableSchema(member, union.scope) +
",",
),
);
}
} else if (schema.anyOf) {
definitions.push(
...schema.anyOf.map(
Expand All @@ -140,7 +295,7 @@ function generateUnion(union: TypeSpecUnion, context: Context): string {
definitions.push("null,");
} else {
// Create a schema with a single type to reuse existing logic
const singleTypeSchema = { ...schema, type: t as any, nullable: undefined };
const singleTypeSchema = { ...schema, type: t, nullable: undefined };
const type = context.generateTypeFromRefableSchema(singleTypeSchema, union.scope);
definitions.push(`${type},`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,29 @@ function generateResponseExpressions({
}

return contents.map(([mediaType, content]) => {
// Special handling for Server-Sent Events
if (mediaType === "text/event-stream") {
context.markSSEUsage();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be moved down to the condition when we detected a valid case


// 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}>`;
Comment on lines +234 to +240
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is wrong, we shouldn't fallback on the main schema for streaming as schema is bounded and that does not work with streams which don't have collections bounds

}

// If no proper schema reference, fall through to regular handling
}

// Attempt to emit just the Body or an intersection of Body & MappedResponse
// if there aren't any custom headers.
const bodySchema = content.schema;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,20 @@ export function transformComponentSchemas(context: Context, models: TypeSpecData
// Extract description and decorators from meaningful union members
const unionMetadata = extractUnionMetadata(schema);

let decorators = [...getDecoratorsForSchema(schema), ...unionMetadata.decorators];

// Check if this is an SSE event schema - if so, replace @oneOf with @events
const schemaRef = `#/components/schemas/${name}`;
if (context.isSSEEventSchema(schemaRef)) {
// Remove @oneOf decorator if present and add @events
decorators = decorators.filter((d) => d.name !== "oneOf");
decorators.push({ name: "events", args: [] });
}

const union: TypeSpecUnion = {
kind: "union",
...getScopeAndName(name),
decorators: [...getDecoratorsForSchema(schema), ...unionMetadata.decorators],
decorators,
doc: schema.description ?? unionMetadata.description,
schema,
};
Expand Down
Loading
Loading