diff --git a/.chronus/changes/fix-openapi3-union-envelope-identity-2026-8-16-8-45-43.md b/.chronus/changes/fix-openapi3-union-envelope-identity-2026-8-16-8-45-43.md new file mode 100644 index 00000000000..cc5a33f0fe6 --- /dev/null +++ b/.chronus/changes/fix-openapi3-union-envelope-identity-2026-8-16-8-45-43.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +Preserve distinct discriminated union envelopes when variants share the same payload type. \ No newline at end of file diff --git a/packages/openapi3/src/schema-emitter.ts b/packages/openapi3/src/schema-emitter.ts index f67a0a18f0b..e8f92b7c1ed 100644 --- a/packages/openapi3/src/schema-emitter.ts +++ b/packages/openapi3/src/schema-emitter.ts @@ -571,11 +571,11 @@ export class OpenAPI3SchemaEmitterBase< } /** - * Mapping of cached envelope models for union variants. + * Envelope models are keyed by variant name, since variants can share a payload type. */ #unionVariantEnvelopeVisibilityMap: WeakMap< Union, - WeakMap }> + Map }> > = new WeakMap(); /** @@ -601,15 +601,15 @@ export class OpenAPI3SchemaEmitterBase< let map = this.#unionVariantEnvelopeVisibilityMap.get(union.type); if (!map) { - map = new WeakMap(); + map = new Map(); this.#unionVariantEnvelopeVisibilityMap.set(union.type, map); } - let entry = map.get(variant); + let entry = map.get(variantName); if (!entry) { // Initialize entry entry = { default: createEnvelopeModel(), byVisibility: new Map() }; - map.set(variant, entry); + map.set(variantName, entry); // Manually track the model's usage according to the union's usage. if (usage) this._visibilityUsage.manuallyTrack(entry.default, usage); diff --git a/packages/openapi3/test/union-schema.test.ts b/packages/openapi3/test/union-schema.test.ts index 7ed4db3655c..167f4510809 100644 --- a/packages/openapi3/test/union-schema.test.ts +++ b/packages/openapi3/test/union-schema.test.ts @@ -54,6 +54,135 @@ worksFor(supportedVersions, ({ diagnoseOpenApiFor, oapiForModel, openApiFor }) = }); }); + it.each([ + { type: "string", declaration: "", schema: { type: "string" } }, + { + type: "Payload", + declaration: "model Payload { value: string; }", + schema: { $ref: "#/components/schemas/Payload" }, + }, + ])( + "preserves distinct envelopes for variants sharing $type", + async ({ type, declaration, schema }) => { + const res = await openApiFor( + ` + @service namespace Example; + ${declaration} + + @discriminated(#{ + discriminatorPropertyName: "tag", + envelopePropertyName: "contents" + }) + union Limit { + daily: ${type}, + weekly: ${type}, + monthly: ${type}, + } + + @route("/limit") @get op read(): Limit; + @route("/limit") @post op write(@body body: Limit): void; + `, + ); + + deepStrictEqual(res.components.schemas.Limit, { + type: "object", + oneOf: [ + { $ref: "#/components/schemas/LimitDaily" }, + { $ref: "#/components/schemas/LimitWeekly" }, + { $ref: "#/components/schemas/LimitMonthly" }, + ], + discriminator: { + propertyName: "tag", + mapping: { + daily: "#/components/schemas/LimitDaily", + weekly: "#/components/schemas/LimitWeekly", + monthly: "#/components/schemas/LimitMonthly", + }, + }, + }); + for (const [name, tag] of [ + ["LimitDaily", "daily"], + ["LimitWeekly", "weekly"], + ["LimitMonthly", "monthly"], + ]) { + deepStrictEqual(res.components.schemas[name], { + type: "object", + properties: { + tag: { type: "string", enum: [tag] }, + contents: schema, + }, + required: ["tag", "contents"], + }); + } + }, + ); + + it("preserves distinct envelopes for variants sharing a visibility-transformed model", async () => { + const res = await openApiFor( + ` + model A { + value: string; + @visibility(Lifecycle.Create) + co: string; + } + + @discriminated + union U { + a: A, + b: A, + } + + @put op update(@body data: U): U; + `, + ); + + for (const suffix of ["", "CreateOrUpdate"]) { + deepStrictEqual(res.components.schemas[`U${suffix}`], { + type: "object", + oneOf: [ + { $ref: `#/components/schemas/UA${suffix}` }, + { $ref: `#/components/schemas/UB${suffix}` }, + ], + discriminator: { + propertyName: "kind", + mapping: { + a: `#/components/schemas/UA${suffix}`, + b: `#/components/schemas/UB${suffix}`, + }, + }, + }); + for (const [name, kind] of [ + ["UA", "a"], + ["UB", "b"], + ]) { + deepStrictEqual(res.components.schemas[`${name}${suffix}`], { + type: "object", + properties: { + kind: { type: "string", enum: [kind] }, + value: { $ref: `#/components/schemas/A${suffix}` }, + }, + required: ["kind", "value"], + }); + } + } + deepStrictEqual(res.components.schemas.A, { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }); + deepStrictEqual(res.components.schemas.ACreateOrUpdate, { + type: "object", + properties: { value: { type: "string" }, co: { type: "string" } }, + required: ["value", "co"], + }); + deepStrictEqual(res.paths["/"].put.requestBody.content["application/json"].schema, { + $ref: "#/components/schemas/UCreateOrUpdate", + }); + deepStrictEqual(res.paths["/"].put.responses["200"].content["application/json"].schema, { + $ref: "#/components/schemas/U", + }); + }); + it("envelope none", async () => { const res = await openApiFor( `