[Bug][kotlin-spring] add a Spring type converter for enum values #21564 (#21579)

* [kotlin-spring] add a Spring type converter for enum values #21564

* [kotlin-spring] simplify unit test for inner enum converter

* update samples

* code review feedback; move containsEnums to ModelUtils

* code review feedback; provide comment for generated EnumConverterConfiguration.kt

* update samples

---------

Co-authored-by: Chris Gual <cgual@omnidian.com>
This commit is contained in:
Christopher Gual 2025-07-21 03:22:32 -07:00 committed by GitHub
parent 31089c0e49
commit bfb69388aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 204 additions and 16 deletions

View File

@ -20,6 +20,7 @@ import com.google.common.collect.ImmutableMap;
import com.samskivert.mustache.Mustache; import com.samskivert.mustache.Mustache;
import com.samskivert.mustache.Mustache.Lambda; import com.samskivert.mustache.Mustache.Lambda;
import com.samskivert.mustache.Template; import com.samskivert.mustache.Template;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.Operation;
import lombok.Getter; import lombok.Getter;
@ -33,6 +34,7 @@ import org.openapitools.codegen.model.ModelMap;
import org.openapitools.codegen.model.ModelsMap; import org.openapitools.codegen.model.ModelsMap;
import org.openapitools.codegen.model.OperationMap; import org.openapitools.codegen.model.OperationMap;
import org.openapitools.codegen.model.OperationsMap; import org.openapitools.codegen.model.OperationsMap;
import org.openapitools.codegen.utils.ModelUtils;
import org.openapitools.codegen.utils.URLPathUtils; import org.openapitools.codegen.utils.URLPathUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -768,6 +770,11 @@ public class KotlinSpringServerCodegen extends AbstractKotlinCodegen
public void preprocessOpenAPI(OpenAPI openAPI) { public void preprocessOpenAPI(OpenAPI openAPI) {
super.preprocessOpenAPI(openAPI); super.preprocessOpenAPI(openAPI);
if (SPRING_BOOT.equals(library) && ModelUtils.containsEnums(this.openAPI)) {
supportingFiles.add(new SupportingFile("converter.mustache",
(sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "EnumConverterConfiguration.kt"));
}
if (!additionalProperties.containsKey(TITLE)) { if (!additionalProperties.containsKey(TITLE)) {
// The purpose of the title is for: // The purpose of the title is for:
// - README documentation // - README documentation

View File

@ -18,7 +18,6 @@
package org.openapitools.codegen.languages; package org.openapitools.codegen.languages;
import com.samskivert.mustache.Mustache; import com.samskivert.mustache.Mustache;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.PathItem; import io.swagger.v3.oas.models.PathItem;
@ -40,6 +39,7 @@ import org.openapitools.codegen.model.OperationsMap;
import org.openapitools.codegen.templating.mustache.SplitStringLambda; import org.openapitools.codegen.templating.mustache.SplitStringLambda;
import org.openapitools.codegen.templating.mustache.SpringHttpStatusLambda; import org.openapitools.codegen.templating.mustache.SpringHttpStatusLambda;
import org.openapitools.codegen.templating.mustache.TrimWhitespaceLambda; import org.openapitools.codegen.templating.mustache.TrimWhitespaceLambda;
import org.openapitools.codegen.utils.ModelUtils;
import org.openapitools.codegen.utils.ProcessUtils; import org.openapitools.codegen.utils.ProcessUtils;
import org.openapitools.codegen.utils.URLPathUtils; import org.openapitools.codegen.utils.URLPathUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -648,20 +648,6 @@ public class SpringCodegen extends AbstractJavaCodegen
supportsAdditionalPropertiesWithComposedSchema = true; supportsAdditionalPropertiesWithComposedSchema = true;
} }
private boolean containsEnums() {
if (openAPI == null) {
return false;
}
Components components = this.openAPI.getComponents();
if (components == null || components.getSchemas() == null) {
return false;
}
return components.getSchemas().values().stream()
.anyMatch(it -> it.getEnum() != null && !it.getEnum().isEmpty());
}
private boolean supportLibraryUseTags() { private boolean supportLibraryUseTags() {
return SPRING_BOOT.equals(library) || SPRING_CLOUD_LIBRARY.equals(library); return SPRING_BOOT.equals(library) || SPRING_CLOUD_LIBRARY.equals(library);
} }
@ -696,7 +682,7 @@ public class SpringCodegen extends AbstractJavaCodegen
public void preprocessOpenAPI(OpenAPI openAPI) { public void preprocessOpenAPI(OpenAPI openAPI) {
super.preprocessOpenAPI(openAPI); super.preprocessOpenAPI(openAPI);
if (SPRING_BOOT.equals(library) && containsEnums()) { if (SPRING_BOOT.equals(library) && ModelUtils.containsEnums(this.openAPI)) {
supportingFiles.add(new SupportingFile("converter.mustache", supportingFiles.add(new SupportingFile("converter.mustache",
(sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "EnumConverterConfiguration.java")); (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "EnumConverterConfiguration.java"));
} }

View File

@ -2435,6 +2435,19 @@ public class ModelUtils {
schema.getContentSchema() != null); schema.getContentSchema() != null);
} }
/**
* Returns true if the OpenAPI specification contains any schemas which are enums.
* @param openAPI OpenAPI specification
* @return true if the OpenAPI specification contains any schemas which are enums.
*/
public static boolean containsEnums(OpenAPI openAPI) {
Map<String, Schema> schemaMap = getSchemas(openAPI);
if (schemaMap.isEmpty()) {
return false;
}
return schemaMap.values().stream().anyMatch(ModelUtils::isEnumSchema);
}
@FunctionalInterface @FunctionalInterface
private interface OpenAPISchemaVisitor { private interface OpenAPISchemaVisitor {

View File

@ -0,0 +1,38 @@
package {{configPackage}}
{{#models}}
{{#model}}
{{#isEnum}}
import {{modelPackage}}.{{classname}}
{{/isEnum}}
{{/model}}
{{/models}}
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.convert.converter.Converter
/**
* This class provides Spring Converter beans for the enum models in the OpenAPI specification.
*
* By default, Spring only converts primitive types to enums using Enum::valueOf, which can prevent
* correct conversion if the OpenAPI specification is using an `enumPropertyNaming` other than
* `original` or the specification has an integer enum.
*/
@Configuration(value = "{{configPackage}}.enumConverterConfiguration")
class EnumConverterConfiguration {
{{#models}}
{{#model}}
{{#isEnum}}
@Bean(name = ["{{configPackage}}.EnumConverterConfiguration.{{classVarName}}Converter"])
fun {{classVarName}}Converter(): Converter<{{{dataType}}}, {{classname}}> {
return object: Converter<{{{dataType}}}, {{classname}}> {
override fun convert(source: {{{dataType}}}): {{classname}} = {{classname}}.forValue(source)
}
}
{{/isEnum}}
{{/model}}
{{/models}}
}

View File

@ -5,6 +5,8 @@ import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.servers.Server; import io.swagger.v3.oas.models.servers.Server;
import io.swagger.v3.parser.core.models.ParseOptions; import io.swagger.v3.parser.core.models.ParseOptions;
import java.util.HashMap;
import java.util.function.Consumer;
import org.apache.commons.io.FileUtils; import org.apache.commons.io.FileUtils;
import org.assertj.core.api.Assertions; import org.assertj.core.api.Assertions;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
@ -12,6 +14,8 @@ import org.openapitools.codegen.ClientOptInput;
import org.openapitools.codegen.CodegenConstants; import org.openapitools.codegen.CodegenConstants;
import org.openapitools.codegen.DefaultGenerator; import org.openapitools.codegen.DefaultGenerator;
import org.openapitools.codegen.TestUtils; import org.openapitools.codegen.TestUtils;
import org.openapitools.codegen.config.CodegenConfigurator;
import org.openapitools.codegen.java.assertions.JavaFileAssert;
import org.openapitools.codegen.kotlin.KotlinTestUtils; import org.openapitools.codegen.kotlin.KotlinTestUtils;
import org.openapitools.codegen.languages.KotlinSpringServerCodegen; import org.openapitools.codegen.languages.KotlinSpringServerCodegen;
import org.openapitools.codegen.languages.features.CXFServerFeatures; import org.openapitools.codegen.languages.features.CXFServerFeatures;
@ -31,8 +35,10 @@ import java.util.Map;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.openapitools.codegen.TestUtils.assertFileContains; import static org.openapitools.codegen.TestUtils.assertFileContains;
import static org.openapitools.codegen.TestUtils.assertFileNotContains; import static org.openapitools.codegen.TestUtils.assertFileNotContains;
import static org.openapitools.codegen.languages.SpringCodegen.SPRING_BOOT;
import static org.openapitools.codegen.languages.features.DocumentationProviderFeatures.ANNOTATION_LIBRARY; import static org.openapitools.codegen.languages.features.DocumentationProviderFeatures.ANNOTATION_LIBRARY;
import static org.openapitools.codegen.languages.features.DocumentationProviderFeatures.DOCUMENTATION_PROVIDER; import static org.openapitools.codegen.languages.features.DocumentationProviderFeatures.DOCUMENTATION_PROVIDER;
@ -748,6 +754,40 @@ public class KotlinSpringServerCodegenTest {
); );
} }
@Test
public void contractWithoutEnumDoesNotContainEnumConverter() throws IOException {
Map<String, File> output = generateFromContract("src/test/resources/3_0/generic.yaml");
assertThat(output).doesNotContainKey("EnumConverterConfiguration.kt");
}
@Test
public void contractWithEnumContainsEnumConverter() throws IOException {
Map<String, File> output = generateFromContract("src/test/resources/3_0/enum.yaml");
File enumConverterFile = output.get("EnumConverterConfiguration.kt");
assertThat(enumConverterFile).isNotNull();
assertFileContains(enumConverterFile.toPath(), "fun typeConverter(): Converter<kotlin.String, Type> {");
assertFileContains(enumConverterFile.toPath(), "return object: Converter<kotlin.String, Type> {");
assertFileContains(enumConverterFile.toPath(), "override fun convert(source: kotlin.String): Type = Type.forValue(source)");
}
@Test
public void contractWithResolvedInnerEnumContainsEnumConverter() throws IOException {
Map<String, File> files = generateFromContract(
"src/test/resources/3_0/inner_enum.yaml",
new HashMap<>(),
new HashMap<>(),
configurator -> configurator.addInlineSchemaOption("RESOLVE_INLINE_ENUMS", "true")
);
File enumConverterFile = files.get("EnumConverterConfiguration.kt");
assertThat(enumConverterFile).isNotNull();
assertFileContains(enumConverterFile.toPath(), "fun ponyTypeConverter(): Converter<kotlin.String, PonyType> {");
assertFileContains(enumConverterFile.toPath(), "return object: Converter<kotlin.String, PonyType> {");
assertFileContains(enumConverterFile.toPath(), "override fun convert(source: kotlin.String): PonyType = PonyType.forValue(source)");
}
@Test @Test
public void givenMultipartFormArray_whenGenerateDelegateAndService_thenParameterIsCreatedAsListOfMultipartFile() throws IOException { public void givenMultipartFormArray_whenGenerateDelegateAndService_thenParameterIsCreatedAsListOfMultipartFile() throws IOException {
File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
@ -1192,4 +1232,54 @@ public class KotlinSpringServerCodegenTest {
"@NotNull", "@Valid", "@Pattern(regexp=\"^[a-zA-Z0-9]+[a-zA-Z0-9\\\\.\\\\-_]*[a-zA-Z0-9]+$\")"); "@NotNull", "@Valid", "@Pattern(regexp=\"^[a-zA-Z0-9]+[a-zA-Z0-9\\\\.\\\\-_]*[a-zA-Z0-9]+$\")");
} }
private Map<String, File> generateFromContract(String url) throws IOException {
return generateFromContract(url, new HashMap<>(), new HashMap<>());
}
private Map<String, File> generateFromContract(String url, Map<String, Object> additionalProperties) throws IOException {
return generateFromContract(url, additionalProperties, new HashMap<>());
}
private Map<String, File> generateFromContract(
String url,
Map<String, Object> additionalProperties,
Map<String, String> generatorPropertyDefaults
) throws IOException {
return generateFromContract(url, additionalProperties, generatorPropertyDefaults, codegen -> {
});
}
/**
* Generate the contract with additional configuration.
* <p>
* use CodegenConfigurator instead of CodegenConfig for easier configuration like in JavaClientCodeGenTest
*/
private Map<String, File> generateFromContract(
String url,
Map<String, Object> additionalProperties,
Map<String, String> generatorPropertyDefaults,
Consumer<CodegenConfigurator> consumer
) throws IOException {
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
output.deleteOnExit();
final CodegenConfigurator configurator = new CodegenConfigurator()
.setGeneratorName("kotlin-spring")
.setAdditionalProperties(additionalProperties)
.setValidateSpec(false)
.setInputSpec(url)
.setLibrary(SPRING_BOOT)
.setOutputDir(output.getAbsolutePath());
consumer.accept(configurator);
ClientOptInput input = configurator.toClientOptInput();
DefaultGenerator generator = new DefaultGenerator();
generator.setGenerateMetadata(false);
generatorPropertyDefaults.forEach(generator::setGeneratorPropertyDefault);
return generator.opts(input).generate().stream()
.collect(Collectors.toMap(File::getName, Function.identity()));
}
} }

View File

@ -9,5 +9,6 @@ settings.gradle
src/main/kotlin/org/openapitools/api/ApiUtil.kt src/main/kotlin/org/openapitools/api/ApiUtil.kt
src/main/kotlin/org/openapitools/api/DefaultApi.kt src/main/kotlin/org/openapitools/api/DefaultApi.kt
src/main/kotlin/org/openapitools/api/Exceptions.kt src/main/kotlin/org/openapitools/api/Exceptions.kt
src/main/kotlin/org/openapitools/configuration/EnumConverterConfiguration.kt
src/main/kotlin/org/openapitools/model/ApiError.kt src/main/kotlin/org/openapitools/model/ApiError.kt
src/main/kotlin/org/openapitools/model/ReasonCode.kt src/main/kotlin/org/openapitools/model/ReasonCode.kt

View File

@ -0,0 +1,26 @@
package org.openapitools.configuration
import org.openapitools.model.ReasonCode
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.convert.converter.Converter
/**
* This class provides Spring Converter beans for the enum models in the OpenAPI specification.
*
* By default, Spring only converts primitive types to enums using Enum::valueOf, which can prevent
* correct conversion if the OpenAPI specification is using an `enumPropertyNaming` other than
* `original` or the specification has an integer enum.
*/
@Configuration(value = "org.openapitools.configuration.enumConverterConfiguration")
class EnumConverterConfiguration {
@Bean(name = ["org.openapitools.configuration.EnumConverterConfiguration.reasonCodeConverter"])
fun reasonCodeConverter(): Converter<kotlin.Int, ReasonCode> {
return object: Converter<kotlin.Int, ReasonCode> {
override fun convert(source: kotlin.Int): ReasonCode = ReasonCode.forValue(source)
}
}
}

View File

@ -12,6 +12,7 @@ src/main/kotlin/org/openapitools/SpringDocConfiguration.kt
src/main/kotlin/org/openapitools/api/ApiUtil.kt src/main/kotlin/org/openapitools/api/ApiUtil.kt
src/main/kotlin/org/openapitools/api/Exceptions.kt src/main/kotlin/org/openapitools/api/Exceptions.kt
src/main/kotlin/org/openapitools/api/MultipartMixedApiController.kt src/main/kotlin/org/openapitools/api/MultipartMixedApiController.kt
src/main/kotlin/org/openapitools/configuration/EnumConverterConfiguration.kt
src/main/kotlin/org/openapitools/model/MultipartMixedRequestMarker.kt src/main/kotlin/org/openapitools/model/MultipartMixedRequestMarker.kt
src/main/kotlin/org/openapitools/model/MultipartMixedStatus.kt src/main/kotlin/org/openapitools/model/MultipartMixedStatus.kt
src/main/resources/application.yaml src/main/resources/application.yaml

View File

@ -0,0 +1,26 @@
package org.openapitools.configuration
import org.openapitools.model.MultipartMixedStatus
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.convert.converter.Converter
/**
* This class provides Spring Converter beans for the enum models in the OpenAPI specification.
*
* By default, Spring only converts primitive types to enums using Enum::valueOf, which can prevent
* correct conversion if the OpenAPI specification is using an `enumPropertyNaming` other than
* `original` or the specification has an integer enum.
*/
@Configuration(value = "org.openapitools.configuration.enumConverterConfiguration")
class EnumConverterConfiguration {
@Bean(name = ["org.openapitools.configuration.EnumConverterConfiguration.multipartMixedStatusConverter"])
fun multipartMixedStatusConverter(): Converter<kotlin.String, MultipartMixedStatus> {
return object: Converter<kotlin.String, MultipartMixedStatus> {
override fun convert(source: kotlin.String): MultipartMixedStatus = MultipartMixedStatus.forValue(source)
}
}
}