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");
* 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.
* @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 {
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.
*/

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");
* 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.
* @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 {
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.

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");
* you may not use this file except in compliance with the License.
@@ -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 {
* @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 {
* @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,44 +75,12 @@ 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)
*/
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).
*/
@@ -129,11 +89,32 @@ public class ResponseStatusException extends NestedRuntimeException {
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
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());
}

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

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