diff --git a/spring-web/src/main/java/org/springframework/http/ResponseEntity.java b/spring-web/src/main/java/org/springframework/http/ResponseEntity.java index dd3899adfc68..38321c8da520 100644 --- a/spring-web/src/main/java/org/springframework/http/ResponseEntity.java +++ b/spring-web/src/main/java/org/springframework/http/ResponseEntity.java @@ -263,13 +263,13 @@ public static ResponseEntity of(Optional body) { } /** - * Create a builder for a {@code ResponseEntity} with the given - * {@link ProblemDetail} as the body, and its - * {@link ProblemDetail#getStatus() status} as the status. - *

Note that {@code ProblemDetail} is supported as a return value from - * controller methods and from {@code @ExceptionHandler} methods. The method - * here is convenient to also add response headers. - * @param body the details for an HTTP error response + * Create a new {@link HeadersBuilder} with its status set to + * {@link ProblemDetail#getStatus()} and its body is set to + * {@link ProblemDetail}. + *

Note: If there are no headers to add, there is usually + * no need to create a {@link ResponseEntity} since {@code ProblemDetail} + * is also supported as a return value from controller methods. + * @param body the problem detail to use * @return the created builder * @since 6.0 */ diff --git a/spring-web/src/main/java/org/springframework/web/DefaultErrorResponseBuilder.java b/spring-web/src/main/java/org/springframework/web/DefaultErrorResponseBuilder.java new file mode 100644 index 000000000000..06b092a2f49b --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/DefaultErrorResponseBuilder.java @@ -0,0 +1,209 @@ +/* + * 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 java.util.function.Consumer; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + + +/** + * Default implementation of {@link ErrorResponse.Builder}. + * + * @author Rossen Stoyanchev + * @since 6.0 + */ +final class DefaultErrorResponseBuilder implements ErrorResponse.Builder { + + private final Throwable exception; + + private final HttpStatusCode statusCode; + + @Nullable + private HttpHeaders headers; + + private final ProblemDetail problemDetail; + + private String detailMessageCode; + + @Nullable + private Object[] detailMessageArguments; + + private String titleMessageCode; + + + DefaultErrorResponseBuilder(Throwable ex, HttpStatusCode statusCode, String detail) { + Assert.notNull(ex, "Throwable is required"); + Assert.notNull(ex, "HttpStatusCode is required"); + Assert.notNull(ex, "`detail` is required"); + this.exception = ex; + this.statusCode = statusCode; + this.problemDetail = ProblemDetail.forStatusAndDetail(statusCode, detail); + this.detailMessageCode = ErrorResponse.getDefaultDetailMessageCode(ex.getClass(), null); + this.titleMessageCode = ErrorResponse.getDefaultTitleMessageCode(ex.getClass()); + } + + + @Override + public ErrorResponse.Builder header(String headerName, String... headerValues) { + this.headers = (this.headers != null ? this.headers : new HttpHeaders()); + for (String headerValue : headerValues) { + this.headers.add(headerName, headerValue); + } + return this; + } + + @Override + public ErrorResponse.Builder headers(Consumer headersConsumer) { + return this; + } + + @Override + public ErrorResponse.Builder detail(String detail) { + this.problemDetail.setDetail(detail); + return this; + } + + @Override + public ErrorResponse.Builder detailMessageCode(String messageCode) { + Assert.notNull(messageCode, "`detailMessageCode` is required"); + this.detailMessageCode = messageCode; + return this; + } + + @Override + public ErrorResponse.Builder detailMessageArguments(Object... messageArguments) { + this.detailMessageArguments = messageArguments; + return this; + } + + @Override + public ErrorResponse.Builder type(URI type) { + this.problemDetail.setType(type); + return this; + } + + @Override + public ErrorResponse.Builder title(@Nullable String title) { + this.problemDetail.setTitle(title); + return this; + } + + @Override + public ErrorResponse.Builder titleMessageCode(String messageCode) { + Assert.notNull(messageCode, "`titleMessageCode` is required"); + this.titleMessageCode = messageCode; + return this; + } + + @Override + public ErrorResponse.Builder instance(@Nullable URI instance) { + this.problemDetail.setInstance(instance); + return this; + } + + @Override + public ErrorResponse.Builder property(String name, Object value) { + this.problemDetail.setProperty(name, value); + return this; + } + + @Override + public ErrorResponse build() { + return new SimpleErrorResponse( + this.exception, this.statusCode, this.headers, this.problemDetail, + this.detailMessageCode, this.detailMessageArguments, this.titleMessageCode); + } + + + /** + * Simple container for {@code ErrorResponse} values. + */ + private static class SimpleErrorResponse implements ErrorResponse { + + private final Throwable exception; + + private final HttpStatusCode statusCode; + + private final HttpHeaders headers; + + private final ProblemDetail problemDetail; + + private final String detailMessageCode; + + @Nullable + private final Object[] detailMessageArguments; + + private final String titleMessageCode; + + SimpleErrorResponse( + Throwable ex, HttpStatusCode statusCode, @Nullable HttpHeaders headers, ProblemDetail problemDetail, + String detailMessageCode, @Nullable Object[] detailMessageArguments, String titleMessageCode) { + + this.exception = ex; + this.statusCode = statusCode; + this.headers = (headers != null ? headers : HttpHeaders.EMPTY); + this.problemDetail = problemDetail; + this.detailMessageCode = detailMessageCode; + this.detailMessageArguments = detailMessageArguments; + this.titleMessageCode = titleMessageCode; + } + + @Override + public HttpStatusCode getStatusCode() { + return this.statusCode; + } + + @Override + public HttpHeaders getHeaders() { + return this.headers; + } + + @Override + public ProblemDetail getBody() { + return this.problemDetail; + } + + @Override + public String getDetailMessageCode() { + return this.detailMessageCode; + } + + @Override + public Object[] getDetailMessageArguments() { + return this.detailMessageArguments; + } + + @Override + public String getTitleMessageCode() { + return this.titleMessageCode; + } + + @Override + public String toString() { + return "ErrorResponse{status=" + this.statusCode + ", " + + "headers=" + this.headers + ", body=" + this.problemDetail + ", " + + "exception=" + this.exception + "}"; + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/ErrorResponse.java b/spring-web/src/main/java/org/springframework/web/ErrorResponse.java index 3f239de6e264..3c046a5a72c9 100644 --- a/spring-web/src/main/java/org/springframework/web/ErrorResponse.java +++ b/spring-web/src/main/java/org/springframework/web/ErrorResponse.java @@ -16,7 +16,9 @@ package org.springframework.web; +import java.net.URI; import java.util.Locale; +import java.util.function.Consumer; import org.springframework.context.MessageSource; import org.springframework.http.HttpHeaders; @@ -148,35 +150,124 @@ static String getDefaultTitleMessageCode(Class exceptionType) { return "problemDetail.title." + exceptionType.getName(); } + /** - * Map the given Exception to an {@link ErrorResponse}. - * @param ex the Exception, mostly to derive message codes, if not provided - * @param status the response status to use - * @param headers optional headers to add to the response - * @param defaultDetail default value for the "detail" field - * @param detailMessageCode the code to use to look up the "detail" field - * through a {@code MessageSource}, falling back on - * {@link #getDefaultDetailMessageCode(Class, String)} - * @param detailMessageArguments the arguments to go with the detailMessageCode - * @return the created {@code ErrorResponse} instance + * Static factory method to build an instance via + * {@link #builder(Throwable, HttpStatusCode, String)}. */ - static ErrorResponse createFor( - Exception ex, HttpStatusCode status, @Nullable HttpHeaders headers, - String defaultDetail, @Nullable String detailMessageCode, @Nullable Object[] detailMessageArguments) { + static ErrorResponse create(Throwable ex, HttpStatusCode statusCode, String detail) { + return builder(ex, statusCode, detail).build(); + } - if (detailMessageCode == null) { - detailMessageCode = ErrorResponse.getDefaultDetailMessageCode(ex.getClass(), null); - } + /** + * Return a builder to create an {@code ErrorResponse} instance. + * @param ex the underlying exception that lead to the error response; + * mainly to derive default values for the + * {@link #getDetailMessageCode() detail message code} and for the + * {@link #getTitleMessageCode() title message code}. + * @param statusCode the status code to set the response to + * @param detail the default value for the + * {@link ProblemDetail#setDetail(String) detail} field, unless overridden + * by a {@link MessageSource} lookup with {@link #getDetailMessageCode()} + */ + static Builder builder(Throwable ex, HttpStatusCode statusCode, String detail) { + return new DefaultErrorResponseBuilder(ex, statusCode, detail); + } - ErrorResponseException errorResponse = new ErrorResponseException( - status, ProblemDetail.forStatusAndDetail(status, defaultDetail), null, - detailMessageCode, detailMessageArguments); - if (headers != null) { - errorResponse.getHeaders().putAll(headers); - } + /** + * Builder for an {@code ErrorResponse}. + */ + interface Builder { + + /** + * Add the given header value(s) under the given name. + * @param headerName the header name + * @param headerValues the header value(s) + * @return the same builder instance + * @see HttpHeaders#add(String, String) + */ + Builder header(String headerName, String... headerValues); + + /** + * Manipulate this response's headers with the given consumer. This is + * useful to {@linkplain HttpHeaders#set(String, String) overwrite} or + * {@linkplain HttpHeaders#remove(Object) remove} existing values, or + * use any other {@link HttpHeaders} methods. + * @param headersConsumer a function that consumes the {@code HttpHeaders} + * @return the same builder instance + */ + Builder headers(Consumer headersConsumer); + + /** + * Set the underlying {@link ProblemDetail#setDetail(String)}. + * @return the same builder instance + */ + Builder detail(String detail); + + /** + * Customize the {@link MessageSource} code for looking up the value for + * the underlying {@link #detail(String)}. + *

By default, this is set to + * {@link ErrorResponse#getDefaultDetailMessageCode(Class, String)} with the + * associated Exception type. + * @param messageCode the message code to use + * @return the same builder instance + * @see ErrorResponse#getDetailMessageCode() + */ + Builder detailMessageCode(String messageCode); + + /** + * Set the arguments to provide to the {@link MessageSource} lookup for + * {@link #detailMessageCode(String)}. + * @param messageArguments the arguments to provide + * @return the same builder instance + * @see ErrorResponse#getDetailMessageArguments() + */ + Builder detailMessageArguments(Object... messageArguments); + + /** + * Set the underlying {@link ProblemDetail#setTitle(String)} field. + * @return the same builder instance + */ + Builder type(URI type); + + /** + * Set the underlying {@link ProblemDetail#setTitle(String)} field. + * @return the same builder instance + */ + Builder title(@Nullable String title); + + /** + * Customize the {@link MessageSource} code for looking up the value for + * the underlying {@link ProblemDetail#setTitle(String)}. + *

By default, set via + * {@link ErrorResponse#getDefaultTitleMessageCode(Class)} with the + * associated Exception type. + * @param messageCode the message code to use + * @return the same builder instance + * @see ErrorResponse#getTitleMessageCode() + */ + Builder titleMessageCode(String messageCode); + + /** + * Set the underlying {@link ProblemDetail#setInstance(URI)} field. + * @return the same builder instance + */ + Builder instance(@Nullable URI instance); + + /** + * Set a "dynamic" {@link ProblemDetail#setProperty(String, Object) + * property} on the underlying {@code ProblemDetail}. + * @return the same builder instance + */ + Builder property(String name, Object value); + + /** + * Build the {@code ErrorResponse} instance. + */ + ErrorResponse build(); - return errorResponse; } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java index 9c0757a6cd5d..2bb5c9454b00 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java @@ -42,6 +42,7 @@ import org.springframework.http.codec.json.Jackson2CodecSupport; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.util.MultiValueMap; +import org.springframework.web.ErrorResponse; import org.springframework.web.reactive.function.BodyInserter; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.result.view.ViewResolver; @@ -104,6 +105,18 @@ static BodyBuilder from(ServerResponse other) { return new DefaultServerResponseBuilder(other); } + /** + * Create a {@code ServerResponse} from the given {@link ErrorResponse}. + * @param response the {@link ErrorResponse} to initialize from + * @return {@code Mono} with the built response + * @since 6.0 + */ + static Mono from(ErrorResponse response) { + return status(response.getStatusCode()) + .headers(headers -> headers.putAll(response.getHeaders())) + .bodyValue(response.getBody()); + } + /** * Create a builder with the given HTTP status. * @param status the response status diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandler.java index 99f8e386fa3e..8bd42efbbf90 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandler.java @@ -309,10 +309,14 @@ protected ProblemDetail createProblemDetail( Exception ex, HttpStatusCode status, String defaultDetail, @Nullable String detailMessageCode, @Nullable Object[] detailMessageArguments, ServerWebExchange exchange) { - ErrorResponse response = ErrorResponse.createFor( - ex, status, null, defaultDetail, detailMessageCode, detailMessageArguments); - - return response.updateAndGetBody(this.messageSource, getLocale(exchange)); + ErrorResponse.Builder builder = ErrorResponse.builder(ex, status, defaultDetail); + if (detailMessageCode != null) { + builder.detailMessageCode(detailMessageCode); + } + if (detailMessageArguments != null) { + builder.detailMessageArguments(detailMessageArguments); + } + return builder.build().updateAndGetBody(this.messageSource, getLocale(exchange)); } private static Locale getLocale(ServerWebExchange exchange) { diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerResponse.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerResponse.java index 026e0814cf1b..0b6beb803a3d 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerResponse.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerResponse.java @@ -47,6 +47,7 @@ import org.springframework.http.converter.HttpMessageConverter; import org.springframework.lang.Nullable; import org.springframework.util.MultiValueMap; +import org.springframework.web.ErrorResponse; import org.springframework.web.servlet.ModelAndView; /** @@ -106,6 +107,18 @@ static BodyBuilder from(ServerResponse other) { return new DefaultServerResponseBuilder(other); } + /** + * Create a {@code ServerResponse} from the given {@link ErrorResponse}. + * @param response the {@link ErrorResponse} to initialize from + * @return the built response + * @since 6.0 + */ + static ServerResponse from(ErrorResponse response) { + return status(response.getStatusCode()) + .headers(headers -> headers.putAll(response.getHeaders())) + .body(response.getBody()); + } + /** * Create a builder with the given HTTP status. * @param status the response status diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java index a30433a34941..7e97cf5d0d98 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java @@ -508,10 +508,14 @@ protected ProblemDetail createProblemDetail( Exception ex, HttpStatusCode status, String defaultDetail, @Nullable String detailMessageCode, @Nullable Object[] detailMessageArguments, WebRequest request) { - ErrorResponse errorResponse = ErrorResponse.createFor( - ex, status, null, defaultDetail, detailMessageCode, detailMessageArguments); - - return errorResponse.updateAndGetBody(this.messageSource, LocaleContextHolder.getLocale()); + ErrorResponse.Builder builder = ErrorResponse.builder(ex, status, defaultDetail); + if (detailMessageCode != null) { + builder.detailMessageCode(detailMessageCode); + } + if (detailMessageArguments != null) { + builder.detailMessageArguments(detailMessageArguments); + } + return builder.build().updateAndGetBody(this.messageSource, LocaleContextHolder.getLocale()); } /**