Skip to content

Commit 6a4370c

Browse files
olsavmicclaude
andcommitted
[kotlin-client] Add oneOf discriminator support for multiplatform library
When a oneOf schema has a discriminator, the kotlin-multiplatform generator now produces a sealed class hierarchy with @JsonClassDiscriminator instead of a brute-force wrapper that tries each type sequentially. This mirrors the approach from OpenAPITools#22610 (kotlin-server) adapted for kotlinx.serialization. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f88a0d6 commit 6a4370c

40 files changed

Lines changed: 1697 additions & 2 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
generatorName: kotlin
2+
outputDir: samples/client/petstore/kotlin-multiplatform-oneOf-discriminator
3+
library: multiplatform
4+
inputSpec: modules/openapi-generator/src/test/resources/3_0/kotlin/polymorphism-oneof-discriminator-simple.yaml
5+
templateDir: modules/openapi-generator/src/main/resources/kotlin-client
6+
additionalProperties:
7+
artifactId: kotlin-multiplatform-oneOf-discriminator
8+
dateLibrary: kotlinx-datetime

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -997,10 +997,53 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
997997
continue;
998998
}
999999

1000+
boolean isOneOfModel = cm.oneOf != null && !cm.oneOf.isEmpty();
1001+
1002+
// For multiplatform oneOf with discriminator, generate sealed class polymorphism
1003+
// instead of brute-force wrapper deserialization
1004+
if (isOneOfModel && getLibrary() != null && getLibrary().equals(MULTIPLATFORM)) {
1005+
// Remove discriminator property from the parent - kotlinx.serialization handles it via @JsonClassDiscriminator
1006+
getAllVarProperties(cm).forEach(list -> list.removeIf(var -> var.name.equals(discriminator.getPropertyName())));
1007+
1008+
// Clear all merged properties from oneOf parent - they belong to children only
1009+
cm.vars.clear();
1010+
cm.allVars.clear();
1011+
cm.requiredVars.clear();
1012+
cm.optionalVars.clear();
1013+
cm.setHasVars(false);
1014+
1015+
// Mark this model to use sealed class rendering in the oneOf template
1016+
cm.vendorExtensions.put("x-oneof-sealed-class", true);
1017+
1018+
for (CodegenDiscriminator.MappedModel mappedModel : discriminator.getMappedModels()) {
1019+
CodegenModel childModel = mappedModel.getModel();
1020+
1021+
// Set parent-child relationship
1022+
childModel.setParent(cm.getClassname());
1023+
childModel.setParentModel(cm);
1024+
1025+
// Set discriminator value for @SerialName annotation
1026+
CodegenProperty additionalProperties = childModel.getAdditionalProperties();
1027+
if (additionalProperties == null) {
1028+
additionalProperties = new CodegenProperty();
1029+
childModel.setAdditionalProperties(additionalProperties);
1030+
}
1031+
additionalProperties.discriminatorValue = mappedModel.getMappingName();
1032+
1033+
// Remove discriminator property from child - handled by kotlinx.serialization
1034+
getAllVarProperties(childModel).forEach(list -> list.removeIf(prop -> prop.name.equals(discriminator.getPropertyName())));
1035+
1036+
if (childModel.vars.isEmpty() && !childModel.isEnum && !childModel.isAlias) {
1037+
childModel.setHasVars(false);
1038+
}
1039+
}
1040+
continue;
1041+
}
1042+
10001043
// When using generateOneOfAnyOfWrappers and encountering oneOf, we keep discriminator properties,
10011044
// because single entity can be referenced in multiple "parent" entities,
10021045
// so discriminator for one might not be discriminator for another.
1003-
boolean shouldKeepDiscriminatorField = generateOneOfAnyOfWrappers && cm.oneOf != null && !cm.oneOf.isEmpty();
1046+
boolean shouldKeepDiscriminatorField = generateOneOfAnyOfWrappers && isOneOfModel;
10041047

10051048
if (shouldKeepDiscriminatorField) {
10061049
continue;

modules/openapi-generator/src/main/resources/kotlin-client/libraries/multiplatform/oneof_class.mustache

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,23 @@
11
{{#multiplatform}}
2+
{{#vendorExtensions.x-oneof-sealed-class}}
3+
import kotlinx.serialization.*
4+
import kotlinx.serialization.descriptors.*
5+
import kotlinx.serialization.encoding.*
6+
import kotlinx.serialization.json.JsonClassDiscriminator
7+
8+
/**
9+
* {{{description}}}
10+
*
11+
*/
12+
{{#isDeprecated}}
13+
@Deprecated(message = "This schema is deprecated.")
14+
{{/isDeprecated}}
15+
@Serializable
16+
@OptIn(ExperimentalSerializationApi::class)
17+
@JsonClassDiscriminator(discriminator = "{{{discriminator.propertyName}}}")
18+
{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}sealed class {{classname}}
19+
{{/vendorExtensions.x-oneof-sealed-class}}
20+
{{^vendorExtensions.x-oneof-sealed-class}}
221
import kotlinx.serialization.*
322
import kotlinx.serialization.descriptors.*
423
import kotlinx.serialization.encoding.*
@@ -76,4 +95,5 @@ import kotlinx.serialization.json.*
7695
}
7796
}
7897
}
79-
{{/multiplatform}}
98+
{{/vendorExtensions.x-oneof-sealed-class}}
99+
{{/multiplatform}}

modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,55 @@ public void testCompanionObjectGeneratesCompanionInModel() throws IOException {
923923
TestUtils.assertFileContains(petModel, "companion object { }");
924924
}
925925

926+
@Test(description = "generate multiplatform oneOf with discriminator as sealed class")
927+
public void multiplatformOneOfDiscriminatorSealedClass() throws IOException {
928+
File output = Files.createTempDirectory("test").toFile();
929+
output.deleteOnExit();
930+
931+
final CodegenConfigurator configurator = new CodegenConfigurator()
932+
.setGeneratorName("kotlin")
933+
.setLibrary("multiplatform")
934+
.setAdditionalProperties(new HashMap<>() {{
935+
put("dateLibrary", "kotlinx-datetime");
936+
}})
937+
.setInputSpec("src/test/resources/3_0/kotlin/polymorphism-oneof-discriminator-simple.yaml")
938+
.setOutputDir(output.getAbsolutePath().replace("\\", "/"));
939+
940+
final ClientOptInput clientOptInput = configurator.toClientOptInput();
941+
DefaultGenerator generator = new DefaultGenerator();
942+
List<File> files = generator.opts(clientOptInput).generate();
943+
944+
final Path petKt = Paths.get(output + "/src/commonMain/kotlin/org/openapitools/client/models/Pet.kt");
945+
// parent is sealed class with discriminator annotation
946+
TestUtils.assertFileContains(petKt, "sealed class Pet");
947+
TestUtils.assertFileContains(petKt, "@JsonClassDiscriminator(discriminator = \"petType\")");
948+
TestUtils.assertFileContains(petKt, "@Serializable");
949+
// parent does not contain discriminator property or child properties
950+
TestUtils.assertFileNotContains(petKt, "val petType");
951+
TestUtils.assertFileNotContains(petKt, "val breed");
952+
TestUtils.assertFileNotContains(petKt, "val color");
953+
954+
final Path dogKt = Paths.get(output + "/src/commonMain/kotlin/org/openapitools/client/models/Dog.kt");
955+
// child extends parent
956+
TestUtils.assertFileContains(dogKt, "data class Dog");
957+
TestUtils.assertFileContains(dogKt, ": Pet()");
958+
// child has discriminator value annotation
959+
TestUtils.assertFileContains(dogKt, "@SerialName(value = \"DOG\")");
960+
// child does not contain discriminator property
961+
TestUtils.assertFileNotContains(dogKt, "val petType");
962+
// child has its own properties
963+
TestUtils.assertFileContains(dogKt, "val breed");
964+
965+
final Path catKt = Paths.get(output + "/src/commonMain/kotlin/org/openapitools/client/models/Cat.kt");
966+
// child extends parent
967+
TestUtils.assertFileContains(catKt, "data class Cat");
968+
TestUtils.assertFileContains(catKt, ": Pet()");
969+
// child has discriminator value annotation
970+
TestUtils.assertFileContains(catKt, "@SerialName(value = \"CAT\")");
971+
// child has its own properties
972+
TestUtils.assertFileContains(catKt, "val color");
973+
}
974+
926975
private static class ModelNameTest {
927976
private final String expectedName;
928977
private final String expectedClassName;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
openapi: 3.0.1
2+
info:
3+
title: Example
4+
description: OneOf with discriminator example
5+
version: '0.1'
6+
contact:
7+
email: contact@example.org
8+
url: 'https://example.org'
9+
servers:
10+
- url: http://example.org
11+
tags:
12+
- name: pet
13+
paths:
14+
'/v1/pet/{id}':
15+
get:
16+
tags:
17+
- pet
18+
responses:
19+
'200':
20+
description: OK
21+
content:
22+
application/json:
23+
schema:
24+
$ref: '#/components/schemas/pet'
25+
operationId: get-pet
26+
parameters:
27+
- schema:
28+
type: string
29+
format: uuid
30+
name: id
31+
in: path
32+
required: true
33+
components:
34+
schemas:
35+
pet:
36+
title: A pet
37+
oneOf:
38+
- $ref: '#/components/schemas/dog'
39+
- $ref: '#/components/schemas/cat'
40+
discriminator:
41+
propertyName: petType
42+
mapping:
43+
DOG: '#/components/schemas/dog'
44+
CAT: '#/components/schemas/cat'
45+
dog:
46+
title: A dog
47+
required:
48+
- petType
49+
- breed
50+
properties:
51+
petType:
52+
type: string
53+
breed:
54+
type: string
55+
bark:
56+
type: boolean
57+
cat:
58+
title: A cat
59+
required:
60+
- petType
61+
- color
62+
properties:
63+
petType:
64+
type: string
65+
color:
66+
type: string
67+
indoor:
68+
type: boolean
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# OpenAPI Generator Ignore
2+
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
3+
4+
# Use this file to prevent files from being overwritten by the generator.
5+
# The patterns follow closely to .gitignore or .dockerignore.
6+
7+
# As an example, the C# client generator defines ApiClient.cs.
8+
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
9+
#ApiClient.cs
10+
11+
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
12+
#foo/*/qux
13+
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
14+
15+
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
16+
#foo/**/qux
17+
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
18+
19+
# You can also negate patterns with an exclamation (!).
20+
# For example, you can ignore all files in a docs folder with the file extension .md:
21+
#docs/*.md
22+
# Then explicitly reverse the ignore rule for a single file:
23+
#!docs/README.md
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
.openapi-generator-ignore
2+
README.md
3+
build.gradle.kts
4+
docs/Cat.md
5+
docs/Dog.md
6+
docs/Pet.md
7+
docs/PetApi.md
8+
gradle/wrapper/gradle-wrapper.jar
9+
gradle/wrapper/gradle-wrapper.properties
10+
gradlew
11+
gradlew.bat
12+
settings.gradle.kts
13+
src/commonMain/kotlin/org/openapitools/client/apis/PetApi.kt
14+
src/commonMain/kotlin/org/openapitools/client/auth/ApiKeyAuth.kt
15+
src/commonMain/kotlin/org/openapitools/client/auth/Authentication.kt
16+
src/commonMain/kotlin/org/openapitools/client/auth/HttpBasicAuth.kt
17+
src/commonMain/kotlin/org/openapitools/client/auth/HttpBearerAuth.kt
18+
src/commonMain/kotlin/org/openapitools/client/auth/OAuth.kt
19+
src/commonMain/kotlin/org/openapitools/client/infrastructure/ApiAbstractions.kt
20+
src/commonMain/kotlin/org/openapitools/client/infrastructure/ApiClient.kt
21+
src/commonMain/kotlin/org/openapitools/client/infrastructure/Base64ByteArray.kt
22+
src/commonMain/kotlin/org/openapitools/client/infrastructure/HttpResponse.kt
23+
src/commonMain/kotlin/org/openapitools/client/infrastructure/OctetByteArray.kt
24+
src/commonMain/kotlin/org/openapitools/client/infrastructure/PartConfig.kt
25+
src/commonMain/kotlin/org/openapitools/client/infrastructure/RequestConfig.kt
26+
src/commonMain/kotlin/org/openapitools/client/infrastructure/RequestMethod.kt
27+
src/commonMain/kotlin/org/openapitools/client/models/Cat.kt
28+
src/commonMain/kotlin/org/openapitools/client/models/Dog.kt
29+
src/commonMain/kotlin/org/openapitools/client/models/Pet.kt
30+
src/test/kotlin/org/openapitools/client/apis/PetApiTest.kt
31+
src/test/kotlin/org/openapitools/client/models/CatTest.kt
32+
src/test/kotlin/org/openapitools/client/models/DogTest.kt
33+
src/test/kotlin/org/openapitools/client/models/PetTest.kt
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
7.21.0-SNAPSHOT
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# org.openapitools.client - Kotlin client library for Example
2+
3+
OneOf with discriminator example
4+
5+
## Overview
6+
This API client was generated by the [OpenAPI Generator](https://openapi-generator.tech) project. By using the [openapi-spec](https://github.com/OAI/OpenAPI-Specification) from a remote server, you can easily generate an API client.
7+
8+
- API version: 0.1
9+
- Package version:
10+
- Generator version: 7.21.0-SNAPSHOT
11+
- Build package: org.openapitools.codegen.languages.KotlinClientCodegen
12+
For more information, please visit [https://example.org](https://example.org)
13+
14+
## Requires
15+
16+
* Kotlin 2.2.20
17+
18+
## Build
19+
20+
```
21+
./gradlew check assemble
22+
```
23+
24+
This runs all tests and packages the library.
25+
26+
## Features/Implementation Notes
27+
28+
* Supports JSON inputs/outputs, File inputs, and Form inputs.
29+
* Supports collection formats for query parameters: csv, tsv, ssv, pipes.
30+
* Some Kotlin and Java types are fully qualified to avoid conflicts with types defined in OpenAPI definitions.
31+
32+
33+
<a id="documentation-for-api-endpoints"></a>
34+
## Documentation for API Endpoints
35+
36+
All URIs are relative to *http://example.org*
37+
38+
| Class | Method | HTTP request | Description |
39+
| ------------ | ------------- | ------------- | ------------- |
40+
| *PetApi* | [**getPet**](docs/PetApi.md#getpet) | **GET** /v1/pet/{id} | |
41+
42+
43+
<a id="documentation-for-models"></a>
44+
## Documentation for Models
45+
46+
- [org.openapitools.client.models.Cat](docs/Cat.md)
47+
- [org.openapitools.client.models.Dog](docs/Dog.md)
48+
- [org.openapitools.client.models.Pet](docs/Pet.md)
49+
50+
51+
<a id="documentation-for-authorization"></a>
52+
## Documentation for Authorization
53+
54+
Endpoints do not require authorization.
55+
56+
57+
58+
## Author
59+
60+
contact@example.org

0 commit comments

Comments
 (0)