diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2CodecSupport.java b/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2CodecSupport.java index edbef7cb38c..7d4329f7390 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2CodecSupport.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2CodecSupport.java @@ -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. @@ -38,6 +38,7 @@ import org.springframework.core.ResolvableType; import org.springframework.core.codec.Hints; import org.springframework.http.HttpLogging; import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.lang.Nullable; @@ -89,6 +90,8 @@ public abstract class Jackson2CodecSupport { private final List mimeTypes; + private final List problemDetailMimeTypes; + /** * Constructor with a Jackson {@link ObjectMapper} to use. @@ -96,8 +99,15 @@ public abstract class Jackson2CodecSupport { protected Jackson2CodecSupport(ObjectMapper objectMapper, MimeType... mimeTypes) { Assert.notNull(objectMapper, "ObjectMapper must not be null"); this.defaultObjectMapper = objectMapper; - this.mimeTypes = !ObjectUtils.isEmpty(mimeTypes) ? - List.of(mimeTypes) : DEFAULT_MIME_TYPES; + this.mimeTypes = (!ObjectUtils.isEmpty(mimeTypes) ? List.of(mimeTypes) : DEFAULT_MIME_TYPES); + this.problemDetailMimeTypes = initProblemDetailMediaTypes(this.mimeTypes); + } + + private static List initProblemDetailMediaTypes(List supportedMimeTypes) { + List mimeTypes = new ArrayList<>(); + mimeTypes.add(MediaType.APPLICATION_PROBLEM_JSON); + mimeTypes.addAll(supportedMimeTypes); + return Collections.unmodifiableList(mimeTypes); } @@ -180,7 +190,10 @@ public abstract class Jackson2CodecSupport { result.addAll(entry.getValue().keySet()); } } - return (CollectionUtils.isEmpty(result) ? getMimeTypes() : result); + if (!CollectionUtils.isEmpty(result)) { + return result; + } + return (ProblemDetail.class.isAssignableFrom(elementClass) ? this.problemDetailMimeTypes : getMimeTypes()); } protected boolean supportsMimeType(@Nullable MimeType mimeType) { diff --git a/spring-web/src/main/java/org/springframework/http/converter/AbstractHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/AbstractHttpMessageConverter.java index 644be4f36e3..b73d7e910d2 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/AbstractHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/AbstractHttpMessageConverter.java @@ -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. @@ -100,12 +100,12 @@ public abstract class AbstractHttpMessageConverter implements HttpMessageConv */ public void setSupportedMediaTypes(List supportedMediaTypes) { Assert.notEmpty(supportedMediaTypes, "MediaType List must not be empty"); - this.supportedMediaTypes = new ArrayList<>(supportedMediaTypes); + this.supportedMediaTypes = Collections.unmodifiableList(new ArrayList<>(supportedMediaTypes)); } @Override public List getSupportedMediaTypes() { - return Collections.unmodifiableList(this.supportedMediaTypes); + return this.supportedMediaTypes; } /** diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java index e6cca1f3792..bad8a410544 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java @@ -53,6 +53,7 @@ import org.springframework.core.GenericTypeResolver; import org.springframework.http.HttpInputMessage; import org.springframework.http.HttpOutputMessage; import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; import org.springframework.http.converter.AbstractGenericHttpMessageConverter; import org.springframework.http.converter.HttpMessageConversionException; import org.springframework.http.converter.HttpMessageConverter; @@ -92,6 +93,8 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener } + private List problemDetailMediaTypes = Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON); + protected ObjectMapper defaultObjectMapper; @Nullable @@ -122,6 +125,19 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener } + @Override + public void setSupportedMediaTypes(List supportedMediaTypes) { + this.problemDetailMediaTypes = initProblemDetailMediaTypes(supportedMediaTypes); + super.setSupportedMediaTypes(supportedMediaTypes); + } + + private List initProblemDetailMediaTypes(List supportedMediaTypes) { + List mediaTypes = new ArrayList<>(); + mediaTypes.add(MediaType.APPLICATION_PROBLEM_JSON); + mediaTypes.addAll(supportedMediaTypes); + return Collections.unmodifiableList(mediaTypes); + } + /** * Configure the main {@code ObjectMapper} to use for Object conversion. * If not set, a default {@link ObjectMapper} instance is created. @@ -198,7 +214,11 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener result.addAll(entry.getValue().keySet()); } } - return (CollectionUtils.isEmpty(result) ? getSupportedMediaTypes() : result); + if (!CollectionUtils.isEmpty(result)) { + return result; + } + return (ProblemDetail.class.isAssignableFrom(clazz) ? + this.problemDetailMediaTypes : getSupportedMediaTypes()); } private Map, Map> getObjectMapperRegistrations() { diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/HandlerResultHandlerSupport.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/HandlerResultHandlerSupport.java index e7a8d3da71c..5b986f3d27f 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/HandlerResultHandlerSupport.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/HandlerResultHandlerSupport.java @@ -111,14 +111,25 @@ public abstract class HandlerResultHandlerSupport implements Ordered { } /** - * Select the best media type for the current request through a content negotiation algorithm. + * Select the best media type for the current request through a content + * negotiation algorithm. * @param exchange the current request - * @param producibleTypesSupplier the media types that can be produced for the current request + * @param producibleTypesSupplier the media types producible for the request * @return the selected media type, or {@code null} if none */ @Nullable + protected MediaType selectMediaType(ServerWebExchange exchange, Supplier> producibleTypesSupplier) { + return selectMediaType(exchange, producibleTypesSupplier, getAcceptableTypes(exchange)); + } + + /** + * Variant of {@link #selectMediaType(ServerWebExchange, Supplier)} with a + * given list of requested (acceptable) media types. + */ + @Nullable protected MediaType selectMediaType( - ServerWebExchange exchange, Supplier> producibleTypesSupplier) { + ServerWebExchange exchange, Supplier> producibleTypesSupplier, + List acceptableTypes) { MediaType contentType = exchange.getResponse().getHeaders().getContentType(); if (contentType != null && contentType.isConcrete()) { @@ -128,7 +139,6 @@ public abstract class HandlerResultHandlerSupport implements Ordered { return contentType; } - List acceptableTypes = getAcceptableTypes(exchange); List producibleTypes = getProducibleTypes(exchange, producibleTypesSupplier); Set compatibleMediaTypes = new LinkedHashSet<>(); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageWriterResultHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageWriterResultHandler.java index a4cfad843c3..96cea2d5823 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageWriterResultHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageWriterResultHandler.java @@ -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. @@ -17,6 +17,7 @@ package org.springframework.web.reactive.result.method.annotation; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Set; @@ -32,6 +33,7 @@ import org.springframework.core.ResolvableType; import org.springframework.core.codec.Hints; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; import org.springframework.http.codec.HttpMessageWriter; import org.springframework.http.converter.HttpMessageNotWritableException; import org.springframework.lang.Nullable; @@ -57,6 +59,9 @@ public abstract class AbstractMessageWriterResultHandler extends HandlerResultHa private final List> messageWriters; + private final List problemMediaTypes = + Arrays.asList(MediaType.APPLICATION_PROBLEM_JSON, MediaType.APPLICATION_PROBLEM_XML); + /** * Constructor with {@link HttpMessageWriter HttpMessageWriters} and a @@ -161,6 +166,12 @@ public abstract class AbstractMessageWriterResultHandler extends HandlerResultHa } throw ex; } + + // Fall back on RFC 7807 format for ProblemDetail + if (bestMediaType == null && elementType.toClass().equals(ProblemDetail.class)) { + bestMediaType = selectMediaType(exchange, () -> getMediaTypesFor(elementType), this.problemMediaTypes); + } + if (bestMediaType != null) { String logPrefix = exchange.getLogPrefix(); if (logger.isDebugEnabled()) { diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandler.java index 852d64e5258..be977767f12 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 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. @@ -16,6 +16,7 @@ package org.springframework.web.reactive.result.method.annotation; +import java.net.URI; import java.util.List; import reactor.core.publisher.Mono; @@ -23,6 +24,8 @@ import reactor.core.publisher.Mono; import org.springframework.core.MethodParameter; import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; import org.springframework.http.codec.HttpMessageWriter; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.reactive.HandlerResult; @@ -83,6 +86,13 @@ public class ResponseBodyResultHandler extends AbstractMessageWriterResultHandle public Mono handleResult(ServerWebExchange exchange, HandlerResult result) { Object body = result.getReturnValue(); MethodParameter bodyTypeParameter = result.getReturnTypeSource(); + if (body instanceof ProblemDetail detail) { + exchange.getResponse().setStatusCode(HttpStatusCode.valueOf(detail.getStatus())); + if (detail.getInstance() == null) { + URI path = URI.create(exchange.getRequest().getPath().value()); + detail.setInstance(path); + } + } return writeBody(body, bodyTypeParameter, exchange); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java index ad5e8f50cd3..f09008c96fd 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java @@ -17,6 +17,7 @@ package org.springframework.web.reactive.result.method.annotation; import java.lang.reflect.Method; +import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -25,14 +26,19 @@ import io.reactivex.rxjava3.core.Single; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; import org.springframework.core.codec.ByteBufferEncoder; import org.springframework.core.codec.CharSequenceEncoder; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; import org.springframework.http.codec.EncoderHttpMessageWriter; import org.springframework.http.codec.HttpMessageWriter; import org.springframework.http.codec.ResourceHttpMessageWriter; import org.springframework.http.codec.json.Jackson2JsonEncoder; import org.springframework.http.codec.xml.Jaxb2XmlEncoder; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; @@ -40,8 +46,11 @@ import org.springframework.web.method.HandlerMethod; import org.springframework.web.reactive.HandlerResult; import org.springframework.web.reactive.accept.RequestedContentTypeResolver; import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; +import org.springframework.web.testfixture.server.MockServerWebExchange; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest.get; import static org.springframework.web.testfixture.method.ResolvableMethod.on; /** @@ -82,7 +91,7 @@ public class ResponseBodyResultHandlerTests { testSupports(controller, method); method = on(TestController.class).annotNotPresent(ResponseBody.class).resolveMethod("doWork"); - HandlerResult handlerResult = getHandlerResult(controller, method); + HandlerResult handlerResult = getHandlerResult(controller, null, method); assertThat(this.resultHandler.supports(handlerResult)).isFalse(); } @@ -105,13 +114,42 @@ public class ResponseBodyResultHandlerTests { } private void testSupports(Object controller, Method method) { - HandlerResult handlerResult = getHandlerResult(controller, method); + HandlerResult handlerResult = getHandlerResult(controller, null, method); assertThat(this.resultHandler.supports(handlerResult)).isTrue(); } - private HandlerResult getHandlerResult(Object controller, Method method) { - HandlerMethod handlerMethod = new HandlerMethod(controller, method); - return new HandlerResult(handlerMethod, null, handlerMethod.getReturnType()); + @Test + void problemDetailContentNegotiation() { + + // Default + MockServerWebExchange exchange = MockServerWebExchange.from(get("/path")); + testProblemDetailMediaType(exchange, MediaType.APPLICATION_PROBLEM_JSON); + + // JSON requested + exchange = MockServerWebExchange.from(get("/path").accept(MediaType.APPLICATION_JSON)); + testProblemDetailMediaType(exchange, MediaType.APPLICATION_JSON); + + // No match fallback + exchange = MockServerWebExchange.from(get("/path").accept(MediaType.APPLICATION_PDF)); + testProblemDetailMediaType(exchange, MediaType.APPLICATION_PROBLEM_JSON); + } + + private void testProblemDetailMediaType(MockServerWebExchange exchange, MediaType expectedMediaType) { + ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + + Method method = on(TestRestController.class).returning(ProblemDetail.class).resolveMethod(); + HandlerResult result = getHandlerResult(new TestRestController(), problemDetail, method); + + this.resultHandler.handleResult(exchange, result).block(Duration.ofSeconds(5)); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(exchange.getResponse().getHeaders().getContentType()).isEqualTo(expectedMediaType); + assertResponseBody(exchange, + "{\"type\":\"about:blank\"," + + "\"title\":\"Bad Request\"," + + "\"status\":400," + + "\"detail\":null," + + "\"instance\":\"/path\"}"); } @Test @@ -119,6 +157,17 @@ public class ResponseBodyResultHandlerTests { assertThat(this.resultHandler.getOrder()).isEqualTo(100); } + private HandlerResult getHandlerResult(Object controller, @Nullable Object returnValue, Method method) { + HandlerMethod handlerMethod = new HandlerMethod(controller, method); + return new HandlerResult(handlerMethod, returnValue, handlerMethod.getReturnType()); + } + + private void assertResponseBody(MockServerWebExchange exchange, @Nullable String responseBody) { + StepVerifier.create(exchange.getResponse().getBody()) + .consumeNextWith(buf -> assertThat(buf.toString(UTF_8)).isEqualTo(responseBody)) + .expectComplete() + .verify(); + } @RestController @@ -142,6 +191,11 @@ public class ResponseBodyResultHandlerTests { public Completable handleToCompletable() { return null; } + + public ProblemDetail handleToProblemDetail() { + return null; + } + } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodProcessor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodProcessor.java index d1421b2454c..57334617f10 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodProcessor.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodProcessor.java @@ -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 problemMediaTypes = + Arrays.asList(MediaType.APPLICATION_PROBLEM_JSON, MediaType.APPLICATION_PROBLEM_XML); + private final Set safeExtensions = new HashSet<>(); @@ -227,21 +233,22 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe } throw ex; } - List producibleTypes = getProducibleMediaTypes(request, valueType, targetType); + List producibleTypes = getProducibleMediaTypes(request, valueType, targetType); if (body != null && producibleTypes.isEmpty()) { throw new HttpMessageNotWritableException( "No converter found for return value of type: " + valueType); } - List mediaTypesToUse = new ArrayList<>(); - for (MediaType requestedType : acceptableTypes) { - for (MediaType producibleType : producibleTypes) { - if (requestedType.isCompatibleWith(producibleType)) { - mediaTypesToUse.add(getMostSpecificMediaType(requestedType, producibleType)); - } - } + + List 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 result = new ArrayList<>(); + Set 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 getAcceptableMediaTypes(HttpServletRequest request) @@ -394,6 +401,18 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe return this.contentNegotiationManager.resolveMediaTypes(new ServletWebRequest(request)); } + private void determineCompatibleMediaTypes( + List acceptableTypes, List producibleTypes, List 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. diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessor.java index 0d8907f7c60..6fa7c574b5e 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessor.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessor.java @@ -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); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java index 75d1217408a..1a163a4c512 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java @@ -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;