Skip to content

Commit 22e1d6a

Browse files
authored
fix(kotlin): support non-discriminator oneOf/anyOf model and array types with kotlinx_serialization (#23007)
* fix(kotlin): use sealed interface for non-discriminator oneOf/anyOf in kotlinx_serialization * add sample and CI config for non-discriminator oneOf/anyOf with kotlinx_serialization * replace petstore with focused spec for non-discriminator oneOf/anyOf sample * add CI path filter for kotlin-oneOf-anyOf-kotlinx-serialization sample * add x-duplicated-data-type guard to kotlinx_serialization oneOf/anyOf templates The Gson path already uses {{^vendorExtensions.x-duplicated-data-type}} to skip duplicate data types, but the new kotlinx_serialization path was missing this guard. Without it, duplicate value class names would be generated if multiple schemas resolve to the same Kotlin dataType, causing compilation errors. * remove invalid path pattern from push.branches in CI config push.branches filters by branch name, not file paths. The sample directory pattern added here had no effect. The pull_request.paths filter remains and correctly triggers CI for this sample. * update generateOneOfAnyOfWrappers docs to include kotlinx_serialization support
1 parent 2f35b81 commit 22e1d6a

64 files changed

Lines changed: 3079 additions & 8 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/samples-kotlin-client.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ on:
1111
- 'samples/client/petstore/kotlin*/**'
1212
- 'samples/client/others/kotlin-jvm-okhttp-parameter-tests/**'
1313
- samples/client/others/kotlin-integer-enum/**
14+
- samples/client/others/kotlin-oneOf-anyOf-kotlinx-serialization/**
1415

1516
jobs:
1617
build:
@@ -70,6 +71,7 @@ jobs:
7071
- samples/client/others/kotlin-integer-enum
7172
- samples/client/petstore/kotlin-allOf-discriminator-kotlinx-serialization
7273
- samples/client/others/kotlin-oneOf-discriminator-kotlinx-serialization
74+
- samples/client/others/kotlin-oneOf-anyOf-kotlinx-serialization
7375
steps:
7476
- uses: actions/checkout@v5
7577
- uses: actions/setup-java@v5
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
generatorName: kotlin
2+
outputDir: samples/client/others/kotlin-oneOf-anyOf-kotlinx-serialization
3+
inputSpec: modules/openapi-generator/src/test/resources/3_0/kotlin/oneof-anyof-non-discriminator.yaml
4+
templateDir: modules/openapi-generator/src/main/resources/kotlin-client
5+
additionalProperties:
6+
artifactId: kotlin-oneOf-anyOf-kotlinx-serialization
7+
serializableModel: "false"
8+
dateLibrary: java8
9+
library: jvm-retrofit2
10+
enumUnknownDefaultCase: true
11+
serializationLibrary: kotlinx_serialization
12+
generateOneOfAnyOfWrappers: true

docs/generators/kotlin.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
2727
|enumPropertyNaming|Naming convention for enum properties: 'camelCase', 'PascalCase', 'snake_case', 'UPPERCASE', and 'original'| |original|
2828
|explicitApi|Generates code with explicit access modifiers to comply with Kotlin Explicit API Mode.| |false|
2929
|failOnUnknownProperties|Fail Jackson de-serialization on unknown properties| |false|
30-
|generateOneOfAnyOfWrappers|Generate oneOf, anyOf schemas as wrappers. Only `jvm-retrofit2`(library), `gson`(serializationLibrary) support this option.| |false|
30+
|generateOneOfAnyOfWrappers|Generate oneOf, anyOf schemas as wrappers. Only `jvm-retrofit2`(library) with `gson` or `kotlinx_serialization`(serializationLibrary) support this option.| |false|
3131
|generateRoomModels|Generate Android Room database models in addition to API models (JVM Volley library only)| |false|
3232
|groupId|Generated artifact package's organization (i.e. maven groupId).| |org.openapitools|
3333
|idea|Add IntelliJ Idea plugin and mark Kotlin main and test folders as source folders.| |false|

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ public KotlinClientCodegen() {
286286

287287
cliOptions.add(new CliOption(MAP_FILE_BINARY_TO_BYTE_ARRAY, "Map File and Binary to ByteArray (default: false)").defaultValue(Boolean.FALSE.toString()));
288288

289-
cliOptions.add(CliOption.newBoolean(GENERATE_ONEOF_ANYOF_WRAPPERS, "Generate oneOf, anyOf schemas as wrappers. Only `jvm-retrofit2`(library), `gson`(serializationLibrary) support this option."));
289+
cliOptions.add(CliOption.newBoolean(GENERATE_ONEOF_ANYOF_WRAPPERS, "Generate oneOf, anyOf schemas as wrappers. Only `jvm-retrofit2`(library) with `gson` or `kotlinx_serialization`(serializationLibrary) support this option."));
290290

291291
CliOption serializationLibraryOpt = new CliOption(CodegenConstants.SERIALIZATION_LIBRARY, SERIALIZATION_LIBRARY_DESC);
292292
cliOptions.add(serializationLibraryOpt.defaultValue(serializationLibrary.name()));
@@ -567,6 +567,7 @@ public void processOpts() {
567567
// We replace paths like `/v1/foo/*` with `/v1/foo/<*>` to avoid this
568568
additionalProperties.put("sanitizePathComment", new ReplaceAllLambda("\\/\\*", "/<*>"));
569569
additionalProperties.put("fnToOneOfWrapperName", new ToOneOfWrapperName());
570+
additionalProperties.put("fnToValueClassName", new ToValueClassName());
570571
}
571572

572573
private void processDateLibrary() {
@@ -1155,6 +1156,28 @@ public String formatFragment(String fragment) {
11551156
}
11561157
}
11571158

1159+
private static class ToValueClassName extends CustomLambda {
1160+
@Override
1161+
public String formatFragment(String fragment) {
1162+
// Strip generic type parameters and extract simple class names
1163+
// e.g. "kotlin.collections.List<kotlin.String>" -> "ListStringValue"
1164+
// e.g. "kotlin.String" -> "StringValue"
1165+
// e.g. "User" -> "UserValue"
1166+
StringBuilder sb = new StringBuilder();
1167+
for (String part : fragment.split("[<>,]")) {
1168+
String trimmed = part.trim();
1169+
if (trimmed.isEmpty()) continue;
1170+
String simpleName = trimmed.contains(".")
1171+
? trimmed.substring(trimmed.lastIndexOf('.') + 1)
1172+
: trimmed;
1173+
sb.append(Character.toUpperCase(simpleName.charAt(0)));
1174+
sb.append(simpleName.substring(1));
1175+
}
1176+
sb.append("Value");
1177+
return sb.toString();
1178+
}
1179+
}
1180+
11581181
@Override
11591182
public void postProcess() {
11601183
System.out.println("################################################################################");

modules/openapi-generator/src/main/resources/kotlin-client/anyof_class.mustache

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,25 @@ import kotlinx.serialization.builtins.serializer
3737
import kotlinx.serialization.encoding.Decoder
3838
import kotlinx.serialization.encoding.Encoder
3939
{{/enumUnknownDefaultCase}}
40+
{{^enumUnknownDefaultCase}}
41+
{{#generateOneOfAnyOfWrappers}}
42+
import kotlinx.serialization.KSerializer
43+
import kotlinx.serialization.encoding.Decoder
44+
import kotlinx.serialization.encoding.Encoder
45+
{{/generateOneOfAnyOfWrappers}}
46+
{{/enumUnknownDefaultCase}}
47+
{{#generateOneOfAnyOfWrappers}}
48+
import kotlinx.serialization.SerializationException
49+
import kotlinx.serialization.descriptors.SerialDescriptor
50+
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
51+
import kotlinx.serialization.json.JsonDecoder
52+
import kotlinx.serialization.json.JsonEncoder
53+
import kotlinx.serialization.json.JsonArray
54+
import kotlinx.serialization.json.JsonElement
55+
import kotlinx.serialization.json.JsonPrimitive
56+
import kotlinx.serialization.json.decodeFromJsonElement
57+
import kotlinx.serialization.json.encodeToJsonElement
58+
{{/generateOneOfAnyOfWrappers}}
4059
{{#hasEnums}}
4160
{{/hasEnums}}
4261
{{/kotlinx_serialization}}
@@ -57,7 +76,9 @@ import java.io.Serializable
5776
import {{roomModelPackage}}.{{classname}}RoomModel
5877
import {{packageName}}.infrastructure.ITransformForStorage
5978
{{/generateRoomModels}}
79+
{{^kotlinx_serialization}}
6080
import java.io.IOException
81+
{{/kotlinx_serialization}}
6182

6283
/**
6384
* {{{description}}}
@@ -66,12 +87,144 @@ import java.io.IOException
6687
{{#parcelizeModels}}
6788
@Parcelize
6889
{{/parcelizeModels}}
90+
{{^generateOneOfAnyOfWrappers}}
6991
{{#multiplatform}}{{^discriminator}}@Serializable{{/discriminator}}{{/multiplatform}}{{#kotlinx_serialization}}{{#serializableModel}}@KSerializable{{/serializableModel}}{{^serializableModel}}@Serializable{{/serializableModel}}{{/kotlinx_serialization}}{{#moshi}}{{#moshiCodeGen}}@JsonClass(generateAdapter = true){{/moshiCodeGen}}{{/moshi}}{{#jackson}}{{#discriminator}}{{>typeInfoAnnotation}}{{/discriminator}}{{/jackson}}
92+
{{/generateOneOfAnyOfWrappers}}
93+
{{#kotlinx_serialization}}
94+
{{#generateOneOfAnyOfWrappers}}
95+
{{#serializableModel}}@KSerializable(with = {{classname}}Serializer::class){{/serializableModel}}{{^serializableModel}}@Serializable(with = {{classname}}Serializer::class){{/serializableModel}}
96+
{{/generateOneOfAnyOfWrappers}}
97+
{{/kotlinx_serialization}}
7098
{{#isDeprecated}}
7199
@Deprecated(message = "This schema is deprecated.")
72100
{{/isDeprecated}}
73101
{{>additionalModelTypeAnnotations}}
74102

103+
{{#kotlinx_serialization}}
104+
{{#generateOneOfAnyOfWrappers}}
105+
{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}sealed interface {{classname}} {
106+
{{#composedSchemas}}
107+
{{#anyOf}}
108+
{{^vendorExtensions.x-duplicated-data-type}}
109+
@JvmInline
110+
{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}value class {{#fnToValueClassName}}{{{dataType}}}{{/fnToValueClassName}}(val value: {{{dataType}}}) : {{classname}}
111+
112+
{{/vendorExtensions.x-duplicated-data-type}}
113+
{{/anyOf}}
114+
{{/composedSchemas}}
115+
}
116+
117+
{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}object {{classname}}Serializer : KSerializer<{{classname}}> {
118+
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("{{classname}}")
119+
120+
override fun serialize(encoder: Encoder, value: {{classname}}) {
121+
val jsonEncoder = encoder as? JsonEncoder ?: throw SerializationException("{{classname}} can only be serialized with Json")
122+
123+
when (value) {
124+
{{#composedSchemas}}
125+
{{#anyOf}}
126+
{{^vendorExtensions.x-duplicated-data-type}}
127+
{{#isArray}}
128+
is {{classname}}.{{#fnToValueClassName}}{{{dataType}}}{{/fnToValueClassName}} -> jsonEncoder.encodeJsonElement(jsonEncoder.json.encodeToJsonElement(value.value))
129+
{{/isArray}}
130+
{{^isArray}}
131+
{{#isPrimitiveType}}
132+
{{#isString}}
133+
is {{classname}}.{{#fnToValueClassName}}{{{dataType}}}{{/fnToValueClassName}} -> jsonEncoder.encodeString(value.value)
134+
{{/isString}}
135+
{{#isBoolean}}
136+
is {{classname}}.{{#fnToValueClassName}}{{{dataType}}}{{/fnToValueClassName}} -> jsonEncoder.encodeBoolean(value.value)
137+
{{/isBoolean}}
138+
{{#isInteger}}
139+
{{^isLong}}
140+
is {{classname}}.{{#fnToValueClassName}}{{{dataType}}}{{/fnToValueClassName}} -> jsonEncoder.encodeInt(value.value)
141+
{{/isLong}}
142+
{{/isInteger}}
143+
{{#isLong}}
144+
is {{classname}}.{{#fnToValueClassName}}{{{dataType}}}{{/fnToValueClassName}} -> jsonEncoder.encodeLong(value.value)
145+
{{/isLong}}
146+
{{#isNumber}}
147+
{{#isDouble}}
148+
is {{classname}}.{{#fnToValueClassName}}{{{dataType}}}{{/fnToValueClassName}} -> jsonEncoder.encodeDouble(value.value)
149+
{{/isDouble}}
150+
{{#isFloat}}
151+
is {{classname}}.{{#fnToValueClassName}}{{{dataType}}}{{/fnToValueClassName}} -> jsonEncoder.encodeFloat(value.value)
152+
{{/isFloat}}
153+
{{/isNumber}}
154+
{{/isPrimitiveType}}
155+
{{^isPrimitiveType}}
156+
is {{classname}}.{{#fnToValueClassName}}{{{dataType}}}{{/fnToValueClassName}} -> jsonEncoder.encodeSerializableValue({{{dataType}}}.serializer(), value.value)
157+
{{/isPrimitiveType}}
158+
{{/isArray}}
159+
{{/vendorExtensions.x-duplicated-data-type}}
160+
{{/anyOf}}
161+
{{/composedSchemas}}
162+
}
163+
}
164+
165+
override fun deserialize(decoder: Decoder): {{classname}} {
166+
val jsonDecoder = decoder as? JsonDecoder ?: throw SerializationException("{{classname}} can only be deserialized with Json")
167+
val jsonElement = jsonDecoder.decodeJsonElement()
168+
169+
val errorMessages = mutableListOf<String>()
170+
171+
{{#composedSchemas}}
172+
{{#anyOf}}
173+
{{^vendorExtensions.x-duplicated-data-type}}
174+
{{#isArray}}
175+
if (jsonElement is JsonArray) {
176+
try {
177+
val instance = jsonDecoder.json.decodeFromJsonElement<{{{dataType}}}>(jsonElement)
178+
return {{classname}}.{{#fnToValueClassName}}{{{dataType}}}{{/fnToValueClassName}}(instance)
179+
} catch (e: Exception) {
180+
errorMessages.add("Failed to deserialize as {{{dataType}}}: ${e.message}")
181+
}
182+
}
183+
{{/isArray}}
184+
{{^isArray}}
185+
{{#isPrimitiveType}}
186+
{{#isString}}
187+
if (jsonElement is JsonPrimitive && jsonElement.isString) {
188+
try {
189+
val instance = jsonDecoder.json.decodeFromJsonElement<{{{dataType}}}>(jsonElement)
190+
return {{classname}}.{{#fnToValueClassName}}{{{dataType}}}{{/fnToValueClassName}}(instance)
191+
} catch (e: Exception) {
192+
errorMessages.add("Failed to deserialize as {{{dataType}}}: ${e.message}")
193+
}
194+
}
195+
{{/isString}}
196+
{{^isString}}
197+
if (jsonElement is JsonPrimitive && !jsonElement.isString) {
198+
try {
199+
val instance = jsonDecoder.json.decodeFromJsonElement<{{{dataType}}}>(jsonElement)
200+
return {{classname}}.{{#fnToValueClassName}}{{{dataType}}}{{/fnToValueClassName}}(instance)
201+
} catch (e: Exception) {
202+
errorMessages.add("Failed to deserialize as {{{dataType}}}: ${e.message}")
203+
}
204+
}
205+
{{/isString}}
206+
{{/isPrimitiveType}}
207+
{{^isPrimitiveType}}
208+
if (jsonElement !is JsonPrimitive) {
209+
try {
210+
val instance = jsonDecoder.json.decodeFromJsonElement<{{{dataType}}}>(jsonElement)
211+
return {{classname}}.{{#fnToValueClassName}}{{{dataType}}}{{/fnToValueClassName}}(instance)
212+
} catch (e: Exception) {
213+
errorMessages.add("Failed to deserialize as {{{dataType}}}: ${e.message}")
214+
}
215+
}
216+
{{/isPrimitiveType}}
217+
{{/isArray}}
218+
{{/vendorExtensions.x-duplicated-data-type}}
219+
{{/anyOf}}
220+
{{/composedSchemas}}
221+
222+
throw SerializationException("Cannot deserialize {{classname}}. Tried: ${errorMessages.joinToString(", ")}")
223+
}
224+
}
225+
{{/generateOneOfAnyOfWrappers}}
226+
{{/kotlinx_serialization}}
227+
{{^kotlinx_serialization}}
75228
{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}data class {{classname}}(var actualInstance: Any? = null) {
76229
77230
class CustomTypeAdapterFactory : TypeAdapterFactory {
@@ -334,4 +487,5 @@ import java.io.IOException
334487
}
335488
}
336489
}
337-
}
490+
}
491+
{{/kotlinx_serialization}}

0 commit comments

Comments
 (0)