diff --git a/docs/generators/kotlin-spring.md b/docs/generators/kotlin-spring.md index 580d834f52c0..c810c850c6ed 100644 --- a/docs/generators/kotlin-spring.md +++ b/docs/generators/kotlin-spring.md @@ -56,6 +56,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |sortModelPropertiesByRequiredFlag|Sort model properties to place required parameters before optional parameters.| |null| |sortParamsByRequiredFlag|Sort method arguments to place required parameters before optional parameters.| |null| |sourceFolder|source folder for generated code| |src/main/kotlin| +|suspendFunctions|Whether to generate suspend functions for API operations. Useful for Spring MVC with Kotlin coroutines without requiring the full reactive stack.| |false| |title|server title name or client service name| |OpenAPI Kotlin Spring| |useBeanValidation|Use BeanValidation API annotations to validate data types| |true| |useFeignClientUrl|Whether to generate Feign client with url parameter.| |true| diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index 5dc081bbd3ab..7bfdc4a0d143 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java @@ -100,6 +100,7 @@ public class KotlinSpringServerCodegen extends AbstractKotlinCodegen public static final String AUTO_X_SPRING_PAGINATED = "autoXSpringPaginated"; public static final String USE_SEALED_RESPONSE_INTERFACES = "useSealedResponseInterfaces"; public static final String COMPANION_OBJECT = "companionObject"; + public static final String SUSPEND_FUNCTIONS = "suspendFunctions"; @Getter public enum DeclarativeInterfaceReactiveMode { @@ -166,6 +167,7 @@ public String getDescription() { @Setter private boolean autoXSpringPaginated = false; @Setter private boolean useSealedResponseInterfaces = false; @Setter private boolean companionObject = false; + @Setter private boolean suspendFunctions = false; @Getter @Setter protected boolean useSpringBoot3 = false; @@ -273,6 +275,7 @@ public KotlinSpringServerCodegen() { addOption(SCHEMA_IMPLEMENTS_FIELDS, "A map of single field or a list of fields per schema name that should be prepended with `override` (serves similar purpose as `x-kotlin-implements-fields`, but is fully decoupled from the api spec). Example: yaml `schemaImplementsFields: {Pet: id, Category: [name, id], Dog: [bark, breed]}` marks fields to be prepended with `override` in schemas `Pet` (field `id`), `Category` (fields `name`, `id`) and `Dog` (fields `bark`, `breed`)", "empty map"); addSwitch(AUTO_X_SPRING_PAGINATED, "Automatically add x-spring-paginated to operations that have 'page', 'size', and 'sort' query parameters. When enabled, operations with all three parameters will have Pageable support automatically applied. Operations with x-spring-paginated explicitly set to false will not be auto-detected.", autoXSpringPaginated); addSwitch(COMPANION_OBJECT, "Whether to generate companion objects in data classes, enabling companion extensions.", companionObject); + addSwitch(SUSPEND_FUNCTIONS, "Whether to generate suspend functions for API operations. Useful for Spring MVC with Kotlin coroutines without requiring the full reactive stack.", suspendFunctions); supportedLibraries.put(SPRING_BOOT, "Spring-boot Server application."); supportedLibraries.put(SPRING_CLOUD_LIBRARY, "Spring-Cloud-Feign client with Spring-Boot auto-configured settings."); @@ -663,6 +666,11 @@ public void processOpts() { writePropertyBack(EXCEPTION_HANDLER, exceptionHandler); writePropertyBack(USE_FLOW_FOR_ARRAY_RETURN_TYPE, useFlowForArrayReturnType); + if (additionalProperties.containsKey(SUSPEND_FUNCTIONS)) { + this.setSuspendFunctions(convertPropertyToBoolean(SUSPEND_FUNCTIONS)); + } + writePropertyBack(SUSPEND_FUNCTIONS, suspendFunctions); + if (additionalProperties.containsKey(BEAN_QUALIFIERS) && library.equals(SPRING_BOOT)) { this.setBeanQualifiers(convertPropertyToBoolean(BEAN_QUALIFIERS)); } diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache index 253cd1719766..292788f2eecf 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache @@ -106,7 +106,7 @@ class {{classname}}Controller({{#serviceInterface}}@Autowired(required = true) v produces = [{{#produces}}"{{{mediaType}}}"{{^-last}}, {{/-last}}{{/produces}}]{{/hasProduces}}{{#hasConsumes}}, consumes = [{{#consumes}}"{{{mediaType}}}"{{^-last}}, {{/-last}}{{/consumes}}]{{/hasConsumes}}{{/singleContentTypes}} ) - {{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}fun {{operationId}}({{#allParams}} + {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}} {{>queryParams}}{{>pathParams}}{{>headerParams}}{{>cookieParams}}{{>bodyParams}}{{>formParams}}{{^-last}},{{/-last}}{{/allParams}}{{#includeHttpRequestContext}}{{#hasParams}}, {{/hasParams}}{{#swagger1AnnotationLibrary}}@ApiParam(hidden = true) {{/swagger1AnnotationLibrary}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true) {{/swagger2AnnotationLibrary}}{{#reactive}}exchange: org.springframework.web.server.ServerWebExchange{{/reactive}}{{^reactive}}request: {{javaxPackage}}.servlet.http.HttpServletRequest{{/reactive}}{{/includeHttpRequestContext}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}}, diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/apiDelegate.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/apiDelegate.mustache index c317d43bf56e..9dbeecc9a1a9 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/apiDelegate.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/apiDelegate.mustache @@ -32,7 +32,7 @@ interface {{classname}}Delegate { /** * @see {{classname}}#{{operationId}} */ - {{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}{{#isBodyParam}}Flow<{{{baseType}}}>{{/isBodyParam}}{{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{/isArray}}{{/reactive}}{{^-last}}, + {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}{{#isBodyParam}}Flow<{{{baseType}}}>{{/isBodyParam}}{{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{/isArray}}{{/reactive}}{{^-last}}, {{/-last}}{{/allParams}}{{#includeHttpRequestContext}}{{#hasParams}}, {{/hasParams}}{{#reactive}}exchange: org.springframework.web.server.ServerWebExchange{{/reactive}}{{^reactive}}request: {{javaxPackage}}.servlet.http.HttpServletRequest{{/reactive}}{{/includeHttpRequestContext}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}}, diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache index 04232e570533..940dc0939fc7 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache @@ -121,7 +121,7 @@ interface {{classname}} { produces = [{{#produces}}"{{{mediaType}}}"{{^-last}}, {{/-last}}{{/produces}}]{{/hasProduces}}{{#hasConsumes}}, consumes = [{{#consumes}}"{{{mediaType}}}"{{^-last}}, {{/-last}}{{/consumes}}]{{/hasConsumes}}{{/singleContentTypes}} ) - {{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}fun {{operationId}}({{#allParams}} + {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}} {{>queryParams}}{{>pathParams}}{{>headerParams}}{{>cookieParams}}{{>bodyParams}}{{>formParams}}{{^-last}},{{/-last}}{{/allParams}}{{#includeHttpRequestContext}}{{#hasParams}}, {{/hasParams}}{{#swagger1AnnotationLibrary}}@ApiParam(hidden = true) {{/swagger1AnnotationLibrary}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true) {{/swagger2AnnotationLibrary}}{{#reactive}}exchange: org.springframework.web.server.ServerWebExchange{{/reactive}}{{^reactive}}request: {{javaxPackage}}.servlet.http.HttpServletRequest{{/reactive}}{{/includeHttpRequestContext}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}}, diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/service.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/service.mustache index 9bb66d0ae97e..c6372d1bf827 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/service.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/service.mustache @@ -33,7 +33,7 @@ interface {{classname}}Service { {{#isDeprecated}} @Deprecated(message="Operation is deprecated") {{/isDeprecated}} - {{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{#isBodyParam}}{{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}Flow<{{{baseType}}}>{{/isArray}}{{/reactive}}{{/isBodyParam}}{{^-last}}, {{/-last}}{{/allParams}}): {{>returnTypes}} + {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{#isBodyParam}}{{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}Flow<{{{baseType}}}>{{/isArray}}{{/reactive}}{{/isBodyParam}}{{^-last}}, {{/-last}}{{/allParams}}): {{>returnTypes}} {{/operation}} } {{/operations}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index 504583c759dd..94b288ad797e 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -5054,4 +5054,111 @@ public void shouldAddParameterWithInHeaderWhenImplicitHeadersIsTrue() throws IOE Assert.assertTrue(content.contains("testHeader"), "Header name 'testHeader' should appear in the annotation"); } + + @Test + public void suspendFunctionsInterfaceOnly() throws Exception { + Path root = generateApiSources(Map.of( + KotlinSpringServerCodegen.SUSPEND_FUNCTIONS, true, + KotlinSpringServerCodegen.DOCUMENTATION_PROVIDER, "none", + KotlinSpringServerCodegen.ANNOTATION_LIBRARY, "none", + KotlinSpringServerCodegen.INTERFACE_ONLY, true, + KotlinSpringServerCodegen.USE_RESPONSE_ENTITY, true + ), Map.of( + CodegenConstants.MODELS, "false", + CodegenConstants.MODEL_TESTS, "false", + CodegenConstants.MODEL_DOCS, "false", + CodegenConstants.APIS, "true", + CodegenConstants.SUPPORTING_FILES, "false" + )); + verifyGeneratedFilesContain( + Map.of( + root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( + "suspend fun deletePet(", + "suspend fun getPetById("), + root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( + "suspend fun logoutUser()"), + root.resolve("src/main/kotlin/org/openapitools/api/StoreApi.kt"), List.of( + "suspend fun getInventory()") + ) + ); + } + + @Test + public void suspendFunctionsWithDelegatePattern() throws Exception { + Path root = generateApiSources(Map.of( + KotlinSpringServerCodegen.SUSPEND_FUNCTIONS, true, + KotlinSpringServerCodegen.DOCUMENTATION_PROVIDER, "none", + KotlinSpringServerCodegen.ANNOTATION_LIBRARY, "none", + KotlinSpringServerCodegen.DELEGATE_PATTERN, true, + KotlinSpringServerCodegen.USE_RESPONSE_ENTITY, true + ), Map.of( + CodegenConstants.MODELS, "false", + CodegenConstants.MODEL_TESTS, "false", + CodegenConstants.MODEL_DOCS, "false", + CodegenConstants.APIS, "true", + CodegenConstants.SUPPORTING_FILES, "false" + )); + verifyGeneratedFilesContain( + Map.of( + root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( + "suspend fun deletePet(", + "suspend fun getPetById("), + root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( + "suspend fun deletePet(", + "suspend fun getPetById(") + ) + ); + } + + @Test + public void suspendFunctionsDefaultsToFalse() throws Exception { + Path root = generateApiSources(Map.of( + KotlinSpringServerCodegen.DOCUMENTATION_PROVIDER, "none", + KotlinSpringServerCodegen.ANNOTATION_LIBRARY, "none", + KotlinSpringServerCodegen.INTERFACE_ONLY, true, + KotlinSpringServerCodegen.USE_RESPONSE_ENTITY, true + ), Map.of( + CodegenConstants.MODELS, "false", + CodegenConstants.MODEL_TESTS, "false", + CodegenConstants.MODEL_DOCS, "false", + CodegenConstants.APIS, "true", + CodegenConstants.SUPPORTING_FILES, "false" + )); + verifyGeneratedFilesContain( + Map.of( + root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( + "fun deletePet(", + "fun getPetById(") + ) + ); + // Verify no suspend keyword appears + Path petApiPath = root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"); + String content = new String(Files.readAllBytes(petApiPath), java.nio.charset.StandardCharsets.UTF_8); + Assert.assertFalse(content.contains("suspend fun"), + "suspend should not be present when suspendFunctions is not enabled"); + } + + @Test + public void suspendFunctionsWithServiceInterface() throws Exception { + Path root = generateApiSources(Map.of( + KotlinSpringServerCodegen.SUSPEND_FUNCTIONS, true, + KotlinSpringServerCodegen.SERVICE_INTERFACE, true, + KotlinSpringServerCodegen.DOCUMENTATION_PROVIDER, "none", + KotlinSpringServerCodegen.ANNOTATION_LIBRARY, "none", + KotlinSpringServerCodegen.USE_RESPONSE_ENTITY, true + ), Map.of( + CodegenConstants.MODELS, "false", + CodegenConstants.MODEL_TESTS, "false", + CodegenConstants.MODEL_DOCS, "false", + CodegenConstants.APIS, "true", + CodegenConstants.SUPPORTING_FILES, "false" + )); + verifyGeneratedFilesContain( + Map.of( + root.resolve("src/main/kotlin/org/openapitools/api/PetApiService.kt"), List.of( + "suspend fun deletePet(", + "suspend fun getPetById(") + ) + ); + } }