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 000000000000..1ec7a4183021 --- /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/reactive/error/DefaultErrorAttributes.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributes.java index c02b439fa9d5..f574c35dd052 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,10 +20,10 @@ 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.Error; import org.springframework.boot.web.error.ErrorAttributeOptions; import org.springframework.boot.web.error.ErrorAttributeOptions.Include; import org.springframework.core.annotation.MergedAnnotation; @@ -32,7 +32,6 @@ import org.springframework.http.HttpStatus; 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.bind.annotation.ResponseStatus; import org.springframework.web.reactive.function.server.ServerRequest; @@ -48,8 +47,8 @@ *
  • error - The error reason
  • *
  • exception - The class name of the root exception (if configured)
  • *
  • message - The exception message (if configured)
  • - *
  • errors - Any {@link ObjectError}s from a {@link BindingResult} or - * {@link MethodValidationResult} exception (if configured)
  • + *
  • 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
  • *
  • requestId - Unique ID associated with the current request
  • @@ -61,6 +60,7 @@ * @author Scott Frederick * @author Moritz Halbritter * @author Yanming Zhou + * @author Yongjun Hong * @since 2.0.0 * @see ErrorAttributes */ @@ -112,19 +112,20 @@ private void handleException(Map errorAttributes, Throwable erro 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 { @@ -139,16 +140,9 @@ else if (error instanceof ResponseStatusException responseStatusException) { } } - private void addMessageAndErrorsFromMethodValidationResult(Map errorAttributes, - MethodValidationResult result) { - List errors = result.getAllErrors() - .stream() - .filter(ObjectError.class::isInstance) - .map(ObjectError.class::cast) - .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 9c351d633f79..0e29fd027321 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.StringWriter; import java.util.Date; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import jakarta.servlet.RequestDispatcher; @@ -28,6 +27,7 @@ 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.core.Ordered; @@ -36,7 +36,6 @@ 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; @@ -52,8 +51,8 @@ *
  • error - The error reason
  • *
  • exception - The class name of the root exception (if configured)
  • *
  • message - The exception message (if configured)
  • - *
  • errors - Any {@link ObjectError}s from a {@link BindingResult} or - * {@link MethodValidationResult} exception (if configured)
  • + *
  • 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
  • * @@ -65,6 +64,7 @@ * @author Scott Frederick * @author Moritz Halbritter * @author Yanming Zhou + * @author Yongjun Hong * @since 2.0.0 * @see ErrorAttributes */ @@ -141,16 +141,27 @@ private void addErrorMessage(Map errorAttributes, WebRequest web 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) { + 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) { @@ -182,27 +193,6 @@ protected String getMessage(WebRequest webRequest, Throwable error) { return "No message available"; } - private void addMessageAndErrorsFromBindingResult(Map errorAttributes, BindingResult result) { - addMessageAndErrorsForValidationFailure(errorAttributes, "object='" + result.getObjectName() + "'", - result.getAllErrors()); - } - - private void addMessageAndErrorsFromMethodValidationResult(Map errorAttributes, - MethodValidationResult result) { - List errors = result.getAllErrors() - .stream() - .filter(ObjectError.class::isInstance) - .map(ObjectError.class::cast) - .toList(); - addMessageAndErrorsForValidationFailure(errorAttributes, "method='" + result.getMethod() + "'", errors); - } - - 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 f24de2fe8e5e..57dcd768c6a8 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. @@ -55,6 +55,7 @@ * @author Scott Frederick * @author Moritz Halbritter * @author Yanming Zhou + * @author Yongjun Hong */ class DefaultErrorAttributesTests { @@ -272,7 +273,8 @@ void extractBindingResultErrors() throws Exception { .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 @@ -287,7 +289,8 @@ void extractBindingResultErrorsThatCausedAResponseStatusException() throws Excep 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 @@ -309,7 +312,7 @@ void extractMethodValidationResultErrors() throws Exception { .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 @@ -326,6 +329,29 @@ void extractBindingResultErrorsExcludeMessageAndErrors() throws Exception { assertThat(attributes).doesNotContainKey("errors"); } + @Test + void extractParameterValidationResultErrors() throws Exception { + Object target = "test"; + Method method = String.class.getMethod("substring", int.class); + MethodParameter parameter = new MethodParameter(method, 0); + ParameterValidationResult parameterValidationResult = new ParameterValidationResult(parameter, -1, + List.of(new ObjectError("beginIndex", "beginIndex is negative")), null, null, null, + (error, sourceType) -> { + throw new IllegalArgumentException("No source object of the given type"); + }); + MethodValidationResult methodValidationResult = MethodValidationResult.create(target, method, + 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"); + assertThat(attributes).containsEntry("errors", + org.springframework.boot.web.error.Error.wrap(methodValidationResult.getAllErrors())); + } + @Test void excludeStatus() { ResponseStatusException error = new ResponseStatusException(HttpStatus.NOT_ACCEPTABLE, 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 999fb37d2001..992dbe3d54d7 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 @@ private void testErrors(List errors, String e 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");