Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: fix
packages:
- "@typespec/openapi3"
---

Addressed an issue where `@discriminated` union envelope schemas could sometimes have duplicate names in the context of visibility transforms.
105 changes: 88 additions & 17 deletions packages/openapi3/src/schema-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,8 +543,90 @@ export class OpenAPI3SchemaEmitterBase<
throw new Error("Method not implemented.");
}

discriminatedUnion(union: DiscriminatedUnion): ObjectBuilder<Schema> {
/**
* Mapping of cached envelope models for union variants.
*/
#unionVariantEnvelopeVisibilityMap: WeakMap<
Union,
WeakMap<Type, { default: Model; byVisibility: Map<Visibility, Model> }>
> = 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<Schema> {
let schema: any;
if (union.options.envelope === "none") {
const items = new ArrayBuilder();
Expand All @@ -562,22 +644,11 @@ export class OpenAPI3SchemaEmitterBase<
} else {
const envelopeVariants = new Map<string, Model>();

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();
Expand Down
7 changes: 7 additions & 0 deletions packages/openapi3/src/visibility-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
export interface VisibilityUsageTracker {
getUsage(type: Type): Set<Visibility> | undefined;
isUnreachable(type: Type): boolean;
manuallyTrack(type: Type, visibility: Set<Visibility>): void;
}

export type OperationContainer = Namespace | Interface | Operation;
Expand Down Expand Up @@ -60,6 +61,12 @@ export function resolveVisibilityUsage(
isUnreachable: (type: Type) => {
return !reachableTypes.has(type);
},
manuallyTrack: (type: Type, visibility: Set<Visibility>) => {
for (const vis of visibility) {
trackUsageExact(usages, type, vis);
}
reachableTypes.add(type);
},
};
}

Expand Down
111 changes: 111 additions & 0 deletions packages/openapi3/test/union-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading