diff --git a/spring-web/src/main/java/org/springframework/http/ProblemDetail.java b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java new file mode 100644 index 000000000000..216361586b12 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java @@ -0,0 +1,279 @@ +/* + * 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http; + +import java.net.URI; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Representation of an RFC 7807 problem detail, including all RFC-defined + * fields. For an extended response with more fields, create a subclass that + * exposes the additional fields. + * + * @author Rossen Stoyanchev + * @since 6.0 + * + * @see RFC 7807 + * @see org.springframework.web.ErrorResponse + * @see org.springframework.web.ErrorResponseException + */ +public class ProblemDetail { + + private static final URI BLANK_TYPE = URI.create("about:blank"); + + + private URI type = BLANK_TYPE; + + @Nullable + private String title; + + private int status; + + @Nullable + private String detail; + + @Nullable + private URI instance; + + + /** + * Protected constructor for subclasses. + *

To create a {@link ProblemDetail} instance, use static factory methods, + * {@link #forStatus(HttpStatus)} or {@link #forRawStatusCode(int)}. + * @param rawStatusCode the response status to use + */ + protected ProblemDetail(int rawStatusCode) { + this.status = rawStatusCode; + } + + /** + * Copy constructor that could be used from a subclass to re-create a + * {@code ProblemDetail} in order to extend it with more fields. + */ + protected ProblemDetail(ProblemDetail other) { + this.type = other.type; + this.title = other.title; + this.status = other.status; + this.detail = other.detail; + this.instance = other.instance; + } + + + /** + * Variant of {@link #setType(URI)} for chained initialization. + * @param type the problem type + * @return the same instance + */ + public ProblemDetail withType(URI type) { + setType(type); + return this; + } + + /** + * Variant of {@link #setTitle(String)} for chained initialization. + * @param title the problem title + * @return the same instance + */ + public ProblemDetail withTitle(@Nullable String title) { + setTitle(title); + return this; + } + + /** + * Variant of {@link #setStatus(int)} for chained initialization. + * @param status the response status for the problem + * @return the same instance + */ + public ProblemDetail withStatus(HttpStatus status) { + Assert.notNull(status, "HttpStatus is required"); + setStatus(status.value()); + return this; + } + + /** + * Variant of {@link #setStatus(int)} for chained initialization. + * @param status the response status value for the problem + * @return the same instance + */ + public ProblemDetail withRawStatusCode(int status) { + setStatus(status); + return this; + } + + /** + * Variant of {@link #setDetail(String)} for chained initialization. + * @param detail the problem detail + * @return the same instance + */ + public ProblemDetail withDetail(@Nullable String detail) { + setDetail(detail); + return this; + } + + /** + * Variant of {@link #setInstance(URI)} for chained initialization. + * @param instance the problem instance URI + * @return the same instance + */ + public ProblemDetail withInstance(@Nullable URI instance) { + setInstance(instance); + return this; + } + + + // Setters for deserialization + + /** + * Setter for the {@link #getType() problem type}. + *

By default, this is {@link #BLANK_TYPE}. + * @param type the problem type + * @see #withType(URI) + */ + public void setType(URI type) { + Assert.notNull(type, "'type' is required"); + this.type = type; + } + + /** + * Setter for the {@link #getTitle() problem title}. + *

By default, if not explicitly set and the status is well-known, this + * is sourced from the {@link HttpStatus#getReasonPhrase()}. + * @param title the problem title + * @see #withTitle(String) + */ + public void setTitle(@Nullable String title) { + this.title = title; + } + + /** + * Setter for the {@link #getStatus() problem status}. + * @param status the problem status + * @see #withStatus(HttpStatus) + * @see #withRawStatusCode(int) + */ + public void setStatus(int status) { + this.status = status; + } + + /** + * Setter for the {@link #getDetail() problem detail}. + *

By default, this is not set. + * @param detail the problem detail + * @see #withDetail(String) + */ + public void setDetail(@Nullable String detail) { + this.detail = detail; + } + + /** + * Setter for the {@link #getInstance() problem instance}. + *

By default, when {@code ProblemDetail} is returned from an + * {@code @ExceptionHandler} method, this is initialized to the request path. + * @param instance the problem instance + * @see #withInstance(URI) + */ + public void setInstance(@Nullable URI instance) { + this.instance = instance; + } + + + // Getters + + /** + * Return the configured {@link #setType(URI) problem type}. + */ + public URI getType() { + return this.type; + } + + /** + * Return the configured {@link #setTitle(String) problem title}. + */ + @Nullable + public String getTitle() { + if (this.title == null) { + HttpStatus httpStatus = HttpStatus.resolve(this.status); + if (httpStatus != null) { + return httpStatus.getReasonPhrase(); + } + } + return this.title; + } + + /** + * Return the status associated with the problem, provided either to the + * constructor or configured via {@link #setStatus(int)}. + */ + public int getStatus() { + return this.status; + } + + /** + * Return the configured {@link #setDetail(String) problem detail}. + */ + @Nullable + public String getDetail() { + return this.detail; + } + + /** + * Return the configured {@link #setInstance(URI) problem instance}. + */ + @Nullable + public URI getInstance() { + return this.instance; + } + + + @Override + public String toString() { + return getClass().getSimpleName() + "[" + initToStringContent() + "]"; + } + + /** + * Return a String representation of the {@code ProblemDetail} fields. + * Subclasses can override this to append additional fields. + */ + protected String initToStringContent() { + return "type='" + this.type + "'" + + ", title='" + getTitle() + "'" + + ", status=" + getStatus() + + ", detail='" + getDetail() + "'" + + ", instance='" + getInstance() + "'"; + } + + + // Static factory methods + + /** + * Create a {@code ProblemDetail} instance with the given status code. + */ + public static ProblemDetail forStatus(HttpStatus status) { + Assert.notNull(status, "HttpStatus is required"); + return forRawStatusCode(status.value()); + } + + /** + * Create a {@code ProblemDetail} instance with the given status value. + */ + public static ProblemDetail forRawStatusCode(int status) { + return new ProblemDetail(status); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/ResponseEntity.java b/spring-web/src/main/java/org/springframework/http/ResponseEntity.java index 768b0835bea0..dc1ec11a97f8 100644 --- a/spring-web/src/main/java/org/springframework/http/ResponseEntity.java +++ b/spring-web/src/main/java/org/springframework/http/ResponseEntity.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. @@ -260,6 +260,27 @@ public static ResponseEntity of(Optional body) { return body.map(ResponseEntity::ok).orElseGet(() -> notFound().build()); } + /** + * Create a builder for a {@code ResponseEntity} with the given + * {@link ProblemDetail} as the body, also matching to its + * {@link ProblemDetail#getStatus() status}. An {@code @ExceptionHandler} + * method can use to add response headers, or otherwise it can return + * {@code ProblemDetail}. + * @param body the details for an HTTP error response + * @return the created builder + * @since 6.0 + */ + public static HeadersBuilder of(ProblemDetail body) { + return new DefaultBuilder(body.getStatus()) { + + @SuppressWarnings("unchecked") + @Override + public ResponseEntity build() { + return (ResponseEntity) body(body); + } + }; + } + /** * Create a new builder with a {@linkplain HttpStatus#CREATED CREATED} status * and a location header set to the given URI. diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandler.java index 35f8044c0eca..e38faa02e1fb 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandler.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. @@ -16,6 +16,7 @@ package org.springframework.web.reactive.result.method.annotation; +import java.net.URI; import java.time.Instant; import java.util.List; import java.util.Set; @@ -30,6 +31,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; import org.springframework.http.codec.HttpMessageWriter; @@ -41,7 +43,8 @@ import org.springframework.web.server.ServerWebExchange; /** - * Handles {@link HttpEntity} and {@link ResponseEntity} return values. + * Handles return values of type {@link HttpEntity}, {@link ResponseEntity}, + * {@link HttpHeaders}, and {@link ProblemDetail}. * *

By default the order for this result handler is set to 0. It is generally * safe to place it early in the order as it looks for a concrete return type. @@ -100,10 +103,12 @@ private static Class resolveReturnValueType(HandlerResult result) { return valueType; } - private boolean isSupportedType(@Nullable Class clazz) { - return (clazz != null && ((HttpEntity.class.isAssignableFrom(clazz) && - !RequestEntity.class.isAssignableFrom(clazz)) || - HttpHeaders.class.isAssignableFrom(clazz))); + private boolean isSupportedType(@Nullable Class type) { + if (type == null) { + return false; + } + return ((HttpEntity.class.isAssignableFrom(type) && !RequestEntity.class.isAssignableFrom(type)) || + HttpHeaders.class.isAssignableFrom(type) || ProblemDetail.class.isAssignableFrom(type)); } @@ -136,11 +141,21 @@ public Mono handleResult(ServerWebExchange exchange, HandlerResult result) else if (returnValue instanceof HttpHeaders) { httpEntity = new ResponseEntity<>((HttpHeaders) returnValue, HttpStatus.OK); } + else if (returnValue instanceof ProblemDetail detail) { + httpEntity = new ResponseEntity<>(returnValue, HttpHeaders.EMPTY, detail.getStatus()); + } else { throw new IllegalArgumentException( "HttpEntity or HttpHeaders expected but got: " + returnValue.getClass()); } + if (httpEntity.getBody() instanceof ProblemDetail detail) { + if (detail.getInstance() == null) { + URI path = URI.create(exchange.getRequest().getPath().value()); + detail.setInstance(path); + } + } + if (httpEntity instanceof ResponseEntity) { exchange.getResponse().setRawStatusCode( ((ResponseEntity) httpEntity).getStatusCodeValue()); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java index 00998c0dd263..34692edd7e7c 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java @@ -46,6 +46,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; import org.springframework.http.codec.EncoderHttpMessageWriter; import org.springframework.http.codec.HttpMessageWriter; @@ -129,6 +130,9 @@ public void supports() throws Exception { returnType = on(TestController.class).resolveReturnType(HttpHeaders.class); assertThat(this.resultHandler.supports(handlerResult(value, returnType))).isTrue(); + returnType = on(TestController.class).resolveReturnType(ProblemDetail.class); + assertThat(this.resultHandler.supports(handlerResult(value, returnType))).isTrue(); + // SPR-15785 value = ResponseEntity.ok("testing"); returnType = on(TestController.class).resolveReturnType(Object.class); @@ -232,6 +236,26 @@ public void handleReturnTypes() { testHandle(returnValue, returnType); } + @Test + public void handleProblemDetail() { + ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + MethodParameter returnType = on(TestController.class).resolveReturnType(ProblemDetail.class); + HandlerResult result = handlerResult(problemDetail, returnType); + MockServerWebExchange exchange = MockServerWebExchange.from(get("/path")); + exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_PROBLEM_JSON); + this.resultHandler.handleResult(exchange, result).block(Duration.ofSeconds(5)); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(exchange.getResponse().getHeaders().size()).isEqualTo(2); + assertThat(exchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PROBLEM_JSON); + assertResponseBody(exchange, + "{\"type\":\"about:blank\"," + + "\"title\":\"Bad Request\"," + + "\"status\":400," + + "\"detail\":null," + + "\"instance\":\"/path\"}"); + } + @Test public void handleReturnValueLastModified() throws Exception { Instant currentTime = Instant.now().truncatedTo(ChronoUnit.SECONDS); @@ -505,6 +529,8 @@ private static class TestController { ResponseEntity responseEntityPerson() { return null; } + ProblemDetail problemDetail() { return null; } + HttpHeaders httpHeaders() { return null; } Mono> mono() { return null; } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java index 6aa15c723521..154fc49a3b6c 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.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. @@ -19,6 +19,7 @@ import java.io.IOException; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.net.URI; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -32,6 +33,7 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.http.ProblemDetail; import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageConverter; @@ -52,10 +54,11 @@ import org.springframework.web.servlet.support.RequestContextUtils; /** - * Resolves {@link HttpEntity} and {@link RequestEntity} method argument values - * and also handles {@link HttpEntity} and {@link ResponseEntity} return values. + * Resolves {@link HttpEntity} and {@link RequestEntity} method argument values, + * as well as return values of type {@link HttpEntity}, {@link ResponseEntity}, + * and {@link ProblemDetail}. * - *

An {@link HttpEntity} return type has a specific purpose. Therefore this + *

An {@link HttpEntity} return type has a specific purpose. Therefore, this * handler should be configured ahead of handlers that support any return * value type annotated with {@code @ModelAttribute} or {@code @ResponseBody} * to ensure they don't take over. @@ -82,9 +85,7 @@ public HttpEntityMethodProcessor(List> converters) { * Suitable for resolving {@code HttpEntity} and handling {@code ResponseEntity} * without {@code Request~} or {@code ResponseBodyAdvice}. */ - public HttpEntityMethodProcessor(List> converters, - ContentNegotiationManager manager) { - + public HttpEntityMethodProcessor(List> converters, ContentNegotiationManager manager) { super(converters, manager); } @@ -119,8 +120,9 @@ public boolean supportsParameter(MethodParameter parameter) { @Override public boolean supportsReturnType(MethodParameter returnType) { - return (HttpEntity.class.isAssignableFrom(returnType.getParameterType()) && - !RequestEntity.class.isAssignableFrom(returnType.getParameterType())); + Class type = returnType.getParameterType(); + return ((HttpEntity.class.isAssignableFrom(type) && !RequestEntity.class.isAssignableFrom(type)) || + ProblemDetail.class.isAssignableFrom(type)); } @Override @@ -177,8 +179,21 @@ public void handleReturnValue(@Nullable Object returnValue, MethodParameter retu ServletServerHttpRequest inputMessage = createInputMessage(webRequest); ServletServerHttpResponse outputMessage = createOutputMessage(webRequest); - Assert.isInstanceOf(HttpEntity.class, returnValue); - HttpEntity httpEntity = (HttpEntity) returnValue; + HttpEntity httpEntity; + if (returnValue instanceof ProblemDetail detail) { + httpEntity = new ResponseEntity<>(returnValue, HttpHeaders.EMPTY, detail.getStatus()); + } + else { + Assert.isInstanceOf(HttpEntity.class, returnValue); + httpEntity = (HttpEntity) returnValue; + } + + if (httpEntity.getBody() instanceof ProblemDetail detail) { + if (detail.getInstance() == null) { + URI path = URI.create(inputMessage.getServletRequest().getRequestURI()); + detail.setInstance(path); + } + } HttpHeaders outputHeaders = outputMessage.getHeaders(); HttpHeaders entityHeaders = httpEntity.getHeaders(); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java index 5351a27e3cf9..0410cff0a764 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.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. @@ -31,6 +31,7 @@ import java.util.Set; import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -46,6 +47,7 @@ import org.springframework.http.HttpOutputMessage; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageConverter; @@ -77,6 +79,8 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM; +import static org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON; +import static org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE; import static org.springframework.http.MediaType.TEXT_PLAIN; import static org.springframework.web.servlet.HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE; @@ -103,6 +107,8 @@ public class HttpEntityMethodProcessorMockTests { private HttpMessageConverter resourceRegionMessageConverter; + private HttpMessageConverter jsonMessageConverter; + private MethodParameter paramHttpEntity; private MethodParameter paramRequestEntity; @@ -123,6 +129,8 @@ public class HttpEntityMethodProcessorMockTests { private MethodParameter returnTypeInt; + private MethodParameter returnTypeProblemDetail; + private ModelAndViewContainer mavContainer; private MockHttpServletRequest servletRequest; @@ -147,8 +155,12 @@ public void setup() throws Exception { given(resourceRegionMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL)); given(resourceRegionMessageConverter.getSupportedMediaTypes(any())).willReturn(Collections.singletonList(MediaType.ALL)); + jsonMessageConverter = mock(HttpMessageConverter.class); + given(jsonMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON)); + given(jsonMessageConverter.getSupportedMediaTypes(any())).willReturn(Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON)); + processor = new HttpEntityMethodProcessor(Arrays.asList( - stringHttpMessageConverter, resourceMessageConverter, resourceRegionMessageConverter)); + stringHttpMessageConverter, resourceMessageConverter, resourceRegionMessageConverter, jsonMessageConverter)); Method handle1 = getClass().getMethod("handle1", HttpEntity.class, ResponseEntity.class, Integer.TYPE, RequestEntity.class); @@ -163,6 +175,7 @@ public void setup() throws Exception { returnTypeHttpEntitySubclass = new MethodParameter(getClass().getMethod("handle2x", HttpEntity.class), -1); returnTypeInt = new MethodParameter(getClass().getMethod("handle3"), -1); returnTypeResponseEntityResource = new MethodParameter(getClass().getMethod("handle5"), -1); + returnTypeProblemDetail = new MethodParameter(getClass().getMethod("handle6"), -1); mavContainer = new ModelAndViewContainer(); servletRequest = new MockHttpServletRequest("GET", "/foo"); @@ -184,6 +197,7 @@ public void supportsReturnType() { assertThat(processor.supportsReturnType(returnTypeResponseEntity)).as("ResponseEntity return type not supported").isTrue(); assertThat(processor.supportsReturnType(returnTypeHttpEntity)).as("HttpEntity return type not supported").isTrue(); assertThat(processor.supportsReturnType(returnTypeHttpEntitySubclass)).as("Custom HttpEntity subclass not supported").isTrue(); + assertThat(processor.supportsReturnType(returnTypeProblemDetail)).isTrue(); assertThat(processor.supportsReturnType(paramRequestEntity)).as("RequestEntity parameter supported").isFalse(); assertThat(processor.supportsReturnType(returnTypeInt)).as("non-ResponseBody return type supported").isFalse(); } @@ -268,6 +282,36 @@ public void shouldHandleReturnValue() throws Exception { verify(stringHttpMessageConverter).write(eq(body), eq(accepted), isA(HttpOutputMessage.class)); } + @Test + public void shouldHandleProblemDetail() throws Exception { + ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + servletRequest.addHeader("Accept", APPLICATION_PROBLEM_JSON_VALUE); + given(jsonMessageConverter.canWrite(ProblemDetail.class, APPLICATION_PROBLEM_JSON)).willReturn(true); + + processor.handleReturnValue(problemDetail, returnTypeProblemDetail, mavContainer, webRequest); + + assertThat(mavContainer.isRequestHandled()).isTrue(); + assertThat(webRequest.getNativeResponse(HttpServletResponse.class).getStatus()).isEqualTo(400); + verify(jsonMessageConverter).write(eq(problemDetail), eq(APPLICATION_PROBLEM_JSON), isA(HttpOutputMessage.class)); + + assertThat(problemDetail).isNotNull() + .extracting(ProblemDetail::getInstance).isNotNull() + .extracting(URI::toString) + .as("Instance was not set to the request path") + .isEqualTo(servletRequest.getRequestURI()); + + + // But if instance is set, it should be respected + problemDetail.setInstance(URI.create("/something/else")); + processor.handleReturnValue(problemDetail, returnTypeProblemDetail, mavContainer, webRequest); + + assertThat(problemDetail).isNotNull() + .extracting(ProblemDetail::getInstance).isNotNull() + .extracting(URI::toString) + .as("Instance was not set to the request path") + .isEqualTo("/something/else"); + } + @Test public void shouldHandleReturnValueWithProducibleMediaType() throws Exception { String body = "Foo"; @@ -797,6 +841,11 @@ public ResponseEntity handle5() { return null; } + @SuppressWarnings("unused") + public ProblemDetail handle6() { + return null; + } + @SuppressWarnings("unused") public static class CustomHttpEntity extends HttpEntity { }