I have @RequestMapping methods defined in my controller as follows:

@RequestMapping(value = "/export", method = RequestMethod.POST)
public ResponseEntity export(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
    ... 
}

@RequestMapping(value = "/export", method = RequestMethod.POST, consumes = "text/csv")
public ResponseEntity exportCSV(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
    ... 
}

@RequestMapping(value = "/export", method = RequestMethod.POST, consumes = "application/json;charset=UTF-8")
public ResponseEntity exportJSON(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
    ... 
}

only differing in the consumes condition. Do note that RequestBody is not required for these mappings.

In Spring 4.3.18, Spring is able to route the request to the proper method based off of the consumes condition and the Content-Type header from the request itself. However, after the following change in ConsumesRequestCondition#getMatchingCondition:

if (!hasBody(request) && !this.bodyRequired) {
    return EMPTY_CONDITION;
}

it is no longer able to do so. Instead, the Content-Type request header is essentially ignored and an Ambiguous handler methods mapped exception is thrown.

Comment From: rstoyanchev

The condition was introduced for #22010 and #21955. It is meant to allow methods with an @RequestBody(required=false) argument to handle requests without content. It will not match if the request has content, or if there is no @RequestBody(required=false) argument, which you don't have, and therefore I'm not sure how to reproduce the issue. I used the following, which is equivalent to your snippet:

@PostMapping(path = "/export")
public String export(HttpServletRequest request, HttpServletResponse response) {
    return "export1";
}

@PostMapping(path = "/export", consumes = "text/csv")
public String exportCSV(HttpServletRequest request, HttpServletResponse response) {
    return "export2";
}

@PostMapping(path = "/export", consumes = "application/json;charset=UTF-8")
public String exportJSON(HttpServletRequest request, HttpServletResponse response) {
    return "export3";
}

Then:

$ curl -v -X POST http://localhost:8080/export
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> POST /export HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.85.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 7
< Date: Mon, 20 Feb 2023 15:25:07 GMT
< 
* Connection #0 to host localhost left intact
export1

Can you please provide more details or better yet sample code?

Comment From: algervuongit

@rstoyanchev sorry about that. I had accidentally removed the optional RequestBody while trimming down the method signature to be more readable.

The following is more representative of what we have:

@RequestMapping(value = "/export", method = RequestMethod.POST)
public ResponseEntity export(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
    ... 
}

@RequestMapping(value = "/export", method = RequestMethod.POST, consumes = "text/csv")
public ResponseEntity exportCSV(@RequestBody(required = false) final String payload, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
    ... 
}

@RequestMapping(value = "/export", method = RequestMethod.POST, consumes = "application/json;charset=UTF-8")
public ResponseEntity exportJSON(@RequestBody(required = false) final String payload, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
    ... 
}

Prior to the change mentioned above, Spring 4.x was able to route the request to the proper method using the presence / absence of the Content-Type header.

Comment From: rstoyanchev

Thanks for the update.

I confirm this is due to the above mentioned fix, which unfortunately comes with a change in behavior, in effect correcting the previous incorrect behavior. If a @RequestBody is to be optional, it needs to match requests without a body which will not have a "Content-Type". You need to switch those to required @RequestBody, and will behave as expected.

Comment From: nathan-jkn

@rstoyanchev can you please clarify why the request body matters here? I can send a "Content-Type" header without a body. If it has this header, I don't understand why Spring is ignoring it and considers it ambiguous.

Comment From: rstoyanchev

It matters because it expresses intent to handle requests without a body, and such requests should not have a "Content-Type" header since there is no content.

Comment From: nathan-jkn

@rstoyanchev I looked through the RFC, and I'm not seeing where it says that the message payload cannot be empty.

https://www.ietf.org/rfc/rfc7231.txt

3.1.1.5. Content-Type

The "Content-Type" header field indicates the media type of the associated representation: either the representation enclosed in the message payload or the selected representation, as determined by the message semantics. The indicated media type defines both the data format and how that data is intended to be processed by a recipient, within the scope of the received message semantics, after any content codings indicated by Content-Encoding are decoded.

 Content-Type = media-type

Media types are defined in Section 3.1.1.1. An example of the field is

 Content-Type: text/html; charset=ISO-8859-4

A sender that generates a message containing a payload body SHOULD generate a Content-Type header field in that message unless the intended media type of the enclosed representation is unknown to the sender. If a Content-Type header field is not present, the recipient MAY either assume a media type of "application/octet-stream" ([RFC2046], Section 4.5.1) or examine the data to determine its type.

In practice, resource owners do not always properly configure their origin server to provide the correct Content-Type for a given representation, with the result that some clients will examine a payload's content and override the specified type. Clients that do so risk drawing incorrect conclusions, which might expose additional security risks (e.g., "privilege escalation"). Furthermore, it is impossible to determine the sender's intent by examining the data format: many data formats match multiple media types that differ only in processing semantics. Implementers are encouraged to provide a means of disabling such "content sniffing" when it is used.

And indeed other folks, for example the authors of the curl library, automatically add a Content-Type header, even for zero length bodies

https://github.com/curl/curl/issues/7381

Is it necessary to have this non-backwards compatible change?

Comment From: rstoyanchev

You're misunderstanding my point. I do not make claims about whether a "Content-Type" header with an empty body is allowed or not. Rather accepting that an empty body without a "Content-Type" header is a normal case and could be reasonably expected, and from there deciding how it should be handled. Such is the request #22010.

It's hard to justify @RequestBody(required=false) not matching to a request without a body. The "Content-Type" at that point may or may not be present, and it becomes irrelevant to a large degree in any case as there is no content. I'm not sure what a "Content-Type" header without content could be used to express.

Looking at your example above, it means that if the client happens to add "text/csv" to an empty body request, it will go to "exportCsv", but if it doesn't, which the client is perfectly okay to do, it will go to export. If you switch exportCsv and exportJSON to required, then an empty body request would only ever go consistently to export.

Last but not least, in major and minor releases, we do take the opportunity to change behavior. In this case the change was done in 5.2, which is a while ago, and as much as I regret this causing you a disruption, changing the behavior yet again at this point would only shift the disruption to others who already depend on the more recent behavior.