Skip to content

Commit e2a62d6

Browse files
committed
Merge pull request #43330 from YongGoose
* pr/43330: Polish "Wrap 'error' attribute for consistent JSON serialization" Wrap 'error' attribute for consistent JSON serialization Closes gh-43330
2 parents 1c991a7 + 977279b commit e2a62d6

File tree

5 files changed

+179
-60
lines changed

5 files changed

+179
-60
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* Copyright 2012-2025 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.ArrayList;
20+
import java.util.Collections;
21+
import java.util.List;
22+
import java.util.Objects;
23+
24+
import org.springframework.context.MessageSourceResolvable;
25+
import org.springframework.util.Assert;
26+
import org.springframework.util.CollectionUtils;
27+
28+
/**
29+
* A wrapper class for {@link MessageSourceResolvable} errors that is safe for JSON
30+
* serialization.
31+
*
32+
* @author Yongjun Hong
33+
* @author Phillip Webb
34+
* @since 3.5.0
35+
*/
36+
public final class Error implements MessageSourceResolvable {
37+
38+
private final MessageSourceResolvable cause;
39+
40+
/**
41+
* Create a new {@code Error} instance with the specified cause.
42+
* @param cause the error cause (must not be {@code null})
43+
*/
44+
private Error(MessageSourceResolvable cause) {
45+
Assert.notNull(cause, "'cause' must not be null");
46+
this.cause = cause;
47+
}
48+
49+
@Override
50+
public String[] getCodes() {
51+
return this.cause.getCodes();
52+
}
53+
54+
@Override
55+
public Object[] getArguments() {
56+
return this.cause.getArguments();
57+
}
58+
59+
@Override
60+
public String getDefaultMessage() {
61+
return this.cause.getDefaultMessage();
62+
}
63+
64+
/**
65+
* Return the original cause of the error.
66+
* @return the error cause
67+
*/
68+
public MessageSourceResolvable getCause() {
69+
return this.cause;
70+
}
71+
72+
@Override
73+
public boolean equals(Object obj) {
74+
if (this == obj) {
75+
return true;
76+
}
77+
if (obj == null || getClass() != obj.getClass()) {
78+
return false;
79+
}
80+
return Objects.equals(this.cause, ((Error) obj).cause);
81+
}
82+
83+
@Override
84+
public int hashCode() {
85+
return Objects.hash(this.cause);
86+
}
87+
88+
@Override
89+
public String toString() {
90+
return this.cause.toString();
91+
}
92+
93+
/**
94+
* Wrap the given errors.
95+
* @param errors the errors to wrap
96+
* @return a new Error list
97+
*/
98+
public static List<Error> wrap(List<? extends MessageSourceResolvable> errors) {
99+
if (CollectionUtils.isEmpty(errors)) {
100+
return Collections.emptyList();
101+
}
102+
List<Error> result = new ArrayList<>(errors.size());
103+
for (MessageSourceResolvable error : errors) {
104+
result.add(new Error(error));
105+
}
106+
return List.copyOf(result);
107+
}
108+
109+
}

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

+14-20
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2024 the original author or authors.
2+
* Copyright 2012-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -20,10 +20,10 @@
2020
import java.io.StringWriter;
2121
import java.util.Date;
2222
import java.util.LinkedHashMap;
23-
import java.util.List;
2423
import java.util.Map;
2524
import java.util.Optional;
2625

26+
import org.springframework.boot.web.error.Error;
2727
import org.springframework.boot.web.error.ErrorAttributeOptions;
2828
import org.springframework.boot.web.error.ErrorAttributeOptions.Include;
2929
import org.springframework.core.annotation.MergedAnnotation;
@@ -32,7 +32,6 @@
3232
import org.springframework.http.HttpStatus;
3333
import org.springframework.util.StringUtils;
3434
import org.springframework.validation.BindingResult;
35-
import org.springframework.validation.ObjectError;
3635
import org.springframework.validation.method.MethodValidationResult;
3736
import org.springframework.web.bind.annotation.ResponseStatus;
3837
import org.springframework.web.reactive.function.server.ServerRequest;
@@ -48,8 +47,8 @@
4847
* <li>error - The error reason</li>
4948
* <li>exception - The class name of the root exception (if configured)</li>
5049
* <li>message - The exception message (if configured)</li>
51-
* <li>errors - Any {@link ObjectError}s from a {@link BindingResult} or
52-
* {@link MethodValidationResult} exception (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>
5352
* <li>trace - The exception stack trace (if configured)</li>
5453
* <li>path - The URL path when the exception was raised</li>
5554
* <li>requestId - Unique ID associated with the current request</li>
@@ -61,6 +60,7 @@
6160
* @author Scott Frederick
6261
* @author Moritz Halbritter
6362
* @author Yanming Zhou
63+
* @author Yongjun Hong
6464
* @since 2.0.0
6565
* @see ErrorAttributes
6666
*/
@@ -112,19 +112,20 @@ private void handleException(Map<String, Object> errorAttributes, Throwable erro
112112
MergedAnnotation<ResponseStatus> responseStatusAnnotation, boolean includeStackTrace) {
113113
Throwable exception;
114114
if (error instanceof BindingResult bindingResult) {
115-
errorAttributes.put("message", error.getMessage());
116-
errorAttributes.put("errors", bindingResult.getAllErrors());
117115
exception = error;
116+
errorAttributes.put("message", error.getMessage());
117+
errorAttributes.put("errors", Error.wrap(bindingResult.getAllErrors()));
118118
}
119119
else if (error instanceof MethodValidationResult methodValidationResult) {
120-
addMessageAndErrorsFromMethodValidationResult(errorAttributes, methodValidationResult);
121120
exception = error;
121+
errorAttributes.put("message", getErrorMessage(methodValidationResult));
122+
errorAttributes.put("errors", Error.wrap(methodValidationResult.getAllErrors()));
122123
}
123124
else if (error instanceof ResponseStatusException responseStatusException) {
124-
errorAttributes.put("message", responseStatusException.getReason());
125125
exception = (responseStatusException.getCause() != null) ? responseStatusException.getCause() : error;
126+
errorAttributes.put("message", responseStatusException.getReason());
126127
if (exception instanceof BindingResult bindingResult) {
127-
errorAttributes.put("errors", bindingResult.getAllErrors());
128+
errorAttributes.put("errors", Error.wrap(bindingResult.getAllErrors()));
128129
}
129130
}
130131
else {
@@ -139,16 +140,9 @@ else if (error instanceof ResponseStatusException responseStatusException) {
139140
}
140141
}
141142

142-
private void addMessageAndErrorsFromMethodValidationResult(Map<String, Object> errorAttributes,
143-
MethodValidationResult result) {
144-
List<ObjectError> errors = result.getAllErrors()
145-
.stream()
146-
.filter(ObjectError.class::isInstance)
147-
.map(ObjectError.class::cast)
148-
.toList();
149-
errorAttributes.put("message",
150-
"Validation failed for method='" + result.getMethod() + "'. Error count: " + errors.size());
151-
errorAttributes.put("errors", errors);
143+
private String getErrorMessage(MethodValidationResult methodValidationResult) {
144+
return "Validation failed for method='%s'. Error count: %s".formatted(methodValidationResult.getMethod(),
145+
methodValidationResult.getAllErrors().size());
152146
}
153147

154148
@Override

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

+24-34
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2024 the original author or authors.
2+
* Copyright 2012-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -20,14 +20,14 @@
2020
import java.io.StringWriter;
2121
import java.util.Date;
2222
import java.util.LinkedHashMap;
23-
import java.util.List;
2423
import java.util.Map;
2524

2625
import jakarta.servlet.RequestDispatcher;
2726
import jakarta.servlet.ServletException;
2827
import jakarta.servlet.http.HttpServletRequest;
2928
import jakarta.servlet.http.HttpServletResponse;
3029

30+
import org.springframework.boot.web.error.Error;
3131
import org.springframework.boot.web.error.ErrorAttributeOptions;
3232
import org.springframework.boot.web.error.ErrorAttributeOptions.Include;
3333
import org.springframework.core.Ordered;
@@ -36,7 +36,6 @@
3636
import org.springframework.util.ObjectUtils;
3737
import org.springframework.util.StringUtils;
3838
import org.springframework.validation.BindingResult;
39-
import org.springframework.validation.ObjectError;
4039
import org.springframework.validation.method.MethodValidationResult;
4140
import org.springframework.web.context.request.RequestAttributes;
4241
import org.springframework.web.context.request.WebRequest;
@@ -52,8 +51,8 @@
5251
* <li>error - The error reason</li>
5352
* <li>exception - The class name of the root exception (if configured)</li>
5453
* <li>message - The exception message (if configured)</li>
55-
* <li>errors - Any {@link ObjectError}s from a {@link BindingResult} or
56-
* {@link MethodValidationResult} exception (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>
5756
* <li>trace - The exception stack trace (if configured)</li>
5857
* <li>path - The URL path when the exception was raised</li>
5958
* </ul>
@@ -65,6 +64,7 @@
6564
* @author Scott Frederick
6665
* @author Moritz Halbritter
6766
* @author Yanming Zhou
67+
* @author Yongjun Hong
6868
* @since 2.0.0
6969
* @see ErrorAttributes
7070
*/
@@ -141,16 +141,27 @@ private void addErrorMessage(Map<String, Object> errorAttributes, WebRequest web
141141
BindingResult bindingResult = extractBindingResult(error);
142142
if (bindingResult != null) {
143143
addMessageAndErrorsFromBindingResult(errorAttributes, bindingResult);
144+
return;
144145
}
145-
else {
146-
MethodValidationResult methodValidationResult = extractMethodValidationResult(error);
147-
if (methodValidationResult != null) {
148-
addMessageAndErrorsFromMethodValidationResult(errorAttributes, methodValidationResult);
149-
}
150-
else {
151-
addExceptionErrorMessage(errorAttributes, webRequest, error);
152-
}
146+
MethodValidationResult methodValidationResult = extractMethodValidationResult(error);
147+
if (methodValidationResult != null) {
148+
addMessageAndErrorsFromMethodValidationResult(errorAttributes, methodValidationResult);
149+
return;
153150
}
151+
addExceptionErrorMessage(errorAttributes, webRequest, error);
152+
}
153+
154+
private void addMessageAndErrorsFromBindingResult(Map<String, Object> errorAttributes, BindingResult result) {
155+
errorAttributes.put("message", "Validation failed for object='%s'. Error count: %s"
156+
.formatted(result.getObjectName(), result.getAllErrors().size()));
157+
errorAttributes.put("errors", Error.wrap(result.getAllErrors()));
158+
}
159+
160+
private void addMessageAndErrorsFromMethodValidationResult(Map<String, Object> errorAttributes,
161+
MethodValidationResult result) {
162+
errorAttributes.put("message", "Validation failed for method='%s'. Error count: %s"
163+
.formatted(result.getMethod(), result.getAllErrors().size()));
164+
errorAttributes.put("errors", Error.wrap(result.getAllErrors()));
154165
}
155166

156167
private void addExceptionErrorMessage(Map<String, Object> errorAttributes, WebRequest webRequest, Throwable error) {
@@ -182,27 +193,6 @@ protected String getMessage(WebRequest webRequest, Throwable error) {
182193
return "No message available";
183194
}
184195

185-
private void addMessageAndErrorsFromBindingResult(Map<String, Object> errorAttributes, BindingResult result) {
186-
addMessageAndErrorsForValidationFailure(errorAttributes, "object='" + result.getObjectName() + "'",
187-
result.getAllErrors());
188-
}
189-
190-
private void addMessageAndErrorsFromMethodValidationResult(Map<String, Object> errorAttributes,
191-
MethodValidationResult result) {
192-
List<ObjectError> errors = result.getAllErrors()
193-
.stream()
194-
.filter(ObjectError.class::isInstance)
195-
.map(ObjectError.class::cast)
196-
.toList();
197-
addMessageAndErrorsForValidationFailure(errorAttributes, "method='" + result.getMethod() + "'", errors);
198-
}
199-
200-
private void addMessageAndErrorsForValidationFailure(Map<String, Object> errorAttributes, String validated,
201-
List<ObjectError> errors) {
202-
errorAttributes.put("message", "Validation failed for " + validated + ". Error count: " + errors.size());
203-
errorAttributes.put("errors", errors);
204-
}
205-
206196
private BindingResult extractBindingResult(Throwable error) {
207197
if (error instanceof BindingResult bindingResult) {
208198
return bindingResult;

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

+30-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2024 the original author or authors.
2+
* Copyright 2012-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -55,6 +55,7 @@
5555
* @author Scott Frederick
5656
* @author Moritz Halbritter
5757
* @author Yanming Zhou
58+
* @author Yongjun Hong
5859
*/
5960
class DefaultErrorAttributesTests {
6061

@@ -272,7 +273,8 @@ void extractBindingResultErrors() throws Exception {
272273
.startsWith("Validation failed for argument at index 0 in method: "
273274
+ "int org.springframework.boot.web.reactive.error.DefaultErrorAttributesTests"
274275
+ ".method(java.lang.String), with 1 error(s)");
275-
assertThat(attributes).containsEntry("errors", bindingResult.getAllErrors());
276+
assertThat(attributes).containsEntry("errors",
277+
org.springframework.boot.web.error.Error.wrap(bindingResult.getAllErrors()));
276278
}
277279

278280
@Test
@@ -287,7 +289,8 @@ void extractBindingResultErrorsThatCausedAResponseStatusException() throws Excep
287289
buildServerRequest(request, new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid", ex)),
288290
ErrorAttributeOptions.of(Include.MESSAGE, Include.BINDING_ERRORS));
289291
assertThat(attributes.get("message")).isEqualTo("Invalid");
290-
assertThat(attributes).containsEntry("errors", bindingResult.getAllErrors());
292+
assertThat(attributes).containsEntry("errors",
293+
org.springframework.boot.web.error.Error.wrap(bindingResult.getAllErrors()));
291294
}
292295

293296
@Test
@@ -309,7 +312,7 @@ void extractMethodValidationResultErrors() throws Exception {
309312
.isEqualTo(
310313
"Validation failed for method='public java.lang.String java.lang.String.substring(int)'. Error count: 1");
311314
assertThat(attributes).containsEntry("errors",
312-
methodValidationResult.getAllErrors().stream().filter(ObjectError.class::isInstance).toList());
315+
org.springframework.boot.web.error.Error.wrap(methodValidationResult.getAllErrors()));
313316
}
314317

315318
@Test
@@ -326,6 +329,29 @@ void extractBindingResultErrorsExcludeMessageAndErrors() throws Exception {
326329
assertThat(attributes).doesNotContainKey("errors");
327330
}
328331

332+
@Test
333+
void extractParameterValidationResultErrors() throws Exception {
334+
Object target = "test";
335+
Method method = String.class.getMethod("substring", int.class);
336+
MethodParameter parameter = new MethodParameter(method, 0);
337+
ParameterValidationResult parameterValidationResult = new ParameterValidationResult(parameter, -1,
338+
List.of(new ObjectError("beginIndex", "beginIndex is negative")), null, null, null,
339+
(error, sourceType) -> {
340+
throw new IllegalArgumentException("No source object of the given type");
341+
});
342+
MethodValidationResult methodValidationResult = MethodValidationResult.create(target, method,
343+
List.of(parameterValidationResult));
344+
HandlerMethodValidationException ex = new HandlerMethodValidationException(methodValidationResult);
345+
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
346+
Map<String, Object> attributes = this.errorAttributes.getErrorAttributes(buildServerRequest(request, ex),
347+
ErrorAttributeOptions.of(Include.MESSAGE, Include.BINDING_ERRORS));
348+
assertThat(attributes.get("message")).asString()
349+
.isEqualTo(
350+
"Validation failed for method='public java.lang.String java.lang.String.substring(int)'. Error count: 1");
351+
assertThat(attributes).containsEntry("errors",
352+
org.springframework.boot.web.error.Error.wrap(methodValidationResult.getAllErrors()));
353+
}
354+
329355
@Test
330356
void excludeStatus() {
331357
ResponseStatusException error = new ResponseStatusException(HttpStatus.NOT_ACCEPTABLE,

0 commit comments

Comments
 (0)