Skip to content

Commit 622804c

Browse files
fix(openapi3): missing discriminator mapping entry when first union variant causes circular emit (#10268)
## Problem When a TypeSpec model uses `@discriminator` on a base type and all subtypes are referenced through a named union, the first variant in the union is silently dropped from the generated `discriminator.mapping` in the OpenAPI output. **Example:** ```typespec @Discriminator("classification") model Pokemon { classification: string; } model NormalPokemon extends Pokemon { classification: "normal"; } model LegendaryPokemon extends Pokemon { classification: "legendary"; } model MythicalPokemon extends Pokemon { classification: "mythical"; } union PokemonVariant { normal: NormalPokemon, legendary: LegendaryPokemon, mythical: MythicalPokemon, } ``` **Generated (broken):** ```yaml discriminator: propertyName: classification mapping: legendary: '#/components/schemas/LegendaryPokemon' mythical: '#/components/schemas/MythicalPokemon' # 'normal' is missing! ``` ## Root Cause `getDiscriminatorMapping` calls `emitTypeReference` for each derived model. The first variant in the union (`NormalPokemon`) is already mid-emission when `applyDiscriminator` runs — because emitting `NormalPokemon` triggered the emission of its base `Pokemon`, which immediately calls `applyDiscriminator`. For a model that is currently being emitted, `emitTypeReference` returns a `Placeholder` (to handle the circular reference). The existing code does: ```ts mapping[key] = (ref.value as any).$ref; ``` `Placeholder` has no `$ref` property, so this evaluates to `undefined`. The `Placeholder` does resolve correctly later (via its `onValue` callback mechanism), but nobody registered a listener to update the mapping — so `mapping["normal"]` stays `undefined` and is silently dropped during YAML serialisation. The second and third variants (`LegendaryPokemon`, `MythicalPokemon`) are not yet in the emit stack at this point, so their `emitTypeReference` calls return proper declarations and their `$ref` values are captured correctly. ## Fix Check whether `ref.value` is a `Placeholder`. If so, register an `onValue` listener that writes the resolved `$ref` into the mapping once the circular reference unwinds. `Placeholder` is already imported in this file. ```ts getDiscriminatorMapping(variants: Map<string, Type>) { const mapping: Record<string, string> | undefined = {}; for (const [key, model] of variants.entries()) { const ref = this.emitter.emitTypeReference(model); compilerAssert(ref.kind === "code", "Unexpected ref schema. Should be kind: code"); if (ref.value instanceof Placeholder) { ref.value.onValue((resolvedValue) => { mapping[key] = (resolvedValue as any).$ref; }); } else { mapping[key] = (ref.value as any).$ref; } } return mapping; } ``` The `onValue` callback fires before YAML serialisation, so the mapping object is complete by the time the output is written. --------- Co-authored-by: Timothee Guerin <tiguerin@microsoft.com>
1 parent 9e6d6bf commit 622804c

4 files changed

Lines changed: 49 additions & 2 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: fix
3+
packages:
4+
- "@typespec/openapi3"
5+
---
6+
7+
Fix missing discriminator mapping entry when the first union variant causes a circular emit, affecting both the OpenAPI 3.0 and 3.2 emitters.

packages/openapi3/src/schema-emitter-3-2.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
AssetEmitter,
44
createAssetEmitter,
55
ObjectBuilder,
6+
Placeholder,
67
TypeEmitter,
78
} from "@typespec/asset-emitter";
89
import { compilerAssert, DiscriminatedUnion, Type } from "@typespec/compiler";
@@ -98,7 +99,13 @@ export class OpenAPI32SchemaEmitter extends OpenAPI31SchemaEmitter {
9899
for (const [key, model] of variants.entries()) {
99100
const ref = this.emitter.emitTypeReference(model);
100101
compilerAssert(ref.kind === "code", "Unexpected ref schema. Should be kind: code");
101-
mapping[key] = (ref.value as any).$ref;
102+
if (ref.value instanceof Placeholder) {
103+
ref.value.onValue((resolvedValue: any) => {
104+
mapping[key] = (resolvedValue as any).$ref;
105+
});
106+
} else {
107+
mapping[key] = (ref.value as any).$ref;
108+
}
102109
}
103110
return mapping;
104111
}

packages/openapi3/src/schema-emitter.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -720,7 +720,13 @@ export class OpenAPI3SchemaEmitterBase<
720720
for (const [key, model] of variants.entries()) {
721721
const ref = this.emitter.emitTypeReference(model);
722722
compilerAssert(ref.kind === "code", "Unexpected ref schema. Should be kind: code");
723-
mapping[key] = (ref.value as any).$ref;
723+
if (ref.value instanceof Placeholder) {
724+
ref.value.onValue((resolvedValue) => {
725+
mapping[key] = (resolvedValue as any).$ref;
726+
});
727+
} else {
728+
mapping[key] = (ref.value as any).$ref;
729+
}
724730
}
725731
return mapping;
726732
}

packages/openapi3/test/discriminator.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,33 @@ worksFor(supportedVersions, ({ checkFor, openApiFor }) => {
345345
]);
346346
});
347347

348+
it("includes all variants in discriminator mapping when first union variant causes circular emit", async () => {
349+
const openApi = await openApiFor(`
350+
@discriminator("kind")
351+
model Pet { kind: string; }
352+
353+
model Cat extends Pet { kind: "cat"; }
354+
model Dog extends Pet { kind: "dog"; }
355+
model Bird extends Pet { kind: "bird"; }
356+
357+
union PetVariant {
358+
cat: Cat,
359+
dog: Dog,
360+
bird: Bird,
361+
}
362+
363+
op read(): { @body body: PetVariant };
364+
`);
365+
deepStrictEqual(openApi.components.schemas.Pet.discriminator, {
366+
propertyName: "kind",
367+
mapping: {
368+
cat: "#/components/schemas/Cat",
369+
dog: "#/components/schemas/Dog",
370+
bird: "#/components/schemas/Bird",
371+
},
372+
});
373+
});
374+
348375
it("discriminator always needs to be marked as required", async () => {
349376
const openApi = await openApiFor(`
350377
@discriminator("kind")

0 commit comments

Comments
 (0)