Do not wrap known-safe MessageSourceResolvables
Build and Deploy Snapshot / Build and Deploy Snapshot (push) Waiting to run Details
Build and Deploy Snapshot / Trigger Docs Build (push) Blocked by required conditions Details
Build and Deploy Snapshot / Verify (push) Blocked by required conditions Details
CI / ${{ matrix.os.name}} | Java ${{ matrix.java.version}} (map[toolchain:false version:17], map[id:${{ vars.UBUNTU_MEDIUM || 'ubuntu-latest' }} name:Linux]) (push) Waiting to run Details
CI / ${{ matrix.os.name}} | Java ${{ matrix.java.version}} (map[toolchain:false version:17], map[id:windows-latest name:Windows]) (push) Waiting to run Details
CI / ${{ matrix.os.name}} | Java ${{ matrix.java.version}} (map[toolchain:false version:21], map[id:${{ vars.UBUNTU_MEDIUM || 'ubuntu-latest' }} name:Linux]) (push) Waiting to run Details
CI / ${{ matrix.os.name}} | Java ${{ matrix.java.version}} (map[toolchain:false version:21], map[id:windows-latest name:Windows]) (push) Waiting to run Details
CI / ${{ matrix.os.name}} | Java ${{ matrix.java.version}} (map[toolchain:true version:24], map[id:${{ vars.UBUNTU_MEDIUM || 'ubuntu-latest' }} name:Linux]) (push) Waiting to run Details
CI / ${{ matrix.os.name}} | Java ${{ matrix.java.version}} (map[toolchain:true version:24], map[id:windows-latest name:Windows]) (push) Waiting to run Details
Run CodeQL Analysis / run-analysis (push) Waiting to run Details
Run System Tests / Java ${{ matrix.java.version}} (map[toolchain:false version:17]) (push) Waiting to run Details
Run System Tests / Java ${{ matrix.java.version}} (map[toolchain:true version:21]) (push) Waiting to run Details

In Spring Boot 3.5, support was added for including the errors from
a  MethodValidationResult in the standard error response. It was
requested in gh-42013 and implemented in gh-43330. With hindsight,
the implementation went too far as it changed how all errors are
serialized to JSON.

This commit reworks the wrapping so that ObjectErrors are not
wrapped, restoring Spring Boot 3.4's behavior. This is considered
safe as the contents of an ObjectError are fairly limited and should
serialize to JSON without problems. Other MessageSourceResolvable
implementations are still wrapped as there are no limits on their
contents and we cannot predict how suitable they are for JSON
serialization.

Closes gh-46260
This commit is contained in:
Andy Wilkinson 2025-07-04 16:05:36 +01:00
parent 12310d36c2
commit efca113f07
6 changed files with 127 additions and 15 deletions

View File

@ -21,9 +21,12 @@ import java.util.Collections;
import java.util.List;
import java.util.Objects;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.validation.ObjectError;
/**
* A wrapper class for {@link MessageSourceResolvable} errors that is safe for JSON
@ -65,6 +68,7 @@ public final class Error implements MessageSourceResolvable {
* Return the original cause of the error.
* @return the error cause
*/
@JsonIgnore
public MessageSourceResolvable getCause() {
return this.cause;
}
@ -91,10 +95,13 @@ public final class Error implements MessageSourceResolvable {
}
/**
* Wrap the given errors.
* Wrap the given errors such that they are suitable for serialization to JSON.
* @param errors the errors to wrap
* @return a new Error list
* @deprecated since 3.5.4 for removal in 4.0.0 in favor of
* {@link #wrapIfNecessary(List)}
*/
@Deprecated(since = "3.5.4", forRemoval = true)
public static List<Error> wrap(List<? extends MessageSourceResolvable> errors) {
if (CollectionUtils.isEmpty(errors)) {
return Collections.emptyList();
@ -106,4 +113,27 @@ public final class Error implements MessageSourceResolvable {
return List.copyOf(result);
}
/**
* Wrap the given errors, if necessary, such that they are suitable for serialization
* to JSON. {@link MessageSourceResolvable} implementations that are known to be
* suitable are not wrapped.
* @param errors the errors to wrap
* @return a new Error list
* @since 3.5.4
*/
public static List<MessageSourceResolvable> wrapIfNecessary(List<? extends MessageSourceResolvable> errors) {
if (CollectionUtils.isEmpty(errors)) {
return Collections.emptyList();
}
List<MessageSourceResolvable> result = new ArrayList<>(errors.size());
for (MessageSourceResolvable error : errors) {
result.add(requiresWrapping(error) ? new Error(error) : error);
}
return List.copyOf(result);
}
private static boolean requiresWrapping(MessageSourceResolvable error) {
return !(error instanceof ObjectError);
}
}

View File

@ -47,8 +47,10 @@ import org.springframework.web.server.ServerWebExchange;
* <li>error - The error reason</li>
* <li>exception - The class name of the root exception (if configured)</li>
* <li>message - The exception message (if configured)</li>
* <li>errors - Any validation errors wrapped in {@link Error}, derived from a
* {@link BindingResult} or {@link MethodValidationResult} exception (if configured)</li>
* <li>errors - Any validation errors derived from a {@link BindingResult} or
* {@link MethodValidationResult} exception (if configured). To ensure safe serialization
* to JSON, errors are {@link Error#wrapIfNecessary(java.util.List) wrapped if
* necessary}</li>
* <li>trace - The exception stack trace (if configured)</li>
* <li>path - The URL path when the exception was raised</li>
* <li>requestId - Unique ID associated with the current request</li>
@ -114,18 +116,18 @@ public class DefaultErrorAttributes implements ErrorAttributes {
if (error instanceof BindingResult bindingResult) {
exception = error;
errorAttributes.put("message", error.getMessage());
errorAttributes.put("errors", Error.wrap(bindingResult.getAllErrors()));
errorAttributes.put("errors", Error.wrapIfNecessary(bindingResult.getAllErrors()));
}
else if (error instanceof MethodValidationResult methodValidationResult) {
exception = error;
errorAttributes.put("message", getErrorMessage(methodValidationResult));
errorAttributes.put("errors", Error.wrap(methodValidationResult.getAllErrors()));
errorAttributes.put("errors", Error.wrapIfNecessary(methodValidationResult.getAllErrors()));
}
else if (error instanceof ResponseStatusException responseStatusException) {
exception = (responseStatusException.getCause() != null) ? responseStatusException.getCause() : error;
errorAttributes.put("message", responseStatusException.getReason());
if (exception instanceof BindingResult bindingResult) {
errorAttributes.put("errors", Error.wrap(bindingResult.getAllErrors()));
errorAttributes.put("errors", Error.wrapIfNecessary(bindingResult.getAllErrors()));
}
}
else {

View File

@ -51,8 +51,10 @@ import org.springframework.web.servlet.ModelAndView;
* <li>error - The error reason</li>
* <li>exception - The class name of the root exception (if configured)</li>
* <li>message - The exception message (if configured)</li>
* <li>errors - Any validation errors wrapped in {@link Error}, derived from a
* {@link BindingResult} or {@link MethodValidationResult} exception (if configured)</li>
* <li>errors - Any validation errors derived from a {@link BindingResult} or
* {@link MethodValidationResult} exception (if configured). To ensure safe serialization
* to JSON, errors are {@link Error#wrapIfNecessary(java.util.List) wrapped if
* necessary}</li>
* <li>trace - The exception stack trace (if configured)</li>
* <li>path - The URL path when the exception was raised</li>
* </ul>
@ -154,14 +156,14 @@ public class DefaultErrorAttributes implements ErrorAttributes, HandlerException
private void addMessageAndErrorsFromBindingResult(Map<String, Object> 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()));
errorAttributes.put("errors", Error.wrapIfNecessary(result.getAllErrors()));
}
private void addMessageAndErrorsFromMethodValidationResult(Map<String, Object> 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()));
errorAttributes.put("errors", Error.wrapIfNecessary(result.getAllErrors()));
}
private void addExceptionErrorMessage(Map<String, Object> errorAttributes, WebRequest webRequest, Throwable error) {

View File

@ -0,0 +1,77 @@
/*
* Copyright 2012-present 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.List;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link Error}.
*
* @author Andy Wilkinson
*/
class ErrorTests {
@Test
@SuppressWarnings({ "rawtypes", "removal" })
@Deprecated(since = "3.5.4", forRemoval = true)
void wrapWrapsAllErrors() {
List<Error> wrapped = Error.wrap(List.of(new ObjectError("name", "message"),
new FieldError("name", "field", "message"), new CustomMessageSourceResolvable("code")));
assertThat(wrapped).extracting((error) -> (Class) error.getClass())
.containsExactly(Error.class, Error.class, Error.class);
}
@Test
@SuppressWarnings("rawtypes")
void wrapIfNecessaryDoesNotWrapFieldErrorOrObjectError() {
List<MessageSourceResolvable> wrapped = Error.wrapIfNecessary(List.of(new ObjectError("name", "message"),
new FieldError("name", "field", "message"), new CustomMessageSourceResolvable("code")));
assertThat(wrapped).extracting((error) -> (Class) error.getClass())
.containsExactly(ObjectError.class, FieldError.class, Error.class);
}
@Test
void errorCauseDoesNotAppearInJson() throws JsonProcessingException {
String json = new ObjectMapper()
.writeValueAsString(Error.wrapIfNecessary(List.of(new CustomMessageSourceResolvable("code"))));
assertThat(json).doesNotContain("some detail");
}
public static class CustomMessageSourceResolvable extends DefaultMessageSourceResolvable {
CustomMessageSourceResolvable(String code) {
super(code);
}
public String getDetail() {
return "some detail";
}
}
}

View File

@ -274,7 +274,7 @@ class DefaultErrorAttributesTests {
+ "int org.springframework.boot.web.reactive.error.DefaultErrorAttributesTests"
+ ".method(java.lang.String), with 1 error(s)");
assertThat(attributes).containsEntry("errors",
org.springframework.boot.web.error.Error.wrap(bindingResult.getAllErrors()));
org.springframework.boot.web.error.Error.wrapIfNecessary(bindingResult.getAllErrors()));
}
@Test
@ -290,7 +290,7 @@ class DefaultErrorAttributesTests {
ErrorAttributeOptions.of(Include.MESSAGE, Include.BINDING_ERRORS));
assertThat(attributes.get("message")).isEqualTo("Invalid");
assertThat(attributes).containsEntry("errors",
org.springframework.boot.web.error.Error.wrap(bindingResult.getAllErrors()));
org.springframework.boot.web.error.Error.wrapIfNecessary(bindingResult.getAllErrors()));
}
@Test
@ -312,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",
org.springframework.boot.web.error.Error.wrap(methodValidationResult.getAllErrors()));
org.springframework.boot.web.error.Error.wrapIfNecessary(methodValidationResult.getAllErrors()));
}
@Test
@ -349,7 +349,7 @@ class DefaultErrorAttributesTests {
.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()));
org.springframework.boot.web.error.Error.wrapIfNecessary(methodValidationResult.getAllErrors()));
}
@Test

View File

@ -241,7 +241,8 @@ class DefaultErrorAttributesTests {
assertThat(attributes).doesNotContainKey("message");
}
if (options.isIncluded(Include.BINDING_ERRORS)) {
assertThat(attributes).containsEntry("errors", org.springframework.boot.web.error.Error.wrap(errors));
assertThat(attributes).containsEntry("errors",
org.springframework.boot.web.error.Error.wrapIfNecessary(errors));
}
else {
assertThat(attributes).doesNotContainKey("errors");