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
This commit is contained in:
rstoyanchev 2022-02-23 14:52:17 +00:00
parent 714d451260
commit 3efedef161
13 changed files with 415 additions and 90 deletions

View File

@ -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();
}

View File

@ -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());
}
}

View File

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -58,11 +58,11 @@ public class MethodNotAllowedException extends ResponseStatusException {
/** /**
* Return HttpHeaders with an "Allow" header. * Return HttpHeaders with an "Allow" header that documents the allowed
* @since 5.1.13 * HTTP methods for this URL, if available, or an empty instance otherwise.
*/ */
@Override @Override
public HttpHeaders getResponseHeaders() { public HttpHeaders getHeaders() {
if (CollectionUtils.isEmpty(this.httpMethods)) { if (CollectionUtils.isEmpty(this.httpMethods)) {
return HttpHeaders.EMPTY; return HttpHeaders.EMPTY;
} }
@ -71,6 +71,17 @@ public class MethodNotAllowedException extends ResponseStatusException {
return headers; 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. * Return the HTTP method for the failed request.
*/ */

View File

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -54,11 +54,11 @@ public class NotAcceptableStatusException extends ResponseStatusException {
/** /**
* Return HttpHeaders with an "Accept" header, or an empty instance. * Return HttpHeaders with an "Accept" header that documents the supported
* @since 5.1.13 * media types, if available, or an empty instance otherwise.
*/ */
@Override @Override
public HttpHeaders getResponseHeaders() { public HttpHeaders getHeaders() {
if (CollectionUtils.isEmpty(this.supportedMediaTypes)) { if (CollectionUtils.isEmpty(this.supportedMediaTypes)) {
return HttpHeaders.EMPTY; return HttpHeaders.EMPTY;
} }
@ -67,6 +67,17 @@ public class NotAcceptableStatusException extends ResponseStatusException {
return headers; 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 * Return the list of supported content types in cases when the Accept
* header is parsed but not supported, or an empty list otherwise. * header is parsed but not supported, or an empty list otherwise.

View File

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -17,23 +17,21 @@
package org.springframework.web.server; package org.springframework.web.server;
import org.springframework.core.NestedExceptionUtils; import org.springframework.core.NestedExceptionUtils;
import org.springframework.core.NestedRuntimeException;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.lang.Nullable; 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 Rossen Stoyanchev
* @author Juergen Hoeller * @author Juergen Hoeller
* @since 5.0 * @since 5.0
*/ */
@SuppressWarnings("serial") @SuppressWarnings("serial")
public class ResponseStatusException extends NestedRuntimeException { public class ResponseStatusException extends ErrorResponseException {
private final int status;
@Nullable @Nullable
private final String reason; private final String reason;
@ -54,10 +52,7 @@ public class ResponseStatusException extends NestedRuntimeException {
* @param reason the associated reason (optional) * @param reason the associated reason (optional)
*/ */
public ResponseStatusException(HttpStatus status, @Nullable String reason) { public ResponseStatusException(HttpStatus status, @Nullable String reason) {
super(""); this(status, reason, null);
Assert.notNull(status, "HttpStatus is required");
this.status = status.value();
this.reason = reason;
} }
/** /**
@ -68,10 +63,7 @@ public class ResponseStatusException extends NestedRuntimeException {
* @param cause a nested exception (optional) * @param cause a nested exception (optional)
*/ */
public ResponseStatusException(HttpStatus status, @Nullable String reason, @Nullable Throwable cause) { public ResponseStatusException(HttpStatus status, @Nullable String reason, @Nullable Throwable cause) {
super(null, cause); this(status.value(), reason, cause);
Assert.notNull(status, "HttpStatus is required");
this.status = status.value();
this.reason = reason;
} }
/** /**
@ -83,44 +75,12 @@ public class ResponseStatusException extends NestedRuntimeException {
* @since 5.3 * @since 5.3
*/ */
public ResponseStatusException(int rawStatusCode, @Nullable String reason, @Nullable Throwable cause) { public ResponseStatusException(int rawStatusCode, @Nullable String reason, @Nullable Throwable cause) {
super(null, cause); super(rawStatusCode, cause);
this.status = rawStatusCode;
this.reason = reason; 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)
*/
public HttpStatus getStatus() {
return HttpStatus.valueOf(this.status);
}
/**
* 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)
*/
public int getRawStatusCode() {
return this.status;
}
/**
* Return headers associated with the exception that should be added to the
* error response, e.g. "Allow", "Accept", etc.
* <p>The default implementation in this class returns empty headers.
* @since 5.1.13
*/
public HttpHeaders getResponseHeaders() {
return HttpHeaders.EMPTY;
}
/** /**
* The reason explaining the exception (potentially {@code null} or empty). * The reason explaining the exception (potentially {@code null} or empty).
*/ */
@ -129,11 +89,32 @@ public class ResponseStatusException extends NestedRuntimeException {
return this.reason; return this.reason;
} }
/**
* Return headers to add to the error response, e.g. "Allow", "Accept", etc.
* <p>By default, delegates to {@link #getResponseHeaders()} for backwards
* compatibility.
*/
@Override
public HttpHeaders getHeaders() {
return getResponseHeaders();
}
/**
* Return headers associated with the exception that should be added to the
* 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;
}
@Override @Override
public String getMessage() { public String getMessage() {
HttpStatus code = HttpStatus.resolve(this.status); HttpStatus code = HttpStatus.resolve(getRawStatusCode());
String msg = (code != null ? code : this.status) + (this.reason != null ? " \"" + this.reason + "\"" : ""); String msg = (code != null ? code : getRawStatusCode()) + (this.reason != null ? " \"" + this.reason + "\"" : "");
return NestedExceptionUtils.buildMessage(msg, getCause()); return NestedExceptionUtils.buildMessage(msg, getCause());
} }

View File

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

View File

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -95,9 +95,8 @@ public class ResponseStatusExceptionHandler implements WebExceptionHandler {
if (code != -1) { if (code != -1) {
if (response.setRawStatusCode(code)) { if (response.setRawStatusCode(code)) {
if (ex instanceof ResponseStatusException) { if (ex instanceof ResponseStatusException) {
((ResponseStatusException) ex).getResponseHeaders() ((ResponseStatusException) ex).getHeaders().forEach((name, values) ->
.forEach((name, values) -> values.forEach(value -> response.getHeaders().add(name, value)));
values.forEach(value -> response.getHeaders().add(name, value)));
} }
result = true; result = true;
} }

View File

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

View File

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -330,7 +330,7 @@ public class RequestMappingInfoHandlerMappingTests {
UnsupportedMediaTypeStatusException umtse = (UnsupportedMediaTypeStatusException) ex; UnsupportedMediaTypeStatusException umtse = (UnsupportedMediaTypeStatusException) ex;
MediaType mediaType = new MediaType("foo", "bar"); MediaType mediaType = new MediaType("foo", "bar");
assertThat(umtse.getSupportedMediaTypes()).containsExactly(mediaType); assertThat(umtse.getSupportedMediaTypes()).containsExactly(mediaType);
assertThat(umtse.getResponseHeaders().getAcceptPatch()).containsExactly(mediaType); assertThat(umtse.getHeaders().getAcceptPatch()).containsExactly(mediaType);
}) })
.verify(); .verify();

View File

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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;
import org.springframework.http.codec.xml.Jaxb2XmlEncoder; import org.springframework.http.codec.xml.Jaxb2XmlEncoder;
import org.springframework.http.converter.HttpMessageNotWritableException; import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.util.ObjectUtils; 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.HandlerResult;
import org.springframework.web.reactive.accept.RequestedContentTypeResolver; import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder;
@ -130,6 +132,9 @@ public class ResponseEntityResultHandlerTests {
returnType = on(TestController.class).resolveReturnType(HttpHeaders.class); returnType = on(TestController.class).resolveReturnType(HttpHeaders.class);
assertThat(this.resultHandler.supports(handlerResult(value, returnType))).isTrue(); 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); returnType = on(TestController.class).resolveReturnType(ProblemDetail.class);
assertThat(this.resultHandler.supports(handlerResult(value, returnType))).isTrue(); assertThat(this.resultHandler.supports(handlerResult(value, returnType))).isTrue();
@ -236,6 +241,28 @@ public class ResponseEntityResultHandlerTests {
testHandle(returnValue, returnType); 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 @Test
public void handleProblemDetail() { public void handleProblemDetail() {
ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
@ -529,6 +556,8 @@ public class ResponseEntityResultHandlerTests {
ResponseEntity<Person> responseEntityPerson() { return null; } ResponseEntity<Person> responseEntityPerson() { return null; }
ErrorResponse errorResponse() { return null; }
ProblemDetail problemDetail() { return null; } ProblemDetail problemDetail() { return null; }
HttpHeaders httpHeaders() { return null; } HttpHeaders httpHeaders() { return null; }

View File

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -116,7 +116,7 @@ public class ResponseStatusExceptionResolver extends AbstractHandlerExceptionRes
/** /**
* Template method that handles an {@link ResponseStatusException}. * Template method that handles an {@link ResponseStatusException}.
* <p>The default implementation applies the headers from * <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 * {@link #applyStatusAndReason} with the status code and reason from the
* exception. * exception.
* @param ex the exception * @param ex the exception
@ -130,9 +130,7 @@ public class ResponseStatusExceptionResolver extends AbstractHandlerExceptionRes
protected ModelAndView resolveResponseStatusException(ResponseStatusException ex, protected ModelAndView resolveResponseStatusException(ResponseStatusException ex,
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws Exception { HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws Exception {
ex.getResponseHeaders().forEach((name, values) -> ex.getHeaders().forEach((name, values) -> values.forEach(value -> response.addHeader(name, value)));
values.forEach(value -> response.addHeader(name, value)));
return applyStatusAndReason(ex.getRawStatusCode(), ex.getReason(), response); return applyStatusAndReason(ex.getRawStatusCode(), ex.getReason(), response);
} }

View File

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

View File

@ -52,6 +52,8 @@ import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotWritableException; 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.HttpMediaTypeNotAcceptableException;
import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@ -129,6 +131,8 @@ public class HttpEntityMethodProcessorMockTests {
private MethodParameter returnTypeInt; private MethodParameter returnTypeInt;
private MethodParameter returnTypeErrorResponse;
private MethodParameter returnTypeProblemDetail; private MethodParameter returnTypeProblemDetail;
private ModelAndViewContainer mavContainer; private ModelAndViewContainer mavContainer;
@ -175,7 +179,8 @@ public class HttpEntityMethodProcessorMockTests {
returnTypeHttpEntitySubclass = new MethodParameter(getClass().getMethod("handle2x", HttpEntity.class), -1); returnTypeHttpEntitySubclass = new MethodParameter(getClass().getMethod("handle2x", HttpEntity.class), -1);
returnTypeInt = new MethodParameter(getClass().getMethod("handle3"), -1); returnTypeInt = new MethodParameter(getClass().getMethod("handle3"), -1);
returnTypeResponseEntityResource = new MethodParameter(getClass().getMethod("handle5"), -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(); mavContainer = new ModelAndViewContainer();
servletRequest = new MockHttpServletRequest("GET", "/foo"); servletRequest = new MockHttpServletRequest("GET", "/foo");
@ -197,6 +202,7 @@ public class HttpEntityMethodProcessorMockTests {
assertThat(processor.supportsReturnType(returnTypeResponseEntity)).as("ResponseEntity return type not supported").isTrue(); 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(returnTypeHttpEntity)).as("HttpEntity return type not supported").isTrue();
assertThat(processor.supportsReturnType(returnTypeHttpEntitySubclass)).as("Custom HttpEntity subclass 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(returnTypeProblemDetail)).isTrue();
assertThat(processor.supportsReturnType(paramRequestEntity)).as("RequestEntity parameter supported").isFalse(); assertThat(processor.supportsReturnType(paramRequestEntity)).as("RequestEntity parameter supported").isFalse();
assertThat(processor.supportsReturnType(returnTypeInt)).as("non-ResponseBody return type supported").isFalse(); assertThat(processor.supportsReturnType(returnTypeInt)).as("non-ResponseBody return type supported").isFalse();
@ -282,6 +288,37 @@ public class HttpEntityMethodProcessorMockTests {
verify(stringHttpMessageConverter).write(eq(body), eq(accepted), isA(HttpOutputMessage.class)); 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 @Test
public void shouldHandleProblemDetail() throws Exception { public void shouldHandleProblemDetail() throws Exception {
ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
@ -294,7 +331,7 @@ public class HttpEntityMethodProcessorMockTests {
assertThat(webRequest.getNativeResponse(HttpServletResponse.class).getStatus()).isEqualTo(400); assertThat(webRequest.getNativeResponse(HttpServletResponse.class).getStatus()).isEqualTo(400);
verify(jsonMessageConverter).write(eq(problemDetail), eq(APPLICATION_PROBLEM_JSON), isA(HttpOutputMessage.class)); verify(jsonMessageConverter).write(eq(problemDetail), eq(APPLICATION_PROBLEM_JSON), isA(HttpOutputMessage.class));
assertThat(problemDetail).isNotNull() assertThat(problemDetail)
.extracting(ProblemDetail::getInstance).isNotNull() .extracting(ProblemDetail::getInstance).isNotNull()
.extracting(URI::toString) .extracting(URI::toString)
.as("Instance was not set to the request path") .as("Instance was not set to the request path")
@ -842,7 +879,12 @@ public class HttpEntityMethodProcessorMockTests {
} }
@SuppressWarnings("unused") @SuppressWarnings("unused")
public ProblemDetail handle6() { public ErrorResponse handle6() {
return null;
}
@SuppressWarnings("unused")
public ProblemDetail handle7() {
return null; return null;
} }