Skip to content

Commit 506fbe5

Browse files
committed
Improve mapping any Exception to ErrorResponse
Add protected, convenience method in ResponseEntityExceptionHandler to create a ProblemDetail for any exception, along with a MessageSource lookup for the "detail" field. Closes gh-29384
1 parent 210019c commit 506fbe5

File tree

5 files changed

+171
-52
lines changed

5 files changed

+171
-52
lines changed

spring-web/src/main/java/org/springframework/web/ErrorResponse.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,24 @@ default Object[] getDetailMessageArguments(MessageSource messageSource, Locale l
9494
return getDetailMessageArguments();
9595
}
9696

97+
/**
98+
* Resolve the {@link #getDetailMessageCode() detailMessageCode} through the
99+
* given {@link MessageSource}, and if found, update the "detail" field.
100+
* @param messageSource the {@code MessageSource} to use for the lookup
101+
* @param locale the {@code Locale} to use for the lookup
102+
*/
103+
default ProblemDetail updateAndGetBody(@Nullable MessageSource messageSource, Locale locale) {
104+
if (messageSource != null) {
105+
Object[] arguments = getDetailMessageArguments(messageSource, locale);
106+
String detail = messageSource.getMessage(getDetailMessageCode(), arguments, null, locale);
107+
if (detail != null) {
108+
getBody().setDetail(detail);
109+
}
110+
}
111+
return getBody();
112+
}
113+
114+
97115
/**
98116
* Build a message code for the given exception type, which consists of
99117
* {@code "problemDetail."} followed by the full {@link Class#getName() class name}.
@@ -105,4 +123,35 @@ static String getDefaultDetailMessageCode(Class<?> exceptionType, @Nullable Stri
105123
return "problemDetail." + exceptionType.getName() + (suffix != null ? "." + suffix : "");
106124
}
107125

126+
/**
127+
* Map the given Exception to an {@link ErrorResponse}.
128+
* @param ex the Exception, mostly to derive message codes, if not provided
129+
* @param status the response status to use
130+
* @param headers optional headers to add to the response
131+
* @param defaultDetail default value for the "detail" field
132+
* @param detailMessageCode the code to use to look up the "detail" field
133+
* through a {@code MessageSource}, falling back on
134+
* {@link #getDefaultDetailMessageCode(Class, String)}
135+
* @param detailMessageArguments the arguments to go with the detailMessageCode
136+
* @return the created {@code ErrorResponse} instance
137+
*/
138+
static ErrorResponse createFor(
139+
Exception ex, HttpStatusCode status, @Nullable HttpHeaders headers,
140+
String defaultDetail, @Nullable String detailMessageCode, @Nullable Object[] detailMessageArguments) {
141+
142+
if (detailMessageCode == null) {
143+
detailMessageCode = ErrorResponse.getDefaultDetailMessageCode(ex.getClass(), null);
144+
}
145+
146+
ErrorResponseException errorResponse = new ErrorResponseException(
147+
status, ProblemDetail.forStatusAndDetail(status, defaultDetail), null,
148+
detailMessageCode, detailMessageArguments);
149+
150+
if (headers != null) {
151+
errorResponse.getHeaders().putAll(headers);
152+
}
153+
154+
return errorResponse;
155+
}
156+
108157
}

spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandler.java

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,35 @@ protected Mono<ResponseEntity<Object>> handleErrorResponseException(
292292
return handleExceptionInternal(ex, null, headers, status, exchange);
293293
}
294294

295+
/**
296+
* Convenience method to create a {@link ProblemDetail} for any exception
297+
* that doesn't implement {@link ErrorResponse}, also performing a
298+
* {@link MessageSource} lookup for the "detail" field.
299+
* @param ex the exception being handled
300+
* @param status the status to associate with the exception
301+
* @param defaultDetail default value for the "detail" field
302+
* @param detailMessageCode the code to use to look up the "detail" field
303+
* through a {@code MessageSource}, falling back on
304+
* {@link ErrorResponse#getDefaultDetailMessageCode(Class, String)}
305+
* @param detailMessageArguments the arguments to go with the detailMessageCode
306+
* @return the created {@code ProblemDetail} instance
307+
*/
308+
protected ProblemDetail createProblemDetail(
309+
Exception ex, HttpStatusCode status, @Nullable HttpHeaders headers,
310+
String defaultDetail, @Nullable String detailMessageCode, @Nullable Object[] detailMessageArguments,
311+
ServerWebExchange exchange) {
312+
313+
ErrorResponse response = ErrorResponse.createFor(
314+
ex, status, headers, defaultDetail, detailMessageCode, detailMessageArguments);
315+
316+
return response.updateAndGetBody(this.messageSource, getLocale(exchange));
317+
}
318+
319+
private static Locale getLocale(ServerWebExchange exchange) {
320+
Locale locale = exchange.getLocaleContext().getLocale();
321+
return (locale != null ? locale : Locale.getDefault());
322+
}
323+
295324
/**
296325
* Internal handler method that all others in this class delegate to, for
297326
* common handling, and for the creation of a {@link ResponseEntity}.
@@ -311,33 +340,20 @@ protected Mono<ResponseEntity<Object>> handleErrorResponseException(
311340
* @return a {@code Mono} with the {@code ResponseEntity} for the response
312341
*/
313342
protected Mono<ResponseEntity<Object>> handleExceptionInternal(
314-
Exception ex, @Nullable Object body, HttpHeaders headers, HttpStatusCode status,
343+
Exception ex, @Nullable Object body, @Nullable HttpHeaders headers, HttpStatusCode status,
315344
ServerWebExchange exchange) {
316345

317346
if (exchange.getResponse().isCommitted()) {
318347
return Mono.error(ex);
319348
}
320349

321350
if (body == null && ex instanceof ErrorResponse errorResponse) {
322-
body = resolveDetailViaMessageSource(errorResponse, exchange.getLocaleContext().getLocale());
351+
body = errorResponse.updateAndGetBody(this.messageSource, getLocale(exchange));
323352
}
324353

325354
return createResponseEntity(body, headers, status, exchange);
326355
}
327356

328-
private ProblemDetail resolveDetailViaMessageSource(ErrorResponse response, @Nullable Locale locale) {
329-
ProblemDetail body = response.getBody();
330-
if (this.messageSource != null) {
331-
locale = (locale != null ? locale : Locale.getDefault());
332-
Object[] arguments = response.getDetailMessageArguments(this.messageSource, locale);
333-
String detail = this.messageSource.getMessage(response.getDetailMessageCode(), arguments, null, locale);
334-
if (detail != null) {
335-
body.setDetail(detail);
336-
}
337-
}
338-
return body;
339-
}
340-
341357
/**
342358
* Create the {@link ResponseEntity} to use from the given body, headers,
343359
* and statusCode. Subclasses can override this method to inspect and possibly
@@ -351,7 +367,8 @@ private ProblemDetail resolveDetailViaMessageSource(ErrorResponse response, @Nul
351367
* @since 6.0
352368
*/
353369
protected Mono<ResponseEntity<Object>> createResponseEntity(
354-
@Nullable Object body, HttpHeaders headers, HttpStatusCode status, ServerWebExchange exchange) {
370+
@Nullable Object body, @Nullable HttpHeaders headers, HttpStatusCode status,
371+
ServerWebExchange exchange) {
355372

356373
return Mono.just(new ResponseEntity<>(body, headers, status));
357374
}

spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandlerTests.java

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
*/
6060
public class ResponseEntityExceptionHandlerTests {
6161

62-
private final ResponseEntityExceptionHandler exceptionHandler = new GlobalExceptionHandler();
62+
private final GlobalExceptionHandler exceptionHandler = new GlobalExceptionHandler();
6363

6464
private final MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/").build());
6565

@@ -149,6 +149,29 @@ void errorResponseProblemDetailViaMessageSource() {
149149
"Content-Type application/json not supported. Supported: [application/atom+xml, application/xml]");
150150
}
151151

152+
@Test
153+
void customExceptionToProblemDetailViaMessageSource() {
154+
155+
Locale locale = Locale.UK;
156+
LocaleContextHolder.setLocale(locale);
157+
158+
StaticMessageSource messageSource = new StaticMessageSource();
159+
messageSource.addMessage(
160+
"problemDetail." + IllegalStateException.class.getName(), locale,
161+
"Invalid state: {0}");
162+
163+
this.exceptionHandler.setMessageSource(messageSource);
164+
165+
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")
166+
.acceptLanguageAsLocales(locale).build());
167+
168+
ResponseEntity<?> responseEntity =
169+
this.exceptionHandler.handleException(new IllegalStateException(), exchange).block();
170+
171+
ProblemDetail body = (ProblemDetail) responseEntity.getBody();
172+
assertThat(body.getDetail()).isEqualTo("Invalid state: A");
173+
}
174+
152175

153176
@SuppressWarnings("unchecked")
154177
private ResponseEntity<ProblemDetail> testException(ErrorResponseException exception) {
@@ -247,6 +270,12 @@ protected Mono<ResponseEntity<Object>> handleErrorResponseException(
247270

248271
return handleAndSetTypeToExceptionName(ex, headers, status, exchange);
249272
}
273+
274+
public Mono<ResponseEntity<Object>> handleException(IllegalStateException ex, ServerWebExchange exchange) {
275+
HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
276+
ProblemDetail body = createProblemDetail(ex, status, null, ex.getMessage(), null, new Object[] {"A"}, exchange);
277+
return handleExceptionInternal(ex, body, null, status, exchange);
278+
}
250279
}
251280

252281
}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java

Lines changed: 34 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@
1616

1717
package org.springframework.web.servlet.mvc.method.annotation;
1818

19-
import java.util.Locale;
20-
2119
import jakarta.servlet.http.HttpServletResponse;
2220
import org.apache.commons.logging.Log;
2321
import org.apache.commons.logging.LogFactory;
@@ -395,9 +393,8 @@ protected ResponseEntity<Object> handleConversionNotSupported(
395393
ConversionNotSupportedException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
396394

397395
Object[] args = {ex.getPropertyName(), ex.getValue()};
398-
399-
ProblemDetail body = resolveDetailViaMessageSource(
400-
status, args, "Failed to convert '" + args[0] + "' with value: '" + args[1] + "'");
396+
String defaultDetail = "Failed to convert '" + args[0] + "' with value: '" + args[1] + "'";
397+
ProblemDetail body = createProblemDetail(ex, status, headers, defaultDetail, null, args, request);
401398

402399
return handleExceptionInternal(ex, body, headers, status, request);
403400
}
@@ -420,9 +417,9 @@ protected ResponseEntity<Object> handleTypeMismatch(
420417
TypeMismatchException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
421418

422419
Object[] args = {ex.getPropertyName(), ex.getValue()};
423-
424-
ProblemDetail body = resolveDetailViaMessageSource(
425-
status, args, "Failed to convert '" + args[0] + "' with value: '" + args[1] + "'");
420+
String defaultDetail = "Failed to convert '" + args[0] + "' with value: '" + args[1] + "'";
421+
String messageCode = ErrorResponse.getDefaultDetailMessageCode(TypeMismatchException.class, null);
422+
ProblemDetail body = createProblemDetail(ex, status, headers, defaultDetail, messageCode, args, request);
426423

427424
return handleExceptionInternal(ex, body, headers, status, request);
428425
}
@@ -444,7 +441,7 @@ protected ResponseEntity<Object> handleTypeMismatch(
444441
protected ResponseEntity<Object> handleHttpMessageNotReadable(
445442
HttpMessageNotReadableException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
446443

447-
ProblemDetail body = resolveDetailViaMessageSource(status, null, "Failed to read request");
444+
ProblemDetail body = createProblemDetail(ex, status, headers, "Failed to read request", null, null, request);
448445
return handleExceptionInternal(ex, body, headers, status, request);
449446
}
450447

@@ -465,7 +462,7 @@ protected ResponseEntity<Object> handleHttpMessageNotReadable(
465462
protected ResponseEntity<Object> handleHttpMessageNotWritable(
466463
HttpMessageNotWritableException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
467464

468-
ProblemDetail body = resolveDetailViaMessageSource(status, null, "Failed to write request");
465+
ProblemDetail body = createProblemDetail(ex, status, headers, "Failed to write request", null, null, request);
469466
return handleExceptionInternal(ex, body, headers, status, request);
470467
}
471468

@@ -492,6 +489,32 @@ protected ResponseEntity<Object> handleBindException(
492489
return handleExceptionInternal(ex, body, headers, status, request);
493490
}
494491

492+
/**
493+
* Convenience method to create a {@link ProblemDetail} for any exception
494+
* that doesn't implement {@link ErrorResponse}, also performing a
495+
* {@link MessageSource} lookup for the "detail" field.
496+
* @param ex the exception being handled
497+
* @param status the status to associate with the exception
498+
* @param defaultDetail default value for the "detail" field
499+
* @param detailMessageCode the code to use to look up the "detail" field
500+
* through a {@code MessageSource}, falling back on
501+
* {@link ErrorResponse#getDefaultDetailMessageCode(Class, String)}
502+
* @param detailMessageArguments the arguments to go with the detailMessageCode
503+
* @param request the current request
504+
* @return the created {@code ProblemDetail} instance
505+
* @since 6.0
506+
*/
507+
protected ProblemDetail createProblemDetail(
508+
Exception ex, HttpStatusCode status, @Nullable HttpHeaders headers,
509+
String defaultDetail, @Nullable String detailMessageCode, @Nullable Object[] detailMessageArguments,
510+
WebRequest request) {
511+
512+
ErrorResponse errorResponse = ErrorResponse.createFor(
513+
ex, status, headers, defaultDetail, detailMessageCode, detailMessageArguments);
514+
515+
return errorResponse.updateAndGetBody(this.messageSource, LocaleContextHolder.getLocale());
516+
}
517+
495518
/**
496519
* Internal handler method that all others in this class delegate to, for
497520
* common handling, and for the creation of a {@link ResponseEntity}.
@@ -530,36 +553,12 @@ protected ResponseEntity<Object> handleExceptionInternal(
530553
}
531554

532555
if (body == null && ex instanceof ErrorResponse errorResponse) {
533-
body = resolveDetailViaMessageSource(errorResponse);
556+
body = errorResponse.updateAndGetBody(this.messageSource, LocaleContextHolder.getLocale());
534557
}
535558

536559
return createResponseEntity(body, headers, statusCode, request);
537560
}
538561

539-
// For non-Web exceptions
540-
private ProblemDetail resolveDetailViaMessageSource(
541-
HttpStatusCode status, @Nullable Object[] arguments, String defaultDetail) {
542-
543-
ProblemDetail body = ProblemDetail.forStatusAndDetail(status, defaultDetail);
544-
ErrorResponseException errorResponseEx = new ErrorResponseException(status, body, null, null, arguments);
545-
body = resolveDetailViaMessageSource(errorResponseEx);
546-
return body;
547-
}
548-
549-
// For ErrorResponse exceptions
550-
private ProblemDetail resolveDetailViaMessageSource(ErrorResponse response) {
551-
ProblemDetail body = response.getBody();
552-
if (this.messageSource != null) {
553-
Locale locale = LocaleContextHolder.getLocale();
554-
Object[] arguments = response.getDetailMessageArguments(this.messageSource, locale);
555-
String detail = this.messageSource.getMessage(response.getDetailMessageCode(), arguments, null, locale);
556-
if (detail != null) {
557-
body.setDetail(detail);
558-
}
559-
}
560-
return body;
561-
}
562-
563562
/**
564563
* Create the {@link ResponseEntity} to use from the given body, headers,
565564
* and statusCode. Subclasses can override this method to inspect and possibly

spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package org.springframework.web.servlet.mvc.method.annotation;
1818

19+
import java.beans.PropertyChangeEvent;
1920
import java.util.Arrays;
2021
import java.util.Collections;
2122
import java.util.List;
@@ -192,6 +193,30 @@ public void typeMismatch() {
192193
testException(new TypeMismatchException("foo", String.class));
193194
}
194195

196+
@Test
197+
public void typeMismatchWithProblemDetailViaMessageSource() {
198+
Locale locale = Locale.UK;
199+
LocaleContextHolder.setLocale(locale);
200+
201+
try {
202+
StaticMessageSource messageSource = new StaticMessageSource();
203+
messageSource.addMessage(
204+
"problemDetail." + TypeMismatchException.class.getName(), locale,
205+
"Failed to set {0} to value: {1}");
206+
207+
this.exceptionHandler.setMessageSource(messageSource);
208+
209+
ResponseEntity<?> entity = testException(
210+
new TypeMismatchException(new PropertyChangeEvent(this, "name", "John", "James"), String.class));
211+
212+
ProblemDetail body = (ProblemDetail) entity.getBody();
213+
assertThat(body.getDetail()).isEqualTo("Failed to set name to value: James");
214+
}
215+
finally {
216+
LocaleContextHolder.resetLocaleContext();
217+
}
218+
}
219+
195220
@Test
196221
@SuppressWarnings("deprecation")
197222
public void httpMessageNotReadable() {

0 commit comments

Comments
 (0)