Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Content type multipart/mixed not supported / 415 UNSUPPORTED_MEDIA_TYPE (after Spring + Spring Boot upgrade) #30971

Closed
rorytorneymf opened this issue May 10, 2022 · 7 comments
Labels
status: invalid An issue that we don't feel is valid

Comments

@rorytorneymf
Copy link

rorytorneymf commented May 10, 2022

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!

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label May 10, 2022
@wilkinsona
Copy link
Member

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.

@rorytorneymf
Copy link
Author

rorytorneymf commented May 11, 2022

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:

image

This is the value of contentType:

image

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?

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels May 11, 2022
@wilkinsona
Copy link
Member

wilkinsona commented May 11, 2022

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.

@wilkinsona wilkinsona added status: waiting-for-feedback We need additional information before we can continue and removed status: feedback-provided Feedback has been provided labels May 11, 2022
@rorytorneymf
Copy link
Author

rorytorneymf commented May 12, 2022

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!

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels May 12, 2022
@philwebb
Copy link
Member

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.

@philwebb philwebb added status: waiting-for-feedback We need additional information before we can continue and removed status: feedback-provided Feedback has been provided labels May 12, 2022
@wilkinsona
Copy link
Member

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);
        }
    }
}

@wilkinsona wilkinsona added status: invalid An issue that we don't feel is valid and removed status: waiting-for-feedback We need additional information before we can continue status: waiting-for-triage An issue we've not yet triaged labels May 12, 2022
@rorytorneymf
Copy link
Author

Thanks so much for your help here @philwebb and @wilkinsona, it's much appreciated!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: invalid An issue that we don't feel is valid
Projects
None yet
Development

No branches or pull requests

4 participants