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 0000000000..216361586b --- /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 768b0835be..dc1ec11a97 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 class ResponseEntity 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 @@ public class ResponseEntityResultHandler extends AbstractMessageWriterResultHand
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 class ResponseEntityResultHandler extends AbstractMessageWriterResultHand
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 00998c0dd2..34692edd7e 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.HttpHeaders;
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 class ResponseEntityResultHandlerTests {
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 class ResponseEntityResultHandlerTests {
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 @@ public class ResponseEntityResultHandlerTests {
ResponseEntity 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 class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro
* Suitable for resolving {@code HttpEntity} and handling {@code ResponseEntity}
* without {@code Request~} or {@code ResponseBodyAdvice}.
*/
- public HttpEntityMethodProcessor(List