diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/error/Error.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/error/Error.java new file mode 100644 index 00000000000..1ec7a418302 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/error/Error.java @@ -0,0 +1,109 @@ +/* + * Copyright 2012-2025 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.boot.web.error; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import org.springframework.context.MessageSourceResolvable; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * A wrapper class for {@link MessageSourceResolvable} errors that is safe for JSON + * serialization. + * + * @author Yongjun Hong + * @author Phillip Webb + * @since 3.5.0 + */ +public final class Error implements MessageSourceResolvable { + + private final MessageSourceResolvable cause; + + /** + * Create a new {@code Error} instance with the specified cause. + * @param cause the error cause (must not be {@code null}) + */ + private Error(MessageSourceResolvable cause) { + Assert.notNull(cause, "'cause' must not be null"); + this.cause = cause; + } + + @Override + public String[] getCodes() { + return this.cause.getCodes(); + } + + @Override + public Object[] getArguments() { + return this.cause.getArguments(); + } + + @Override + public String getDefaultMessage() { + return this.cause.getDefaultMessage(); + } + + /** + * Return the original cause of the error. + * @return the error cause + */ + public MessageSourceResolvable getCause() { + return this.cause; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + return Objects.equals(this.cause, ((Error) obj).cause); + } + + @Override + public int hashCode() { + return Objects.hash(this.cause); + } + + @Override + public String toString() { + return this.cause.toString(); + } + + /** + * Wrap the given errors. + * @param errors the errors to wrap + * @return a new Error list + */ + public static List wrap(List errors) { + if (CollectionUtils.isEmpty(errors)) { + return Collections.emptyList(); + } + List result = new ArrayList<>(errors.size()); + for (MessageSourceResolvable error : errors) { + result.add(new Error(error)); + } + return List.copyOf(result); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/error/ErrorWrapper.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/error/ErrorWrapper.java deleted file mode 100644 index e0d7e9d021b..00000000000 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/error/ErrorWrapper.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2012-2024 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.boot.web.error; - -import jakarta.annotation.Nullable; -import org.springframework.context.MessageSourceResolvable; -import org.springframework.context.support.DefaultMessageSourceResolvable; -import org.springframework.util.Assert; - -/** - * A wrapper class for error objects that implements {@link MessageSourceResolvable}. - * This class extends {@link DefaultMessageSourceResolvable} and delegates the - * message resolution to the wrapped error object. - * - * @author Yongjun Hong - * @since 3.5.0 - */ -public class ErrorWrapper extends DefaultMessageSourceResolvable { - - private final Object error; - - /** - * Create a new {@code ErrorWrapper} instance with the specified error. - * - * @param error the error object to wrap (must not be {@code null}) - */ - public ErrorWrapper(Object error) { - this(error, null, null, null); - } - - /** - * Create a new {@code ErrorWrapper} instance with the specified error, codes, - * arguments, and default message. - * - * @param error the error object to wrap (must not be {@code null}) - * @param codes the codes to be used for message resolution - * @param arguments the arguments to be used for message resolution - * @param defaultMessage the default message to be used if no message is found - */ - public ErrorWrapper(Object error, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage) { - super(codes, arguments, defaultMessage); - Assert.notNull(error, "Error must not be null"); - this.error = error; - } - - /** - * Return the codes to be used for message resolution. - * - * @return the codes to be used for message resolution - */ - @Override - public String[] getCodes() { - return ((MessageSourceResolvable) this.error).getCodes(); - } - - /** - * Return the arguments to be used for message resolution. - * - * @return the arguments to be used for message resolution - */ - @Override - public Object[] getArguments() { - return ((MessageSourceResolvable) this.error).getArguments(); - } - - /** - * Return the default message to be used if no message is found. - * - * @return the default message to be used if no message is found - */ - @Override - public String getDefaultMessage() { - return ((MessageSourceResolvable) this.error).getDefaultMessage(); - } - -} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributes.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributes.java index db8bc3f8082..f574c35dd05 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributes.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributes.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 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. @@ -20,11 +20,10 @@ import java.io.PrintWriter; import java.io.StringWriter; import java.util.Date; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import java.util.Optional; -import org.springframework.boot.web.error.ErrorWrapper; +import org.springframework.boot.web.error.Error; import org.springframework.boot.web.error.ErrorAttributeOptions; import org.springframework.boot.web.error.ErrorAttributeOptions.Include; import org.springframework.core.annotation.MergedAnnotation; @@ -48,7 +47,7 @@ import org.springframework.web.server.ServerWebExchange; *
  • error - The error reason
  • *
  • exception - The class name of the root exception (if configured)
  • *
  • message - The exception message (if configured)
  • - *
  • errors - Any validation errors wrapped in {@link ErrorWrapper}, derived from a + *
  • errors - Any validation errors wrapped in {@link Error}, derived from a * {@link BindingResult} or {@link MethodValidationResult} exception (if configured)
  • *
  • trace - The exception stack trace (if configured)
  • *
  • path - The URL path when the exception was raised
  • @@ -113,19 +112,20 @@ public class DefaultErrorAttributes implements ErrorAttributes { MergedAnnotation responseStatusAnnotation, boolean includeStackTrace) { Throwable exception; if (error instanceof BindingResult bindingResult) { - errorAttributes.put("message", error.getMessage()); - errorAttributes.put("errors", bindingResult.getAllErrors()); exception = error; + errorAttributes.put("message", error.getMessage()); + errorAttributes.put("errors", Error.wrap(bindingResult.getAllErrors())); } else if (error instanceof MethodValidationResult methodValidationResult) { - addMessageAndErrorsFromMethodValidationResult(errorAttributes, methodValidationResult); exception = error; + errorAttributes.put("message", getErrorMessage(methodValidationResult)); + errorAttributes.put("errors", Error.wrap(methodValidationResult.getAllErrors())); } else if (error instanceof ResponseStatusException responseStatusException) { - errorAttributes.put("message", responseStatusException.getReason()); exception = (responseStatusException.getCause() != null) ? responseStatusException.getCause() : error; + errorAttributes.put("message", responseStatusException.getReason()); if (exception instanceof BindingResult bindingResult) { - errorAttributes.put("errors", bindingResult.getAllErrors()); + errorAttributes.put("errors", Error.wrap(bindingResult.getAllErrors())); } } else { @@ -140,15 +140,9 @@ public class DefaultErrorAttributes implements ErrorAttributes { } } - private void addMessageAndErrorsFromMethodValidationResult(Map errorAttributes, - MethodValidationResult result) { - List errors = result.getAllErrors() - .stream() - .map(ErrorWrapper::new) - .toList(); - errorAttributes.put("message", - "Validation failed for method='" + result.getMethod() + "'. Error count: " + errors.size()); - errorAttributes.put("errors", errors); + private String getErrorMessage(MethodValidationResult methodValidationResult) { + return "Validation failed for method='%s'. Error count: %s".formatted(methodValidationResult.getMethod(), + methodValidationResult.getAllErrors().size()); } @Override diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributes.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributes.java index fdeceb6ee62..0e29fd02732 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributes.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributes.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 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. @@ -20,7 +20,6 @@ import java.io.PrintWriter; import java.io.StringWriter; import java.util.Date; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import jakarta.servlet.RequestDispatcher; @@ -28,16 +27,15 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.boot.web.error.Error; import org.springframework.boot.web.error.ErrorAttributeOptions; import org.springframework.boot.web.error.ErrorAttributeOptions.Include; -import org.springframework.boot.web.error.ErrorWrapper; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; import org.springframework.validation.BindingResult; -import org.springframework.validation.ObjectError; import org.springframework.validation.method.MethodValidationResult; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.WebRequest; @@ -53,7 +51,7 @@ import org.springframework.web.servlet.ModelAndView; *
  • error - The error reason
  • *
  • exception - The class name of the root exception (if configured)
  • *
  • message - The exception message (if configured)
  • - *
  • errors - Any validation errors wrapped in {@link ErrorWrapper}, derived from a + *
  • errors - Any validation errors wrapped in {@link Error}, derived from a * {@link BindingResult} or {@link MethodValidationResult} exception (if configured)
  • *
  • trace - The exception stack trace (if configured)
  • *
  • path - The URL path when the exception was raised
  • @@ -143,27 +141,27 @@ public class DefaultErrorAttributes implements ErrorAttributes, HandlerException BindingResult bindingResult = extractBindingResult(error); if (bindingResult != null) { addMessageAndErrorsFromBindingResult(errorAttributes, bindingResult); + return; } - else { - MethodValidationResult methodValidationResult = extractMethodValidationResult(error); - if (methodValidationResult != null) { - addMessageAndErrorsFromMethodValidationResult(errorAttributes, methodValidationResult); - } - else { - addExceptionErrorMessage(errorAttributes, webRequest, error); - } + MethodValidationResult methodValidationResult = extractMethodValidationResult(error); + if (methodValidationResult != null) { + addMessageAndErrorsFromMethodValidationResult(errorAttributes, methodValidationResult); + return; } + addExceptionErrorMessage(errorAttributes, webRequest, error); + } + + private void addMessageAndErrorsFromBindingResult(Map errorAttributes, BindingResult result) { + errorAttributes.put("message", "Validation failed for object='%s'. Error count: %s" + .formatted(result.getObjectName(), result.getAllErrors().size())); + errorAttributes.put("errors", Error.wrap(result.getAllErrors())); } private void addMessageAndErrorsFromMethodValidationResult(Map errorAttributes, MethodValidationResult result) { - List errors = result.getAllErrors() - .stream() - .map(ErrorWrapper::new) - .toList(); - errorAttributes.put("message", - "Validation failed for method='" + result.getMethod() + "'. Error count: " + errors.size()); - errorAttributes.put("errors", errors); + errorAttributes.put("message", "Validation failed for method='%s'. Error count: %s" + .formatted(result.getMethod(), result.getAllErrors().size())); + errorAttributes.put("errors", Error.wrap(result.getAllErrors())); } private void addExceptionErrorMessage(Map errorAttributes, WebRequest webRequest, Throwable error) { @@ -195,17 +193,6 @@ public class DefaultErrorAttributes implements ErrorAttributes, HandlerException return "No message available"; } - private void addMessageAndErrorsFromBindingResult(Map errorAttributes, BindingResult result) { - addMessageAndErrorsForValidationFailure(errorAttributes, "object='" + result.getObjectName() + "'", - result.getAllErrors()); - } - - private void addMessageAndErrorsForValidationFailure(Map errorAttributes, String validated, - List errors) { - errorAttributes.put("message", "Validation failed for " + validated + ". Error count: " + errors.size()); - errorAttributes.put("errors", errors); - } - private BindingResult extractBindingResult(Throwable error) { if (error instanceof BindingResult bindingResult) { return bindingResult; diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributesTests.java index 5ac81ae153c..57dcd768c6a 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributesTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 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. @@ -273,7 +273,8 @@ class DefaultErrorAttributesTests { .startsWith("Validation failed for argument at index 0 in method: " + "int org.springframework.boot.web.reactive.error.DefaultErrorAttributesTests" + ".method(java.lang.String), with 1 error(s)"); - assertThat(attributes).containsEntry("errors", bindingResult.getAllErrors()); + assertThat(attributes).containsEntry("errors", + org.springframework.boot.web.error.Error.wrap(bindingResult.getAllErrors())); } @Test @@ -288,7 +289,8 @@ class DefaultErrorAttributesTests { buildServerRequest(request, new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid", ex)), ErrorAttributeOptions.of(Include.MESSAGE, Include.BINDING_ERRORS)); assertThat(attributes.get("message")).isEqualTo("Invalid"); - assertThat(attributes).containsEntry("errors", bindingResult.getAllErrors()); + assertThat(attributes).containsEntry("errors", + org.springframework.boot.web.error.Error.wrap(bindingResult.getAllErrors())); } @Test @@ -310,7 +312,7 @@ class DefaultErrorAttributesTests { .isEqualTo( "Validation failed for method='public java.lang.String java.lang.String.substring(int)'. Error count: 1"); assertThat(attributes).containsEntry("errors", - methodValidationResult.getAllErrors().stream().filter(ObjectError.class::isInstance).toList()); + org.springframework.boot.web.error.Error.wrap(methodValidationResult.getAllErrors())); } @Test @@ -341,14 +343,13 @@ class DefaultErrorAttributesTests { List.of(parameterValidationResult)); HandlerMethodValidationException ex = new HandlerMethodValidationException(methodValidationResult); MockServerHttpRequest request = MockServerHttpRequest.get("/test").build(); - Map attributes = this.errorAttributes.getErrorAttributes(buildServerRequest(request, ex), ErrorAttributeOptions.of(Include.MESSAGE, Include.BINDING_ERRORS)); - assertThat(attributes.get("message")).asString() - .isEqualTo("Validation failed for method='public java.lang.String java.lang.String.substring(int)'. Error count: 1"); + .isEqualTo( + "Validation failed for method='public java.lang.String java.lang.String.substring(int)'. Error count: 1"); assertThat(attributes).containsEntry("errors", - methodValidationResult.getAllErrors().stream().filter(ObjectError.class::isInstance).toList()); + org.springframework.boot.web.error.Error.wrap(methodValidationResult.getAllErrors())); } @Test diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributesTests.java index 999fb37d200..992dbe3d54d 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributesTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 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. @@ -241,7 +241,7 @@ class DefaultErrorAttributesTests { assertThat(attributes).doesNotContainKey("message"); } if (options.isIncluded(Include.BINDING_ERRORS)) { - assertThat(attributes).containsEntry("errors", errors); + assertThat(attributes).containsEntry("errors", org.springframework.boot.web.error.Error.wrap(errors)); } else { assertThat(attributes).doesNotContainKey("errors");