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