Skip to content

Commit f6a830d

Browse files
joseegman-idoneeajoseegarciaclaude
authored
Generate @RequestPart parameters for multipart/form-data controllers (#389)
* Generate @RequestPart parameters for multipart/form-data controllers The server interface template collapsed a multipart/form-data body into a single immutable wrapper DTO passed as a bare @Valid parameter, with no @RequestPart/@RequestParam binding and no consumes declaration, so the generated controller could not bind multipart requests. Now each part of the form-data schema is exposed as its own @RequestPart parameter (files as MultipartFile / List<MultipartFile>), the @RequestMapping declares consumes = MediaType.MULTIPART_FORM_DATA_VALUE, and the now-unused wrapper model is no longer generated (OpenApiUtil.processRequestBody skips multipart/form-data bodies). Extends the testFormDataMultipartGeneration fixture with a file-array part and updates the golden interface accordingly. Fixes #388 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Bump version to 6.6.2 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 d2e961b commit f6a830d

7 files changed

Lines changed: 49 additions & 14 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.1</version>
7+
<version>6.6.2</version>
88
<packaging>jar</packaging>
99

1010
<properties>

multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/OpenApiUtil.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,11 @@ private static void processParameters(final HashMap<String, JsonNode> basicJsonN
207207
private static void processRequestBody(final HashMap<String, JsonNode> basicJsonNodeMap, final JsonNode operation, SpecFile specFile) {
208208
if (ApiTool.hasNode(operation, "requestBody") && !operation.at("/requestBody/content").isMissingNode()) {
209209
final var content = operation.at("/requestBody/content");
210+
if (content.has("multipart/form-data")) {
211+
// multipart parts are exposed individually as @RequestPart parameters,
212+
// so no wrapper model is generated for them.
213+
return;
214+
}
210215
final var schema = content.findValue("schema");
211216
if (!ApiTool.hasRef(schema)) {
212217
basicJsonNodeMap.put(StringCaseUtils.titleToSnakeCase(MapperUtil.getPojoName("InlineObject" + StringUtils.capitalize(getOperationId(operation)), specFile)), schema);

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

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,28 @@ package <#if packageApi??>${packageApi}<#elseif package??> ${package}</#if>;
33

44
<#assign imports=[]>
55
<#assign model_imports=[]>
6+
<#assign needsMultipartFile=false>
67
<#list pathObjects as path>
78
<#list path.operationObjects as operation>
89
<#list operation.requestObjects as request>
10+
<#if request.isFormData>
11+
<#list request.contentObjects as content>
12+
<#if content.schemaObject??>
13+
<#list content.schemaObject.fieldObjectList as field>
14+
<#if "${field.dataType}"?contains("MultipartFile")>
15+
<#assign needsMultipartFile=true>
16+
</#if>
17+
</#list>
18+
</#if>
19+
</#list>
20+
</#if>
21+
</#list>
22+
</#list>
23+
</#list>
24+
<#list pathObjects as path>
25+
<#list path.operationObjects as operation>
26+
<#list operation.requestObjects as request>
27+
<#if !request.isFormData>
928
<#list request.contentObjects as content>
1029
<#if content.importName?? && (!imports?seq_contains(content.importName)) && (!model_imports?seq_contains(content.importName))>
1130
<#if (!checkBasicTypes?seq_contains(content.importName))>
@@ -17,6 +36,7 @@ package <#if packageApi??>${packageApi}<#elseif package??> ${package}</#if>;
1736
</#if>
1837
</#if>
1938
</#list>
39+
</#if>
2040
</#list>
2141
<#list operation.responseObjects as response>
2242
<#list response.contentObjects as content>
@@ -62,6 +82,9 @@ import org.springframework.http.HttpStatus;
6282
import org.springframework.http.ResponseEntity;
6383
import org.springframework.web.bind.annotation.*;
6484
import org.springframework.web.context.request.NativeWebRequest;
85+
<#if needsMultipartFile>
86+
import org.springframework.web.multipart.MultipartFile;
87+
</#if>
6588

6689
<#list model_imports as import>
6790
import <#if packageModel??>${packageModel}<#elseif package??>${package}</#if>.${import};
@@ -80,7 +103,7 @@ public interface ${className?cap_first}Api {
80103
*<#list path.parameterObjects as parameter> @param ${parameter.name} <#if parameter.description?has_content>${parameter.description} </#if>${parameter.required?c}</#list>
81104
</#if>
82105
<#if operation.requestObjects?has_content>
83-
*<#list operation.requestObjects as request><#list request.contentObjects as content> @param ${content.dataType?api.getVariableNameString()}<#if content?has_next>, </#if></#list>${request.description! ""}<#if request.required == true> (required)</#if></#list>
106+
*<#list operation.requestObjects as request><#if request.isFormData><#list request.contentObjects[0].schemaObject.fieldObjectList as part> @param ${part.baseName}</#list><#else><#list request.contentObjects as content> @param ${content.dataType?api.getVariableNameString()}<#if content?has_next>, </#if></#list></#if>${request.description! ""}<#if request.required == true> (required)</#if></#list>
84107
</#if>
85108
* @return<#list operation.responseObjects as response><#if response.responseName != "default"> ${response.description}; (status code ${response.responseName})</#if></#list></#if>
86109
*/
@@ -97,15 +120,16 @@ public interface ${className?cap_first}Api {
97120
@RequestMapping(
98121
method = RequestMethod.${operation.operationType},
99122
value = "${path.pathName}",
100-
produces = {"application/json"}
123+
produces = {"application/json"}<#if operation.requestObjects?has_content && operation.requestObjects[0].isFormData>,
124+
consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}</#if>
101125
)
102126

103127
default ResponseEntity<@compress single_line=true><#if operation.responseObjects[0].contentObjects[0]??>
104128
<${operation.responseObjects[0].contentObjects[0].dataType}<#else><Void</#if>></@compress> ${operation.operationId}(<@compress single_line=true>
105129
<#if operation.parameterObjects?has_content><#list operation.parameterObjects as parameter> @Parameter(name = "${parameter.name}", <#if parameter.description?has_content>description = "${parameter.description}", </#if>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>
106130
<#if path.parameterObjects?has_content><#list path.parameterObjects as parameter> @Parameter(name = "${parameter.name}", <#if parameter.description?has_content>description = "${parameter.description}", </#if>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>
107-
<#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 <#if request.isFormData == false>@RequestBody</#if>
108-
${request.contentObjects[0].dataType} ${request.contentObjects[0].dataType?api.getVariableNameString()} <#if request?has_next>, </#if></#list></#if></@compress>) {
131+
<#if operation.requestObjects?has_content><#list operation.requestObjects as request><#if request.isFormData><#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}) ${part.dataType} ${part.baseName} <#if part?has_next || request?has_next>, </#if></#list><#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
132+
${request.contentObjects[0].dataType} ${request.contentObjects[0].dataType?api.getVariableNameString()} <#if request?has_next>, </#if></#if></#list></#if></@compress>) {
109133
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
110134
}
111135
</#list>

multiapi-engine/src/test/resources/openapigenerator/testFormDataMultipartGeneration/api-test.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ paths:
2222
someFile:
2323
type: string
2424
format: binary
25+
someFiles:
26+
type: array
27+
items:
28+
type: string
29+
format: binary
2530

2631
responses:
2732
'200':

multiapi-engine/src/test/resources/openapigenerator/testFormDataMultipartGeneration/assets/TestApi.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@
1515
import org.springframework.http.ResponseEntity;
1616
import org.springframework.web.bind.annotation.*;
1717
import org.springframework.web.context.request.NativeWebRequest;
18+
import org.springframework.web.multipart.MultipartFile;
1819

19-
import com.sngular.multifileplugin.testformdatamultipartgeneration.InlineObjectTestMultipart;
2020

2121
public interface TestApi {
2222

2323
/**
2424
* GET /test
25-
* @param inlineObjectTestMultipart (required)
25+
* @param someFile @param someFiles @param someString (required)
2626
* @return OK; (status code 200)
2727
*/
2828

@@ -36,10 +36,11 @@ public interface TestApi {
3636
@RequestMapping(
3737
method = RequestMethod.GET,
3838
value = "/test",
39-
produces = {"application/json"}
39+
produces = {"application/json"},
40+
consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}
4041
)
4142

42-
default ResponseEntity<Void> testMultipart(@Parameter(name = "inlineObjectTestMultipart", description = "", required = true, schema = @Schema(description = "")) @Valid InlineObjectTestMultipart inlineObjectTestMultipart) {
43+
default ResponseEntity<Void> testMultipart(@Parameter(name = "someFile", required = false, schema = @Schema(description = "")) @RequestPart(value = "someFile", required = false) MultipartFile someFile , @Parameter(name = "someFiles", required = false, schema = @Schema(description = "")) @RequestPart(value = "someFiles", required = false) List<MultipartFile> someFiles , @Parameter(name = "someString", required = false, schema = @Schema(description = "")) @RequestPart(value = "someString", required = false) String someString) {
4344
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
4445
}
4546

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.1'
24+
version = '6.6.2'
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.1'
34+
implementation 'com.sngular:multiapi-engine:6.6.2'
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.1'
103+
implementation 'com.sngular:scs-multiapi-gradle-plugin:6.6.2'
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.1</version>
7+
<version>6.6.2</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.1</version>
274+
<version>6.6.2</version>
275275
</dependency>
276276
<dependency>
277277
<groupId>org.apache.maven</groupId>

0 commit comments

Comments
 (0)