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..c52bc22a37 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,14 @@ export const StreamSse: { }) } +const sseDataJsonSchema = (data: Schema.Constraint) => { + const json = Schema.fromJsonString(data) + const identifier = SchemaAST.resolveIdentifier(data.ast) + // 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` }) +} + /** * 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" + }) + }) })