Skip to content

Commit efca113

Browse files
committed
Do not wrap known-safe MessageSourceResolvables
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
1 parent 12310d3 commit efca113

File tree

6 files changed

+127
-15
lines changed

6 files changed

+127
-15
lines changed

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/error/Error.java

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@
2121
import java.util.List;
2222
import java.util.Objects;
2323

24+
import com.fasterxml.jackson.annotation.JsonIgnore;
25+
2426
import org.springframework.context.MessageSourceResolvable;
2527
import org.springframework.util.Assert;
2628
import org.springframework.util.CollectionUtils;
29+
import org.springframework.validation.ObjectError;
2730

2831
/**
2932
* A wrapper class for {@link MessageSourceResolvable} errors that is safe for JSON
@@ -65,6 +68,7 @@ public String getDefaultMessage() {
6568
* Return the original cause of the error.
6669
* @return the error cause
6770
*/
71+
@JsonIgnore
6872
public MessageSourceResolvable getCause() {
6973
return this.cause;
7074
}
@@ -91,10 +95,13 @@ public String toString() {
9195
}
9296

9397
/**
94-
* Wrap the given errors.
98+
* Wrap the given errors such that they are suitable for serialization to JSON.
9599
* @param errors the errors to wrap
96100
* @return a new Error list
101+
* @deprecated since 3.5.4 for removal in 4.0.0 in favor of
102+
* {@link #wrapIfNecessary(List)}
97103
*/
104+
@Deprecated(since = "3.5.4", forRemoval = true)
98105
public static List<Error> wrap(List<? extends MessageSourceResolvable> errors) {
99106
if (CollectionUtils.isEmpty(errors)) {
100107
return Collections.emptyList();
@@ -106,4 +113,27 @@ public static List<Error> wrap(List<? extends MessageSourceResolvable> errors) {
106113
return List.copyOf(result);
107114
}
108115

116+
/**
117+
* Wrap the given errors, if necessary, such that they are suitable for serialization
118+
* to JSON. {@link MessageSourceResolvable} implementations that are known to be
119+
* suitable are not wrapped.
120+
* @param errors the errors to wrap
121+
* @return a new Error list
122+
* @since 3.5.4
123+
*/
124+
public static List<MessageSourceResolvable> wrapIfNecessary(List<? extends MessageSourceResolvable> errors) {
125+
if (CollectionUtils.isEmpty(errors)) {
126+
return Collections.emptyList();
127+
}
128+
List<MessageSourceResolvable> result = new ArrayList<>(errors.size());
129+
for (MessageSourceResolvable error : errors) {
130+
result.add(requiresWrapping(error) ? new Error(error) : error);
131+
}
132+
return List.copyOf(result);
133+
}
134+
135+
private static boolean requiresWrapping(MessageSourceResolvable error) {
136+
return !(error instanceof ObjectError);
137+
}
138+
109139
}

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributes.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@
4747
* <li>error - The error reason</li>
4848
* <li>exception - The class name of the root exception (if configured)</li>
4949
* <li>message - The exception message (if configured)</li>
50-
* <li>errors - Any validation errors wrapped in {@link Error}, derived from a
51-
* {@link BindingResult} or {@link MethodValidationResult} exception (if configured)</li>
50+
* <li>errors - Any validation errors derived from a {@link BindingResult} or
51+
* {@link MethodValidationResult} exception (if configured). To ensure safe serialization
52+
* to JSON, errors are {@link Error#wrapIfNecessary(java.util.List) wrapped if
53+
* necessary}</li>
5254
* <li>trace - The exception stack trace (if configured)</li>
5355
* <li>path - The URL path when the exception was raised</li>
5456
* <li>requestId - Unique ID associated with the current request</li>
@@ -114,18 +116,18 @@ private void handleException(Map<String, Object> errorAttributes, Throwable erro
114116
if (error instanceof BindingResult bindingResult) {
115117
exception = error;
116118
errorAttributes.put("message", error.getMessage());
117-
errorAttributes.put("errors", Error.wrap(bindingResult.getAllErrors()));
119+
errorAttributes.put("errors", Error.wrapIfNecessary(bindingResult.getAllErrors()));
118120
}
119121
else if (error instanceof MethodValidationResult methodValidationResult) {
120122
exception = error;
121123
errorAttributes.put("message", getErrorMessage(methodValidationResult));
122-
errorAttributes.put("errors", Error.wrap(methodValidationResult.getAllErrors()));
124+
errorAttributes.put("errors", Error.wrapIfNecessary(methodValidationResult.getAllErrors()));
123125
}
124126
else if (error instanceof ResponseStatusException responseStatusException) {
125127
exception = (responseStatusException.getCause() != null) ? responseStatusException.getCause() : error;
126128
errorAttributes.put("message", responseStatusException.getReason());
127129
if (exception instanceof BindingResult bindingResult) {
128-
errorAttributes.put("errors", Error.wrap(bindingResult.getAllErrors()));
130+
errorAttributes.put("errors", Error.wrapIfNecessary(bindingResult.getAllErrors()));
129131
}
130132
}
131133
else {

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributes.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,10 @@
5151
* <li>error - The error reason</li>
5252
* <li>exception - The class name of the root exception (if configured)</li>
5353
* <li>message - The exception message (if configured)</li>
54-
* <li>errors - Any validation errors wrapped in {@link Error}, derived from a
55-
* {@link BindingResult} or {@link MethodValidationResult} exception (if configured)</li>
54+
* <li>errors - Any validation errors derived from a {@link BindingResult} or
55+
* {@link MethodValidationResult} exception (if configured). To ensure safe serialization
56+
* to JSON, errors are {@link Error#wrapIfNecessary(java.util.List) wrapped if
57+
* necessary}</li>
5658
* <li>trace - The exception stack trace (if configured)</li>
5759
* <li>path - The URL path when the exception was raised</li>
5860
* </ul>
@@ -154,14 +156,14 @@ private void addErrorMessage(Map<String, Object> errorAttributes, WebRequest web
154156
private void addMessageAndErrorsFromBindingResult(Map<String, Object> errorAttributes, BindingResult result) {
155157
errorAttributes.put("message", "Validation failed for object='%s'. Error count: %s"
156158
.formatted(result.getObjectName(), result.getAllErrors().size()));
157-
errorAttributes.put("errors", Error.wrap(result.getAllErrors()));
159+
errorAttributes.put("errors", Error.wrapIfNecessary(result.getAllErrors()));
158160
}
159161

160162
private void addMessageAndErrorsFromMethodValidationResult(Map<String, Object> errorAttributes,
161163
MethodValidationResult result) {
162164
errorAttributes.put("message", "Validation failed for method='%s'. Error count: %s"
163165
.formatted(result.getMethod(), result.getAllErrors().size()));
164-
errorAttributes.put("errors", Error.wrap(result.getAllErrors()));
166+
errorAttributes.put("errors", Error.wrapIfNecessary(result.getAllErrors()));
165167
}
166168

167169
private void addExceptionErrorMessage(Map<String, Object> errorAttributes, WebRequest webRequest, Throwable error) {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright 2012-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.web.error;
18+
19+
import java.util.List;
20+
21+
import com.fasterxml.jackson.core.JsonProcessingException;
22+
import com.fasterxml.jackson.databind.ObjectMapper;
23+
import org.junit.jupiter.api.Test;
24+
25+
import org.springframework.context.MessageSourceResolvable;
26+
import org.springframework.context.support.DefaultMessageSourceResolvable;
27+
import org.springframework.validation.FieldError;
28+
import org.springframework.validation.ObjectError;
29+
30+
import static org.assertj.core.api.Assertions.assertThat;
31+
32+
/**
33+
* Tests for {@link Error}.
34+
*
35+
* @author Andy Wilkinson
36+
*/
37+
class ErrorTests {
38+
39+
@Test
40+
@SuppressWarnings({ "rawtypes", "removal" })
41+
@Deprecated(since = "3.5.4", forRemoval = true)
42+
void wrapWrapsAllErrors() {
43+
List<Error> wrapped = Error.wrap(List.of(new ObjectError("name", "message"),
44+
new FieldError("name", "field", "message"), new CustomMessageSourceResolvable("code")));
45+
assertThat(wrapped).extracting((error) -> (Class) error.getClass())
46+
.containsExactly(Error.class, Error.class, Error.class);
47+
}
48+
49+
@Test
50+
@SuppressWarnings("rawtypes")
51+
void wrapIfNecessaryDoesNotWrapFieldErrorOrObjectError() {
52+
List<MessageSourceResolvable> wrapped = Error.wrapIfNecessary(List.of(new ObjectError("name", "message"),
53+
new FieldError("name", "field", "message"), new CustomMessageSourceResolvable("code")));
54+
assertThat(wrapped).extracting((error) -> (Class) error.getClass())
55+
.containsExactly(ObjectError.class, FieldError.class, Error.class);
56+
}
57+
58+
@Test
59+
void errorCauseDoesNotAppearInJson() throws JsonProcessingException {
60+
String json = new ObjectMapper()
61+
.writeValueAsString(Error.wrapIfNecessary(List.of(new CustomMessageSourceResolvable("code"))));
62+
assertThat(json).doesNotContain("some detail");
63+
}
64+
65+
public static class CustomMessageSourceResolvable extends DefaultMessageSourceResolvable {
66+
67+
CustomMessageSourceResolvable(String code) {
68+
super(code);
69+
}
70+
71+
public String getDetail() {
72+
return "some detail";
73+
}
74+
75+
}
76+
77+
}

spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributesTests.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ void extractBindingResultErrors() throws Exception {
274274
+ "int org.springframework.boot.web.reactive.error.DefaultErrorAttributesTests"
275275
+ ".method(java.lang.String), with 1 error(s)");
276276
assertThat(attributes).containsEntry("errors",
277-
org.springframework.boot.web.error.Error.wrap(bindingResult.getAllErrors()));
277+
org.springframework.boot.web.error.Error.wrapIfNecessary(bindingResult.getAllErrors()));
278278
}
279279

280280
@Test
@@ -290,7 +290,7 @@ void extractBindingResultErrorsThatCausedAResponseStatusException() throws Excep
290290
ErrorAttributeOptions.of(Include.MESSAGE, Include.BINDING_ERRORS));
291291
assertThat(attributes.get("message")).isEqualTo("Invalid");
292292
assertThat(attributes).containsEntry("errors",
293-
org.springframework.boot.web.error.Error.wrap(bindingResult.getAllErrors()));
293+
org.springframework.boot.web.error.Error.wrapIfNecessary(bindingResult.getAllErrors()));
294294
}
295295

296296
@Test
@@ -312,7 +312,7 @@ void extractMethodValidationResultErrors() throws Exception {
312312
.isEqualTo(
313313
"Validation failed for method='public java.lang.String java.lang.String.substring(int)'. Error count: 1");
314314
assertThat(attributes).containsEntry("errors",
315-
org.springframework.boot.web.error.Error.wrap(methodValidationResult.getAllErrors()));
315+
org.springframework.boot.web.error.Error.wrapIfNecessary(methodValidationResult.getAllErrors()));
316316
}
317317

318318
@Test
@@ -349,7 +349,7 @@ void extractParameterValidationResultErrors() throws Exception {
349349
.isEqualTo(
350350
"Validation failed for method='public java.lang.String java.lang.String.substring(int)'. Error count: 1");
351351
assertThat(attributes).containsEntry("errors",
352-
org.springframework.boot.web.error.Error.wrap(methodValidationResult.getAllErrors()));
352+
org.springframework.boot.web.error.Error.wrapIfNecessary(methodValidationResult.getAllErrors()));
353353
}
354354

355355
@Test

spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributesTests.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,8 @@ private void testErrors(List<? extends MessageSourceResolvable> errors, String e
241241
assertThat(attributes).doesNotContainKey("message");
242242
}
243243
if (options.isIncluded(Include.BINDING_ERRORS)) {
244-
assertThat(attributes).containsEntry("errors", org.springframework.boot.web.error.Error.wrap(errors));
244+
assertThat(attributes).containsEntry("errors",
245+
org.springframework.boot.web.error.Error.wrapIfNecessary(errors));
245246
}
246247
else {
247248
assertThat(attributes).doesNotContainKey("errors");

0 commit comments

Comments
 (0)