Affects: 2.2.6.RELEASE
I was previously using the following versions of Spring + Spring Boot:
<springVersion>5.0.16.RELEASE</springVersion>
<springBootVersion>2.0.5.RELEASE</springBootVersion>
And the following classes, which handled a multipart/mixed upload. This is all working fine using the Spring versions above:
StagingApi.java
import io.swagger.annotations.*;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import javax.validation.Valid;
import javax.validation.constraints.*;
@javax.annotation.Generated(value = "io.swagger.codegen.v3.generators.java.SpringCodegen", date = "2022-05-10T11:14:13.587+01:00[Europe/London]")
@Api(value = "Staging", description = "API")
public interface StagingApi {
@ApiOperation(value = "", nickname = "createOrReplaceBatch", notes = "", tags={ })
@ApiResponses(value = {
@ApiResponse(code = 200, message = ""),
@ApiResponse(code = 400, message = ""),
@ApiResponse(code = 500, message = "") })
@RequestMapping(value = "/batches/{batchId}",
consumes = { "multipart/mixed" },
method = RequestMethod.PUT)
ResponseEntity<Void> createOrReplaceBatch(
@ApiParam(value = "" ,required=true) @RequestHeader(value="X-TENANT-ID", required=true) String X_TENANT_ID,
@Size(min=1) @ApiParam(value = "",required=true) @PathVariable("batchId") String batchId,
@ApiParam(value = "" ) @Valid @RequestBody Object body);
}
StagingController.java
public ResponseEntity<Void> createOrReplaceBatch(
@ApiParam(value = "Identifies the tenant making the request.", required = true)
@RequestHeader(value = "X-TENANT-ID", required = true) String X_TENANT_ID,
@Size(min = 1) @ApiParam(value = "Identifies the batch.", required = true)
@PathVariable("batchId") String batchId,
Object body)
{
final ServletFileUpload fileUpload = new ServletFileUpload();
final FileItemIterator fileItemIterator;
try {
fileItemIterator = fileUpload.getItemIterator(request);
} catch (final FileUploadException | IOException ex) {
LOGGER.error("Error getting FileItemIterator", ex);
throw new WebMvcHandledRuntimeException(HttpStatus.BAD_REQUEST, ex.getMessage());
}
try {
batchDao.saveFiles(new TenantId(X_TENANT_ID), new BatchId(batchId), fileItemIterator);
return new ResponseEntity<>(HttpStatus.OK);
} catch (final InvalidTenantIdException | InvalidBatchIdException | IncompleteBatchException | InvalidBatchException ex) {
throw new WebMvcHandledRuntimeException(HttpStatus.BAD_REQUEST, ex.getMessage());
} catch (final StagingException ex) {
throw new WebMvcHandledRuntimeException(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
}
}
Log of successfully handled request:
[2022-05-10 11:12:14.861Z #bc7.042 DEBUG - - ] o.s.w.s.m.m.a.RequestMappingHandlerMapping: Looking up handler method for path /batches/test-batch
[2022-05-10 11:12:14.861Z #bc7.042 DEBUG - - ] o.s.w.a.FixedContentNegotiationStrategy: Requested media types: [application/json, */*]
[2022-05-10 11:12:14.861Z #bc7.042 DEBUG - - ] o.s.w.a.FixedContentNegotiationStrategy: Requested media types: [application/json, */*]
[2022-05-10 11:12:14.862Z #bc7.042 DEBUG - - ] o.s.w.a.FixedContentNegotiationStrategy: Requested media types: [application/json, */*]
[2022-05-10 11:12:14.863Z #bc7.042 DEBUG - - ] o.s.w.s.m.m.a.RequestMappingHandlerMapping: Returning handler method [public org.springframework.http.ResponseEntity<java.lang.Void> com.acme.corp.staging.StagingController.createOrReplaceBatch(java.lang.String,java.lang.String,java.lang.Object)]
[2022-05-10 11:12:14.863Z #bc7.042 DEBUG - - ] o.s.b.f.s.DefaultListableBeanFactory: Returning cached instance of singleton bean 'stagingController'
[2022-05-10 11:12:14.866Z #bc7.042 DEBUG - - ] o.s.b.w.s.f.OrderedRequestContextFilter: Bound request context to thread: org.apache.catalina.connector.RequestFacade@37f2254a
[2022-05-10 11:12:14.870Z #bc7.042 DEBUG - - ] o.s.w.s.DispatcherServlet: DispatcherServlet with name 'dispatcherServlet' processing PUT request for [/batches/test-batch]
[2022-05-10 11:12:14.871Z #bc7.042 DEBUG - - ] o.s.w.s.m.m.a.RequestMappingHandlerMapping: Looking up handler method for path /batches/test-batch
[2022-05-10 11:12:14.871Z #bc7.042 DEBUG - - ] o.s.w.a.FixedContentNegotiationStrategy: Requested media types: [application/json, */*]
[2022-05-10 11:12:14.871Z #bc7.042 DEBUG - - ] o.s.w.a.FixedContentNegotiationStrategy: Requested media types: [application/json, */*]
[2022-05-10 11:12:14.871Z #bc7.042 DEBUG - - ] o.s.w.a.FixedContentNegotiationStrategy: Requested media types: [application/json, */*]
[2022-05-10 11:12:14.871Z #bc7.042 DEBUG - - ] o.s.w.s.m.m.a.RequestMappingHandlerMapping: Returning handler method [public org.springframework.http.ResponseEntity<java.lang.Void> com.acme.corp.staging.StagingController.createOrReplaceBatch(java.lang.String,java.lang.String,java.lang.Object)]
[2022-05-10 11:12:14.871Z #bc7.042 DEBUG - - ] o.s.b.f.s.DefaultListableBeanFactory: Returning cached instance of singleton bean 'stagingController'
[2022-05-10 11:12:14.932Z #bc7.042 DEBUG - 10c9] o.s.w.a.FixedContentNegotiationStrategy: Requested media types: [application/json, */*]
[2022-05-10 11:12:14.933Z #bc7.042 DEBUG - - ] o.s.w.s.DispatcherServlet: Null ModelAndView returned to DispatcherServlet with name 'dispatcherServlet': assuming HandlerAdapter completed request handling
[2022-05-10 11:12:14.933Z #bc7.042 DEBUG - - ] o.s.w.s.DispatcherServlet: Successfully completed request
However, when I try to update the versions of Spring and Spring Boot to:
<springVersion>5.2.4.RELEASE</springVersion>
<springBootVersion>2.2.6.RELEASE</springBootVersion>
the same request fails with a 415 UNSUPPORTED_MEDIA_TYPE response.
Log of failed request:
[2022-05-10 11:31:52.158Z #dc2.024 INFO - - ] o.a.c.c.C..localhost.: Initializing Spring DispatcherServlet 'dispatcherServlet'
[2022-05-10 11:31:52.158Z #dc2.024 INFO - - ] o.s.w.s.DispatcherServlet: Initializing Servlet 'dispatcherServlet'
[2022-05-10 11:31:52.158Z #dc2.024 DEBUG - - ] o.s.w.s.DispatcherServlet: Detected StandardServletMultipartResolver
[2022-05-10 11:31:52.162Z #dc2.024 DEBUG - - ] o.s.w.s.DispatcherServlet: enableLoggingRequestDetails='false': request parameters and headers will be masked to prevent unsafe logging of potentially sensitive data
[2022-05-10 11:31:52.162Z #dc2.024 INFO - - ] o.s.w.s.DispatcherServlet: Completed initialization in 4 ms
[2022-05-10 11:31:52.165Z #dc2.024 DEBUG - - ] o.s.w.s.DispatcherServlet: PUT "/batches/test-batch", parameters={}
[2022-05-10 11:31:52.192Z #dc2.024 DEBUG - - ] o.s.w.s.m.m.a.RequestMappingHandlerMapping: Mapped to com.acme.corp.staging.StagingController#createOrReplaceBatch(String, String, Object)
[2022-05-10 11:31:52.202Z #dc2.024 DEBUG - a17e] o.s.w.s.m.m.a.ServletInvocableHandlerMethod: Could not resolve parameter [2] in public org.springframework.http.ResponseEntity<java.lang.Void> com.acme.corp.staging.StagingController.createOrReplaceBatch(java.lang.String,java.lang.String,java.lang.Object): Content type 'multipart/mixed;boundary=efb8369b-607b-4dcf-9f92-e6cd8244db1e;charset=UTF-8' not supported
[2022-05-10 11:31:52.204Z #dc2.024 DEBUG - a17e] o.s.w.s.m.m.a.ExceptionHandlerExceptionResolver: Using @ExceptionHandler acme.corp.staging.exceptions.WebMvcExceptionHandler#handleException(Exception, WebRequest)
[2022-05-10 11:31:52.206Z #dc2.024 DEBUG - a17e] o.s.w.s.m.m.a.HttpEntityMethodProcessor: No match for [application/json, */*], supported: []
[2022-05-10 11:31:52.206Z #dc2.024 DEBUG - a17e] o.s.w.s.m.m.a.ExceptionHandlerExceptionResolver: Resolved [org.springframework.web.HttpMediaTypeNotSupportedException: Content type 'multipart/mixed;boundary=efb8369b-607b-4dcf-9f92-e6cd8244db1e;charset=UTF-8' not supported]
[2022-05-10 11:31:52.206Z #dc2.024 DEBUG - a17e] o.s.w.s.DispatcherServlet: Completed 415 UNSUPPORTED_MEDIA_TYPE
Any help is much appreciated!
Comment From: wilkinsona
Thanks for the report but Spring Boot 2.2 has been out of OSS support since 16 October 2020. If you can reproduce the problem with Spring Boot 2.5.x or 2.6.x and you would like us to take a look, please provide a complete yet minimal sample that reproduces the problem using one of those versions. You can share the sample with us by zipping it up and attaching it to this issue or by pushing it to a separate repository on GitHub.
Comment From: rorytorneymf
I have tried this again with the latest versions (sorry I do not have a minimal reproducible sample yet):
<springVersion>5.3.19</springVersion>
<springBootVersion>2.6.7</springBootVersion>
This is what I found:
When I send a PUT request to my endpoint described above, using the latest Spring versions, the AbstractMessageConverterMethodArgumentResolver is called:
It loops through a list of message converters, and checks if any of the converters canRead the request:
for (HttpMessageConverter<?> converter : this.messageConverters) {
Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
GenericHttpMessageConverter<?> genericConverter =
(converter instanceof GenericHttpMessageConverter ? (GenericHttpMessageConverter<?>) converter : null);
if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
(targetClass != null && converter.canRead(targetClass, contentType))) {
if (message.hasBody()) {
HttpInputMessage msgToUse =
getAdvice().beforeBodyRead(message, parameter, targetType, converterType);
body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
body = getAdvice().afterBodyRead(body, msgToUse, parameter, targetType, converterType);
}
else {
body = getAdvice().handleEmptyBody(null, message, parameter, targetType, converterType);
}
break;
}
}
}
The targetType and targetClass variables are java.lang.Object.
This is the list of message converters the loop is iterating through:
This is the value of contentType:
So, what is happening is that each of these converters is returning false when asked:
canRead(targetClass=java.lang.Object, contentType=multipart/mixed; boundary=efb8369b-607b-4dcf-9f92-e6cd8244db1e;charset=UTF-8
This results in body not getting assgined a value, and a HttpMediaTypeNotSupportedException being thrown:
if (body == NO_VALUE) {
if (httpMethod == null || !SUPPORTED_METHODS.contains(httpMethod) ||
(noContentType && !message.hasBody())) {
return null;
}
throw new HttpMediaTypeNotSupportedException(contentType,
getSupportedMediaTypes(targetClass != null ? targetClass : Object.class));
}
I tried to step through the same code using the older Spring versions listed in my original post (in order to see, for example, if the earlier Spring versions had more message converters):
<springVersion>5.0.16.RELEASE</springVersion>
<springBootVersion>2.0.5.RELEASE</springBootVersion>
but what I found was that this code that loops through the message converters is not executed using these older Spring versions
I am not sure what has changed between these Spring versions that this code is now being executed (and throwing an exception), where it was not before?
Comment From: wilkinsona
Thanks for the additional details, but I am afraid there are still too many unknowns. I cannot reproduce the problem with a minimal Spring Boot 2.6.7 application that uses spring-boot-starter-web and looks like this:
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@SpringBootApplication
public class Gh30971Application {
public static void main(String[] args) {
SpringApplication.run(Gh30971Application.class, args);
}
@Controller
static class ExampleController {
@RequestMapping(value = "/batches/{batchId}", consumes = { "multipart/mixed" }, method = RequestMethod.PUT)
public ResponseEntity<String> createOrReplaceBatch(@PathVariable("batchId") String batchId, Object body) {
return ResponseEntity.ok().build();
}
}
}
Sending a multipart/mixed request using curl produces the expected 200 OK response:
$ curl -i -F "one=alpha" -F "two=bravo" -X PUT -H "Content-Type: multipart/mixed" http://localhost:8080/batches/1
HTTP/1.1 200
Content-Length: 0
Date: Wed, 11 May 2022 18:14:22 GMT
As I said above, if you would like us to spend some more time investigating, please spend some time providing a complete yet minimal sample that reproduces the problem. You can share it with us by pushing it to a separate repository on GitHub or by zipping it up and attaching it to this issue.
Comment From: rorytorneymf
Thanks @wilkinsona
I see in your example though you are missing the @Valid @RequestBody annotations, so although the request got through to the controller, it would be empty when we tried to read it into the org.apache.commons.fileupload.FileItemIterator.
I have added a minimal sample application that reproduces the problem here:
https://github.com/rorytorneymf/staging-service-min2
You can switch Spring versions in the pom.xml and see how the older versions are returning a 200 response whereas the new Spring versions are returning a 415 response.
Let me know if there is anything else I can provide, thanks!
Comment From: philwebb
Debugging the sample I think the problem is related to this Spring Framework issue that was fixed in 5.1. With the earlier version, the body parameter in the createOrReplaceBatch method is resolved using a ServletModelAttributeMethodProcessor. With the later version the @RequestBody annotation in the StagingApi interface is considered and the RequestResponseBodyMethodProcessor is picked. This one throws an exception because is can't actually convert multipart/mixed data to an Object.
@rorytorneymf What's the body object actually used for? In your sample it appears that the HttpServletRequest is used to actually read the data. I think you can probably drop the parameter or remove the @RequestBody annotation on the interface.
Comment From: wilkinsona
I think I've figured this out. In addition to finding @RequestBody on the interface as Phil described above, Spring Framework 5.1 also supports multipart PUT requests. This means that Spring MVC is now reading the body, making it unavailable to commons upload.
You should switch to using MVC's built-in multipart support. There are a few different ways to do that, for example:
package com.github.cafdataprocessing.services.staging;
import java.io.IOException;
import java.util.Collection;
import javax.servlet.ServletException;
import javax.servlet.http.Part;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartHttpServletRequest;
@RestController
public class StagingController implements StagingApi
{
private static final Logger LOGGER = LoggerFactory.getLogger(StagingController.class);
public ResponseEntity<Void> createOrReplaceBatch(@PathVariable("batchId") String batchId, MultipartHttpServletRequest request)
{
try {
Collection<Part> parts = request.getParts();
// Make sure we have read the staging-service-payload.txt properly
if (parts.size() != 1) {
throw new RuntimeException("Expected request to contain 1 item but it contains " + parts.size());
}
return new ResponseEntity<>(HttpStatus.OK);
} catch (final ServletException | IOException ex) {
LOGGER.error("Error getting request parts", ex);
throw new RuntimeException(ex);
}
}
}
Comment From: rorytorneymf
Thanks so much for your help here @philwebb and @wilkinsona, it's much appreciated!