From ec18c9e3754bde422911075c66dae411ce004a89 Mon Sep 17 00:00:00 2001 From: erenlmn Date: Tue, 19 May 2026 21:37:10 +0100 Subject: [PATCH] [kotlin-client] Add decodeOrNull helper for Jackson enums, fix decode KDoc (#23791) Splits the two contracts that PR #22535 (7.18.0) conflated: - decode() stays strict and Jackson-bound (@JsonCreator entry point) - decodeOrNull() is added as a lenient counterpart for direct callers that prefer the pre-7.19.0 null-on-unknown behavior This gives users impacted by the 7.19.0 change a trivial migration path (MyEnum.decode(x) -> MyEnum.decodeOrNull(x)) without giving up the Jackson safety fix or matching the kotlin-spring/Java client behavior. Also corrects the KDoc on decode() which still claimed "null otherwise" even though the function now throws. --- .../kotlin-client/enum_class.mustache | 22 ++++++++++++- .../kotlin/KotlinClientCodegenModelTest.java | 33 +++++++++++++++++++ .../client/models/StringEnumRef.kt | 16 ++++++++- .../client/models/StringEnumRef.kt | 16 ++++++++- 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/kotlin-client/enum_class.mustache b/modules/openapi-generator/src/main/resources/kotlin-client/enum_class.mustache index 1027bc0a9695..984c62791148 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-client/enum_class.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-client/enum_class.mustache @@ -108,10 +108,10 @@ import kotlinx.serialization.encoding.Encoder */ {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}fun encode(data: kotlin.Any?): kotlin.String? = if (data is {{classname}}) "$data" else null +{{^jackson}} /** * Returns a valid [{{classname}}] for [data], null otherwise. */ -{{^jackson}} {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}fun decode(data: kotlin.Any?): {{classname}}? = data?.let { val normalizedData = "$it".lowercase() entries.firstOrNull { value -> @@ -120,6 +120,12 @@ import kotlinx.serialization.encoding.Encoder } {{/jackson}} {{#jackson}} + /** + * Returns a valid [{{classname}}] for [data].{{^isNullable}}{{^enumUnknownDefaultCase}} + * + * Throws [IllegalArgumentException] when [data] is null or does not match a known value. + * For lenient lookup that returns null on unknown values, see [decodeOrNull].{{/enumUnknownDefaultCase}}{{/isNullable}} + */ @JvmStatic @JsonCreator {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}fun decode(data: kotlin.Any?): {{classname}}{{#isNullable}}?{{/isNullable}} { @@ -138,6 +144,20 @@ import kotlinx.serialization.encoding.Encoder ?: {{#allowableValues}}{{#enumVars}}{{#-last}}{{&name}}{{/-last}}{{/enumVars}}{{/allowableValues}}{{/enumUnknownDefaultCase}}{{^enumUnknownDefaultCase}} ?: throw IllegalArgumentException("Unknown {{classname}} value: $data"){{/enumUnknownDefaultCase}}{{/isNullable}} } + + /** + * Returns a valid [{{classname}}] for [data], or null when [data] is null or does not match a known value. + * + * Lenient counterpart to [decode], intended for direct calls from Kotlin code. + * Jackson deserialization uses [decode], which is strict. + */ + @JvmStatic + {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}fun decodeOrNull(data: kotlin.Any?): {{classname}}? = data?.let { + val normalizedData = "$it".lowercase() + entries.firstOrNull { value -> + it == value || normalizedData == "$value".lowercase() + } + } {{/jackson}} } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java index 23f98e3c443e..84ada332de35 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java @@ -819,6 +819,39 @@ public void testJacksonEnumsThrowForUnknownValue() throws IOException { TestUtils.assertFileContains(enumKt, "throw IllegalArgumentException(\"Unknown ExampleNumericEnum value: $data\")"); } + @Test + public void testJacksonEnumsExposeDecodeOrNullHelper() throws IOException { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("kotlin") + .setLibrary("jvm-retrofit2") + .setAdditionalProperties(new HashMap<>() {{ + put(CodegenConstants.SERIALIZATION_LIBRARY, "jackson"); + put(CodegenConstants.MODEL_PACKAGE, "model"); + }}) + .setInputSpec("src/test/resources/3_0/kotlin/issue22534-kotlin-numeric-enum.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + final ClientOptInput clientOptInput = configurator.toClientOptInput(); + DefaultGenerator generator = new DefaultGenerator(); + + generator.opts(clientOptInput).generate(); + + final Path enumKt = Paths.get(output + "/src/main/kotlin/model/ExampleNumericEnum.kt"); + + // decodeOrNull should always be generated alongside decode in the Jackson branch + // and must return null for unknown values without throwing. + TestUtils.assertFileContains(enumKt, "fun decodeOrNull(data: kotlin.Any?): ExampleNumericEnum?"); + // decodeOrNull should not be annotated with @JsonCreator — only decode is the Jackson entry point. + // Verify by checking @JsonCreator appears exactly once in the file. + String content = new String(java.nio.file.Files.readAllBytes(enumKt), java.nio.charset.StandardCharsets.UTF_8); + int jsonCreatorCount = content.split("@JsonCreator", -1).length - 1; + Assert.assertEquals(jsonCreatorCount, 1, + "Expected exactly one @JsonCreator annotation in the generated enum, found " + jsonCreatorCount); + } + @Test public void testJacksonEnumsWithUnknownDefaultCase() throws IOException { File output = Files.createTempDirectory("test").toFile(); diff --git a/samples/client/echo_api/kotlin-jvm-spring-3-restclient/src/main/kotlin/org/openapitools/client/models/StringEnumRef.kt b/samples/client/echo_api/kotlin-jvm-spring-3-restclient/src/main/kotlin/org/openapitools/client/models/StringEnumRef.kt index 4ee5cbad784f..e6da45a1a7d6 100644 --- a/samples/client/echo_api/kotlin-jvm-spring-3-restclient/src/main/kotlin/org/openapitools/client/models/StringEnumRef.kt +++ b/samples/client/echo_api/kotlin-jvm-spring-3-restclient/src/main/kotlin/org/openapitools/client/models/StringEnumRef.kt @@ -66,7 +66,7 @@ enum class StringEnumRef(@get:JsonValue val value: kotlin.String) { fun encode(data: kotlin.Any?): kotlin.String? = if (data is StringEnumRef) "$data" else null /** - * Returns a valid [StringEnumRef] for [data], null otherwise. + * Returns a valid [StringEnumRef] for [data]. */ @JvmStatic @JsonCreator @@ -80,6 +80,20 @@ enum class StringEnumRef(@get:JsonValue val value: kotlin.String) { } ?: unknown_default_open_api } + + /** + * Returns a valid [StringEnumRef] for [data], or null when [data] is null or does not match a known value. + * + * Lenient counterpart to [decode], intended for direct calls from Kotlin code. + * Jackson deserialization uses [decode], which is strict. + */ + @JvmStatic + fun decodeOrNull(data: kotlin.Any?): StringEnumRef? = data?.let { + val normalizedData = "$it".lowercase() + entries.firstOrNull { value -> + it == value || normalizedData == "$value".lowercase() + } + } } } diff --git a/samples/client/echo_api/kotlin-jvm-spring-3-webclient/src/main/kotlin/org/openapitools/client/models/StringEnumRef.kt b/samples/client/echo_api/kotlin-jvm-spring-3-webclient/src/main/kotlin/org/openapitools/client/models/StringEnumRef.kt index 4ee5cbad784f..e6da45a1a7d6 100644 --- a/samples/client/echo_api/kotlin-jvm-spring-3-webclient/src/main/kotlin/org/openapitools/client/models/StringEnumRef.kt +++ b/samples/client/echo_api/kotlin-jvm-spring-3-webclient/src/main/kotlin/org/openapitools/client/models/StringEnumRef.kt @@ -66,7 +66,7 @@ enum class StringEnumRef(@get:JsonValue val value: kotlin.String) { fun encode(data: kotlin.Any?): kotlin.String? = if (data is StringEnumRef) "$data" else null /** - * Returns a valid [StringEnumRef] for [data], null otherwise. + * Returns a valid [StringEnumRef] for [data]. */ @JvmStatic @JsonCreator @@ -80,6 +80,20 @@ enum class StringEnumRef(@get:JsonValue val value: kotlin.String) { } ?: unknown_default_open_api } + + /** + * Returns a valid [StringEnumRef] for [data], or null when [data] is null or does not match a known value. + * + * Lenient counterpart to [decode], intended for direct calls from Kotlin code. + * Jackson deserialization uses [decode], which is strict. + */ + @JvmStatic + fun decodeOrNull(data: kotlin.Any?): StringEnumRef? = data?.let { + val normalizedData = "$it".lowercase() + entries.firstOrNull { value -> + it == value || normalizedData == "$value".lowercase() + } + } } }