diff --git a/spring-web/src/main/java/org/springframework/web/ErrorResponse.java b/spring-web/src/main/java/org/springframework/web/ErrorResponse.java index 7b2d335b18..5d7066ccc0 100644 --- a/spring-web/src/main/java/org/springframework/web/ErrorResponse.java +++ b/spring-web/src/main/java/org/springframework/web/ErrorResponse.java @@ -16,9 +16,13 @@ package org.springframework.web; +import java.util.Locale; + +import org.springframework.context.MessageSource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.ProblemDetail; +import org.springframework.lang.Nullable; /** @@ -59,4 +63,48 @@ public interface ErrorResponse { */ ProblemDetail getBody(); + /** + * Return a code to use to resolve the problem "detail" for this exception + * through a {@link org.springframework.context.MessageSource}. + *

By default this is initialized via + * {@link #getDefaultDetailMessageCode(Class, String)} but each exception + * overrides this to provide relevant data that that can be expanded into + * placeholders within the message. + */ + default String getDetailMessageCode() { + return getDefaultDetailMessageCode(getClass(), null); + } + + /** + * Return the arguments to use to resolve the problem "detail" through a + * {@link MessageSource}. + */ + @Nullable + default Object[] getDetailMessageArguments() { + return null; + } + + /** + * Variant of {@link #getDetailMessageArguments()} that uses the given + * {@link MessageSource} to resolve the message arguments. + *

By default this delegates to {@link #getDetailMessageArguments()} + * by concrete implementations may override it, for example in order to + * resolve validation errors through a {@code MessageSource}. + */ + @Nullable + default Object[] getDetailMessageArguments(MessageSource messageSource, Locale locale) { + return getDetailMessageArguments(); + } + + /** + * Build a message code for the given exception type, which consists of + * {@code "problemDetail."} followed by the full {@link Class#getName() class name}. + * @param exceptionType the exception type for which to build a code + * @param suffix an optional suffix, e.g. for exceptions that may have multiple + * error message with different arguments. + */ + static String getDefaultDetailMessageCode(Class exceptionType, @Nullable String suffix) { + return "problemDetail." + exceptionType.getName() + (suffix != null ? "." + suffix : ""); + } + } diff --git a/spring-web/src/main/java/org/springframework/web/ErrorResponseException.java b/spring-web/src/main/java/org/springframework/web/ErrorResponseException.java index e0ba18b8c9..b4ec54e24b 100644 --- a/spring-web/src/main/java/org/springframework/web/ErrorResponseException.java +++ b/spring-web/src/main/java/org/springframework/web/ErrorResponseException.java @@ -46,6 +46,11 @@ public class ErrorResponseException extends NestedRuntimeException implements Er private final ProblemDetail body; + private final String messageDetailCode; + + @Nullable + private final Object[] messageDetailArguments; + /** * Constructor with a {@link HttpStatusCode}. @@ -66,11 +71,32 @@ public class ErrorResponseException extends NestedRuntimeException implements Er * subclass of {@code ProblemDetail} with extended fields. */ public ErrorResponseException(HttpStatusCode status, ProblemDetail body, @Nullable Throwable cause) { + this(status, body, cause, null, null); + } + + /** + * Constructor with a given {@link ProblemDetail}, and a + * {@link org.springframework.context.MessageSource} code and arguments to + * resolve the detail message with. + * @since 6.0 + */ + protected ErrorResponseException( + HttpStatusCode status, ProblemDetail body, @Nullable Throwable cause, + @Nullable String messageDetailCode, @Nullable Object[] messageDetailArguments) { + super(null, cause); this.status = status; this.body = body; + this.messageDetailCode = initMessageDetailCode(messageDetailCode); + this.messageDetailArguments = messageDetailArguments; } + private String initMessageDetailCode(@Nullable String messageDetailCode) { + return (messageDetailCode != null ? + messageDetailCode : ErrorResponse.getDefaultDetailMessageCode(getClass(), null)); + } + + @Override public HttpStatusCode getStatusCode() { return this.status; @@ -133,6 +159,16 @@ public class ErrorResponseException extends NestedRuntimeException implements Er return this.body; } + @Override + public String getDetailMessageCode() { + return this.messageDetailCode; + } + + @Override + public Object[] getDetailMessageArguments() { + return this.messageDetailArguments; + } + @Override public String getMessage() { return this.status + (!this.headers.isEmpty() ? ", headers=" + this.headers : "") + ", " + this.body; diff --git a/spring-web/src/main/java/org/springframework/web/HttpMediaTypeException.java b/spring-web/src/main/java/org/springframework/web/HttpMediaTypeException.java index 17230b4777..de55087427 100644 --- a/spring-web/src/main/java/org/springframework/web/HttpMediaTypeException.java +++ b/spring-web/src/main/java/org/springframework/web/HttpMediaTypeException.java @@ -23,6 +23,7 @@ import jakarta.servlet.ServletException; import org.springframework.http.MediaType; import org.springframework.http.ProblemDetail; +import org.springframework.lang.Nullable; /** * Abstract base for exceptions related to media types. Adds a list of supported {@link MediaType MediaTypes}. @@ -37,23 +38,49 @@ public abstract class HttpMediaTypeException extends ServletException implements private final ProblemDetail body = ProblemDetail.forStatus(getStatusCode()); + private final String messageDetailCode; + + @Nullable + private final Object[] messageDetailArguments; + /** * Create a new HttpMediaTypeException. * @param message the exception message + * @deprecated as of 6.0 */ + @Deprecated protected HttpMediaTypeException(String message) { - super(message); - this.supportedMediaTypes = Collections.emptyList(); + this(message, Collections.emptyList()); } /** * Create a new HttpMediaTypeException with a list of supported media types. * @param supportedMediaTypes the list of supported media types + * @deprecated as of 6.0 */ + @Deprecated protected HttpMediaTypeException(String message, List supportedMediaTypes) { + this(message, supportedMediaTypes, null, null); + } + + /** + * Create a new HttpMediaTypeException with a list of supported media types. + * @param supportedMediaTypes the list of supported media types + * @param messageDetailCode the code to use to resolve the problem "detail" + * through a {@link org.springframework.context.MessageSource} + * @param messageDetailArguments the arguments to make available when + * resolving the problem "detail" through a {@code MessageSource} + * @since 6.0 + */ + protected HttpMediaTypeException(String message, List supportedMediaTypes, + @Nullable String messageDetailCode, @Nullable Object[] messageDetailArguments) { + super(message); this.supportedMediaTypes = Collections.unmodifiableList(supportedMediaTypes); + this.messageDetailCode = (messageDetailCode != null ? + messageDetailCode : ErrorResponse.getDefaultDetailMessageCode(getClass(), null)); + this.messageDetailArguments = messageDetailArguments; } @@ -69,4 +96,14 @@ public abstract class HttpMediaTypeException extends ServletException implements return this.body; } + @Override + public String getDetailMessageCode() { + return this.messageDetailCode; + } + + @Override + public Object[] getDetailMessageArguments() { + return this.messageDetailArguments; + } + } diff --git a/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotAcceptableException.java b/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotAcceptableException.java index 417b3cc92d..1eff5410f1 100644 --- a/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotAcceptableException.java +++ b/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotAcceptableException.java @@ -16,8 +16,8 @@ package org.springframework.web; +import java.util.Collections; import java.util.List; -import java.util.stream.Collectors; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -35,12 +35,16 @@ import org.springframework.util.CollectionUtils; @SuppressWarnings("serial") public class HttpMediaTypeNotAcceptableException extends HttpMediaTypeException { + private static final String PARSE_ERROR_DETAIL_CODE = + ErrorResponse.getDefaultDetailMessageCode(HttpMediaTypeNotAcceptableException.class, "parseError"); + + /** * Constructor for when the {@code Accept} header cannot be parsed. * @param message the parse error message */ public HttpMediaTypeNotAcceptableException(String message) { - super(message); + super(message, Collections.emptyList(), PARSE_ERROR_DETAIL_CODE, null); getBody().setDetail("Could not parse Accept header."); } @@ -49,9 +53,8 @@ public class HttpMediaTypeNotAcceptableException extends HttpMediaTypeException * @param mediaTypes the list of supported media types */ public HttpMediaTypeNotAcceptableException(List mediaTypes) { - super("No acceptable representation", mediaTypes); - getBody().setDetail("Acceptable representations: " + - mediaTypes.stream().map(MediaType::toString).collect(Collectors.joining(", ", "'", "'")) + "."); + super("No acceptable representation", mediaTypes, null, new Object[] {mediaTypes}); + getBody().setDetail("Acceptable representations: " + mediaTypes + "."); } diff --git a/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotSupportedException.java b/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotSupportedException.java index 1a08ef005f..4a3c2fc01c 100644 --- a/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotSupportedException.java +++ b/spring-web/src/main/java/org/springframework/web/HttpMediaTypeNotSupportedException.java @@ -16,6 +16,7 @@ package org.springframework.web; +import java.util.Collections; import java.util.List; import org.springframework.http.HttpHeaders; @@ -37,6 +38,10 @@ import org.springframework.util.CollectionUtils; @SuppressWarnings("serial") public class HttpMediaTypeNotSupportedException extends HttpMediaTypeException { + private static final String PARSE_ERROR_DETAIL_CODE = + ErrorResponse.getDefaultDetailMessageCode(HttpMediaTypeNotSupportedException.class, "parseError"); + + @Nullable private final MediaType contentType; @@ -49,7 +54,7 @@ public class HttpMediaTypeNotSupportedException extends HttpMediaTypeException { * @param message the exception message */ public HttpMediaTypeNotSupportedException(String message) { - super(message); + super(message, Collections.emptyList(), PARSE_ERROR_DETAIL_CODE, null); this.contentType = null; this.httpMethod = null; getBody().setDetail("Could not parse Content-Type."); @@ -89,7 +94,7 @@ public class HttpMediaTypeNotSupportedException extends HttpMediaTypeException { public HttpMediaTypeNotSupportedException(@Nullable MediaType contentType, List supportedMediaTypes, @Nullable HttpMethod httpMethod, String message) { - super(message, supportedMediaTypes); + super(message, supportedMediaTypes, null, new Object[] {contentType, supportedMediaTypes}); this.contentType = contentType; this.httpMethod = httpMethod; getBody().setDetail("Content-Type '" + this.contentType + "' is not supported."); diff --git a/spring-web/src/main/java/org/springframework/web/HttpRequestMethodNotSupportedException.java b/spring-web/src/main/java/org/springframework/web/HttpRequestMethodNotSupportedException.java index 5a28d87d44..26a75f8dfe 100644 --- a/spring-web/src/main/java/org/springframework/web/HttpRequestMethodNotSupportedException.java +++ b/spring-web/src/main/java/org/springframework/web/HttpRequestMethodNotSupportedException.java @@ -52,7 +52,9 @@ public class HttpRequestMethodNotSupportedException extends ServletException imp /** * Create a new HttpRequestMethodNotSupportedException. * @param method the unsupported HTTP request method + * @deprecated 6.0 in favor of {@link #HttpRequestMethodNotSupportedException(String, Collection)} */ + @Deprecated(since = "6.0", forRemoval = true) public HttpRequestMethodNotSupportedException(String method) { this(method, (String[]) null); } @@ -61,7 +63,9 @@ public class HttpRequestMethodNotSupportedException extends ServletException imp * Create a new HttpRequestMethodNotSupportedException. * @param method the unsupported HTTP request method * @param msg the detail message + * @deprecated in favor of {@link #HttpRequestMethodNotSupportedException(String, Collection)} */ + @Deprecated(since = "6.0", forRemoval = true) public HttpRequestMethodNotSupportedException(String method, String msg) { this(method, null, msg); } @@ -69,7 +73,7 @@ public class HttpRequestMethodNotSupportedException extends ServletException imp /** * Create a new HttpRequestMethodNotSupportedException. * @param method the unsupported HTTP request method - * @param supportedMethods the actually supported HTTP methods (may be {@code null}) + * @param supportedMethods the actually supported HTTP methods (possibly {@code null}) */ public HttpRequestMethodNotSupportedException(String method, @Nullable Collection supportedMethods) { this(method, (supportedMethods != null ? StringUtils.toStringArray(supportedMethods) : null)); @@ -78,8 +82,10 @@ public class HttpRequestMethodNotSupportedException extends ServletException imp /** * Create a new HttpRequestMethodNotSupportedException. * @param method the unsupported HTTP request method - * @param supportedMethods the actually supported HTTP methods (may be {@code null}) + * @param supportedMethods the actually supported HTTP methods (possibly {@code null}) + * @deprecated in favor of {@link #HttpRequestMethodNotSupportedException(String, Collection)} */ + @Deprecated(since = "6.0", forRemoval = true) public HttpRequestMethodNotSupportedException(String method, @Nullable String[] supportedMethods) { this(method, supportedMethods, "Request method '" + method + "' is not supported"); } @@ -89,7 +95,9 @@ public class HttpRequestMethodNotSupportedException extends ServletException imp * @param method the unsupported HTTP request method * @param supportedMethods the actually supported HTTP methods * @param msg the detail message + * @deprecated in favor of {@link #HttpRequestMethodNotSupportedException(String, Collection)} */ + @Deprecated(since = "6.0", forRemoval = true) public HttpRequestMethodNotSupportedException(String method, @Nullable String[] supportedMethods, String msg) { super(msg); this.method = method; @@ -153,4 +161,9 @@ public class HttpRequestMethodNotSupportedException extends ServletException imp return this.body; } + @Override + public Object[] getDetailMessageArguments() { + return new Object[] {getMethod(), getSupportedHttpMethods()}; + } + } diff --git a/spring-web/src/main/java/org/springframework/web/bind/MethodArgumentNotValidException.java b/spring-web/src/main/java/org/springframework/web/bind/MethodArgumentNotValidException.java index 1f43c7be88..9f5214df68 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/MethodArgumentNotValidException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/MethodArgumentNotValidException.java @@ -16,12 +16,20 @@ package org.springframework.web.bind; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.Function; + +import org.springframework.context.MessageSource; import org.springframework.core.MethodParameter; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.ProblemDetail; +import org.springframework.util.StringUtils; import org.springframework.validation.BindException; import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; import org.springframework.validation.ObjectError; import org.springframework.web.ErrorResponse; @@ -52,6 +60,7 @@ public class MethodArgumentNotValidException extends BindException implements Er this.body = ProblemDetail.forStatusAndDetail(getStatusCode(), "Invalid request content."); } + @Override public HttpStatusCode getStatusCode() { return HttpStatus.BAD_REQUEST; @@ -85,4 +94,58 @@ public class MethodArgumentNotValidException extends BindException implements Er return sb.toString(); } + @Override + public Object[] getDetailMessageArguments() { + return new Object[] { + errorsToStringList(getBindingResult().getGlobalErrors()), + errorsToStringList(getBindingResult().getFieldErrors()) + }; + } + + @Override + public Object[] getDetailMessageArguments(MessageSource messageSource, Locale locale) { + return new Object[] { + errorsToStringList(getBindingResult().getGlobalErrors(), messageSource, locale), + errorsToStringList(getBindingResult().getFieldErrors(), messageSource, locale) + }; + } + + /** + * Convert each given {@link ObjectError} to a single quote String, taking + * either an error's default message as a first choice, or its error code. + * @since 6.0 + */ + public static List errorsToStringList(List errors) { + return errorsToStringList(errors, error -> + error.getDefaultMessage() != null ? error.getDefaultMessage() : error.getCode()); + } + + /** + * Variant of {@link #errorsToStringList(List)} that uses the provided + * {@link MessageSource} to resolve the error code, or otherwise fall + * back on its default message. + * @since 6.0 + */ + @SuppressWarnings("ConstantConditions") + public static List errorsToStringList( + List errors, MessageSource source, Locale locale) { + + return errorsToStringList(errors, error -> source.getMessage( + error.getCode(), error.getArguments(), error.getDefaultMessage(), locale)); + } + + private static List errorsToStringList( + List errors, Function formatter) { + + List result = new ArrayList<>(errors.size()); + for (ObjectError error : errors) { + String value = formatter.apply(error); + if (StringUtils.hasText(value)) { + result.add(error instanceof FieldError fieldError ? + fieldError.getField() + ": '" + value + "'" : "'" + value + "'"); + } + } + return result; + } + } diff --git a/spring-web/src/main/java/org/springframework/web/bind/MissingMatrixVariableException.java b/spring-web/src/main/java/org/springframework/web/bind/MissingMatrixVariableException.java index d1c1f0ea4f..44a4ffac44 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/MissingMatrixVariableException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/MissingMatrixVariableException.java @@ -54,7 +54,7 @@ public class MissingMatrixVariableException extends MissingRequestValueException public MissingMatrixVariableException( String variableName, MethodParameter parameter, boolean missingAfterConversion) { - super("", missingAfterConversion); + super("", missingAfterConversion, null, new Object[] {variableName}); this.variableName = variableName; this.parameter = parameter; getBody().setDetail("Required path parameter '" + this.variableName + "' is not present."); diff --git a/spring-web/src/main/java/org/springframework/web/bind/MissingPathVariableException.java b/spring-web/src/main/java/org/springframework/web/bind/MissingPathVariableException.java index 5cc6ceb423..e528077543 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/MissingPathVariableException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/MissingPathVariableException.java @@ -58,7 +58,7 @@ public class MissingPathVariableException extends MissingRequestValueException { public MissingPathVariableException( String variableName, MethodParameter parameter, boolean missingAfterConversion) { - super("", missingAfterConversion); + super("", missingAfterConversion, null, new Object[] {variableName}); this.variableName = variableName; this.parameter = parameter; getBody().setDetail("Required path variable '" + this.variableName + "' is not present."); diff --git a/spring-web/src/main/java/org/springframework/web/bind/MissingRequestCookieException.java b/spring-web/src/main/java/org/springframework/web/bind/MissingRequestCookieException.java index 284e211dcb..6792eef770 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/MissingRequestCookieException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/MissingRequestCookieException.java @@ -54,7 +54,7 @@ public class MissingRequestCookieException extends MissingRequestValueException public MissingRequestCookieException( String cookieName, MethodParameter parameter, boolean missingAfterConversion) { - super("", missingAfterConversion); + super("", missingAfterConversion, null, new Object[] {cookieName}); this.cookieName = cookieName; this.parameter = parameter; getBody().setDetail("Required cookie '" + this.cookieName + "' is not present."); diff --git a/spring-web/src/main/java/org/springframework/web/bind/MissingRequestHeaderException.java b/spring-web/src/main/java/org/springframework/web/bind/MissingRequestHeaderException.java index 2d30629a1e..93030e4884 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/MissingRequestHeaderException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/MissingRequestHeaderException.java @@ -54,7 +54,7 @@ public class MissingRequestHeaderException extends MissingRequestValueException public MissingRequestHeaderException( String headerName, MethodParameter parameter, boolean missingAfterConversion) { - super("", missingAfterConversion); + super("", missingAfterConversion, null, new Object[] {headerName}); this.headerName = headerName; this.parameter = parameter; getBody().setDetail("Required header '" + this.headerName + "' is not present."); diff --git a/spring-web/src/main/java/org/springframework/web/bind/MissingRequestValueException.java b/spring-web/src/main/java/org/springframework/web/bind/MissingRequestValueException.java index de873b0adf..16b14655f8 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/MissingRequestValueException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/MissingRequestValueException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2022 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. @@ -16,6 +16,9 @@ package org.springframework.web.bind; +import org.springframework.http.ProblemDetail; +import org.springframework.lang.Nullable; + /** * Base class for {@link ServletRequestBindingException} exceptions that could * not bind because the request value is required but is either missing or @@ -30,15 +33,35 @@ public class MissingRequestValueException extends ServletRequestBindingException private final boolean missingAfterConversion; + /** + * Constructor with a message only. + */ public MissingRequestValueException(String msg) { this(msg, false); } + /** + * Constructor with a message and a flag that indicates whether the value + * was not completely missing but became was {@code null} after conversion. + */ public MissingRequestValueException(String msg, boolean missingAfterConversion) { super(msg); this.missingAfterConversion = missingAfterConversion; } + /** + * Constructor with a given {@link ProblemDetail}, and a + * {@link org.springframework.context.MessageSource} code and arguments to + * resolve the detail message with. + * @since 6.0 + */ + protected MissingRequestValueException(String msg, boolean missingAfterConversion, + @Nullable String messageDetailCode, @Nullable Object[] messageDetailArguments) { + + super(msg, messageDetailCode, messageDetailArguments); + this.missingAfterConversion = missingAfterConversion; + } + /** * Whether the request value was present but converted to {@code null}, e.g. via diff --git a/spring-web/src/main/java/org/springframework/web/bind/MissingServletRequestParameterException.java b/spring-web/src/main/java/org/springframework/web/bind/MissingServletRequestParameterException.java index 5088b7c4d6..c16a74a04f 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/MissingServletRequestParameterException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/MissingServletRequestParameterException.java @@ -49,7 +49,7 @@ public class MissingServletRequestParameterException extends MissingRequestValue public MissingServletRequestParameterException( String parameterName, String parameterType, boolean missingAfterConversion) { - super("", missingAfterConversion); + super("", missingAfterConversion, null, new Object[] {parameterName}); this.parameterName = parameterName; this.parameterType = parameterType; getBody().setDetail("Required parameter '" + this.parameterName + "' is not present."); diff --git a/spring-web/src/main/java/org/springframework/web/bind/ServletRequestBindingException.java b/spring-web/src/main/java/org/springframework/web/bind/ServletRequestBindingException.java index 78297d229a..a188414249 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/ServletRequestBindingException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/ServletRequestBindingException.java @@ -21,6 +21,7 @@ import jakarta.servlet.ServletException; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.ProblemDetail; +import org.springframework.lang.Nullable; import org.springframework.web.ErrorResponse; /** @@ -39,24 +40,68 @@ public class ServletRequestBindingException extends ServletException implements private final ProblemDetail body = ProblemDetail.forStatus(getStatusCode()); + private final String messageDetailCode; + + @Nullable + private final Object[] messageDetailArguments; + + + /** + * Constructor with a message only. + * @param msg the detail message + */ + public ServletRequestBindingException(String msg) { + this(msg, null, null); + } + + /** + * Constructor with a message and a cause. + * @param msg the detail message + * @param cause the root cause + */ + public ServletRequestBindingException(String msg, Throwable cause) { + this(msg, cause, null, null); + } /** * Constructor for ServletRequestBindingException. * @param msg the detail message + * @param messageDetailCode the code to use to resolve the problem "detail" + * through a {@link org.springframework.context.MessageSource} + * @param messageDetailArguments the arguments to make available when + * resolving the problem "detail" through a {@code MessageSource} + * @since 6.0 */ - public ServletRequestBindingException(String msg) { - super(msg); + protected ServletRequestBindingException( + String msg, @Nullable String messageDetailCode, @Nullable Object[] messageDetailArguments) { + + this(msg, null, messageDetailCode, messageDetailArguments); } /** * Constructor for ServletRequestBindingException. * @param msg the detail message * @param cause the root cause + * @param messageDetailCode the code to use to resolve the problem "detail" + * through a {@link org.springframework.context.MessageSource} + * @param messageDetailArguments the arguments to make available when + * resolving the problem "detail" through a {@code MessageSource} + * @since 6.0 */ - public ServletRequestBindingException(String msg, Throwable cause) { + protected ServletRequestBindingException(String msg, @Nullable Throwable cause, + @Nullable String messageDetailCode, @Nullable Object[] messageDetailArguments) { + super(msg, cause); + this.messageDetailCode = initMessageDetailCode(messageDetailCode); + this.messageDetailArguments = messageDetailArguments; } + private String initMessageDetailCode(@Nullable String messageDetailCode) { + return (messageDetailCode != null ? + messageDetailCode : ErrorResponse.getDefaultDetailMessageCode(getClass(), null)); + } + + @Override public HttpStatusCode getStatusCode() { return HttpStatus.BAD_REQUEST; @@ -67,4 +112,14 @@ public class ServletRequestBindingException extends ServletException implements return this.body; } + @Override + public String getDetailMessageCode() { + return this.messageDetailCode; + } + + @Override + public Object[] getDetailMessageArguments() { + return this.messageDetailArguments; + } + } diff --git a/spring-web/src/main/java/org/springframework/web/bind/UnsatisfiedServletRequestParameterException.java b/spring-web/src/main/java/org/springframework/web/bind/UnsatisfiedServletRequestParameterException.java index 224f0af0fd..a65ef06ca2 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/UnsatisfiedServletRequestParameterException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/UnsatisfiedServletRequestParameterException.java @@ -19,6 +19,7 @@ package org.springframework.web.bind; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; @@ -56,30 +57,28 @@ public class UnsatisfiedServletRequestParameterException extends ServletRequestB * @param actualParams the actual parameter Map associated with the ServletRequest * @since 4.2 */ - public UnsatisfiedServletRequestParameterException(List paramConditions, - Map actualParams) { + public UnsatisfiedServletRequestParameterException( + List paramConditions, Map actualParams) { - super(""); - Assert.notEmpty(paramConditions, "Parameter conditions must not be empty"); + super("", null, new Object[] {paramsToStringList(paramConditions)}); this.paramConditions = paramConditions; this.actualParams = actualParams; getBody().setDetail("Invalid request parameters."); } + private static List paramsToStringList(List paramConditions) { + Assert.notEmpty(paramConditions, "Parameter conditions must not be empty"); + return paramConditions.stream() + .map(c -> "\"" + StringUtils.arrayToDelimitedString(c, ", ") + "\"") + .collect(Collectors.toList()); + } + @Override public String getMessage() { StringBuilder sb = new StringBuilder("Parameter conditions "); int i = 0; - for (String[] conditions : this.paramConditions) { - if (i > 0) { - sb.append(" OR "); - } - sb.append('"'); - sb.append(StringUtils.arrayToDelimitedString(conditions, ", ")); - sb.append('"'); - i++; - } + sb.append(String.join(" OR ", paramsToStringList(this.paramConditions))); sb.append(" not met for actual request parameters: "); sb.append(requestParameterMapToString(this.actualParams)); return sb.toString(); diff --git a/spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeBindException.java b/spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeBindException.java index 79927df5a8..4442668535 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeBindException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeBindException.java @@ -18,9 +18,11 @@ package org.springframework.web.bind.support; import java.beans.PropertyEditor; import java.util.List; +import java.util.Locale; import java.util.Map; import org.springframework.beans.PropertyEditorRegistry; +import org.springframework.context.MessageSource; import org.springframework.core.MethodParameter; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -29,6 +31,7 @@ import org.springframework.validation.BindingResult; import org.springframework.validation.Errors; import org.springframework.validation.FieldError; import org.springframework.validation.ObjectError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.server.ServerWebInputException; /** @@ -45,11 +48,18 @@ public class WebExchangeBindException extends ServerWebInputException implements public WebExchangeBindException(MethodParameter parameter, BindingResult bindingResult) { - super("Validation failure", parameter); + super("Validation failure", parameter, null, null, initMessageDetailArguments(bindingResult)); this.bindingResult = bindingResult; getBody().setDetail("Invalid request content."); } + private static Object[] initMessageDetailArguments(BindingResult bindingResult) { + return new Object[] { + MethodArgumentNotValidException.errorsToStringList(bindingResult.getGlobalErrors()), + MethodArgumentNotValidException.errorsToStringList(bindingResult.getFieldErrors()) + }; + } + /** * Return the BindingResult that this BindException wraps. @@ -289,6 +299,14 @@ public class WebExchangeBindException extends ServerWebInputException implements return sb.toString(); } + @Override + public Object[] getDetailMessageArguments(MessageSource source, Locale locale) { + return new Object[] { + MethodArgumentNotValidException.errorsToStringList(this.bindingResult.getGlobalErrors(), source, locale), + MethodArgumentNotValidException.errorsToStringList(this.bindingResult.getFieldErrors(), source, locale) + }; + } + @Override public boolean equals(@Nullable Object other) { return (this == other || this.bindingResult.equals(other)); diff --git a/spring-web/src/main/java/org/springframework/web/multipart/support/MissingServletRequestPartException.java b/spring-web/src/main/java/org/springframework/web/multipart/support/MissingServletRequestPartException.java index cd98d9176f..1dd4f5806f 100644 --- a/spring-web/src/main/java/org/springframework/web/multipart/support/MissingServletRequestPartException.java +++ b/spring-web/src/main/java/org/springframework/web/multipart/support/MissingServletRequestPartException.java @@ -84,4 +84,9 @@ public class MissingServletRequestPartException extends ServletException impleme return this.body; } + @Override + public Object[] getDetailMessageArguments() { + return new Object[] {getRequestPartName()}; + } + } diff --git a/spring-web/src/main/java/org/springframework/web/server/MethodNotAllowedException.java b/spring-web/src/main/java/org/springframework/web/server/MethodNotAllowedException.java index a8622953e3..244471bcab 100644 --- a/spring-web/src/main/java/org/springframework/web/server/MethodNotAllowedException.java +++ b/spring-web/src/main/java/org/springframework/web/server/MethodNotAllowedException.java @@ -20,7 +20,6 @@ import java.util.Collection; import java.util.Collections; import java.util.LinkedHashSet; import java.util.Set; -import java.util.stream.Collectors; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -48,17 +47,17 @@ public class MethodNotAllowedException extends ResponseStatusException { } public MethodNotAllowedException(String method, @Nullable Collection supportedMethods) { - super(HttpStatus.METHOD_NOT_ALLOWED, "Request method '" + method + "' is not supported."); + super(HttpStatus.METHOD_NOT_ALLOWED, "Request method '" + method + "' is not supported.", + null, null, new Object[] {method, supportedMethods}); + Assert.notNull(method, "'method' is required"); if (supportedMethods == null) { supportedMethods = Collections.emptySet(); } this.method = method; this.httpMethods = Collections.unmodifiableSet(new LinkedHashSet<>(supportedMethods)); - - getBody().setDetail(this.httpMethods.isEmpty() ? getReason() : - "Supported methods: " + this.httpMethods.stream() - .map(HttpMethod::toString).collect(Collectors.joining("', '", "'", "'"))); + getBody().setDetail(this.httpMethods.isEmpty() ? + getReason() : "Supported methods: " + this.httpMethods); } diff --git a/spring-web/src/main/java/org/springframework/web/server/MissingRequestValueException.java b/spring-web/src/main/java/org/springframework/web/server/MissingRequestValueException.java index 0f4380b19d..f987fb63ab 100644 --- a/spring-web/src/main/java/org/springframework/web/server/MissingRequestValueException.java +++ b/spring-web/src/main/java/org/springframework/web/server/MissingRequestValueException.java @@ -36,7 +36,9 @@ public class MissingRequestValueException extends ServerWebInputException { public MissingRequestValueException(String name, Class type, String label, MethodParameter parameter) { - super("Required " + label + " '" + name + "' is not present.", parameter); + super("Required " + label + " '" + name + "' is not present.", parameter, + null, null, new Object[] {label, name}); + this.name = name; this.type = type; this.label = label; diff --git a/spring-web/src/main/java/org/springframework/web/server/NotAcceptableStatusException.java b/spring-web/src/main/java/org/springframework/web/server/NotAcceptableStatusException.java index c81d7e49ac..51014e1d3b 100644 --- a/spring-web/src/main/java/org/springframework/web/server/NotAcceptableStatusException.java +++ b/spring-web/src/main/java/org/springframework/web/server/NotAcceptableStatusException.java @@ -18,12 +18,12 @@ package org.springframework.web.server; import java.util.Collections; import java.util.List; -import java.util.stream.Collectors; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.util.CollectionUtils; +import org.springframework.web.ErrorResponse; /** * Exception for errors that fit response status 406 (not acceptable). @@ -34,6 +34,10 @@ import org.springframework.util.CollectionUtils; @SuppressWarnings("serial") public class NotAcceptableStatusException extends ResponseStatusException { + private static final String PARSE_ERROR_DETAIL_CODE = + ErrorResponse.getDefaultDetailMessageCode(NotAcceptableStatusException.class, "parseError"); + + private final List supportedMediaTypes; @@ -41,7 +45,7 @@ public class NotAcceptableStatusException extends ResponseStatusException { * Constructor for when the requested Content-Type is invalid. */ public NotAcceptableStatusException(String reason) { - super(HttpStatus.NOT_ACCEPTABLE, reason); + super(HttpStatus.NOT_ACCEPTABLE, reason, null, PARSE_ERROR_DETAIL_CODE, null); this.supportedMediaTypes = Collections.emptyList(); getBody().setDetail("Could not parse Accept header."); } @@ -50,10 +54,11 @@ public class NotAcceptableStatusException extends ResponseStatusException { * Constructor for when the requested Content-Type is not supported. */ public NotAcceptableStatusException(List mediaTypes) { - super(HttpStatus.NOT_ACCEPTABLE, "Could not find acceptable representation"); + super(HttpStatus.NOT_ACCEPTABLE, + "Could not find acceptable representation", null, null, new Object[] {mediaTypes}); + this.supportedMediaTypes = Collections.unmodifiableList(mediaTypes); - getBody().setDetail("Acceptable representations: " + - mediaTypes.stream().map(MediaType::toString).collect(Collectors.joining(", ", "'", "'")) + "."); + getBody().setDetail("Acceptable representations: " + mediaTypes + "."); } diff --git a/spring-web/src/main/java/org/springframework/web/server/ResponseStatusException.java b/spring-web/src/main/java/org/springframework/web/server/ResponseStatusException.java index 2ca8936195..218d7579fa 100644 --- a/spring-web/src/main/java/org/springframework/web/server/ResponseStatusException.java +++ b/spring-web/src/main/java/org/springframework/web/server/ResponseStatusException.java @@ -18,6 +18,7 @@ package org.springframework.web.server; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; import org.springframework.lang.Nullable; import org.springframework.web.ErrorResponseException; @@ -78,6 +79,22 @@ public class ResponseStatusException extends ErrorResponseException { this.reason = reason; } + /** + * Constructor with a {@link org.springframework.context.MessageSource} + * code and arguments to resolve the detail message with. + * @param status the HTTP status (required) + * @param reason the associated reason (optional) + * @param cause a nested exception (optional) + * @since 6.0 + */ + protected ResponseStatusException( + HttpStatusCode status, @Nullable String reason, @Nullable Throwable cause, + @Nullable String messageDetailCode, @Nullable Object[] messageDetailArguments) { + + super(status, ProblemDetail.forStatus(status), cause, messageDetailCode, messageDetailArguments); + this.reason = reason; + } + /** * The reason explaining the exception (potentially {@code null} or empty). diff --git a/spring-web/src/main/java/org/springframework/web/server/ServerErrorException.java b/spring-web/src/main/java/org/springframework/web/server/ServerErrorException.java index 5e4a8d6353..a11a6135c3 100644 --- a/spring-web/src/main/java/org/springframework/web/server/ServerErrorException.java +++ b/spring-web/src/main/java/org/springframework/web/server/ServerErrorException.java @@ -45,7 +45,7 @@ public class ServerErrorException extends ResponseStatusException { * @since 5.0.5 */ public ServerErrorException(String reason, @Nullable Throwable cause) { - super(HttpStatus.INTERNAL_SERVER_ERROR, reason, cause); + super(HttpStatus.INTERNAL_SERVER_ERROR, reason, cause, null, new Object[] {reason}); this.handlerMethod = null; this.parameter = null; } @@ -55,7 +55,7 @@ public class ServerErrorException extends ResponseStatusException { * @since 5.0.5 */ public ServerErrorException(String reason, Method handlerMethod, @Nullable Throwable cause) { - super(HttpStatus.INTERNAL_SERVER_ERROR, reason, cause); + super(HttpStatus.INTERNAL_SERVER_ERROR, reason, cause, null, new Object[] {reason}); this.handlerMethod = handlerMethod; this.parameter = null; } @@ -64,7 +64,7 @@ public class ServerErrorException extends ResponseStatusException { * Constructor for a 500 error with a {@link MethodParameter} and an optional cause. */ public ServerErrorException(String reason, MethodParameter parameter, @Nullable Throwable cause) { - super(HttpStatus.INTERNAL_SERVER_ERROR, reason, cause); + super(HttpStatus.INTERNAL_SERVER_ERROR, reason, cause, null, new Object[] {reason}); this.handlerMethod = parameter.getMethod(); this.parameter = parameter; } diff --git a/spring-web/src/main/java/org/springframework/web/server/ServerWebInputException.java b/spring-web/src/main/java/org/springframework/web/server/ServerWebInputException.java index fbb2cd9ef8..5853facaf2 100644 --- a/spring-web/src/main/java/org/springframework/web/server/ServerWebInputException.java +++ b/spring-web/src/main/java/org/springframework/web/server/ServerWebInputException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2022 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. @@ -53,7 +53,18 @@ public class ServerWebInputException extends ResponseStatusException { * Constructor for a 400 error with a root cause. */ public ServerWebInputException(String reason, @Nullable MethodParameter parameter, @Nullable Throwable cause) { - super(HttpStatus.BAD_REQUEST, reason, cause); + this(reason, parameter, cause, null, null); + } + + /** + * Constructor with a {@link org.springframework.context.MessageSource} code + * and arguments to resolve the detail message with. + * @since 6.0 + */ + protected ServerWebInputException(String reason, @Nullable MethodParameter parameter, @Nullable Throwable cause, + @Nullable String messageDetailCode, @Nullable Object[] messageDetailArguments) { + + super(HttpStatus.BAD_REQUEST, reason, cause, messageDetailCode, messageDetailArguments); this.parameter = parameter; } diff --git a/spring-web/src/main/java/org/springframework/web/server/UnsatisfiedRequestParameterException.java b/spring-web/src/main/java/org/springframework/web/server/UnsatisfiedRequestParameterException.java index 9ed5be422d..778c251216 100644 --- a/spring-web/src/main/java/org/springframework/web/server/UnsatisfiedRequestParameterException.java +++ b/spring-web/src/main/java/org/springframework/web/server/UnsatisfiedRequestParameterException.java @@ -36,12 +36,10 @@ public class UnsatisfiedRequestParameterException extends ServerWebInputExceptio private final MultiValueMap requestParams; - public UnsatisfiedRequestParameterException( - List conditions, MultiValueMap requestParams) { - - super(initReason(conditions, requestParams)); + public UnsatisfiedRequestParameterException(List conditions, MultiValueMap params) { + super(initReason(conditions, params), null, null, null, new Object[] {conditions}); this.conditions = conditions; - this.requestParams = requestParams; + this.requestParams = params; getBody().setDetail("Invalid request parameters."); } diff --git a/spring-web/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java b/spring-web/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java index dc1db9fac7..dadf69d39f 100644 --- a/spring-web/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java +++ b/spring-web/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java @@ -26,6 +26,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.lang.Nullable; import org.springframework.util.CollectionUtils; +import org.springframework.web.ErrorResponse; /** * Exception for errors that fit response status 415 (unsupported media type). @@ -36,6 +37,10 @@ import org.springframework.util.CollectionUtils; @SuppressWarnings("serial") public class UnsupportedMediaTypeStatusException extends ResponseStatusException { + private static final String PARSE_ERROR_DETAIL_CODE = + ErrorResponse.getDefaultDetailMessageCode(UnsupportedMediaTypeStatusException.class, "parseError"); + + @Nullable private final MediaType contentType; @@ -52,7 +57,7 @@ public class UnsupportedMediaTypeStatusException extends ResponseStatusException * Constructor for when the specified Content-Type is invalid. */ public UnsupportedMediaTypeStatusException(@Nullable String reason) { - super(HttpStatus.UNSUPPORTED_MEDIA_TYPE, reason); + super(HttpStatus.UNSUPPORTED_MEDIA_TYPE, reason, null, PARSE_ERROR_DETAIL_CODE, null); this.contentType = null; this.supportedMediaTypes = Collections.emptyList(); this.bodyType = null; @@ -92,9 +97,8 @@ public class UnsupportedMediaTypeStatusException extends ResponseStatusException public UnsupportedMediaTypeStatusException(@Nullable MediaType contentType, List supportedTypes, @Nullable ResolvableType bodyType, @Nullable HttpMethod method) { - super(HttpStatus.UNSUPPORTED_MEDIA_TYPE, - "Content type '" + (contentType != null ? contentType : "") + "' not supported" + - (bodyType != null ? " for bodyType=" + bodyType : "")); + super(HttpStatus.UNSUPPORTED_MEDIA_TYPE, initMessage(contentType, bodyType), + null, null, new Object[] {contentType, supportedTypes}); this.contentType = contentType; this.supportedMediaTypes = Collections.unmodifiableList(supportedTypes); @@ -104,6 +108,11 @@ public class UnsupportedMediaTypeStatusException extends ResponseStatusException setDetail(contentType != null ? "Content-Type '" + contentType + "' is not supported." : null); } + private static String initMessage(@Nullable MediaType contentType, @Nullable ResolvableType bodyType) { + return "Content type '" + (contentType != null ? contentType : "") + "' not supported" + + (bodyType != null ? " for bodyType=" + bodyType : ""); + } + /** * Return the request Content-Type header if it was parsed successfully, diff --git a/spring-web/src/test/java/org/springframework/web/ErrorResponseExceptionTests.java b/spring-web/src/test/java/org/springframework/web/ErrorResponseExceptionTests.java index aa4e9594b0..428984a439 100644 --- a/spring-web/src/test/java/org/springframework/web/ErrorResponseExceptionTests.java +++ b/spring-web/src/test/java/org/springframework/web/ErrorResponseExceptionTests.java @@ -19,9 +19,12 @@ package org.springframework.web; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Locale; import org.junit.jupiter.api.Test; +import org.springframework.beans.testfixture.beans.TestBean; +import org.springframework.context.support.StaticMessageSource; import org.springframework.core.MethodParameter; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -32,7 +35,6 @@ import org.springframework.lang.Nullable; import org.springframework.util.LinkedMultiValueMap; import org.springframework.validation.BindException; import org.springframework.validation.BindingResult; -import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingMatrixVariableException; import org.springframework.web.bind.MissingPathVariableException; @@ -46,13 +48,13 @@ import org.springframework.web.multipart.support.MissingServletRequestPartExcept import org.springframework.web.server.MethodNotAllowedException; import org.springframework.web.server.MissingRequestValueException; import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerErrorException; import org.springframework.web.server.UnsatisfiedRequestParameterException; import org.springframework.web.server.UnsupportedMediaTypeStatusException; import org.springframework.web.testfixture.method.ResolvableMethod; import static org.assertj.core.api.Assertions.assertThat; - /** * Unit tests that verify the HTTP response details exposed by exceptions in the * {@link ErrorResponse} hierarchy. @@ -71,12 +73,12 @@ public class ErrorResponseExceptionTests { List mediaTypes = Arrays.asList(MediaType.APPLICATION_JSON, MediaType.APPLICATION_CBOR); - ErrorResponse ex = new HttpMediaTypeNotSupportedException( + HttpMediaTypeNotSupportedException ex = new HttpMediaTypeNotSupportedException( MediaType.APPLICATION_XML, mediaTypes, HttpMethod.PATCH, "Custom message"); - assertStatus(ex, HttpStatus.UNSUPPORTED_MEDIA_TYPE); assertDetail(ex, "Content-Type 'application/xml' is not supported."); + assertDetailMessageCode(ex, null, new Object[] {ex.getContentType(), ex.getSupportedMediaTypes()}); HttpHeaders headers = ex.getHeaders(); assertThat(headers.getAccept()).isEqualTo(mediaTypes); @@ -89,9 +91,10 @@ public class ErrorResponseExceptionTests { ErrorResponse ex = new HttpMediaTypeNotSupportedException( "Could not parse Accept header: Invalid mime type \"foo\": does not contain '/'"); - assertStatus(ex, HttpStatus.UNSUPPORTED_MEDIA_TYPE); assertDetail(ex, "Could not parse Content-Type."); + assertDetailMessageCode(ex, "parseError", null); + assertThat(ex.getHeaders()).isEmpty(); } @@ -99,11 +102,11 @@ public class ErrorResponseExceptionTests { void httpMediaTypeNotAcceptableException() { List mediaTypes = Arrays.asList(MediaType.APPLICATION_JSON, MediaType.APPLICATION_CBOR); - ErrorResponse ex = new HttpMediaTypeNotAcceptableException(mediaTypes); - + HttpMediaTypeNotAcceptableException ex = new HttpMediaTypeNotAcceptableException(mediaTypes); assertStatus(ex, HttpStatus.NOT_ACCEPTABLE); - assertDetail(ex, "Acceptable representations: 'application/json, application/cbor'."); + assertDetail(ex, "Acceptable representations: [application/json, application/cbor]."); + assertDetailMessageCode(ex, null, new Object[] {ex.getSupportedMediaTypes()}); assertThat(ex.getHeaders()).hasSize(1); assertThat(ex.getHeaders().getAccept()).isEqualTo(mediaTypes); @@ -115,9 +118,10 @@ public class ErrorResponseExceptionTests { ErrorResponse ex = new HttpMediaTypeNotAcceptableException( "Could not parse Accept header: Invalid mime type \"foo\": does not contain '/'"); - assertStatus(ex, HttpStatus.NOT_ACCEPTABLE); assertDetail(ex, "Could not parse Accept header."); + assertDetailMessageCode(ex, "parseError", null); + assertThat(ex.getHeaders()).isEmpty(); } @@ -125,22 +129,23 @@ public class ErrorResponseExceptionTests { void asyncRequestTimeoutException() { ErrorResponse ex = new AsyncRequestTimeoutException(); - + assertDetailMessageCode(ex, null, null); assertStatus(ex, HttpStatus.SERVICE_UNAVAILABLE); assertDetail(ex, null); + assertThat(ex.getHeaders()).isEmpty(); } @Test void httpRequestMethodNotSupportedException() { - String[] supportedMethods = new String[] { "GET", "POST" }; - ErrorResponse ex = new HttpRequestMethodNotSupportedException("PUT", supportedMethods, "Custom message"); - + HttpRequestMethodNotSupportedException ex = + new HttpRequestMethodNotSupportedException("PUT", Arrays.asList("GET", "POST")); assertStatus(ex, HttpStatus.METHOD_NOT_ALLOWED); assertDetail(ex, "Method 'PUT' is not supported."); + assertDetailMessageCode(ex, null, new Object[] {ex.getMethod(), ex.getSupportedHttpMethods()}); assertThat(ex.getHeaders()).hasSize(1); assertThat(ex.getHeaders().getAllow()).containsExactly(HttpMethod.GET, HttpMethod.POST); @@ -149,90 +154,101 @@ public class ErrorResponseExceptionTests { @Test void missingRequestHeaderException() { - ErrorResponse ex = new MissingRequestHeaderException("Authorization", this.methodParameter); - + MissingRequestHeaderException ex = new MissingRequestHeaderException("Authorization", this.methodParameter); assertStatus(ex, HttpStatus.BAD_REQUEST); assertDetail(ex, "Required header 'Authorization' is not present."); + assertDetailMessageCode(ex, null, new Object[] {ex.getHeaderName()}); + assertThat(ex.getHeaders()).isEmpty(); } @Test void missingServletRequestParameterException() { - ErrorResponse ex = new MissingServletRequestParameterException("query", "String"); - + MissingServletRequestParameterException ex = new MissingServletRequestParameterException("query", "String"); assertStatus(ex, HttpStatus.BAD_REQUEST); assertDetail(ex, "Required parameter 'query' is not present."); + assertDetailMessageCode(ex, null, new Object[] {ex.getParameterName()}); + assertThat(ex.getHeaders()).isEmpty(); } @Test void missingMatrixVariableException() { - ErrorResponse ex = new MissingMatrixVariableException("region", this.methodParameter); + MissingMatrixVariableException ex = new MissingMatrixVariableException("region", this.methodParameter); assertStatus(ex, HttpStatus.BAD_REQUEST); assertDetail(ex, "Required path parameter 'region' is not present."); + assertDetailMessageCode(ex, null, new Object[] {ex.getVariableName()}); + assertThat(ex.getHeaders()).isEmpty(); } @Test void missingPathVariableException() { - ErrorResponse ex = new MissingPathVariableException("id", this.methodParameter); - + MissingPathVariableException ex = new MissingPathVariableException("id", this.methodParameter); assertStatus(ex, HttpStatus.INTERNAL_SERVER_ERROR); assertDetail(ex, "Required path variable 'id' is not present."); + assertDetailMessageCode(ex, null, new Object[] {ex.getVariableName()}); + assertThat(ex.getHeaders()).isEmpty(); } @Test void missingRequestCookieException() { - ErrorResponse ex = new MissingRequestCookieException("oreo", this.methodParameter); - + MissingRequestCookieException ex = new MissingRequestCookieException("oreo", this.methodParameter); assertStatus(ex, HttpStatus.BAD_REQUEST); assertDetail(ex, "Required cookie 'oreo' is not present."); + assertDetailMessageCode(ex, null, new Object[] {ex.getCookieName()}); + assertThat(ex.getHeaders()).isEmpty(); } @Test void unsatisfiedServletRequestParameterException() { - ErrorResponse ex = new UnsatisfiedServletRequestParameterException( + UnsatisfiedServletRequestParameterException ex = new UnsatisfiedServletRequestParameterException( new String[] { "foo=bar", "bar=baz" }, Collections.singletonMap("q", new String[] {"1"})); - assertStatus(ex, HttpStatus.BAD_REQUEST); assertDetail(ex, "Invalid request parameters."); + assertDetailMessageCode(ex, null, new Object[] {List.of("\"foo=bar, bar=baz\"")}); + assertThat(ex.getHeaders()).isEmpty(); } @Test void missingServletRequestPartException() { - ErrorResponse ex = new MissingServletRequestPartException("file"); + MissingServletRequestPartException ex = new MissingServletRequestPartException("file"); assertStatus(ex, HttpStatus.BAD_REQUEST); assertDetail(ex, "Required part 'file' is not present."); + assertDetailMessageCode(ex, null, new Object[] {ex.getRequestPartName()}); + assertThat(ex.getHeaders()).isEmpty(); } @Test void methodArgumentNotValidException() { - BindingResult bindingResult = new BindException(new Object(), "object"); - bindingResult.addError(new FieldError("object", "field", "message")); + MessageSourceTestHelper messageSourceHelper = new MessageSourceTestHelper(MethodArgumentNotValidException.class); + BindingResult bindingResult = messageSourceHelper.initBindingResult(); ErrorResponse ex = new MethodArgumentNotValidException(this.methodParameter, bindingResult); assertStatus(ex, HttpStatus.BAD_REQUEST); assertDetail(ex, "Invalid request content."); + messageSourceHelper.assertDetailMessage(ex); + assertThat(ex.getHeaders()).isEmpty(); } @@ -242,11 +258,12 @@ public class ErrorResponseExceptionTests { List mediaTypes = Arrays.asList(MediaType.APPLICATION_JSON, MediaType.APPLICATION_CBOR); - ErrorResponse ex = new UnsupportedMediaTypeStatusException( + UnsupportedMediaTypeStatusException ex = new UnsupportedMediaTypeStatusException( MediaType.APPLICATION_XML, mediaTypes, HttpMethod.PATCH); assertStatus(ex, HttpStatus.UNSUPPORTED_MEDIA_TYPE); assertDetail(ex, "Content-Type 'application/xml' is not supported."); + assertDetailMessageCode(ex, null, new Object[] {ex.getContentType(), ex.getSupportedMediaTypes()}); HttpHeaders headers = ex.getHeaders(); assertThat(headers.getAccept()).isEqualTo(mediaTypes); @@ -261,19 +278,20 @@ public class ErrorResponseExceptionTests { assertStatus(ex, HttpStatus.UNSUPPORTED_MEDIA_TYPE); assertDetail(ex, "Could not parse Content-Type."); + assertDetailMessageCode(ex, "parseError", null); + assertThat(ex.getHeaders()).isEmpty(); } @Test void notAcceptableStatusException() { - List mediaTypes = - Arrays.asList(MediaType.APPLICATION_JSON, MediaType.APPLICATION_CBOR); - - ErrorResponse ex = new NotAcceptableStatusException(mediaTypes); + List mediaTypes = Arrays.asList(MediaType.APPLICATION_JSON, MediaType.APPLICATION_CBOR); + NotAcceptableStatusException ex = new NotAcceptableStatusException(mediaTypes); assertStatus(ex, HttpStatus.NOT_ACCEPTABLE); - assertDetail(ex, "Acceptable representations: 'application/json, application/cbor'."); + assertDetail(ex, "Acceptable representations: [application/json, application/cbor]."); + assertDetailMessageCode(ex, null, new Object[] {ex.getSupportedMediaTypes()}); assertThat(ex.getHeaders()).hasSize(1); assertThat(ex.getHeaders().getAccept()).isEqualTo(mediaTypes); @@ -285,45 +303,65 @@ public class ErrorResponseExceptionTests { ErrorResponse ex = new NotAcceptableStatusException( "Could not parse Accept header: Invalid mime type \"foo\": does not contain '/'"); - assertStatus(ex, HttpStatus.NOT_ACCEPTABLE); assertDetail(ex, "Could not parse Accept header."); + assertDetailMessageCode(ex, "parseError", null); + + assertThat(ex.getHeaders()).isEmpty(); + } + + @Test + void serverErrorException() { + + ServerErrorException ex = new ServerErrorException("Failure", null); + + assertStatus(ex, HttpStatus.INTERNAL_SERVER_ERROR); + assertDetail(ex, null); + assertDetailMessageCode(ex, null, new Object[] {ex.getReason()}); + assertThat(ex.getHeaders()).isEmpty(); } @Test void missingRequestValueException() { - ErrorResponse ex = new MissingRequestValueException( - "foo", String.class, "header", this.methodParameter); + MissingRequestValueException ex = + new MissingRequestValueException("foo", String.class, "header", this.methodParameter); assertStatus(ex, HttpStatus.BAD_REQUEST); assertDetail(ex, "Required header 'foo' is not present."); + assertDetailMessageCode(ex, null, new Object[] {ex.getLabel(), ex.getName()}); + assertThat(ex.getHeaders()).isEmpty(); } @Test void unsatisfiedRequestParameterException() { - ErrorResponse ex = new UnsatisfiedRequestParameterException( - Arrays.asList("foo=bar", "bar=baz"), - new LinkedMultiValueMap<>(Collections.singletonMap("q", Arrays.asList("1", "2")))); + UnsatisfiedRequestParameterException ex = + new UnsatisfiedRequestParameterException( + Arrays.asList("foo=bar", "bar=baz"), + new LinkedMultiValueMap<>(Collections.singletonMap("q", Arrays.asList("1", "2")))); assertStatus(ex, HttpStatus.BAD_REQUEST); assertDetail(ex, "Invalid request parameters."); + assertDetailMessageCode(ex, null, new Object[] {ex.getConditions()}); + assertThat(ex.getHeaders()).isEmpty(); } @Test void webExchangeBindException() { - BindingResult bindingResult = new BindException(new Object(), "object"); - bindingResult.addError(new FieldError("object", "field", "message")); + MessageSourceTestHelper messageSourceHelper = new MessageSourceTestHelper(WebExchangeBindException.class); + BindingResult bindingResult = messageSourceHelper.initBindingResult(); - ErrorResponse ex = new WebExchangeBindException(this.methodParameter, bindingResult); + WebExchangeBindException ex = new WebExchangeBindException(this.methodParameter, bindingResult); assertStatus(ex, HttpStatus.BAD_REQUEST); assertDetail(ex, "Invalid request content."); + messageSourceHelper.assertDetailMessage(ex); + assertThat(ex.getHeaders()).isEmpty(); } @@ -331,11 +369,11 @@ public class ErrorResponseExceptionTests { void methodNotAllowedException() { List supportedMethods = Arrays.asList(HttpMethod.GET, HttpMethod.POST); - ErrorResponse ex = new MethodNotAllowedException(HttpMethod.PUT, supportedMethods); - + MethodNotAllowedException ex = new MethodNotAllowedException(HttpMethod.PUT, supportedMethods); assertStatus(ex, HttpStatus.METHOD_NOT_ALLOWED); - assertDetail(ex, "Supported methods: 'GET', 'POST'"); + assertDetail(ex, "Supported methods: [GET, POST]"); + assertDetailMessageCode(ex, null, new Object[] {ex.getHttpMethod(), supportedMethods}); assertThat(ex.getHeaders()).hasSize(1); assertThat(ex.getHeaders().getAllow()).containsExactly(HttpMethod.GET, HttpMethod.POST); @@ -344,11 +382,12 @@ public class ErrorResponseExceptionTests { @Test void methodNotAllowedExceptionWithoutSupportedMethods() { - ErrorResponse ex = new MethodNotAllowedException(HttpMethod.PUT, Collections.emptyList()); - + MethodNotAllowedException ex = new MethodNotAllowedException(HttpMethod.PUT, Collections.emptyList()); assertStatus(ex, HttpStatus.METHOD_NOT_ALLOWED); assertDetail(ex, "Request method 'PUT' is not supported."); + assertDetailMessageCode(ex, null, new Object[] {ex.getHttpMethod(), Collections.emptyList()}); + assertThat(ex.getHeaders()).isEmpty(); } @@ -368,8 +407,64 @@ public class ErrorResponseExceptionTests { } } + private void assertDetailMessageCode( + ErrorResponse ex, @Nullable String suffix, @Nullable Object[] arguments) { + + assertThat(ex.getDetailMessageCode()) + .isEqualTo(ErrorResponse.getDefaultDetailMessageCode(((Exception) ex).getClass(), suffix)); + + if (arguments != null) { + assertThat(ex.getDetailMessageArguments()).containsExactlyElementsOf(Arrays.asList(arguments)); + } + else { + assertThat(ex.getDetailMessageArguments()).isNull(); + } + } + @SuppressWarnings("unused") private void handle(String arg) {} + + private static class MessageSourceTestHelper { + + private final String code; + + public MessageSourceTestHelper(Class exceptionType) { + this.code = "problemDetail." + exceptionType.getName(); + } + + public BindingResult initBindingResult() { + BindingResult bindingResult = new BindException(new TestBean(), "myBean"); + bindingResult.reject("bean.invalid.A", "Invalid bean message"); + bindingResult.reject("bean.invalid.B"); + bindingResult.rejectValue("name", "name.required", "Name is required"); + bindingResult.rejectValue("age", "age.min"); + return bindingResult; + } + + private void assertDetailMessage(ErrorResponse ex) { + StaticMessageSource messageSource = new StaticMessageSource(); + messageSource.addMessage(this.code, Locale.UK, "Failures {0}. nested failures: {1}"); + messageSource.addMessage("bean.invalid.A", Locale.UK, "Bean A message"); + messageSource.addMessage("bean.invalid.B", Locale.UK, "Bean B message"); + messageSource.addMessage("name.required", Locale.UK, "Required name message"); + messageSource.addMessage("age.min", Locale.UK, "Minimum age message"); + + String message = messageSource.getMessage( + ex.getDetailMessageCode(), ex.getDetailMessageArguments(), Locale.UK); + + assertThat(message).isEqualTo("" + + "Failures ['Invalid bean message', 'bean.invalid.B']. " + + "nested failures: [name: 'Name is required', age: 'age.min']"); + + message = messageSource.getMessage( + ex.getDetailMessageCode(), ex.getDetailMessageArguments(messageSource, Locale.UK), Locale.UK); + + assertThat(message).isEqualTo("" + + "Failures ['Bean A message', 'Bean B message']. " + + "nested failures: [name: 'Required name message', age: 'Minimum age message']"); + } + } + } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandler.java index 5d94001abc..cd9e9d6f8f 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandler.java @@ -16,10 +16,14 @@ package org.springframework.web.reactive.result.method.annotation; +import java.util.Locale; + import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import reactor.core.publisher.Mono; +import org.springframework.context.MessageSource; +import org.springframework.context.MessageSourceAware; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.ProblemDetail; @@ -55,13 +59,22 @@ import org.springframework.web.server.UnsupportedMediaTypeStatusException; * @author Rossen Stoyanchev * @since 6.0 */ -public abstract class ResponseEntityExceptionHandler { +public abstract class ResponseEntityExceptionHandler implements MessageSourceAware { /** * Common logger for use in subclasses. */ protected final Log logger = LogFactory.getLog(getClass()); + @Nullable + private MessageSource messageSource; + + + @Override + public void setMessageSource(MessageSource messageSource) { + this.messageSource = messageSource; + } + /** * Handle all exceptions raised within Spring MVC handling of the request . @@ -306,12 +319,25 @@ public abstract class ResponseEntityExceptionHandler { } if (body == null && ex instanceof ErrorResponse errorResponse) { - body = errorResponse.getBody(); + body = resolveDetailViaMessageSource(errorResponse, exchange.getLocaleContext().getLocale()); } return createResponseEntity(body, headers, status, exchange); } + private ProblemDetail resolveDetailViaMessageSource(ErrorResponse response, @Nullable Locale locale) { + ProblemDetail body = response.getBody(); + if (this.messageSource != null) { + locale = (locale != null ? locale : Locale.getDefault()); + Object[] arguments = response.getDetailMessageArguments(this.messageSource, locale); + String detail = this.messageSource.getMessage(response.getDetailMessageCode(), arguments, null, locale); + if (detail != null) { + body.setDetail(detail); + } + } + return body; + } + /** * Create the {@link ResponseEntity} to use from the given body, headers, * and statusCode. Subclasses can override this method to inspect and possibly diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandlerTests.java index ee3ff6d156..28b58b5a58 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandlerTests.java @@ -18,13 +18,15 @@ package org.springframework.web.reactive.result.method.annotation; import java.lang.reflect.Method; -import java.net.URI; import java.util.Collections; import java.util.List; +import java.util.Locale; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.context.support.StaticMessageSource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -33,6 +35,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; import org.springframework.util.LinkedMultiValueMap; +import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.web.ErrorResponseException; import org.springframework.web.bind.support.WebExchangeBindException; import org.springframework.web.server.MethodNotAllowedException; @@ -97,7 +100,7 @@ public class ResponseEntityExceptionHandlerTests { @Test void handleWebExchangeBindException() { - testException(new WebExchangeBindException(null, null)); + testException(new WebExchangeBindException(null, new BeanPropertyBindingResult(new Object(), "foo"))); } @Test @@ -120,20 +123,40 @@ public class ResponseEntityExceptionHandlerTests { testException(new ErrorResponseException(HttpStatus.CONFLICT)); } + @Test + void errorResponseProblemDetailViaMessageSource() { + + Locale locale = Locale.UK; + LocaleContextHolder.setLocale(locale); + + StaticMessageSource messageSource = new StaticMessageSource(); + messageSource.addMessage( + "problemDetail." + UnsupportedMediaTypeStatusException.class.getName(), locale, + "Content-Type {0} not supported. Supported: {1}"); + + this.exceptionHandler.setMessageSource(messageSource); + + Exception ex = new UnsupportedMediaTypeStatusException(MediaType.APPLICATION_JSON, + List.of(MediaType.APPLICATION_ATOM_XML, MediaType.APPLICATION_XML)); + + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/") + .acceptLanguageAsLocales(locale).build()); + + ResponseEntity responseEntity = this.exceptionHandler.handleException(ex, exchange).block(); + + ProblemDetail body = (ProblemDetail) responseEntity.getBody(); + assertThat(body.getDetail()).isEqualTo( + "Content-Type application/json not supported. Supported: [application/atom+xml, application/xml]"); + } + @SuppressWarnings("unchecked") private ResponseEntity testException(ErrorResponseException exception) { - ResponseEntity responseEntity = - this.exceptionHandler.handleException(exception, this.exchange).block(); - - assertThat(responseEntity).isNotNull(); - assertThat(responseEntity.getStatusCode()).isEqualTo(exception.getStatusCode()); - - assertThat(responseEntity.getBody()).isNotNull().isInstanceOf(ProblemDetail.class); - ProblemDetail body = (ProblemDetail) responseEntity.getBody(); - assertThat(body.getType()).isEqualTo(URI.create(exception.getClass().getName())); - - return (ResponseEntity) responseEntity; + ResponseEntity entity = this.exceptionHandler.handleException(exception, this.exchange).block(); + assertThat(entity).isNotNull(); + assertThat(entity.getStatusCode()).isEqualTo(exception.getStatusCode()); + assertThat(entity.getBody()).isNotNull().isInstanceOf(ProblemDetail.class); + return (ResponseEntity) entity; } @@ -142,9 +165,7 @@ public class ResponseEntityExceptionHandlerTests { private Mono> handleAndSetTypeToExceptionName( ErrorResponseException ex, HttpHeaders headers, HttpStatusCode status, ServerWebExchange exchange) { - ProblemDetail body = ex.getBody(); - body.setType(URI.create(ex.getClass().getName())); - return handleExceptionInternal(ex, body, headers, status, exchange); + return handleExceptionInternal(ex, null, headers, status, exchange); } @Override diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java index a2a77a48ea..1c33945f45 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java @@ -16,12 +16,17 @@ package org.springframework.web.servlet.mvc.method.annotation; +import java.util.Locale; + import jakarta.servlet.http.HttpServletResponse; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.beans.ConversionNotSupportedException; import org.springframework.beans.TypeMismatchException; +import org.springframework.context.MessageSource; +import org.springframework.context.MessageSourceAware; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; @@ -64,7 +69,7 @@ import org.springframework.web.util.WebUtils; * @author Rossen Stoyanchev * @since 3.2 */ -public abstract class ResponseEntityExceptionHandler { +public abstract class ResponseEntityExceptionHandler implements MessageSourceAware { /** * Log category to use when no mapped handler is found for a request. @@ -84,6 +89,16 @@ public abstract class ResponseEntityExceptionHandler { protected final Log logger = LogFactory.getLog(getClass()); + @Nullable + private MessageSource messageSource; + + + @Override + public void setMessageSource(MessageSource messageSource) { + this.messageSource = messageSource; + } + + /** * Handle all exceptions raised within Spring MVC handling of the request . * @param ex the exception to handle @@ -504,12 +519,25 @@ public abstract class ResponseEntityExceptionHandler { } if (body == null && ex instanceof ErrorResponse errorResponse) { - body = errorResponse.getBody(); + body = resolveDetailViaMessageSource(errorResponse); } return createResponseEntity(body, headers, statusCode, request); } + private ProblemDetail resolveDetailViaMessageSource(ErrorResponse response) { + ProblemDetail body = response.getBody(); + if (this.messageSource != null) { + Locale locale = LocaleContextHolder.getLocale(); + Object[] arguments = response.getDetailMessageArguments(this.messageSource, locale); + String detail = this.messageSource.getMessage(response.getDetailMessageCode(), arguments, null, locale); + if (detail != null) { + body.setDetail(detail); + } + } + return body; + } + /** * Create the {@link ResponseEntity} to use from the given body, headers, * and statusCode. Subclasses can override this method to inspect and possibly diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java index 999c6093cf..f4370f8682 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java @@ -19,18 +19,22 @@ package org.springframework.web.servlet.mvc.method.annotation; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Locale; import jakarta.servlet.ServletException; import org.junit.jupiter.api.Test; import org.springframework.beans.ConversionNotSupportedException; import org.springframework.beans.TypeMismatchException; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.context.support.StaticMessageSource; import org.springframework.core.MethodParameter; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotWritableException; @@ -106,7 +110,7 @@ public class ResponseEntityExceptionHandlerTests { } @Test - public void handleHttpMediaTypeNotSupported() { + public void httpMediaTypeNotSupported() { ResponseEntity entity = testException(new HttpMediaTypeNotSupportedException( MediaType.APPLICATION_JSON, List.of(MediaType.APPLICATION_ATOM_XML, MediaType.APPLICATION_XML))); @@ -152,6 +156,32 @@ public class ResponseEntityExceptionHandlerTests { testException(new ServletRequestBindingException("message")); } + @Test + public void errorResponseProblemDetailViaMessageSource() { + + Locale locale = Locale.UK; + LocaleContextHolder.setLocale(locale); + + try { + StaticMessageSource messageSource = new StaticMessageSource(); + messageSource.addMessage( + "problemDetail." + HttpMediaTypeNotSupportedException.class.getName(), locale, + "Content-Type {0} not supported. Supported: {1}"); + + this.exceptionHandler.setMessageSource(messageSource); + + ResponseEntity entity = testException(new HttpMediaTypeNotSupportedException( + MediaType.APPLICATION_JSON, List.of(MediaType.APPLICATION_ATOM_XML, MediaType.APPLICATION_XML))); + + ProblemDetail body = (ProblemDetail) entity.getBody(); + assertThat(body.getDetail()).isEqualTo( + "Content-Type application/json not supported. Supported: [application/atom+xml, application/xml]"); + } + finally { + LocaleContextHolder.resetLocaleContext(); + } + } + @Test public void conversionNotSupported() { testException(new ConversionNotSupportedException(new Object(), Object.class, null)); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolverTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolverTests.java index dbaee4d709..cf248b9bb5 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolverTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolverTests.java @@ -17,6 +17,7 @@ package org.springframework.web.servlet.mvc.support; import java.lang.reflect.Method; +import java.util.Arrays; import java.util.Collections; import org.junit.jupiter.api.BeforeEach; @@ -71,7 +72,7 @@ public class DefaultHandlerExceptionResolverTests { @Test public void handleHttpRequestMethodNotSupported() { HttpRequestMethodNotSupportedException ex = - new HttpRequestMethodNotSupportedException("GET", new String[]{"POST", "PUT"}); + new HttpRequestMethodNotSupportedException("GET", Arrays.asList("POST", "PUT")); ModelAndView mav = exceptionResolver.resolveException(request, response, null, ex); assertThat(mav).as("No ModelAndView returned").isNotNull(); assertThat(mav.isEmpty()).as("No Empty ModelAndView returned").isTrue();