Skip to content
Open
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
Expand Up @@ -460,6 +460,22 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
}
}

// Build a set of classnames that are oneOf models (union types)
Set<String> oneOfModelNames = allModels.stream()
.filter(m -> !m.oneOf.isEmpty())
.map(m -> m.classname)
.collect(Collectors.toSet());

// Mark models whose parent is a oneOf model — these cannot use
// "interface X extends Parent" because TypeScript does not allow
// an interface to extend a union type. They use
// "type X = Parent & { ... }" instead.
for (ExtendedCodegenModel m : allModels) {
if (m.parent != null && oneOfModelNames.contains(m.parent)) {
m.parentIsOneOf = true;
}
}

for (ExtendedCodegenModel rootModel : allModels) {
for (String curImport : rootModel.imports) {
boolean isModelImport = false;
Expand Down Expand Up @@ -1545,6 +1561,8 @@ public class ExtendedCodegenModel extends CodegenModel {
public Set<CodegenProperty> oneOfPrimitives = new HashSet<>();
@Getter @Setter
public CodegenDiscriminator.MappedModel selfReferencingDiscriminatorMapping;
@Getter @Setter
public boolean parentIsOneOf; // true when this model's parent is a oneOf union type

public boolean isEntity; // Is a model containing an "id" property marked as isUniqueId
public String returnPassthrough;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
/**
* {{#lambda.indented_star_1}}{{{unescapedDescription}}}{{/lambda.indented_star_1}}
* @export
{{^parentIsOneOf}}
* @interface {{classname}}
*/
export interface {{classname}} {{#parent}}extends {{{.}}} {{/parent}}{
{{/parentIsOneOf}}
{{#parentIsOneOf}}
*/
export type {{classname}} = {{{parent}}} & {
{{/parentIsOneOf}}
{{#additionalPropertiesType}}
[key: string]: {{{additionalPropertiesType}}}{{#hasVars}} | any{{/hasVars}};
{{/additionalPropertiesType}}
Expand All @@ -18,7 +24,7 @@ export interface {{classname}} {{#parent}}extends {{{.}}} {{/parent}}{
*/
{{#isReadOnly}}readonly {{/isReadOnly}}{{name}}{{^required}}?{{/required}}: {{{datatypeWithEnum}}}{{#isNullable}} | null{{/isNullable}};
{{/vars}}
}{{#hasEnums}}
}{{#parentIsOneOf}};{{/parentIsOneOf}}{{#hasEnums}}

{{#vars}}
{{#isEnum}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,43 @@ public void testRequestOptsNotInInterfaceWhenDisabled() throws IOException {
assertThat(classSection).contains("async addPetRequestOpts(");
}

/**
* When a oneOf variant uses allOf to reference another oneOf (nested discriminated unions),
* the child model must be generated as a type alias with intersection rather than an
* interface with extends, because TypeScript does not allow interfaces to extend union types.
*/
@Test(description = "Verify nested oneOf generates type alias instead of interface extends")
public void testNestedOneOfGeneratesTypeAliasForOneOfParent() throws IOException {
File output = generate(
Collections.emptyMap(),
"src/test/resources/3_0/typescript-fetch/nested-oneOf.yaml"
);

// OuterComposed's parent is Inner (a oneOf union type), so it must use
// "type OuterComposed = Inner & { ... }" instead of "interface OuterComposed extends Inner"
Path outerComposed = Paths.get(output + "/models/OuterComposed.ts");
TestUtils.assertFileExists(outerComposed);
TestUtils.assertFileContains(outerComposed, "export type OuterComposed = Inner & {");
TestUtils.assertFileNotContains(outerComposed, "export interface OuterComposed extends Inner");

// Inner should still be a proper oneOf union type with discriminator dispatch
Path inner = Paths.get(output + "/models/Inner.ts");
TestUtils.assertFileExists(inner);
TestUtils.assertFileContains(inner, "export type Inner = { innerDiscriminator: 'a' } & InnerA | { innerDiscriminator: 'b' } & InnerB");
TestUtils.assertFileContains(inner, "switch (json['innerDiscriminator'])");

// Outer should dispatch on outerDiscriminator, including the composed variant
Path outer = Paths.get(output + "/models/Outer.ts");
TestUtils.assertFileExists(outer);
TestUtils.assertFileContains(outer, "switch (json['outerDiscriminator'])");
TestUtils.assertFileContains(outer, "case 'composed':");

// Regular models (not extending a oneOf parent) should still use interface
Path outerPlain = Paths.get(output + "/models/OuterPlain.ts");
TestUtils.assertFileExists(outerPlain);
TestUtils.assertFileContains(outerPlain, "export interface OuterPlain {");
}

private static File generate(
Map<String, Object> properties
) throws IOException {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
openapi: "3.0.3"
info:
title: Nested OneOf Test
description: >
Tests that a oneOf variant referencing another oneOf via allOf generates
correct TypeScript types. The outer union (Outer) is discriminated by
"outerDiscriminator"; one of its variants (OuterComposed) uses allOf to
compose a fixed discriminator value with a $ref to an inner union (Inner)
discriminated by "innerDiscriminator". A plain variant (OuterPlain) is
included to verify normal interface generation is unaffected.
version: "1.0"
paths:
/items:
get:
operationId: getItems
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Outer"
components:
schemas:
# ── Outer oneOf (discriminated by "outerDiscriminator") ──────────────
Outer:
oneOf:
- $ref: "#/components/schemas/OuterPlain"
- $ref: "#/components/schemas/OuterComposed"
discriminator:
propertyName: outerDiscriminator
mapping:
plain: "#/components/schemas/OuterPlain"
composed: "#/components/schemas/OuterComposed"

OuterPlain:
type: object
required: [outerDiscriminator, plainValue]
properties:
outerDiscriminator:
type: string
plainValue:
type: string

# Uses allOf to merge a fixed discriminator value with a nested oneOf ref
OuterComposed:
allOf:
- type: object
required: [outerDiscriminator]
properties:
outerDiscriminator:
type: string
- $ref: "#/components/schemas/Inner"

# ── Inner oneOf (discriminated by "innerDiscriminator") ─────────────
Inner:
oneOf:
- $ref: "#/components/schemas/InnerA"
- $ref: "#/components/schemas/InnerB"
discriminator:
propertyName: innerDiscriminator
mapping:
a: "#/components/schemas/InnerA"
b: "#/components/schemas/InnerB"

InnerA:
type: object
required: [innerDiscriminator, fieldA]
properties:
innerDiscriminator:
type: string
fieldA:
type: string

InnerB:
type: object
required: [innerDiscriminator, fieldB]
properties:
innerDiscriminator:
type: string
fieldB:
type: integer