M. Justin opened SPR-14481 and commented
I would like to see an option for specifying the preferred default media type in a per controller/controller method basis, and not just globally.
Searching through the documentation, I have not found any method for doing this. Furthermore, I asked on Stack Overflow (which per the Spring Website is the proper channel for requesting community support), and received no responses as to whether this functionality exists. http://stackoverflow.com/questions/37038894/can-you-specify-preferred-default-media-type-for-a-single-path-in-spring-mvc
Use Case:
I have a Jersey application which has been converted to Spring MVC. One piece of functionality that I don't see a way to port directly over is the ability, per endpoint, to specify the preferred media type if none is specified. In Jersey, I could specify the "qs" property on the media type, and it would use that to determine which response type to send if none were specified (or if multiple options were specified in the Accept header, I believe this value was multiplied by the quality scores specified).
I don't see any easy way to do this in Spring MVC, particularly not if I want to restrict the default to applying to just that one endpoint (there are other endpoints in the app that should have a different preferred default). I do see that there is a way to globally set a default content type (per the "defaultContentType" and "defaultContentTypeStrategy" methods in ContentNegotiationConfigurer), but that does not easily address the per-endpoint use case.
No further details from SPR-14481
Comment From: spring-projects-issues
Rossen Stoyanchev commented
I've responded on SO. Let's continue the conversation there as this seems more like a question.
Comment From: spring-projects-issues
M. Justin commented
I've commented on the SO response, but this is more a request to have first-class support for being able to specify the default media type at that controller/controller method level, than an actual question. I have updated this ticket slightly to make that more obvious.
Comment From: spring-projects-issues
Rossen Stoyanchev commented
Can you provide an example of what you would like it to look like in the controller?
Comment From: spring-projects-issues
M. Justin commented
Maybe something like the following?
@Controller
@RequestMapping(produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE})
@DefaultContentType(MediaType.APPLICATION_JSON_VALUE)
public class MyController {
or
@Controller
@RequestMapping(
produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE},
defaultContentType = MediaType.APPLICATION_JSON_VALUE
)
public class MyController {
That said, as I was looking in to @RequestMapping
and the like when formulating this response, I'm not sure whether Spring MVC handles things in a way that really aligns with this view of the world. Though I'm not sure what a better approach might be. I don't like the idea of having the default content types separate from the actual controller classes/methods, since then it would be easy to miss that it's happening, and easy to forget to add to new controllers or controller methods.
Ultimately what I'm looking for is a way to specify — at the controller and controller method level, and in the same code location as the controller — which content type to use when multiple response types could be returned and none is specified in the request.
Comment From: spring-projects-issues
Rossen Stoyanchev commented
Not sure about adding an extra annotation for this. A defaultContentType
isn't a good fit for @RequestMapping
conceptually and also doesn't help to resolve any ambiguity if one method produces JSON and another XML. We can look into a qs parameter similar to what Jersey does.
Comment From: spring-projects-issues
M. Justin commented
Yeah, a qs-style parameter would definitely solve this issue.
Comment From: mjustin
I just ran into this again in a different project. I have a controller consisting of multiple JSON endpoints and a single PDF endpoint. It would be natural to return an application/pdf
if there was no accept header specified by the client for the PDF endpoint, but I wouldn't want to change the behavior of the other endpoints in the controller or application.
The error I'm getting back if I don't specify the "Accept" header is: {"timestamp":1575567481985,"status":406,"error":"Not Acceptable","message":"Could not find acceptable representation","path":"/path/to/endpoint"}
Comment From: rstoyanchev
Can you provide the request mapping details for those endpoints and the request details?
Comment From: mjustin
@rstoyanchev
In the following Spring Boot/Spring MVC example, the following work as desired:
http://localhost:8080/example/json
(uses default JSON media type)http://localhost:8080/example/pdf
; Header:Accept=application/pdf
http://localhost:8080/example/pdf.pdf
(forces media type by appending ".pdf"
This returns a JSON-formatted 406 "Not Acceptable" response, but I would prefer it to return the PDF content:
http://localhost:8080/example/pdf
(would like it to return PDF if no "Accept" header)
{
"timestamp": 1577392253630,
"status": 406,
"error": "Not Acceptable",
"message": "Could not find acceptable representation",
"path": "/example/pdf"
}
package com.example;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/example")
public class ExampleController {
@GetMapping(value = "json")
public JsonResponse getJsonResponse() {
return new JsonResponse("Example JSON");
}
@GetMapping(value = "/pdf", produces = MediaType.APPLICATION_PDF_VALUE)
public byte[] getPdfResponse() {
return getPdfBytes();
}
private byte[] getPdfBytes() {
// https://brendanzagaeski.appspot.com/0004.html
return ("%PDF-1.1\r"
+ "%¥±ë\r"
+ "\r"
+ "1 0 obj\r"
+ " << /Type /Catalog\r"
+ " /Pages 2 0 R\r"
+ " >>\r"
+ "endobj\r"
+ "\r"
+ "2 0 obj\r"
+ " << /Type /Pages\r"
+ " /Kids [3 0 R]\r"
+ " /Count 1\r"
+ " /MediaBox [0 0 300 144]\r"
+ " >>\r"
+ "endobj\r"
+ "\r"
+ "3 0 obj\r"
+ " << /Type /Page\r"
+ " /Parent 2 0 R\r"
+ " /Resources\r"
+ " << /Font\r"
+ " << /F1\r"
+ " << /Type /Font\r"
+ " /Subtype /Type1\r"
+ " /BaseFont /Times-Roman\r"
+ " >>\r"
+ " >>\r"
+ " >>\r"
+ " /Contents 4 0 R\r"
+ " >>\r"
+ "endobj\r"
+ "\r"
+ "4 0 obj\r"
+ " << /Length 55 >>\r"
+ "stream\r"
+ " BT\r"
+ " /F1 18 Tf\r"
+ " 0 0 Td\r"
+ " (Hello World) Tj\r"
+ " ET\r"
+ "endstream\r"
+ "endobj\r"
+ "\r"
+ "xref\r"
+ "0 5\r"
+ "0000000000 65535 f \r"
+ "0000000018 00000 n \r"
+ "0000000077 00000 n \r"
+ "0000000178 00000 n \r"
+ "0000000457 00000 n \r"
+ "trailer\r"
+ " << /Root 1 0 R\r"
+ " /Size 5\r"
+ " >>\r"
+ "startxref\r"
+ "565\r"
+ "%%EOF").getBytes(StandardCharsets.US_ASCII);
}
private static class JsonResponse {
private final String value;
public JsonResponse(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
}
Comment From: mjustin
Note that this also fails with a 406 in my example, but I would expect it to return a PDF:
http://localhost:8080/example/pdf; Header: Accept=*/*
Comment From: mjustin
One could argue that my current use case illustrates a more specific instance of this problem. Namely, if no accept header (or the */*
"any" accept header) is used, and the given endpoint does not produce data of the same type as the application default, a 406 is returned. One would think that specifying that any type is acceptable would lead the app to produce content if it had a mapping for it, especially in the case when there's only one matching content type for that endpoint.
Comment From: rstoyanchev
Thanks, but I can't reproduce the issue. Using the above controller curl -v http://localhost:8080/example/pdf
returns 200 with the PDF, which is what I would in the given scenario. Please, provide an actual sample that I can run and debug.
Comment From: mjustin
@rstoyanchev
I did some further investigation, and you're absolutely correct that this last use case works as expected, and does not fall under the scope of this issue's requested enhancement. It turns out that the application I seeing testing this behavior in had overridden the (global app-wide) default content type to JSON, so it was not handling the case when it was anything else, such as PDF.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer.defaultContentType(MediaType.APPLICATION_JSON_UTF8);
}
}
For anybody else who stumbles upon this in the future, I was able to address this specific issue in my application by adding MediaType.ALL
to the list of default content types, as suggested by the defaultContentType
Javadocs.
configurer.defaultContentType(MediaType.APPLICATION_JSON_UTF8, MediaType.ALL);
Comment From: mjustin
Following up on my previous comment, the initial reported issue request is still a thing; my latest example just turned out to be a non-example.
To make it concrete, both of the following illustrate the reported issue:
@RestController
@RequestMapping("/example")
public class ExampleController {
@GetMapping(value = "/jsonbydefault", produces = MediaType.APPLICATION_JSON_VALUE)
public JsonResponse getJsonResponseWithJsonDefault() {
return new JsonResponse("Example JSON");
}
@GetMapping(value = "/jsonbydefault", produces = MediaType.APPLICATION_PDF_VALUE)
public byte[] getPdfResponseWithJsonDefault() {
return getPdfBytes();
}
@GetMapping(value = "/pdfbydefault", produces = MediaType.APPLICATION_JSON_VALUE)
public JsonResponse getJsonResponseWithPdfDefault() {
return new JsonResponse("Example JSON");
}
@GetMapping(value = "/pdfbydefault", produces = MediaType.APPLICATION_PDF_VALUE)
public byte[] getPdfResponseWithPdfDefault() {
return getPdfBytes();
}
// See prior GitHub comment for definitions of getPdfBytes & JsonResponse
// ...
}
@RestController
@RequestMapping("/example/jsonbydefault")
public class ExampleJsonByDefaultController {
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public JsonResponse getJsonResponseWithJsonDefault() {
return new JsonResponse("Example JSON");
}
@GetMapping(produces = MediaType.APPLICATION_PDF_VALUE)
public byte[] getPdfResponseWithJsonDefault() {
return getPdfBytes();
}
}
@RestController
@RequestMapping("/example/pdfbydefault")
public class ExamplePdfByDefaultController {
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public JsonResponse getJsonResponseWithPdfDefault() {
return new JsonResponse("Example JSON");
}
@GetMapping(produces = MediaType.APPLICATION_PDF_VALUE)
public byte[] getPdfResponseWithPdfDefault() {
return getPdfBytes();
}
// See prior GitHub comment for definitions of getPdfBytes & JsonResponse
// ...
}
In both of these cases, the goal would be for a request to /example/jsonbydefault
to produce JSON if no (or */*
) accept header, and for /example/pdfbydefault
to produce PDF if no (or */*
) accept header. On the other hand, if an accept header of "application/pdf" or "application/json" was provided, it should honor that type.
$ curl -i http://localhost:8080/example/jsonbydefault
$ curl -i http://localhost:8080/example/pdfbydefault
Comment From: rstoyanchev
Okay I get that but we are not going to provide such a @DefaultContentType
annotation in the framework.
If you really do need this, one option to consider is a custom request condition by overriding RequestMappingHandlerMapping#getCustomMethodCondition that checks for the presence of a @DefaultContentType
annotation on the handler method. The custom condition would always match but in the compareTo, it would prioritize methods that have the annotation, over those that don't.
The above could be helpful if you use it a lot, or need to package it conveniently. For a one off occurrence, you could plug in a custom defaultContentTypeStrategy
via ContentNegotiationConfigurer
that checks for a specific URL and returns a preferred media type.
Comment From: mjustin
@rstoyanchev I attempted this approach, but it does not seem to be preferring the method annotated with @DefaultContentType
. It's very possible I just have the configuration wrong. Is this basically what you were suggesting?
@Component
public class DefaultContentTypeWebMvcConfigurationSupport extends WebMvcConfigurationSupport {
@Bean
@Override
public RequestMappingHandlerMapping requestMappingHandlerMapping(ContentNegotiationManager contentNegotiationManager, FormattingConversionService conversionService, ResourceUrlProvider resourceUrlProvider) {
return new DefaultContentTypeRequestMappingHandlerMapping();
}
private static class DefaultContentTypeRequestMappingHandlerMapping extends RequestMappingHandlerMapping {
@Override
protected RequestCondition<?> getCustomMethodCondition(Method method) {
return new DefaultContentTypeRequestCondition(method);
}
}
private static class DefaultContentTypeRequestCondition extends AbstractRequestCondition<DefaultContentTypeRequestCondition> {
private final boolean isDefault;
public DefaultContentTypeRequestCondition(Method method) {
// I've confirmed that this is being called for each controller method with the expected annotation presence value
DefaultContentType annotation = AnnotationUtils.findAnnotation(method, DefaultContentType.class);
isDefault = annotation != null;
}
@Override
protected Collection<?> getContent() {
return List.of(DefaultContentType.class);
}
@Override
protected String getToStringInfix() {
return " ";
}
@Override
public DefaultContentTypeRequestCondition combine(DefaultContentTypeRequestCondition other) {
return this;
}
@Override
public DefaultContentTypeRequestCondition getMatchingCondition(HttpServletRequest request) {
return this;
}
@Override
public int compareTo(DefaultContentTypeRequestCondition other, HttpServletRequest request) {
return Boolean.compare(isDefault, other.isDefault);
}
}
}
Comment From: rstoyanchev
Generally looks okay. Could it be a configuration issue, have you confirmed if that code gets used? One thing I would expect that class to be @Configuration
. Also if this is in a Boot application and just need to extend the RequestMappingHandlerMapping
you might want to look into using WebMvcRegistrations
.