Support content negotiation for RFC 7807

Closes gh-28189
This commit is contained in:
rstoyanchev
2022-05-09 18:31:47 +01:00
parent f3fd8f9e1d
commit 78ab4d7118
10 changed files with 228 additions and 33 deletions

View File

@@ -19,8 +19,10 @@ package org.springframework.web.servlet.mvc.method.annotation;
import java.io.IOException;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@@ -44,6 +46,7 @@ import org.springframework.http.HttpRange;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ProblemDetail;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotWritableException;
@@ -93,6 +96,9 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe
private final ContentNegotiationManager contentNegotiationManager;
private final List<MediaType> problemMediaTypes =
Arrays.asList(MediaType.APPLICATION_PROBLEM_JSON, MediaType.APPLICATION_PROBLEM_XML);
private final Set<String> safeExtensions = new HashSet<>();
@@ -227,21 +233,22 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe
}
throw ex;
}
List<MediaType> producibleTypes = getProducibleMediaTypes(request, valueType, targetType);
List<MediaType> producibleTypes = getProducibleMediaTypes(request, valueType, targetType);
if (body != null && producibleTypes.isEmpty()) {
throw new HttpMessageNotWritableException(
"No converter found for return value of type: " + valueType);
}
List<MediaType> mediaTypesToUse = new ArrayList<>();
for (MediaType requestedType : acceptableTypes) {
for (MediaType producibleType : producibleTypes) {
if (requestedType.isCompatibleWith(producibleType)) {
mediaTypesToUse.add(getMostSpecificMediaType(requestedType, producibleType));
}
}
List<MediaType> compatibleMediaTypes = new ArrayList<>();
determineCompatibleMediaTypes(acceptableTypes, producibleTypes, compatibleMediaTypes);
// Fall back on RFC 7807 format for ProblemDetail
if (compatibleMediaTypes.isEmpty() && ProblemDetail.class.isAssignableFrom(valueType)) {
determineCompatibleMediaTypes(this.problemMediaTypes, producibleTypes, compatibleMediaTypes);
}
if (mediaTypesToUse.isEmpty()) {
if (compatibleMediaTypes.isEmpty()) {
if (logger.isDebugEnabled()) {
logger.debug("No match for " + acceptableTypes + ", supported: " + producibleTypes);
}
@@ -251,9 +258,9 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe
return;
}
MimeTypeUtils.sortBySpecificity(mediaTypesToUse);
MimeTypeUtils.sortBySpecificity(compatibleMediaTypes);
for (MediaType mediaType : mediaTypesToUse) {
for (MediaType mediaType : compatibleMediaTypes) {
if (mediaType.isConcrete()) {
selectedMediaType = mediaType;
break;
@@ -374,7 +381,7 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe
if (!CollectionUtils.isEmpty(mediaTypes)) {
return new ArrayList<>(mediaTypes);
}
List<MediaType> result = new ArrayList<>();
Set<MediaType> result = new LinkedHashSet<>();
for (HttpMessageConverter<?> converter : this.messageConverters) {
if (converter instanceof GenericHttpMessageConverter && targetType != null) {
if (((GenericHttpMessageConverter<?>) converter).canWrite(targetType, valueClass, null)) {
@@ -385,7 +392,7 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe
result.addAll(converter.getSupportedMediaTypes(valueClass));
}
}
return (result.isEmpty() ? Collections.singletonList(MediaType.ALL) : result);
return (result.isEmpty() ? Collections.singletonList(MediaType.ALL) : new ArrayList<>(result));
}
private List<MediaType> getAcceptableMediaTypes(HttpServletRequest request)
@@ -394,6 +401,18 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe
return this.contentNegotiationManager.resolveMediaTypes(new ServletWebRequest(request));
}
private void determineCompatibleMediaTypes(
List<MediaType> acceptableTypes, List<MediaType> producibleTypes, List<MediaType> mediaTypesToUse) {
for (MediaType requestedType : acceptableTypes) {
for (MediaType producibleType : producibleTypes) {
if (requestedType.isCompatibleWith(producibleType)) {
mediaTypesToUse.add(getMostSpecificMediaType(requestedType, producibleType));
}
}
}
}
/**
* Return the more specific of the acceptable and the producible media types
* with the q-value of the former.

View File

@@ -18,6 +18,7 @@ package org.springframework.web.servlet.mvc.method.annotation;
import java.io.IOException;
import java.lang.reflect.Type;
import java.net.URI;
import java.util.List;
import jakarta.servlet.http.HttpServletRequest;
@@ -25,6 +26,8 @@ import jakarta.servlet.http.HttpServletRequest;
import org.springframework.core.Conventions;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;
@@ -179,6 +182,14 @@ public class RequestResponseBodyMethodProcessor extends AbstractMessageConverter
ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
if (returnValue instanceof ProblemDetail detail) {
outputMessage.setStatusCode(HttpStatusCode.valueOf(detail.getStatus()));
if (detail.getInstance() == null) {
URI path = URI.create(inputMessage.getServletRequest().getRequestURI());
detail.setInstance(path);
}
}
// Try even with null return value. ResponseBodyAdvice could get involved.
writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -43,6 +43,7 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
@@ -393,6 +394,48 @@ public class RequestResponseBodyMethodProcessorTests {
"}");
}
@Test
void problemDetailDefaultMediaType() throws Exception {
testProblemDetailMediaType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
}
@Test
void problemDetailWhenJsonRequested() throws Exception {
this.servletRequest.addHeader("Accept", MediaType.APPLICATION_JSON_VALUE);
testProblemDetailMediaType(MediaType.APPLICATION_JSON_VALUE);
}
@Test
void problemDetailWhenNoMatchingMediaTypeRequested() throws Exception {
this.servletRequest.addHeader("Accept", MediaType.APPLICATION_PDF_VALUE);
testProblemDetailMediaType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
}
private void testProblemDetailMediaType(String expectedContentType) throws Exception {
ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
this.servletRequest.setRequestURI("/path");
RequestResponseBodyMethodProcessor processor =
new RequestResponseBodyMethodProcessor(
Collections.singletonList(new MappingJackson2HttpMessageConverter()));
MethodParameter returnType =
new MethodParameter(getClass().getDeclaredMethod("handleAndReturnProblemDetail"), -1);
processor.handleReturnValue(problemDetail, returnType, this.container, this.request);
assertThat(this.servletResponse.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
assertThat(this.servletResponse.getContentType()).isEqualTo(expectedContentType);
assertThat(this.servletResponse.getContentAsString()).isEqualTo(
"{\"type\":\"about:blank\"," +
"\"title\":\"Bad Request\"," +
"\"status\":400," +
"\"detail\":null," +
"\"instance\":\"/path\"}");
}
@Test // SPR-13135
public void handleReturnValueWithInvalidReturnType() throws Exception {
Method method = getClass().getDeclaredMethod("handleAndReturnOutputStream");
@@ -806,6 +849,10 @@ public class RequestResponseBodyMethodProcessorTests {
return null;
}
ProblemDetail handleAndReturnProblemDetail() {
return null;
}
@RequestMapping
OutputStream handleAndReturnOutputStream() {
return null;