Browse Source

Add ErrorResponse and ErrorResponseException

ErrorResponse represents a complete error response with status, headers,
and an  RFC 7807 ProblemDetail body.

ErrorResponseException implements ErrorResponse and is usable on its
own or as a base class. ResponseStatusException extends
ErrorResponseException and now also supports RFC 7807 and so does its
sub-hierarchy.

ErrorResponse can be returned from `@ExceptionHandler` methods and is
mapped to ResponseEntity.

See gh-27052
pull/28113/head
rstoyanchev 3 years ago
parent
commit
3efedef161
  1. 71
      spring-web/src/main/java/org/springframework/web/ErrorResponse.java
  2. 156
      spring-web/src/main/java/org/springframework/web/ErrorResponseException.java
  3. 19
      spring-web/src/main/java/org/springframework/web/server/MethodNotAllowedException.java
  4. 19
      spring-web/src/main/java/org/springframework/web/server/NotAcceptableStatusException.java
  5. 65
      spring-web/src/main/java/org/springframework/web/server/ResponseStatusException.java
  6. 36
      spring-web/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java
  7. 7
      spring-web/src/main/java/org/springframework/web/server/handler/ResponseStatusExceptionHandler.java
  8. 13
      spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandler.java
  9. 4
      spring-webflux/src/test/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMappingTests.java
  10. 31
      spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java
  11. 8
      spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/annotation/ResponseStatusExceptionResolver.java
  12. 10
      spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java
  13. 48
      spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java

71
spring-web/src/main/java/org/springframework/web/ErrorResponse.java

@ -0,0 +1,71 @@ @@ -0,0 +1,71 @@
/*
* 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.web;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
/**
* Representation of a complete RFC 7807 error response including status,
* headers, and an RFC 7808 formatted {@link ProblemDetail} body. Allows any
* exception to expose HTTP error response information.
*
* <p>{@link ErrorResponseException} is a default implementation of this
* interface and a convenient base class for other exceptions to use.
*
* <p>An {@code @ExceptionHandler} method can use
* {@link org.springframework.http.ResponseEntity#of(ErrorResponse)} to map an
* {@code ErrorResponse} to a {@code ResponseEntity}.
*
* @author Rossen Stoyanchev
* @since 6.0
* @see ErrorResponseException
* @see org.springframework.http.ResponseEntity#of(ErrorResponse)
*/
public interface ErrorResponse {
/**
* Return the HTTP status to use for the response.
* @throws IllegalArgumentException for an unknown HTTP status code
*/
default HttpStatus getStatus() {
return HttpStatus.valueOf(getRawStatusCode());
}
/**
* Return the HTTP status value for the response, potentially non-standard
* and not resolvable via {@link HttpStatus}.
*/
int getRawStatusCode();
/**
* Return headers to use for the response.
*/
default HttpHeaders getHeaders() {
return HttpHeaders.EMPTY;
}
/**
* Return the body for the response, formatted as an RFC 7807
* {@link ProblemDetail} whose {@link ProblemDetail#getStatus() status}
* should match the response status.
*/
ProblemDetail getBody();
}

156
spring-web/src/main/java/org/springframework/web/ErrorResponseException.java

@ -0,0 +1,156 @@ @@ -0,0 +1,156 @@
/*
* 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.web;
import java.net.URI;
import org.springframework.core.NestedExceptionUtils;
import org.springframework.core.NestedRuntimeException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.lang.Nullable;
/**
* {@link RuntimeException} that implements {@link ErrorResponse} to expose
* an HTTP status, response headers, and a body formatted as an RFC 7808
* {@link ProblemDetail}.
*
* <p>The exception can be used as is, or it can be extended as a more specific
* exception that populates the {@link ProblemDetail#setType(URI) type} or
* {@link ProblemDetail#setDetail(String) detail} fields, or potentially adds
* other non-standard fields.
*
* @author Rossen Stoyanchev
* @since 6.0
*/
@SuppressWarnings("serial")
public class ErrorResponseException extends NestedRuntimeException implements ErrorResponse {
private final int status;
private final HttpHeaders headers = new HttpHeaders();
private final ProblemDetail body;
/**
* Constructor with a well-known {@link HttpStatus}.
*/
public ErrorResponseException(HttpStatus status) {
this(status, null);
}
/**
* Constructor with a well-known {@link HttpStatus} and an optional cause.
*/
public ErrorResponseException(HttpStatus status, @Nullable Throwable cause) {
this(status.value(), null);
}
/**
* Constructor that accepts any status value, possibly not resolvable as an
* {@link HttpStatus} enum, and an optional cause.
*/
public ErrorResponseException(int status, @Nullable Throwable cause) {
this(status, ProblemDetail.forRawStatusCode(status), cause);
}
/**
* Constructor with a given {@link ProblemDetail} instance, possibly a
* subclass of {@code ProblemDetail} with extended fields.
*/
public ErrorResponseException(int status, ProblemDetail body, @Nullable Throwable cause) {
super(null, cause);
this.status = status;
this.body = body;
}
@Override
public int getRawStatusCode() {
return this.status;
}
@Override
public HttpHeaders getHeaders() {
return this.headers;
}
/**
* Set the {@link ProblemDetail#setType(URI) type} field of the response body.
* @param type the problem type
*/
public void setType(URI type) {
this.body.setType(type);
}
/**
* Set the {@link ProblemDetail#setTitle(String) title} field of the response body.
* @param title the problem title
*/
public void setTitle(@Nullable String title) {
this.body.setTitle(title);
}
/**
* Set the {@link ProblemDetail#setDetail(String) detail} field of the response body.
* @param detail the problem detail
*/
public void setDetail(@Nullable String detail) {
this.body.setDetail(detail);
}
/**
* Set the {@link ProblemDetail#setInstance(URI) instance} field of the response body.
* @param instance the problem instance
*/
public void setInstance(@Nullable URI instance) {
this.body.setInstance(instance);
}
/**
* Return the body for the response. To customize the body content, use:
* <ul>
* <li>{@link #setType(URI)}
* <li>{@link #setTitle(String)}
* <li>{@link #setDetail(String)}
* <li>{@link #setInstance(URI)}
* </ul>
* <p>By default, the status field of {@link ProblemDetail} is initialized
* from the status provided to the constructor, which in turn may also
* initialize the title field from the status reason phrase, if the status
* is well-known. The instance field, if not set, is initialized from the
* request path when a {@code ProblemDetail} is returned from an
* {@code @ExceptionHandler} method.
*/
@Override
public final ProblemDetail getBody() {
return this.body;
}
@Override
public String getMessage() {
HttpStatus httpStatus = HttpStatus.resolve(this.status);
String message = (httpStatus != null ? httpStatus : String.valueOf(this.status)) +
(!this.headers.isEmpty() ? ", headers=" + this.headers : "") + ", " + this.body;
return NestedExceptionUtils.buildMessage(message, getCause());
}
}

19
spring-web/src/main/java/org/springframework/web/server/MethodNotAllowedException.java

@ -1,5 +1,5 @@ @@ -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.
@ -58,11 +58,11 @@ public class MethodNotAllowedException extends ResponseStatusException { @@ -58,11 +58,11 @@ public class MethodNotAllowedException extends ResponseStatusException {
/**
* Return HttpHeaders with an "Allow" header.
* @since 5.1.13
* Return HttpHeaders with an "Allow" header that documents the allowed
* HTTP methods for this URL, if available, or an empty instance otherwise.
*/
@Override
public HttpHeaders getResponseHeaders() {
public HttpHeaders getHeaders() {
if (CollectionUtils.isEmpty(this.httpMethods)) {
return HttpHeaders.EMPTY;
}
@ -71,6 +71,17 @@ public class MethodNotAllowedException extends ResponseStatusException { @@ -71,6 +71,17 @@ public class MethodNotAllowedException extends ResponseStatusException {
return headers;
}
/**
* Delegates to {@link #getHeaders()}.
* @since 5.1.13
* @deprecated as of 6.0 in favor of {@link #getHeaders()}
*/
@Deprecated
@Override
public HttpHeaders getResponseHeaders() {
return getHeaders();
}
/**
* Return the HTTP method for the failed request.
*/

19
spring-web/src/main/java/org/springframework/web/server/NotAcceptableStatusException.java

@ -1,5 +1,5 @@ @@ -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.
@ -54,11 +54,11 @@ public class NotAcceptableStatusException extends ResponseStatusException { @@ -54,11 +54,11 @@ public class NotAcceptableStatusException extends ResponseStatusException {
/**
* Return HttpHeaders with an "Accept" header, or an empty instance.
* @since 5.1.13
* Return HttpHeaders with an "Accept" header that documents the supported
* media types, if available, or an empty instance otherwise.
*/
@Override
public HttpHeaders getResponseHeaders() {
public HttpHeaders getHeaders() {
if (CollectionUtils.isEmpty(this.supportedMediaTypes)) {
return HttpHeaders.EMPTY;
}
@ -67,6 +67,17 @@ public class NotAcceptableStatusException extends ResponseStatusException { @@ -67,6 +67,17 @@ public class NotAcceptableStatusException extends ResponseStatusException {
return headers;
}
/**
* Delegates to {@link #getHeaders()}.
* @since 5.1.13
* @deprecated as of 6.0 in favor of {@link #getHeaders()}
*/
@Deprecated
@Override
public HttpHeaders getResponseHeaders() {
return getHeaders();
}
/**
* Return the list of supported content types in cases when the Accept
* header is parsed but not supported, or an empty list otherwise.

65
spring-web/src/main/java/org/springframework/web/server/ResponseStatusException.java

@ -1,5 +1,5 @@ @@ -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,23 +17,21 @@ @@ -17,23 +17,21 @@
package org.springframework.web.server;
import org.springframework.core.NestedExceptionUtils;
import org.springframework.core.NestedRuntimeException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.web.ErrorResponseException;
/**
* Base class for exceptions associated with specific HTTP response status codes.
* Subclass of {@link ErrorResponseException} that accepts a "reason" and maps
* it to the "detail" property of {@link org.springframework.http.ProblemDetail}.
*
* @author Rossen Stoyanchev
* @author Juergen Hoeller
* @since 5.0
*/
@SuppressWarnings("serial")
public class ResponseStatusException extends NestedRuntimeException {
private final int status;
public class ResponseStatusException extends ErrorResponseException {
@Nullable
private final String reason;
@ -54,10 +52,7 @@ public class ResponseStatusException extends NestedRuntimeException { @@ -54,10 +52,7 @@ public class ResponseStatusException extends NestedRuntimeException {
* @param reason the associated reason (optional)
*/
public ResponseStatusException(HttpStatus status, @Nullable String reason) {
super("");
Assert.notNull(status, "HttpStatus is required");
this.status = status.value();
this.reason = reason;
this(status, reason, null);
}
/**
@ -68,10 +63,7 @@ public class ResponseStatusException extends NestedRuntimeException { @@ -68,10 +63,7 @@ public class ResponseStatusException extends NestedRuntimeException {
* @param cause a nested exception (optional)
*/
public ResponseStatusException(HttpStatus status, @Nullable String reason, @Nullable Throwable cause) {
super(null, cause);
Assert.notNull(status, "HttpStatus is required");
this.status = status.value();
this.reason = reason;
this(status.value(), reason, cause);
}
/**
@ -83,32 +75,28 @@ public class ResponseStatusException extends NestedRuntimeException { @@ -83,32 +75,28 @@ public class ResponseStatusException extends NestedRuntimeException {
* @since 5.3
*/
public ResponseStatusException(int rawStatusCode, @Nullable String reason, @Nullable Throwable cause) {
super(null, cause);
this.status = rawStatusCode;
super(rawStatusCode, cause);
this.reason = reason;
setDetail(reason);
}
/**
* Return the HTTP status associated with this exception.
* @throws IllegalArgumentException in case of an unknown HTTP status code
* @since #getRawStatusCode()
* @see HttpStatus#valueOf(int)
* The reason explaining the exception (potentially {@code null} or empty).
*/
public HttpStatus getStatus() {
return HttpStatus.valueOf(this.status);
@Nullable
public String getReason() {
return this.reason;
}
/**
* Return the HTTP status code (potentially non-standard and not resolvable
* through the {@link HttpStatus} enum) as an integer.
* @return the HTTP status as an integer value
* @since 5.3
* @see #getStatus()
* @see HttpStatus#resolve(int)
* Return headers to add to the error response, e.g. "Allow", "Accept", etc.
* <p>By default, delegates to {@link #getResponseHeaders()} for backwards
* compatibility.
*/
public int getRawStatusCode() {
return this.status;
@Override
public HttpHeaders getHeaders() {
return getResponseHeaders();
}
/**
@ -116,24 +104,17 @@ public class ResponseStatusException extends NestedRuntimeException { @@ -116,24 +104,17 @@ public class ResponseStatusException extends NestedRuntimeException {
* error response, e.g. "Allow", "Accept", etc.
* <p>The default implementation in this class returns empty headers.
* @since 5.1.13
* @deprecated as of 6.0 in favor of {@link #getHeaders()}
*/
@Deprecated
public HttpHeaders getResponseHeaders() {
return HttpHeaders.EMPTY;
}
/**
* The reason explaining the exception (potentially {@code null} or empty).
*/
@Nullable
public String getReason() {
return this.reason;
}
@Override
public String getMessage() {
HttpStatus code = HttpStatus.resolve(this.status);
String msg = (code != null ? code : this.status) + (this.reason != null ? " \"" + this.reason + "\"" : "");
HttpStatus code = HttpStatus.resolve(getRawStatusCode());
String msg = (code != null ? code : getRawStatusCode()) + (this.reason != null ? " \"" + this.reason + "\"" : "");
return NestedExceptionUtils.buildMessage(msg, getCause());
}

36
spring-web/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java

@ -1,5 +1,5 @@ @@ -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.
@ -91,16 +91,17 @@ public class UnsupportedMediaTypeStatusException extends ResponseStatusException @@ -91,16 +91,17 @@ public class UnsupportedMediaTypeStatusException extends ResponseStatusException
public UnsupportedMediaTypeStatusException(@Nullable MediaType contentType, List<MediaType> supportedTypes,
@Nullable ResolvableType bodyType, @Nullable HttpMethod method) {
super(HttpStatus.UNSUPPORTED_MEDIA_TYPE, initReason(contentType, bodyType));
super(HttpStatus.UNSUPPORTED_MEDIA_TYPE,
"Content type '" + (contentType != null ? contentType : "") + "' not supported" +
(bodyType != null ? " for bodyType=" + bodyType : ""));
this.contentType = contentType;
this.supportedMediaTypes = Collections.unmodifiableList(supportedTypes);
this.bodyType = bodyType;
this.method = method;
}
private static String initReason(@Nullable MediaType contentType, @Nullable ResolvableType bodyType) {
return "Content type '" + (contentType != null ? contentType : "") + "' not supported" +
(bodyType != null ? " for bodyType=" + bodyType.toString() : "");
// Set explicitly to avoid implementation details
setDetail(contentType != null ? "Content-Type '" + contentType + "' is not supported" : null);
}
@ -133,14 +134,31 @@ public class UnsupportedMediaTypeStatusException extends ResponseStatusException @@ -133,14 +134,31 @@ public class UnsupportedMediaTypeStatusException extends ResponseStatusException
return this.bodyType;
}
/**
* Return HttpHeaders with an "Accept" header that documents the supported
* media types, if available, or an empty instance otherwise.
*/
@Override
public HttpHeaders getResponseHeaders() {
if (HttpMethod.PATCH != this.method || CollectionUtils.isEmpty(this.supportedMediaTypes) ) {
public HttpHeaders getHeaders() {
if (CollectionUtils.isEmpty(this.supportedMediaTypes) ) {
return HttpHeaders.EMPTY;
}
HttpHeaders headers = new HttpHeaders();
headers.setAcceptPatch(this.supportedMediaTypes);
headers.setAccept(this.supportedMediaTypes);
if (this.method == HttpMethod.PATCH) {
headers.setAcceptPatch(this.supportedMediaTypes);
}
return headers;
}
/**
* Delegates to {@link #getHeaders()}.
* @deprecated as of 6.0 in favor of {@link #getHeaders()}
*/
@Deprecated
@Override
public HttpHeaders getResponseHeaders() {
return getHeaders();
}
}

7
spring-web/src/main/java/org/springframework/web/server/handler/ResponseStatusExceptionHandler.java

@ -1,5 +1,5 @@ @@ -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.
@ -95,9 +95,8 @@ public class ResponseStatusExceptionHandler implements WebExceptionHandler { @@ -95,9 +95,8 @@ public class ResponseStatusExceptionHandler implements WebExceptionHandler {
if (code != -1) {
if (response.setRawStatusCode(code)) {
if (ex instanceof ResponseStatusException) {
((ResponseStatusException) ex).getResponseHeaders()
.forEach((name, values) ->
values.forEach(value -> response.getHeaders().add(name, value)));
((ResponseStatusException) ex).getHeaders().forEach((name, values) ->
values.forEach(value -> response.getHeaders().add(name, value)));
}
result = true;
}

13
spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandler.java

@ -37,6 +37,7 @@ import org.springframework.http.ResponseEntity; @@ -37,6 +37,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.http.codec.HttpMessageWriter;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.web.ErrorResponse;
import org.springframework.web.reactive.HandlerResult;
import org.springframework.web.reactive.HandlerResultHandler;
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
@ -44,7 +45,7 @@ import org.springframework.web.server.ServerWebExchange; @@ -44,7 +45,7 @@ import org.springframework.web.server.ServerWebExchange;
/**
* Handles return values of type {@link HttpEntity}, {@link ResponseEntity},
* {@link HttpHeaders}, and {@link ProblemDetail}.
* {@link HttpHeaders}, {@link ErrorResponse}, and {@link ProblemDetail}.
*
* <p>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.
@ -108,7 +109,8 @@ public class ResponseEntityResultHandler extends AbstractMessageWriterResultHand @@ -108,7 +109,8 @@ public class ResponseEntityResultHandler extends AbstractMessageWriterResultHand
return false;
}
return ((HttpEntity.class.isAssignableFrom(type) && !RequestEntity.class.isAssignableFrom(type)) ||
HttpHeaders.class.isAssignableFrom(type) || ProblemDetail.class.isAssignableFrom(type));
ErrorResponse.class.isAssignableFrom(type) || ProblemDetail.class.isAssignableFrom(type) ||
HttpHeaders.class.isAssignableFrom(type));
}
@ -138,12 +140,15 @@ public class ResponseEntityResultHandler extends AbstractMessageWriterResultHand @@ -138,12 +140,15 @@ public class ResponseEntityResultHandler extends AbstractMessageWriterResultHand
if (returnValue instanceof HttpEntity) {
httpEntity = (HttpEntity<?>) returnValue;
}
else if (returnValue instanceof HttpHeaders) {
httpEntity = new ResponseEntity<>((HttpHeaders) returnValue, HttpStatus.OK);
else if (returnValue instanceof ErrorResponse response) {
httpEntity = new ResponseEntity<>(response.getBody(), response.getHeaders(), response.getRawStatusCode());
}
else if (returnValue instanceof ProblemDetail detail) {
httpEntity = new ResponseEntity<>(returnValue, HttpHeaders.EMPTY, detail.getStatus());
}
else if (returnValue instanceof HttpHeaders) {
httpEntity = new ResponseEntity<>((HttpHeaders) returnValue, HttpStatus.OK);
}
else {
throw new IllegalArgumentException(
"HttpEntity or HttpHeaders expected but got: " + returnValue.getClass());

4
spring-webflux/src/test/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMappingTests.java

@ -1,5 +1,5 @@ @@ -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.
@ -330,7 +330,7 @@ public class RequestMappingInfoHandlerMappingTests { @@ -330,7 +330,7 @@ public class RequestMappingInfoHandlerMappingTests {
UnsupportedMediaTypeStatusException umtse = (UnsupportedMediaTypeStatusException) ex;
MediaType mediaType = new MediaType("foo", "bar");
assertThat(umtse.getSupportedMediaTypes()).containsExactly(mediaType);
assertThat(umtse.getResponseHeaders().getAcceptPatch()).containsExactly(mediaType);
assertThat(umtse.getHeaders().getAcceptPatch()).containsExactly(mediaType);
})
.verify();

31
spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java

@ -1,5 +1,5 @@ @@ -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.
@ -55,6 +55,8 @@ import org.springframework.http.codec.json.Jackson2JsonEncoder; @@ -55,6 +55,8 @@ import org.springframework.http.codec.json.Jackson2JsonEncoder;
import org.springframework.http.codec.xml.Jaxb2XmlEncoder;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.util.ObjectUtils;
import org.springframework.web.ErrorResponse;
import org.springframework.web.ErrorResponseException;
import org.springframework.web.reactive.HandlerResult;
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder;
@ -130,6 +132,9 @@ public class ResponseEntityResultHandlerTests { @@ -130,6 +132,9 @@ public class ResponseEntityResultHandlerTests {
returnType = on(TestController.class).resolveReturnType(HttpHeaders.class);
assertThat(this.resultHandler.supports(handlerResult(value, returnType))).isTrue();
returnType = on(TestController.class).resolveReturnType(ErrorResponse.class);
assertThat(this.resultHandler.supports(handlerResult(value, returnType))).isTrue();
returnType = on(TestController.class).resolveReturnType(ProblemDetail.class);
assertThat(this.resultHandler.supports(handlerResult(value, returnType))).isTrue();
@ -236,6 +241,28 @@ public class ResponseEntityResultHandlerTests { @@ -236,6 +241,28 @@ public class ResponseEntityResultHandlerTests {
testHandle(returnValue, returnType);
}
@Test
public void handleErrorResponse() {
ErrorResponseException ex = new ErrorResponseException(HttpStatus.BAD_REQUEST);
ex.getHeaders().add("foo", "bar");
MethodParameter returnType = on(TestController.class).resolveReturnType(ErrorResponse.class);
HandlerResult result = handlerResult(ex, 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()).hasSize(3);
assertThat(exchange.getResponse().getHeaders().get("foo")).containsExactly("bar");
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 handleProblemDetail() {
ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
@ -529,6 +556,8 @@ public class ResponseEntityResultHandlerTests { @@ -529,6 +556,8 @@ public class ResponseEntityResultHandlerTests {
ResponseEntity<Person> responseEntityPerson() { return null; }
ErrorResponse errorResponse() { return null; }
ProblemDetail problemDetail() { return null; }
HttpHeaders httpHeaders() { return null; }

8
spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/annotation/ResponseStatusExceptionResolver.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2002-2020 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.
@ -116,7 +116,7 @@ public class ResponseStatusExceptionResolver extends AbstractHandlerExceptionRes @@ -116,7 +116,7 @@ public class ResponseStatusExceptionResolver extends AbstractHandlerExceptionRes
/**
* Template method that handles an {@link ResponseStatusException}.
* <p>The default implementation applies the headers from
* {@link ResponseStatusException#getResponseHeaders()} and delegates to
* {@link ResponseStatusException#getHeaders()} and delegates to
* {@link #applyStatusAndReason} with the status code and reason from the
* exception.
* @param ex the exception
@ -130,9 +130,7 @@ public class ResponseStatusExceptionResolver extends AbstractHandlerExceptionRes @@ -130,9 +130,7 @@ public class ResponseStatusExceptionResolver extends AbstractHandlerExceptionRes
protected ModelAndView resolveResponseStatusException(ResponseStatusException ex,
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws Exception {
ex.getResponseHeaders().forEach((name, values) ->
values.forEach(value -> response.addHeader(name, value)));
ex.getHeaders().forEach((name, values) -> values.forEach(value -> response.addHeader(name, value)));
return applyStatusAndReason(ex.getRawStatusCode(), ex.getReason(), response);
}

10
spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java

@ -44,6 +44,7 @@ import org.springframework.ui.ModelMap; @@ -44,6 +44,7 @@ import org.springframework.ui.ModelMap;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.ErrorResponse;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.accept.ContentNegotiationManager;
import org.springframework.web.bind.support.WebDataBinderFactory;
@ -56,7 +57,7 @@ import org.springframework.web.servlet.support.RequestContextUtils; @@ -56,7 +57,7 @@ import org.springframework.web.servlet.support.RequestContextUtils;
/**
* Resolves {@link HttpEntity} and {@link RequestEntity} method argument values,
* as well as return values of type {@link HttpEntity}, {@link ResponseEntity},
* and {@link ProblemDetail}.
* {@link ErrorResponse} and {@link ProblemDetail}.
*
* <p>An {@link HttpEntity} return type has a specific purpose. Therefore, this
* handler should be configured ahead of handlers that support any return
@ -122,7 +123,7 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro @@ -122,7 +123,7 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro
public boolean supportsReturnType(MethodParameter returnType) {
Class<?> type = returnType.getParameterType();
return ((HttpEntity.class.isAssignableFrom(type) && !RequestEntity.class.isAssignableFrom(type)) ||
ProblemDetail.class.isAssignableFrom(type));
ErrorResponse.class.isAssignableFrom(type) || ProblemDetail.class.isAssignableFrom(type));
}
@Override
@ -180,7 +181,10 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro @@ -180,7 +181,10 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro
ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
HttpEntity<?> httpEntity;
if (returnValue instanceof ProblemDetail detail) {
if (returnValue instanceof ErrorResponse response) {
httpEntity = new ResponseEntity<>(response.getBody(), response.getHeaders(), response.getRawStatusCode());
}
else if (returnValue instanceof ProblemDetail detail) {
httpEntity = new ResponseEntity<>(returnValue, HttpHeaders.EMPTY, detail.getStatus());
}
else {

48
spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java

@ -52,6 +52,8 @@ import org.springframework.http.RequestEntity; @@ -52,6 +52,8 @@ import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.web.ErrorResponse;
import org.springframework.web.ErrorResponseException;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.bind.annotation.RequestMapping;
@ -129,6 +131,8 @@ public class HttpEntityMethodProcessorMockTests { @@ -129,6 +131,8 @@ public class HttpEntityMethodProcessorMockTests {
private MethodParameter returnTypeInt;
private MethodParameter returnTypeErrorResponse;
private MethodParameter returnTypeProblemDetail;
private ModelAndViewContainer mavContainer;
@ -175,7 +179,8 @@ public class HttpEntityMethodProcessorMockTests { @@ -175,7 +179,8 @@ public class HttpEntityMethodProcessorMockTests {
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);
returnTypeErrorResponse = new MethodParameter(getClass().getMethod("handle6"), -1);
returnTypeProblemDetail = new MethodParameter(getClass().getMethod("handle7"), -1);
mavContainer = new ModelAndViewContainer();
servletRequest = new MockHttpServletRequest("GET", "/foo");
@ -197,6 +202,7 @@ public class HttpEntityMethodProcessorMockTests { @@ -197,6 +202,7 @@ public class HttpEntityMethodProcessorMockTests {
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(returnTypeErrorResponse)).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();
@ -282,6 +288,37 @@ public class HttpEntityMethodProcessorMockTests { @@ -282,6 +288,37 @@ public class HttpEntityMethodProcessorMockTests {
verify(stringHttpMessageConverter).write(eq(body), eq(accepted), isA(HttpOutputMessage.class));
}
@Test
public void shouldHandleErrorResponse() throws Exception {
ErrorResponseException ex = new ErrorResponseException(HttpStatus.BAD_REQUEST);
ex.getHeaders().add("foo", "bar");
servletRequest.addHeader("Accept", APPLICATION_PROBLEM_JSON_VALUE);
given(jsonMessageConverter.canWrite(ProblemDetail.class, APPLICATION_PROBLEM_JSON)).willReturn(true);
processor.handleReturnValue(ex, returnTypeProblemDetail, mavContainer, webRequest);
assertThat(mavContainer.isRequestHandled()).isTrue();
assertThat(webRequest.getNativeResponse(HttpServletResponse.class).getStatus()).isEqualTo(400);
verify(jsonMessageConverter).write(eq(ex.getBody()), eq(APPLICATION_PROBLEM_JSON), isA(HttpOutputMessage.class));
assertThat(ex.getBody()).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
ex.getBody().setInstance(URI.create("/something/else"));
processor.handleReturnValue(ex, returnTypeProblemDetail, mavContainer, webRequest);
assertThat(ex.getBody()).isNotNull()
.extracting(ProblemDetail::getInstance).isNotNull()
.extracting(URI::toString)
.as("Instance was not set to the request path")
.isEqualTo("/something/else");
}
@Test
public void shouldHandleProblemDetail() throws Exception {
ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
@ -294,7 +331,7 @@ public class HttpEntityMethodProcessorMockTests { @@ -294,7 +331,7 @@ public class HttpEntityMethodProcessorMockTests {
assertThat(webRequest.getNativeResponse(HttpServletResponse.class).getStatus()).isEqualTo(400);
verify(jsonMessageConverter).write(eq(problemDetail), eq(APPLICATION_PROBLEM_JSON), isA(HttpOutputMessage.class));
assertThat(problemDetail).isNotNull()
assertThat(problemDetail)
.extracting(ProblemDetail::getInstance).isNotNull()
.extracting(URI::toString)
.as("Instance was not set to the request path")
@ -842,7 +879,12 @@ public class HttpEntityMethodProcessorMockTests { @@ -842,7 +879,12 @@ public class HttpEntityMethodProcessorMockTests {
}
@SuppressWarnings("unused")
public ProblemDetail handle6() {
public ErrorResponse handle6() {
return null;
}
@SuppressWarnings("unused")
public ProblemDetail handle7() {
return null;
}

Loading…
Cancel
Save