diff --git a/.chronus/changes/witemple-msft-oapi3-discriminated-visibility-variant-2025-9-10-12-12-53.md b/.chronus/changes/witemple-msft-oapi3-discriminated-visibility-variant-2025-9-10-12-12-53.md new file mode 100644 index 00000000000..44057277594 --- /dev/null +++ b/.chronus/changes/witemple-msft-oapi3-discriminated-visibility-variant-2025-9-10-12-12-53.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +Addressed an issue where `@discriminated` union envelope schemas could sometimes have duplicate names in the context of visibility transforms. \ No newline at end of file diff --git a/packages/openapi3/src/schema-emitter.ts b/packages/openapi3/src/schema-emitter.ts index ba4a14e3801..9a569b7611d 100644 --- a/packages/openapi3/src/schema-emitter.ts +++ b/packages/openapi3/src/schema-emitter.ts @@ -543,8 +543,90 @@ export class OpenAPI3SchemaEmitterBase< throw new Error("Method not implemented."); } - discriminatedUnion(union: DiscriminatedUnion): ObjectBuilder { + /** + * Mapping of cached envelope models for union variants. + */ + #unionVariantEnvelopeVisibilityMap: WeakMap< + Union, + WeakMap }> + > = new WeakMap(); + + /** + * Get or create an envelope model for a given discriminated union variant. + * + * This method is cached and will return the same model for the same variant according to visibility transforms, + * in order to prevent duplicate schema declarations. + * + * @param union - The discriminated union containing the variant. + * @param variantName - The name of the variant. + * @param variant - The type of the variant. + * @returns The envelope model for the variant. + */ + #getOrCreateVariantEnvelopeModel( + union: DiscriminatedUnion, + variantName: string, + variant: Type, + ): Model { const tk = $(this.emitter.getProgram()); + + const usage = this._visibilityUsage.getUsage(union.type); + + let map = this.#unionVariantEnvelopeVisibilityMap.get(union.type); + + if (!map) { + map = new WeakMap(); + this.#unionVariantEnvelopeVisibilityMap.set(union.type, map); + } + + let entry = map.get(variant); + if (!entry) { + // Initialize entry + entry = { default: createEnvelopeModel(), byVisibility: new Map() }; + map.set(variant, entry); + + // Manually track the model's usage according to the union's usage. + if (usage) this._visibilityUsage.manuallyTrack(entry.default, usage); + } + + const visibility = this.#getVisibilityContext(); + + // We only create envelope models per visibility if the variant type is transformed in that visibility. + // Otherwise, we will just use the default envelope model. + if (this._metadataInfo.isTransformed(variant, visibility)) { + let byVis = entry.byVisibility.get(visibility); + + if (!byVis) { + byVis = createEnvelopeModel(); + + // Manually track the model's usage according to the union's usage. + if (usage) this._visibilityUsage.manuallyTrack(byVis, usage); + + entry.byVisibility.set(visibility, byVis); + } + + return byVis; + } else { + return entry.default; + } + + function createEnvelopeModel(): Model { + return tk.model.create({ + name: union.type.name + capitalize(variantName), + properties: { + [union.options.discriminatorPropertyName]: tk.modelProperty.create({ + name: union.options.discriminatorPropertyName, + type: tk.literal.createString(variantName), + }), + [union.options.envelopePropertyName]: tk.modelProperty.create({ + name: union.options.envelopePropertyName, + type: variant, + }), + }, + }); + } + } + + discriminatedUnion(union: DiscriminatedUnion): ObjectBuilder { let schema: any; if (union.options.envelope === "none") { const items = new ArrayBuilder(); @@ -562,22 +644,11 @@ export class OpenAPI3SchemaEmitterBase< } else { const envelopeVariants = new Map(); - for (const [name, variant] of union.variants) { - const envelopeModel = tk.model.create({ - name: union.type.name + capitalize(name), - properties: { - [union.options.discriminatorPropertyName]: tk.modelProperty.create({ - name: union.options.discriminatorPropertyName, - type: tk.literal.createString(name), - }), - [union.options.envelopePropertyName]: tk.modelProperty.create({ - name: union.options.envelopePropertyName, - type: variant, - }), - }, - }); - - envelopeVariants.set(name, envelopeModel); + for (const [variantName, variant] of union.variants) { + envelopeVariants.set( + variantName, + this.#getOrCreateVariantEnvelopeModel(union, variantName, variant), + ); } const items = new ArrayBuilder(); diff --git a/packages/openapi3/src/visibility-usage.ts b/packages/openapi3/src/visibility-usage.ts index f441c863b8b..b01ed33dedb 100644 --- a/packages/openapi3/src/visibility-usage.ts +++ b/packages/openapi3/src/visibility-usage.ts @@ -18,6 +18,7 @@ import { export interface VisibilityUsageTracker { getUsage(type: Type): Set | undefined; isUnreachable(type: Type): boolean; + manuallyTrack(type: Type, visibility: Set): void; } export type OperationContainer = Namespace | Interface | Operation; @@ -60,6 +61,12 @@ export function resolveVisibilityUsage( isUnreachable: (type: Type) => { return !reachableTypes.has(type); }, + manuallyTrack: (type: Type, visibility: Set) => { + for (const vis of visibility) { + trackUsageExact(usages, type, vis); + } + reachableTypes.add(type); + }, }; } diff --git a/packages/openapi3/test/union-schema.test.ts b/packages/openapi3/test/union-schema.test.ts index 91b078e6eaa..bec01a0ae90 100644 --- a/packages/openapi3/test/union-schema.test.ts +++ b/packages/openapi3/test/union-schema.test.ts @@ -85,6 +85,117 @@ worksFor(["3.0.0", "3.1.0"], ({ diagnoseOpenApiFor, oapiForModel, openApiFor }) }, }); }); + + it("apply name suffixes to synthetic envelope types", async () => { + const res = await openApiFor( + ` + model A { + a: string; + @visibility(Lifecycle.Read) + ro: string; + } + + model B { + b: string; + @visibility(Lifecycle.Create) + co: string; + } + + @discriminated + union U { + a: A, + b: B, + } + + @put op update(@body data: U): U; + `, + ); + + // Union schemas + deepStrictEqual(res.components.schemas.U, { + type: "object", + oneOf: [{ $ref: "#/components/schemas/UA" }, { $ref: "#/components/schemas/UB" }], + discriminator: { + propertyName: "kind", + mapping: { + a: "#/components/schemas/UA", + b: "#/components/schemas/UB", + }, + }, + }); + deepStrictEqual(res.components.schemas["UA"], { + type: "object", + properties: { + kind: { type: "string", enum: ["a"] }, + value: { $ref: "#/components/schemas/A" }, + }, + required: ["kind", "value"], + }); + deepStrictEqual(res.components.schemas["UB"], { + type: "object", + properties: { + kind: { type: "string", enum: ["b"] }, + value: { $ref: "#/components/schemas/B" }, + }, + required: ["kind", "value"], + }); + + deepStrictEqual(res.components.schemas["UCreateOrUpdate"], { + type: "object", + oneOf: [ + { $ref: "#/components/schemas/UA" }, + { $ref: "#/components/schemas/UBCreateOrUpdate" }, + ], + discriminator: { + propertyName: "kind", + mapping: { + a: "#/components/schemas/UA", + b: "#/components/schemas/UBCreateOrUpdate", + }, + }, + }); + deepStrictEqual(res.components.schemas["UBCreateOrUpdate"], { + type: "object", + properties: { + kind: { type: "string", enum: ["b"] }, + value: { $ref: "#/components/schemas/BCreateOrUpdate" }, + }, + required: ["kind", "value"], + }); + + // Model schemas + deepStrictEqual(res.components.schemas["A"], { + type: "object", + properties: { + a: { type: "string" }, + ro: { type: "string", readOnly: true }, + }, + required: ["a", "ro"], + }); + deepStrictEqual(res.components.schemas["B"], { + type: "object", + properties: { + b: { type: "string" }, + }, + required: ["b"], + }); + deepStrictEqual(res.components.schemas["BCreateOrUpdate"], { + type: "object", + properties: { + b: { type: "string" }, + co: { type: "string" }, + }, + required: ["b", "co"], + }); + + // Routes + 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", + }); + }); }); describe("union literals", () => {