Skip to content

Commit b78504d

Browse files
committed
@RequestPart JSON parameters missing Content-Type in generated curl commands, causing 415 errors. Fixes #3050
1 parent 06e62cb commit b78504d

File tree

12 files changed

+330
-43
lines changed

12 files changed

+330
-43
lines changed

springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/RequestBodyService.java

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@
3535
import io.swagger.v3.core.util.AnnotationsUtils;
3636
import io.swagger.v3.oas.models.Components;
3737
import io.swagger.v3.oas.models.media.Content;
38+
import io.swagger.v3.oas.models.media.Encoding;
3839
import io.swagger.v3.oas.models.media.MediaType;
3940
import io.swagger.v3.oas.models.media.Schema;
4041
import io.swagger.v3.oas.models.parameters.RequestBody;
42+
import jakarta.validation.constraints.NotNull;
4143
import org.apache.commons.lang3.StringUtils;
4244
import org.springdoc.core.models.MethodAttributes;
4345
import org.springdoc.core.models.ParameterInfo;
@@ -46,6 +48,7 @@
4648
import org.springdoc.core.utils.SpringDocAnnotationsUtils;
4749

4850
import org.springframework.core.MethodParameter;
51+
import org.springframework.util.CollectionUtils;
4952
import org.springframework.web.bind.annotation.RequestPart;
5053

5154
import static org.springdoc.core.utils.SpringDocAnnotationsUtils.mergeSchema;
@@ -146,9 +149,9 @@ public Optional<RequestBody> buildRequestBodyFromDoc(
146149
* @param requestBodyObject the request body object
147150
*/
148151
private void buildRequestBodyContent(io.swagger.v3.oas.annotations.parameters.RequestBody requestBody,
149-
RequestBody requestBodyOp, MethodAttributes methodAttributes,
150-
Components components, JsonView jsonViewAnnotation, String[] classConsumes,
151-
String[] methodConsumes, RequestBody requestBodyObject) {
152+
RequestBody requestBodyOp, MethodAttributes methodAttributes,
153+
Components components, JsonView jsonViewAnnotation, String[] classConsumes,
154+
String[] methodConsumes, RequestBody requestBodyObject) {
152155
Optional<Content> optionalContent = SpringDocAnnotationsUtils
153156
.getContent(requestBody.content(), getConsumes(classConsumes),
154157
getConsumes(methodConsumes), null, components, jsonViewAnnotation, parameterBuilder.isOpenapi31());
@@ -296,12 +299,14 @@ private RequestBody buildRequestBody(RequestBody requestBody, Components compone
296299
if (requestBody.getContent() == null) {
297300
Schema<?> schema = parameterBuilder.calculateSchema(components, parameterInfo, requestBodyInfo,
298301
methodAttributes.getJsonViewAnnotationForRequestBody());
299-
buildContent(requestBody, methodAttributes, schema);
302+
Map<String, Encoding> parameterEncoding = getParameterEncoding(parameterInfo);
303+
buildContent(requestBody, methodAttributes, schema, parameterEncoding);
300304
}
301305
else if (!methodAttributes.isWithResponseBodySchemaDoc()) {
302306
Schema<?> schema = parameterBuilder.calculateSchema(components, parameterInfo, requestBodyInfo,
303307
methodAttributes.getJsonViewAnnotationForRequestBody());
304-
mergeContent(requestBody, methodAttributes, schema);
308+
Map<String, Encoding> parameterEncoding = getParameterEncoding(parameterInfo);
309+
mergeContent(requestBody, methodAttributes, schema, parameterEncoding);
305310
}
306311

307312
// Add requestBody javadoc
@@ -318,38 +323,40 @@ else if (!methodAttributes.isWithResponseBodySchemaDoc()) {
318323
/**
319324
* Merge content.
320325
*
321-
* @param requestBody the request body
322-
* @param methodAttributes the method attributes
323-
* @param schema the schema
326+
* @param requestBody the request body
327+
* @param methodAttributes the method attributes
328+
* @param parameterEncoding the parameter encoding
324329
*/
325-
private void mergeContent(RequestBody requestBody, MethodAttributes methodAttributes, Schema<?> schema) {
330+
private void mergeContent(RequestBody requestBody, MethodAttributes methodAttributes, Schema<?> schema, Map<String, Encoding> parameterEncoding) {
326331
Content content = requestBody.getContent();
327-
buildContent(requestBody, methodAttributes, schema, content);
332+
buildContent(requestBody, methodAttributes, schema, content, parameterEncoding);
328333
}
329334

330335
/**
331336
* Build content.
332337
*
333-
* @param requestBody the request body
334-
* @param methodAttributes the method attributes
335-
* @param schema the schema
338+
* @param requestBody the request body
339+
* @param methodAttributes the method attributes
340+
* @param schema the schema
341+
* @param parameterEncoding the parameter encoding
336342
*/
337-
private void buildContent(RequestBody requestBody, MethodAttributes methodAttributes, Schema<?> schema) {
343+
private void buildContent(RequestBody requestBody, MethodAttributes methodAttributes, Schema<?> schema, Map<String, Encoding> parameterEncoding) {
338344
Content content = new Content();
339-
buildContent(requestBody, methodAttributes, schema, content);
345+
buildContent(requestBody, methodAttributes, schema, content, parameterEncoding);
340346
}
341347

342348
/**
343349
* Build content.
344350
*
345-
* @param requestBody the request body
346-
* @param methodAttributes the method attributes
347-
* @param schema the schema
348-
* @param content the content
351+
* @param requestBody the request body
352+
* @param methodAttributes the method attributes
353+
* @param schema the schema
354+
* @param content the content
355+
* @param parameterEncoding the parameter encoding
349356
*/
350-
private void buildContent(RequestBody requestBody, MethodAttributes methodAttributes, Schema<?> schema, Content content) {
357+
private void buildContent(RequestBody requestBody, MethodAttributes methodAttributes, Schema<?> schema, Content content, Map<String, Encoding> parameterEncoding) {
351358
for (String value : methodAttributes.getMethodConsumes()) {
352-
io.swagger.v3.oas.models.media.MediaType mediaTypeObject = new io.swagger.v3.oas.models.media.MediaType();
359+
MediaType mediaTypeObject = new MediaType();
353360
mediaTypeObject.setSchema(schema);
354361
MediaType mediaType = content.get(value);
355362
if (mediaType != null) {
@@ -360,8 +367,37 @@ private void buildContent(RequestBody requestBody, MethodAttributes methodAttrib
360367
if (mediaType.getEncoding() != null)
361368
mediaTypeObject.setEncoding(mediaType.getEncoding());
362369
}
370+
else if (!CollectionUtils.isEmpty(parameterEncoding)) {
371+
mediaTypeObject.setEncoding(parameterEncoding);
372+
}
363373
content.addMediaType(value, mediaTypeObject);
364374
}
365375
requestBody.setContent(content);
366376
}
377+
378+
/**
379+
* Gets parameter encoding.
380+
*
381+
* @param parameterInfo the parameter info
382+
* @return the parameter encoding
383+
*/
384+
@NotNull
385+
private Map<String, Encoding> getParameterEncoding(ParameterInfo parameterInfo) {
386+
if (parameterInfo.getParameterModel() != null) {
387+
Content parameterContent = parameterInfo.getParameterModel().getContent();
388+
if (parameterContent != null && parameterContent.size() == 1) {
389+
Map<String, Encoding> encoding = parameterContent.values().iterator().next().getEncoding();
390+
if (!CollectionUtils.isEmpty(encoding)) {
391+
return encoding;
392+
}
393+
else {
394+
String encodingContentType = parameterContent.keySet().iterator().next();
395+
if(StringUtils.isNotBlank(encodingContentType)) {
396+
return Map.of(parameterInfo.getpName(), new Encoding().contentType(encodingContentType));
397+
}
398+
}
399+
}
400+
}
401+
return Map.of();
402+
}
367403
}

springdoc-openapi-starter-webflux-api/src/test/resources/results/3.0.1/app189.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@
3737
"format": "binary"
3838
}
3939
}
40+
},
41+
"encoding": {
42+
"resumeFile": {
43+
"contentType": "application/octet-stream"
44+
}
4045
}
4146
}
4247
}

springdoc-openapi-starter-webflux-api/src/test/resources/results/3.0.1/app4.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@
3737
"format": "binary"
3838
}
3939
}
40+
},
41+
"encoding": {
42+
"resumeFile": {
43+
"contentType": "application/octet-stream"
44+
}
4045
}
4146
}
4247
}

springdoc-openapi-starter-webflux-api/src/test/resources/results/3.0.1/app78.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@
3636
}
3737
}
3838
}
39+
},
40+
"encoding": {
41+
"files": {
42+
"contentType": "application/octet-stream"
43+
}
3944
}
4045
}
4146
}
@@ -49,4 +54,4 @@
4954
}
5055
},
5156
"components": {}
52-
}
57+
}

springdoc-openapi-starter-webflux-api/src/test/resources/results/3.1.0/app189.json

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,21 @@
2626
"content": {
2727
"multipart/form-data": {
2828
"schema": {
29-
"required": [
30-
"resumeFile"
31-
],
3229
"type": "object",
3330
"properties": {
3431
"resumeFile": {
3532
"type": "string",
36-
"description": "Resume file to be parsed",
37-
"format": "binary"
33+
"format": "binary",
34+
"description": "Resume file to be parsed"
3835
}
36+
},
37+
"required": [
38+
"resumeFile"
39+
]
40+
},
41+
"encoding": {
42+
"resumeFile": {
43+
"contentType": "application/octet-stream"
3944
}
4045
}
4146
}

springdoc-openapi-starter-webflux-api/src/test/resources/results/3.1.0/app4.json

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,21 @@
2626
"content": {
2727
"multipart/form-data": {
2828
"schema": {
29-
"required": [
30-
"resumeFile"
31-
],
3229
"type": "object",
3330
"properties": {
3431
"resumeFile": {
3532
"type": "string",
36-
"description": "Resume file to be parsed",
37-
"format": "binary"
33+
"format": "binary",
34+
"description": "Resume file to be parsed"
3835
}
36+
},
37+
"required": [
38+
"resumeFile"
39+
]
40+
},
41+
"encoding": {
42+
"resumeFile": {
43+
"contentType": "application/octet-stream"
3944
}
4045
}
4146
}

springdoc-openapi-starter-webflux-api/src/test/resources/results/3.1.0/app78.json

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,6 @@
2222
"content": {
2323
"multipart/form-data": {
2424
"schema": {
25-
"required": [
26-
"files"
27-
],
2825
"type": "object",
2926
"properties": {
3027
"files": {
@@ -35,6 +32,14 @@
3532
"format": "binary"
3633
}
3734
}
35+
},
36+
"required": [
37+
"files"
38+
]
39+
},
40+
"encoding": {
41+
"files": {
42+
"contentType": "application/octet-stream"
3843
}
3944
}
4045
}
@@ -49,4 +54,4 @@
4954
}
5055
},
5156
"components": {}
52-
}
57+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * *
7+
* * * * * * Copyright 2019-2025 the original author or authors.
8+
* * * * * *
9+
* * * * * * Licensed under the Apache License, Version 2.0 (the "License");
10+
* * * * * * you may not use this file except in compliance with the License.
11+
* * * * * * You may obtain a copy of the License at
12+
* * * * * *
13+
* * * * * * https://www.apache.org/licenses/LICENSE-2.0
14+
* * * * * *
15+
* * * * * * Unless required by applicable law or agreed to in writing, software
16+
* * * * * * distributed under the License is distributed on an "AS IS" BASIS,
17+
* * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* * * * * * See the License for the specific language governing permissions and
19+
* * * * * * limitations under the License.
20+
* * * * *
21+
* * * *
22+
* * *
23+
* *
24+
*
25+
*/
26+
27+
package test.org.springdoc.api.v31.app247;
28+
29+
import java.util.Map;
30+
31+
import io.swagger.v3.oas.annotations.Parameter;
32+
import io.swagger.v3.oas.annotations.media.Content;
33+
import io.swagger.v3.oas.annotations.media.Encoding;
34+
import io.swagger.v3.oas.annotations.media.Schema;
35+
36+
import org.springframework.http.MediaType;
37+
import org.springframework.web.bind.annotation.PostMapping;
38+
import org.springframework.web.bind.annotation.RequestPart;
39+
import org.springframework.web.bind.annotation.RestController;
40+
41+
42+
@RestController
43+
public class HelloController {
44+
45+
@PostMapping(value = "/class-works1", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
46+
public String dataClass1(
47+
@Parameter(
48+
description = "Metadata as a concrete class",
49+
required = true,
50+
content = @Content(
51+
mediaType = "application/json",
52+
schema = @Schema(implementation = MetaData.class)
53+
)
54+
)
55+
@RequestPart("metadata1") MetaData metadata1
56+
) {
57+
return "Received metadata: " + metadata1.name() + " = " + metadata1.value();
58+
}
59+
60+
@PostMapping(value = "/class-works2", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
61+
public String dataClass2(
62+
@Parameter(
63+
description = "Metadata as a concrete class",
64+
required = true,
65+
content = @Content(encoding = @Encoding(name = "metadata", contentType = "application/json"),
66+
schema = @Schema(implementation = MetaData.class)
67+
)
68+
)
69+
@RequestPart("metadata2") MetaData metadata2
70+
) {
71+
return "Received metadata: " + metadata2.name() + " = " + metadata2.value();
72+
}
73+
}
74+
75+
record MetaData(String name, String value, Map<String, String> settings) {
76+
public MetaData {
77+
if (settings == null) settings = Map.of(); // default like Kotlin's = emptyMap()
78+
}
79+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package test.org.springdoc.api.v31.app247;
2+
3+
import test.org.springdoc.api.v31.AbstractSpringDocTest;
4+
5+
import org.springframework.boot.autoconfigure.SpringBootApplication;
6+
7+
public class SpringDocApp247Test extends AbstractSpringDocTest {
8+
9+
@SpringBootApplication
10+
static class SpringDocTestApp {}
11+
}

0 commit comments

Comments
 (0)