Skip to content

Commit 23082aa

Browse files
joseegman-idoneeajoseegarciaclaude
authored
Generate @RequestPart parameters for reactive multipart/form-data controllers (#392)
* Generate @RequestPart parameters for reactive multipart/form-data controllers templateReactive.ftlh had no multipart branch: it always wrapped the request body in Mono/Flux with @Valid @RequestBody, so reactive multipart uploads lacked @RequestPart binding and a consumes declaration. Now each form-data part is exposed as its own @RequestPart parameter using the WebFlux multipart API (single file -> FilePart, file array -> Flux<FilePart>, non-file parts keep their declared type), and the @RequestMapping declares consumes = MediaType.MULTIPART_FORM_DATA_VALUE. FilePart is imported only when a file part is present, and the (no longer generated) wrapper model is not imported. Adds a testReactiveFormDataMultipart fixture and golden interface. Fixes #391 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Bump version to 6.6.3 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: joseegarcia <jose.garcia@disashop.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2e4e2cc commit 23082aa

8 files changed

Lines changed: 144 additions & 12 deletions

File tree

multiapi-engine/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
<groupId>com.sngular</groupId>
66
<artifactId>multiapi-engine</artifactId>
7-
<version>6.6.2</version>
7+
<version>6.6.3</version>
88
<packaging>jar</packaging>
99

1010
<properties>

multiapi-engine/src/main/resources/templates/openapi/templateReactive.ftlh

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<#ftl output_format="plainText">
2+
<#macro formDataParts request><#list request.contentObjects[0].schemaObject.fieldObjectList as part> @Parameter(name = "${part.baseName}", required = ${part.required?c}, schema = @Schema(description = "")) @RequestPart(value = "${part.baseName}", required = ${part.required?c}) <#if part.dataType.baseType == "multipartfile">FilePart<#elseif part.dataType.baseType == "array" && part.dataType.innerType.baseType == "multipartfile">Flux<FilePart><#else>${part.dataType}</#if> ${part.baseName}<#if part?has_next>, </#if></#list></#macro>
23
package <#if packageApi??>${packageApi}<#elseif package??>${package}</#if>;
34

45
import java.util.List;
@@ -21,18 +22,41 @@ import org.springframework.web.server.ServerWebExchange;
2122
import reactor.core.publisher.Mono;
2223
import reactor.core.publisher.Flux;
2324
import springfox.documentation.annotations.ApiIgnore;
25+
<#assign needsFilePart=false>
26+
<#list pathObjects as path>
27+
<#list path.operationObjects as operation>
28+
<#list operation.requestObjects as request>
29+
<#if request.isFormData>
30+
<#list request.contentObjects as content>
31+
<#if content.schemaObject??>
32+
<#list content.schemaObject.fieldObjectList as field>
33+
<#if "${field.dataType}"?contains("MultipartFile")>
34+
<#assign needsFilePart=true>
35+
</#if>
36+
</#list>
37+
</#if>
38+
</#list>
39+
</#if>
40+
</#list>
41+
</#list>
42+
</#list>
43+
<#if needsFilePart>
44+
import org.springframework.http.codec.multipart.FilePart;
45+
</#if>
2446

2547
<#assign imports=[]>
2648
<#list pathObjects as path>
2749
<#list path.operationObjects as operation>
2850
<#list operation.requestObjects as request>
51+
<#if !request.isFormData>
2952
<#list request.contentObjects as content>
3053
<#if content.importName?? && (!imports?seq_contains(content.importName))>
3154
<#if (!checkBasicTypes?seq_contains(content.importName))>
3255
<#assign imports = imports+[content.importName]>
3356
</#if>
3457
</#if>
3558
</#list>
59+
</#if>
3660
</#list>
3761
<#list operation.responseObjects as response>
3862
<#list response.contentObjects as content>
@@ -70,7 +94,7 @@ public interface ${className?cap_first}Api {
7094
* @param <#list path.parameterObjects as parameter> ${parameter.name} ${parameter.description} ${parameter.required?c}</#list>
7195
</#if>
7296
<#if operation.requestObjects?has_content>
73-
*<#list operation.requestObjects as request> @param <#list request.contentObjects as content>${content.dataType?api.getVariableNameString()}<#if content?has_next>, </#if></#list>${request.description! ""}<#if request.required == true> (required)</#if></#list>
97+
*<#list operation.requestObjects as request> @param <#if request.isFormData><#list request.contentObjects[0].schemaObject.fieldObjectList as part>${part.baseName}<#if part?has_next>, </#if></#list><#else><#list request.contentObjects as content>${content.dataType?api.getVariableNameString()}<#if content?has_next>, </#if></#list></#if>${request.description! ""}<#if request.required == true> (required)</#if></#list>
7498
</#if>
7599
* @return<#list operation.responseObjects as response><#if response.responseName != "default"> ${response.description}; (status code ${response.responseName})</#if></#list>
76100
</#if>
@@ -88,22 +112,23 @@ public interface ${className?cap_first}Api {
88112
@RequestMapping(
89113
method = RequestMethod.${operation.operationType},
90114
value = "${path.pathName}",
91-
produces = {"application/json"}
115+
produces = {"application/json"}<#if operation.requestObjects?has_content && operation.requestObjects[0].isFormData>,
116+
consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}</#if>
92117
)
93118
<#if operation.securities?has_content>
94119
default Mono<ResponseEntity<@compress single_line=true><<#if operation.responseObjects[0].contentObjects[0]??><#if operation.responseObjects[0].contentObjects[0].dataType.baseType == "array" || operation.responseObjects[0].contentObjects[0].dataType.baseType == "map">Flux<${operation.responseObjects[0].contentObjects[0].dataType.innerType}><#else>${operation.responseObjects[0].contentObjects[0].dataType}></#if><#else>Void</#if>></@compress> ${operation.operationId}(<@compress single_line=true>
95120
<#if operation.parameterObjects?has_content><#list operation.parameterObjects as parameter> @Parameter(name = "${parameter.name}", description = "${parameter.description}", required = ${parameter.required?c}, schema = @Schema(description = "")) <#if parameter.in == "path"> @PathVariable("${parameter.name}") <#elseif parameter.in == "query"> @RequestParam(required = ${parameter.required?c}) </#if> ${parameter.dataType} ${parameter.name} <#if parameter?has_next || operation.requestObjects?has_content>, </#if></#list></#if><#if path.parameterObjects?has_content><#list path.parameterObjects as parameter> @Parameter(name = "${parameter.name}", description = "${parameter.description}", required = ${parameter.required?c}, schema = @Schema(description = "")) <#if parameter.in == "path"> @PathVariable("${parameter.name}") <#elseif parameter.in == "query"> @RequestParam(required = ${parameter.required?c}) </#if> ${parameter.dataType} ${parameter.name}<#if parameter?has_next || operation.requestObjects?has_content>, </#if></#list></#if>
96-
<#if operation.requestObjects?has_content><#list operation.requestObjects as request> @Parameter(name = "${request.contentObjects[0].dataType?api.getVariableNameString()}", description = "${request.description! ""}", required = ${request.required?c}, schema = @Schema(description = "${request.contentObjects[0].description! ""}")) @Valid @RequestBody
121+
<#if operation.requestObjects?has_content><#list operation.requestObjects as request><#if request.isFormData><@formDataParts request/><#else> @Parameter(name = "${request.contentObjects[0].dataType?api.getVariableNameString()}", description = "${request.description! ""}", required = ${request.required?c}, schema = @Schema(description = "${request.contentObjects[0].description! ""}")) @Valid @RequestBody
97122
<#if request.contentObjects[0].dataType.baseType == "array" || request.contentObjects[0].dataType.baseType == "map"> Flux<${request.contentObjects[0].dataType.innerType}>
98-
<#else> Mono<${request.contentObjects[0].dataType}> </#if> ${request.contentObjects[0].dataType?api.getVariableNameString()}<#if request?has_next>, </#if></#list></#if></@compress><#if operation.parameterObjects?has_content || operation.requestObjects?has_content>, </#if><#if path.parameterObjects?has_content || path.requestObjects?has_content>, </#if>@ApiIgnore final ServerWebExchange exchange) {
123+
<#else> Mono<${request.contentObjects[0].dataType}> </#if> ${request.contentObjects[0].dataType?api.getVariableNameString()}<#if request?has_next>, </#if></#if></#list></#if></@compress><#if operation.parameterObjects?has_content || operation.requestObjects?has_content>, </#if><#if path.parameterObjects?has_content || path.requestObjects?has_content>, </#if>@ApiIgnore final ServerWebExchange exchange) {
99124
return Mono.just(new ResponseEntity(HttpStatus.NOT_IMPLEMENTED));
100125
}
101126
<#else>
102127
default ResponseEntity<@compress single_line=true><<#if operation.responseObjects[0].contentObjects[0]??><#if operation.responseObjects[0].contentObjects[0].dataType.baseType == "array" || operation.responseObjects[0].contentObjects[0].dataType.baseType == "map">Flux<${operation.responseObjects[0].contentObjects[0].dataType.innerType}><#else>Mono<${operation.responseObjects[0].contentObjects[0].dataType}></#if><#else>Void</#if>></@compress> ${operation.operationId}(<@compress single_line=true>
103128
<#if operation.parameterObjects?has_content><#list operation.parameterObjects as parameter> @Parameter(name = "${parameter.name}", description = "${parameter.description}", required = ${parameter.required?c}, schema = @Schema(description = "")) <#if parameter.in == "path"> @PathVariable("${parameter.name}") <#elseif parameter.in == "query"> @RequestParam(required = ${parameter.required?c}) </#if> ${parameter.dataType} ${parameter.name} <#if parameter?has_next || operation.requestObjects?has_content>, </#if></#list></#if><#if path.parameterObjects?has_content><#list path.parameterObjects as parameter> @Parameter(name = "${parameter.name}", description = "${parameter.description}", required = ${parameter.required?c}, schema = @Schema(description = "")) <#if parameter.in == "path"> @PathVariable("${parameter.name}") <#elseif parameter.in == "query"> @RequestParam(required = ${parameter.required?c}) </#if> ${parameter.dataType} ${parameter.name}<#if parameter?has_next || operation.requestObjects?has_content>, </#if></#list></#if>
104-
<#if operation.requestObjects?has_content><#list operation.requestObjects as request> @Parameter(name = "${request.contentObjects[0].dataType?api.getVariableNameString()}", description = "${request.description! ""}", required = ${request.required?c}, schema = @Schema(description = "${request.contentObjects[0].description! ""}")) @Valid @RequestBody
129+
<#if operation.requestObjects?has_content><#list operation.requestObjects as request><#if request.isFormData><@formDataParts request/><#else> @Parameter(name = "${request.contentObjects[0].dataType?api.getVariableNameString()}", description = "${request.description! ""}", required = ${request.required?c}, schema = @Schema(description = "${request.contentObjects[0].description! ""}")) @Valid @RequestBody
105130
<#if request.contentObjects[0].dataType.baseType == "array" || request.contentObjects[0].dataType.baseType == "map"> Flux<${request.contentObjects[0].dataType.innerType}>
106-
<#else> Mono<${request.contentObjects[0].dataType}> </#if> ${request.contentObjects[0].dataType?api.getVariableNameString()}<#if request?has_next>, </#if></#list></#if></@compress><#if operation.parameterObjects?has_content || operation.requestObjects?has_content>, </#if><#if path.parameterObjects?has_content || path.requestObjects?has_content>, </#if>@ApiIgnore final ServerWebExchange exchange) {
131+
<#else> Mono<${request.contentObjects[0].dataType}> </#if> ${request.contentObjects[0].dataType?api.getVariableNameString()}<#if request?has_next>, </#if></#if></#list></#if></@compress><#if operation.parameterObjects?has_content || operation.requestObjects?has_content>, </#if><#if path.parameterObjects?has_content || path.requestObjects?has_content>, </#if>@ApiIgnore final ServerWebExchange exchange) {
107132
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
108133
}
109134
</#if>

multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorFixtures.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ public final class OpenApiGeneratorFixtures {
8181
.modelPackage("com.sngular.multifileplugin.reactivegeneration.model").modelNamePrefix("Api")
8282
.modelNameSuffix("DTO").useLombokModelAnnotation(false).isReactive(true).build());
8383

84+
static final List<SpecFile> TEST_REACTIVE_FORM_DATA_MULTIPART = List
85+
.of(SpecFile.builder().filePath("openapigenerator/testReactiveFormDataMultipart/api-test.yml")
86+
.apiPackage("com.sngular.multifileplugin.testreactiveformdatamultipart")
87+
.useLombokModelAnnotation(false).isReactive(true).build());
88+
8489
static final List<SpecFile> TEST_API_TAGS_GENERATION = List
8590
.of(SpecFile.builder().filePath("openapigenerator/testApiTagsGeneration/api-tags-test.yml")
8691
.apiPackage("com.sngular.multifileplugin.tagsgeneration")
@@ -602,6 +607,22 @@ static Function<Path, Boolean> validateApiReactiveGeneration(int springBootVersi
602607

603608
}
604609

610+
static Function<Path, Boolean> validateReactiveFormDataMultipart() {
611+
612+
final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/testreactiveformdatamultipart";
613+
614+
final String DEFAULT_MODEL_API = "generated/com/sngular/multifileplugin/testreactiveformdatamultipart/model";
615+
616+
final String COMMON_PATH = "openapigenerator/testReactiveFormDataMultipart/";
617+
618+
final String ASSETS_PATH = COMMON_PATH + "assets/";
619+
620+
final List<String> expectedTestApiFile = List.of(ASSETS_PATH + "UploadApi.java");
621+
622+
return path -> commonTest(path, expectedTestApiFile, Collections.emptyList(), DEFAULT_TARGET_API,
623+
DEFAULT_MODEL_API, Collections.emptyList(), null);
624+
}
625+
605626
static Function<Path, Boolean> validateTagsGeneration() {
606627

607628
final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/tagsgeneration";

multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ static Stream<Arguments> fileSpecToProcess() {
6363
OpenApiGeneratorFixtures.validatePathWithSpecialCharGeneration()),
6464
Arguments.of("testApiReactiveGeneration", OpenApiGeneratorFixtures.TEST_API_REACTIVE_GENERATION,
6565
OpenApiGeneratorFixtures.validateApiReactiveGeneration(SPRING_BOOT_VERSION)),
66+
Arguments.of("testReactiveFormDataMultipart", OpenApiGeneratorFixtures.TEST_REACTIVE_FORM_DATA_MULTIPART,
67+
OpenApiGeneratorFixtures.validateReactiveFormDataMultipart()),
6668
Arguments.of("testApiTagsGeneration", OpenApiGeneratorFixtures.TEST_API_TAGS_GENERATION,
6769
OpenApiGeneratorFixtures.validateTagsGeneration()),
6870
Arguments.of("testMultipleRefGeneration", OpenApiGeneratorFixtures.TEST_MULTIPLE_REF_GENERATION,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
openapi: 3.0.2
2+
info:
3+
title: Testing reactive multipart file upload
4+
version: 1.0.0
5+
servers:
6+
- url: http://localhost/v1
7+
paths:
8+
/upload:
9+
post:
10+
tags:
11+
- test
12+
operationId: uploadMultipart
13+
requestBody:
14+
required: true
15+
content:
16+
multipart/form-data:
17+
schema:
18+
type: object
19+
properties:
20+
someString:
21+
type: string
22+
someFile:
23+
type: string
24+
format: binary
25+
someFiles:
26+
type: array
27+
items:
28+
type: string
29+
format: binary
30+
responses:
31+
'200':
32+
description:
33+
OK
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.sngular.multifileplugin.testreactiveformdatamultipart;
2+
3+
import java.util.List;
4+
import java.util.Map;
5+
import java.nio.charset.StandardCharsets;
6+
import javax.validation.Valid;
7+
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.Parameter;
10+
import io.swagger.v3.oas.annotations.media.Content;
11+
import io.swagger.v3.oas.annotations.media.Schema;
12+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
13+
import org.springframework.http.MediaType;
14+
import org.springframework.http.HttpStatus;
15+
import org.springframework.http.ResponseEntity;
16+
import org.springframework.web.bind.annotation.*;
17+
import org.springframework.web.context.request.NativeWebRequest;
18+
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
19+
import org.springframework.web.server.ServerWebExchange;
20+
import reactor.core.publisher.Mono;
21+
import reactor.core.publisher.Flux;
22+
import springfox.documentation.annotations.ApiIgnore;
23+
import org.springframework.http.codec.multipart.FilePart;
24+
25+
26+
public interface UploadApi {
27+
28+
/**
29+
* POST /upload
30+
* @param someFile, someFiles, someString (required)
31+
* @return OK; (status code 200)
32+
* @throws WebClientResponseException if an error occurs while attempting to invoke the API
33+
*/
34+
@Operation(
35+
operationId = "uploadMultipart",
36+
tags = {"test"},
37+
responses = {
38+
@ApiResponse(responseCode = "200", description = "OK")
39+
}
40+
)
41+
@RequestMapping(
42+
method = RequestMethod.POST,
43+
value = "/upload",
44+
produces = {"application/json"},
45+
consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}
46+
)
47+
default ResponseEntity<Void> uploadMultipart(@Parameter(name = "someFile", required = false, schema = @Schema(description = "")) @RequestPart(value = "someFile", required = false) FilePart someFile, @Parameter(name = "someFiles", required = false, schema = @Schema(description = "")) @RequestPart(value = "someFiles", required = false) Flux<FilePart> someFiles, @Parameter(name = "someString", required = false, schema = @Schema(description = "")) @RequestPart(value = "someString", required = false) String someString, @ApiIgnore final ServerWebExchange exchange) {
48+
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
49+
}
50+
51+
}

scs-multiapi-gradle-plugin/build.gradle

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ repositories {
2121
}
2222

2323
group = 'com.sngular'
24-
version = '6.6.2'
24+
version = '6.6.3'
2525

2626
def SCSMultiApiPluginGroupId = group
2727
def SCSMultiApiPluginVersion = version
@@ -31,7 +31,7 @@ dependencies {
3131
shadow localGroovy()
3232
shadow gradleApi()
3333

34-
implementation 'com.sngular:multiapi-engine:6.6.2'
34+
implementation 'com.sngular:multiapi-engine:6.6.3'
3535
testImplementation 'org.assertj:assertj-core:3.24.2'
3636
testImplementation 'com.puppycrawl.tools:checkstyle:10.12.3'
3737
testImplementation 'org.junit.platform:junit-platform-launcher:1.9.2'
@@ -100,7 +100,7 @@ testing {
100100

101101
integrationTest(JvmTestSuite) {
102102
dependencies {
103-
implementation 'com.sngular:scs-multiapi-gradle-plugin:6.6.2'
103+
implementation 'com.sngular:scs-multiapi-gradle-plugin:6.6.3'
104104
implementation 'org.assertj:assertj-core:3.24.2'
105105
}
106106

scs-multiapi-maven-plugin/pom.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
<groupId>com.sngular</groupId>
66
<artifactId>scs-multiapi-maven-plugin</artifactId>
7-
<version>6.6.2</version>
7+
<version>6.6.3</version>
88
<packaging>maven-plugin</packaging>
99

1010
<name>AsyncApi - OpenApi Code Generator Maven Plugin</name>
@@ -271,7 +271,7 @@
271271
<dependency>
272272
<groupId>com.sngular</groupId>
273273
<artifactId>multiapi-engine</artifactId>
274-
<version>6.6.2</version>
274+
<version>6.6.3</version>
275275
</dependency>
276276
<dependency>
277277
<groupId>org.apache.maven</groupId>

0 commit comments

Comments
 (0)