From d1384d0a6cf818ee11b1f3923fb9363e7d241849 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 27 Jun 2026 23:13:08 -0400 Subject: [PATCH 1/2] Preserve SSE data schema identifier in OpenAPI output HttpApiSchema.StreamSse({ data }) encodes the SSE data field as a JSON string. That string wrapper was claiming the data schema's identifier in the generated OpenAPI spec, pushing the decoded schema to a suffixed name (e.g. the string wrapper became MyEvent while the object became MyEvent1). The wrapper now takes its own ${identifier}Stream name. --- .changeset/fix-streamsse-data-identifier.md | 7 ++++ .../src/unstable/httpapi/HttpApiSchema.ts | 11 +++++- .../test/unstable/httpapi/OpenApi.test.ts | 36 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-streamsse-data-identifier.md diff --git a/.changeset/fix-streamsse-data-identifier.md b/.changeset/fix-streamsse-data-identifier.md new file mode 100644 index 0000000000..ff1b7e0e8f --- /dev/null +++ b/.changeset/fix-streamsse-data-identifier.md @@ -0,0 +1,7 @@ +--- +"effect": patch +--- + +Fix `HttpApiSchema.StreamSse({ data })` so the decoded data schema keeps its identifier in the generated OpenAPI spec. + +The SSE `data` field is encoded as a JSON string, and that string wrapper was claiming the data schema's `identifier`, pushing the real schema to a suffixed name (e.g. `MyEvent` became the `string` wrapper while the decoded object became `MyEvent1`). This inverted the names for OpenAPI codegen consumers. The wrapper now gets its own `${identifier}Stream` name, leaving the data schema's identifier intact. diff --git a/packages/effect/src/unstable/httpapi/HttpApiSchema.ts b/packages/effect/src/unstable/httpapi/HttpApiSchema.ts index 622f08e01e..c228aeb917 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiSchema.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiSchema.ts @@ -406,7 +406,7 @@ export const StreamSse: { const events = options.events ?? (options.data === undefined ? undefined : Schema.Struct({ id: Schema.UndefinedOr(Schema.String), event: Schema.String, - data: Schema.fromJsonString(options.data) + data: sseDataJsonSchema(options.data) })) if (events === undefined) { throw new Error("StreamSse requires either an events schema or a data schema") @@ -422,6 +422,15 @@ export const StreamSse: { }) } +const sseDataJsonSchema = (data: Schema.Constraint) => { + const identifier = SchemaAST.resolveIdentifier(data.ast) + return identifier === undefined ? Schema.fromJsonString(data) : Schema.fromJsonString(data).annotate({ + // The SSE transport field is a JSON string. Give that wrapper its own + // OpenAPI identifier so it does not claim the decoded data schema's name. + identifier: `${identifier}Stream` + }) +} + /** * Creates a streaming `Uint8Array` success response schema. * diff --git a/packages/effect/test/unstable/httpapi/OpenApi.test.ts b/packages/effect/test/unstable/httpapi/OpenApi.test.ts index 5b6dd0620a..61b4261de6 100644 --- a/packages/effect/test/unstable/httpapi/OpenApi.test.ts +++ b/packages/effect/test/unstable/httpapi/OpenApi.test.ts @@ -36,4 +36,40 @@ describe("OpenApi", () => { assert.property(streamExtension, "causeSchema") assert.property(streamExtension, "errorSchema") }) + + it("preserves the data schema identifier for SSE streams", () => { + const Event = Schema.Struct({ + kind: Schema.String, + payload: Schema.String + }).annotate({ identifier: "MyEvent" }) + + const Api = HttpApi.make("Api").add( + HttpApiGroup.make("test").add( + HttpApiEndpoint.get("stream", "/stream", { + success: [HttpApiSchema.StreamSse({ data: Event })] + }) + ) + ) + + const spec = OpenApi.fromApi(Api) + const schemas = spec.components?.schemas + + // The decoded data schema keeps its identifier instead of being suffixed. + assert.deepStrictEqual(schemas?.MyEvent, { + type: "object", + properties: { + kind: { type: "string" }, + payload: { type: "string" } + }, + required: ["kind", "payload"], + additionalProperties: false + }) + + // The JSON-string transport wrapper gets its own `${identifier}Stream` name. + assert.deepStrictEqual(schemas?.MyEventStream, { + type: "string", + contentSchema: { $ref: "#/components/schemas/MyEvent" }, + contentMediaType: "application/json" + }) + }) }) From 75ff915fe21fe644bb5887c88932e42f7052927f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 27 Jun 2026 23:15:18 -0400 Subject: [PATCH 2/2] Avoid building the JSON wrapper schema twice --- packages/effect/src/unstable/httpapi/HttpApiSchema.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/effect/src/unstable/httpapi/HttpApiSchema.ts b/packages/effect/src/unstable/httpapi/HttpApiSchema.ts index c228aeb917..c52bc22a37 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiSchema.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiSchema.ts @@ -423,12 +423,11 @@ export const StreamSse: { } const sseDataJsonSchema = (data: Schema.Constraint) => { + const json = Schema.fromJsonString(data) const identifier = SchemaAST.resolveIdentifier(data.ast) - return identifier === undefined ? Schema.fromJsonString(data) : Schema.fromJsonString(data).annotate({ - // The SSE transport field is a JSON string. Give that wrapper its own - // OpenAPI identifier so it does not claim the decoded data schema's name. - identifier: `${identifier}Stream` - }) + // The SSE transport field is a JSON string. Give that wrapper its own + // OpenAPI identifier so it does not claim the decoded data schema's name. + return identifier === undefined ? json : json.annotate({ identifier: `${identifier}Stream` }) } /**