From 805becf4dafe5c635a63ee44dc60140ac4bbb98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9E=AC=ED=98=84?= Date: Tue, 14 Jul 2026 10:19:45 +0900 Subject: [PATCH] Keep reactive multipart handling on the WebFlux type system Kotlin Spring reactive server endpoints must accept WebFlux Part values, including Flux for file arrays, so generated controllers and service surfaces remain compatible with the reactive request pipeline. Derive the template condition inside the server generator and preserve the existing MVC and declarative-interface output. Constraint: WebFlux server generation requires Part and Flux; blocking and declarative HTTP interface output must retain MultipartFile. Rejected: Rewrite broader multipart templates | would churn unrelated model, form-field, and array rendering. Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep reactiveMultipart derived from reactive Spring Boot server mode; do not key this shared partial on reactive alone. Tested: Focused multipart tests; KotlinSpringServerCodegenTest 236/0/0/0; checkstyle; both sample main source sets compile; base/current generated-output differential. Not-tested: Official JDK 11 and Gradle 7.6.4 sample CI matrix; baseline-stale unrelated sample test-source errors remain. --- .../languages/KotlinSpringServerCodegen.java | 2 + .../kotlin-spring/optionalDataType.mustache | 2 +- .../spring/KotlinSpringServerCodegenTest.java | 129 +++++++++++++++--- .../org/openapitools/api/PetApiController.kt | 2 +- .../org/openapitools/api/PetApiService.kt | 2 +- .../org/openapitools/api/PetApiServiceImpl.kt | 2 +- .../kotlin/org/openapitools/api/PetApiTest.kt | 2 +- .../org/openapitools/api/PetApiController.kt | 2 +- .../org/openapitools/api/PetApiService.kt | 2 +- .../org/openapitools/api/PetApiServiceImpl.kt | 2 +- .../kotlin/org/openapitools/api/PetApiTest.kt | 2 +- 11 files changed, 118 insertions(+), 31 deletions(-) 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 619d44868dc4..86cf4300e508 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 @@ -87,6 +87,7 @@ public class KotlinSpringServerCodegen extends AbstractKotlinCodegen public static final String SKIP_DEFAULT_API_INTERFACE = "skipDefaultApiInterface"; public static final String SKIP_DEFAULT_DELEGATE_INTERFACE = "skipDefaultDelegateInterface"; public static final String REACTIVE = "reactive"; + private static final String REACTIVE_MULTIPART = "reactiveMultipart"; public static final String INTERFACE_ONLY = "interfaceOnly"; public static final String USE_FEIGN_CLIENT_URL = "useFeignClientUrl"; public static final String USE_FEIGN_CLIENT = "useFeignClient"; @@ -725,6 +726,7 @@ public void processOpts() { writePropertyBack(SKIP_DEFAULT_INTERFACE, skipDefaultInterface); } writePropertyBack(REACTIVE, reactive); + writePropertyBack(REACTIVE_MULTIPART, reactive && SPRING_BOOT.equals(library)); writePropertyBack(EXCEPTION_HANDLER, exceptionHandler); writePropertyBack(USE_FLOW_FOR_ARRAY_RETURN_TYPE, useFlowForArrayReturnType); diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/optionalDataType.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/optionalDataType.mustache index 1a4578db7733..2ea8b749deb4 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/optionalDataType.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/optionalDataType.mustache @@ -1 +1 @@ -{{^isFile}}{{{dataType}}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isFile}}{{#isFile}}{{#isArray}}Array<{{/isArray}}org.springframework.web.multipart.MultipartFile{{#isArray}}>{{/isArray}}{{#isNullable}}?{{/isNullable}}{{/isFile}} \ No newline at end of file +{{^isFile}}{{{dataType}}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isFile}}{{#isFile}}{{#reactiveMultipart}}{{#isArray}}reactor.core.publisher.Flux<{{/isArray}}org.springframework.http.codec.multipart.Part{{#isArray}}>{{/isArray}}{{/reactiveMultipart}}{{^reactiveMultipart}}{{#isArray}}Array<{{/isArray}}org.springframework.web.multipart.MultipartFile{{#isArray}}>{{/isArray}}{{/reactiveMultipart}}{{#isNullable}}?{{/isNullable}}{{/isFile}} \ No newline at end of file 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 c99cf7a0aae1..e0e398aed1a4 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 @@ -56,6 +56,8 @@ public class KotlinSpringServerCodegenTest { + private static final String MULTIPART_SPEC = "src/test/resources/3_0/form-multipart-binary-array.yaml"; + @Test(description = "test embedded enum array") public void embeddedEnumArrayTest() throws Exception { String baseModelPackage = "zz"; @@ -432,6 +434,26 @@ public void testNullableMultipartFile() throws IOException { + " )"); } + @Test + public void testNullableMultipartFileReactive() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/kotlin/feat-multipartfile_nullable.yaml", + Map.of(KotlinSpringServerCodegen.REACTIVE, true)); + + assertFileContains(files.get("NullableMultipartfileApiController.kt").toPath(), + "file: org.springframework.http.codec.multipart.Part?" + + " )"); + assertFileContains(files.get("NullableMultipartfileArrayApiController.kt").toPath(), + "files: reactor.core.publisher.Flux?" + + " )"); + assertFileContains(files.get("NonNullableMultipartfileApiController.kt").toPath(), + "file: org.springframework.http.codec.multipart.Part" + + " )"); + assertFileContains(files.get("NonNullableMultipartfileArrayApiController.kt").toPath(), + "files: reactor.core.publisher.Flux" + + " )"); + } + @Test public void arrayItemsCanBeNullable() throws IOException { File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); @@ -984,35 +1006,98 @@ public void givenOctetStreamResponseType_whenGenerateServer_thenReturnTypeIsReso @Test public void givenMultipartForm_whenGenerateReactiveServer_thenParameterAreCreatedAsRequestPart() throws IOException { - File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); - output.deleteOnExit(); - String outputPath = output.getAbsolutePath().replace('\\', '/'); + Map files = generateFromContract( + MULTIPART_SPEC, + Map.of( + KotlinSpringServerCodegen.REACTIVE, true, + KotlinSpringServerCodegen.SERVICE_IMPLEMENTATION, true)); - final OpenAPI openAPI = TestUtils.parseFlattenSpec("src/test/resources/3_0/kotlin/petstore-with-tags.yaml"); - final KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen(); - codegen.setOpenAPI(openAPI); - codegen.setOutputDir(output.getAbsolutePath()); + assertReactiveMultipartParameters(files, "Controller.kt"); + assertReactiveMultipartParameters(files, "Service.kt"); + assertReactiveMultipartParameters(files, "ServiceImpl.kt"); + assertReactiveMultipartParameters(files, "Test.kt"); + } - ClientOptInput input = new ClientOptInput(); - input.openAPI(openAPI); - input.config(codegen); + @Test + public void givenMultipartForm_whenGenerateReactiveDelegate_thenParametersUseRequestPart() throws IOException { + Map files = generateFromContract( + MULTIPART_SPEC, + Map.of( + KotlinSpringServerCodegen.REACTIVE, true, + KotlinSpringServerCodegen.DELEGATE_PATTERN, true)); - DefaultGenerator generator = new DefaultGenerator(); + assertReactiveMultipartParameters(files, ".kt"); + assertReactiveMultipartParameters(files, "Delegate.kt"); + } - generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "false"); - generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_TESTS, "false"); - generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_DOCS, "false"); - generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "true"); - generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false"); + @Test + public void givenMultipartForm_whenGenerateBlockingServer_thenMultipartFileTypesRemain() throws IOException { + Map files = generateFromContract( + MULTIPART_SPEC, + Map.of(KotlinSpringServerCodegen.SERVICE_IMPLEMENTATION, true)); - generator.opts(input).generate(); + assertBlockingMultipartParameters(files, "Controller.kt"); + assertBlockingMultipartParameters(files, "Service.kt"); + assertBlockingMultipartParameters(files, "ServiceImpl.kt"); + assertBlockingMultipartParameters(files, "Test.kt"); + } - Path outputFilepath = Paths.get(outputPath + "/src/main/kotlin/org/openapitools/api/PetApiController.kt"); + @Test + public void givenMultipartForm_whenGenerateReactiveDeclarativeHttpInterface_thenMultipartFileTypesRemain() throws IOException { + Map files = generateFromContract( + MULTIPART_SPEC, + Map.of( + KotlinSpringServerCodegen.REACTIVE, true, + KotlinSpringServerCodegen.USE_FLOW_FOR_ARRAY_RETURN_TYPE, false), + new HashMap<>(), + configurator -> configurator.setLibrary(SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY)); - assertFileContains(outputFilepath, - "@Parameter(description = \"Additional data to pass to server\") @Valid @RequestParam(value = \"additionalMetadata\", required = false) additionalMetadata: kotlin.String?"); - assertFileContains(outputFilepath, - "@Parameter(description = \"image to upload\") @Valid @RequestPart(\"image\", required = false) image: org.springframework.web.multipart.MultipartFile"); + Path apiFile = files.get("MultipartApi.kt").toPath(); + assertFileContains(apiFile, + "files: Array", + "file: org.springframework.web.multipart.MultipartFile", + "status: MultipartMixedStatus", + "marker: MultipartMixedRequestMarker?", + "statusArray: kotlin.collections.List?"); + assertFileNotContains(apiFile, "org.springframework.http.codec.multipart.Part"); + } + + private void assertReactiveMultipartParameters(Map files, String fileSuffix) { + Path arrayFile = files.get("MultipartArrayApi" + fileSuffix).toPath(); + Path singleFile = files.get("MultipartSingleApi" + fileSuffix).toPath(); + Path mixedFile = files.get("MultipartMixedApi" + fileSuffix).toPath(); + + assertFileContains(arrayFile, + "files: reactor.core.publisher.Flux"); + assertFileContains(singleFile, + "file: org.springframework.http.codec.multipart.Part"); + assertFileContains(mixedFile, + "status: MultipartMixedStatus", + "file: org.springframework.http.codec.multipart.Part", + "marker: MultipartMixedRequestMarker?", + "statusArray: kotlin.collections.List?"); + assertFileNotContains(arrayFile, "org.springframework.web.multipart.MultipartFile"); + assertFileNotContains(singleFile, "org.springframework.web.multipart.MultipartFile"); + assertFileNotContains(mixedFile, "org.springframework.web.multipart.MultipartFile"); + } + + private void assertBlockingMultipartParameters(Map files, String fileSuffix) { + Path arrayFile = files.get("MultipartArrayApi" + fileSuffix).toPath(); + Path singleFile = files.get("MultipartSingleApi" + fileSuffix).toPath(); + Path mixedFile = files.get("MultipartMixedApi" + fileSuffix).toPath(); + + assertFileContains(arrayFile, + "files: Array"); + assertFileContains(singleFile, + "file: org.springframework.web.multipart.MultipartFile"); + assertFileContains(mixedFile, + "status: MultipartMixedStatus", + "file: org.springframework.web.multipart.MultipartFile", + "marker: MultipartMixedRequestMarker?", + "statusArray: kotlin.collections.List?"); + assertFileNotContains(arrayFile, "org.springframework.http.codec.multipart.Part"); + assertFileNotContains(singleFile, "org.springframework.http.codec.multipart.Part"); + assertFileNotContains(mixedFile, "org.springframework.http.codec.multipart.Part"); } diff --git a/samples/server/petstore/kotlin-springboot-reactive-without-flow/src/main/kotlin/org/openapitools/api/PetApiController.kt b/samples/server/petstore/kotlin-springboot-reactive-without-flow/src/main/kotlin/org/openapitools/api/PetApiController.kt index 165920c56390..3bb47cd5712f 100644 --- a/samples/server/petstore/kotlin-springboot-reactive-without-flow/src/main/kotlin/org/openapitools/api/PetApiController.kt +++ b/samples/server/petstore/kotlin-springboot-reactive-without-flow/src/main/kotlin/org/openapitools/api/PetApiController.kt @@ -206,7 +206,7 @@ class PetApiController(@Autowired(required = true) val service: PetApiService) { suspend fun uploadFile( @Parameter(description = "ID of pet to update", required = true) @PathVariable("petId") petId: kotlin.Long, @Parameter(description = "Additional data to pass to server") @Valid @RequestParam(value = "additionalMetadata", required = false) additionalMetadata: kotlin.String?, - @Parameter(description = "file to upload") @Valid @RequestPart("file", required = false) file: org.springframework.web.multipart.MultipartFile + @Parameter(description = "file to upload") @Valid @RequestPart("file", required = false) file: org.springframework.http.codec.multipart.Part ): ResponseEntity { return ResponseEntity(service.uploadFile(petId, additionalMetadata, file), HttpStatus.valueOf(200)) } diff --git a/samples/server/petstore/kotlin-springboot-reactive-without-flow/src/main/kotlin/org/openapitools/api/PetApiService.kt b/samples/server/petstore/kotlin-springboot-reactive-without-flow/src/main/kotlin/org/openapitools/api/PetApiService.kt index ed714a425820..77b25feace88 100644 --- a/samples/server/petstore/kotlin-springboot-reactive-without-flow/src/main/kotlin/org/openapitools/api/PetApiService.kt +++ b/samples/server/petstore/kotlin-springboot-reactive-without-flow/src/main/kotlin/org/openapitools/api/PetApiService.kt @@ -101,5 +101,5 @@ interface PetApiService { * @return successful operation (status code 200) * @see PetApi#uploadFile */ - suspend fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String?, file: org.springframework.web.multipart.MultipartFile): ModelApiResponse + suspend fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String?, file: org.springframework.http.codec.multipart.Part): ModelApiResponse } diff --git a/samples/server/petstore/kotlin-springboot-reactive-without-flow/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt b/samples/server/petstore/kotlin-springboot-reactive-without-flow/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt index 751d8c0b477e..58f2d3fcfb5a 100644 --- a/samples/server/petstore/kotlin-springboot-reactive-without-flow/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt +++ b/samples/server/petstore/kotlin-springboot-reactive-without-flow/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt @@ -35,7 +35,7 @@ class PetApiServiceImpl : PetApiService { TODO("Implement me") } - override suspend fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String?, file: org.springframework.web.multipart.MultipartFile): ModelApiResponse { + override suspend fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String?, file: org.springframework.http.codec.multipart.Part): ModelApiResponse { TODO("Implement me") } } diff --git a/samples/server/petstore/kotlin-springboot-reactive-without-flow/src/test/kotlin/org/openapitools/api/PetApiTest.kt b/samples/server/petstore/kotlin-springboot-reactive-without-flow/src/test/kotlin/org/openapitools/api/PetApiTest.kt index 401b2fc8829f..2bf0ad9fa15c 100644 --- a/samples/server/petstore/kotlin-springboot-reactive-without-flow/src/test/kotlin/org/openapitools/api/PetApiTest.kt +++ b/samples/server/petstore/kotlin-springboot-reactive-without-flow/src/test/kotlin/org/openapitools/api/PetApiTest.kt @@ -123,7 +123,7 @@ class PetApiTest { fun uploadFileTest() = runBlockingTest { val petId: kotlin.Long = TODO() val additionalMetadata: kotlin.String? = TODO() - val file: org.springframework.web.multipart.MultipartFile? = TODO() + val file: org.springframework.http.codec.multipart.Part = TODO() val response: ResponseEntity = api.uploadFile(petId, additionalMetadata, file) // TODO: test validations diff --git a/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/PetApiController.kt b/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/PetApiController.kt index 682a15ef48e1..b3c53ae479c6 100644 --- a/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/PetApiController.kt +++ b/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/PetApiController.kt @@ -206,7 +206,7 @@ class PetApiController(@Autowired(required = true) val service: PetApiService) { suspend fun uploadFile( @Parameter(description = "ID of pet to update", required = true) @PathVariable("petId") petId: kotlin.Long, @Parameter(description = "Additional data to pass to server") @Valid @RequestParam(value = "additionalMetadata", required = false) additionalMetadata: kotlin.String?, - @Parameter(description = "file to upload") @Valid @RequestPart("file", required = false) file: org.springframework.web.multipart.MultipartFile + @Parameter(description = "file to upload") @Valid @RequestPart("file", required = false) file: org.springframework.http.codec.multipart.Part ): ResponseEntity { return ResponseEntity(service.uploadFile(petId, additionalMetadata, file), HttpStatus.valueOf(200)) } diff --git a/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/PetApiService.kt b/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/PetApiService.kt index 79151790a6ca..b2ac669dfb7f 100644 --- a/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/PetApiService.kt +++ b/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/PetApiService.kt @@ -101,5 +101,5 @@ interface PetApiService { * @return successful operation (status code 200) * @see PetApi#uploadFile */ - suspend fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String?, file: org.springframework.web.multipart.MultipartFile): ModelApiResponse + suspend fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String?, file: org.springframework.http.codec.multipart.Part): ModelApiResponse } diff --git a/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt b/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt index d1fce342257d..6efce8cb9beb 100644 --- a/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt +++ b/samples/server/petstore/kotlin-springboot-reactive/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt @@ -35,7 +35,7 @@ class PetApiServiceImpl : PetApiService { TODO("Implement me") } - override suspend fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String?, file: org.springframework.web.multipart.MultipartFile): ModelApiResponse { + override suspend fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String?, file: org.springframework.http.codec.multipart.Part): ModelApiResponse { TODO("Implement me") } } diff --git a/samples/server/petstore/kotlin-springboot-reactive/src/test/kotlin/org/openapitools/api/PetApiTest.kt b/samples/server/petstore/kotlin-springboot-reactive/src/test/kotlin/org/openapitools/api/PetApiTest.kt index 26f1a1f9e0b6..50b9ced32062 100644 --- a/samples/server/petstore/kotlin-springboot-reactive/src/test/kotlin/org/openapitools/api/PetApiTest.kt +++ b/samples/server/petstore/kotlin-springboot-reactive/src/test/kotlin/org/openapitools/api/PetApiTest.kt @@ -123,7 +123,7 @@ class PetApiTest { fun uploadFileTest() = runBlockingTest { val petId: kotlin.Long = TODO() val additionalMetadata: kotlin.String? = TODO() - val file: org.springframework.web.multipart.MultipartFile? = TODO() + val file: org.springframework.http.codec.multipart.Part = TODO() val response: ResponseEntity = api.uploadFile(petId, additionalMetadata, file) // TODO: test validations