- All Superinterfaces:
Errors
- All Known Implementing Classes:
AbstractBindingResult
,AbstractPropertyBindingResult
,BeanPropertyBindingResult
,BindException
,DirectFieldBindingResult
,MapBindingResult
,MethodArgumentNotValidException
,WebExchangeBindException
public interface BindingResult
extends Errors
General interface that represents binding results. Extends the
Errors
interface for error registration capabilities,
allowing for a Validator
to be applied, and adds
binding-specific analysis and model building.
Serves as result holder for a DataBinder
, obtained via
the DataBinder.getBindingResult()
method. BindingResult
implementations can also be used directly, for example to invoke
a Validator
on it (e.g. as part of a unit test).
- Since:
- 2.0
- Author:
- Juergen Hoeller
- See Also:
-
DataBinder
Errors
Validator
BeanPropertyBindingResult
DirectFieldBindingResult
MapBindingResult
-
Field Summary
Fields
Prefix for the name of the BindingResult instance in a model,
followed by the object name. -
Method Summary
void
Find a custom property editor for the given type and property.
getModel()
Return a model Map for the obtained state, exposing a BindingResult
instance as ‘MODEL_KEY_PREFIX
+ objectName’
and the object itself as ‘objectName’.Return the underlying PropertyEditorRegistry.
Extract the raw field value for the given field.
Return the list of fields that were suppressed during the bind process.
getTarget()
Return the wrapped target object, which may be a bean, an object with
public fields, a Map — depending on the concrete binding strategy.default void
Record the given value for the specified field.
default void
Mark the specified disallowed field as suppressed.
Resolve the given error code into message codes.
Resolve the given error code into message codes for the given field.
Methods inherited from interface org.springframework.validation.Errors
addAllErrors, getAllErrors, getErrorCount, getFieldError, getFieldError, getFieldErrorCount, getFieldErrorCount, getFieldErrors, getFieldErrors, getFieldType, getFieldValue, getGlobalError, getGlobalErrorCount, getGlobalErrors, getNestedPath, getObjectName, hasErrors, hasFieldErrors, hasFieldErrors, hasGlobalErrors, popNestedPath, pushNestedPath, reject, reject, reject, rejectValue, rejectValue, rejectValue, setNestedPath
-
Field Details
-
MODEL_KEY_PREFIX
static final String MODEL_KEY_PREFIX
Prefix for the name of the BindingResult instance in a model,
followed by the object name.
-
-
Method Details
-
getTarget
Return the wrapped target object, which may be a bean, an object with
public fields, a Map — depending on the concrete binding strategy. -
getModel
Return a model Map for the obtained state, exposing a BindingResult
instance as ‘MODEL_KEY_PREFIX
+ objectName’
and the object itself as ‘objectName’.Note that the Map is constructed every time you’re calling this method.
Adding things to the map and then re-calling this method will not work.The attributes in the model Map returned by this method are usually
included in theModelAndView
for a form view that uses Spring’sbind
tag in a JSP,
which needs access to the BindingResult instance. Spring’s pre-built
form controllers will do this for you when rendering a form view.
When building the ModelAndView instance yourself, you need to include
the attributes from the model Map returned by this method.- See Also:
-
Errors.getObjectName()
MODEL_KEY_PREFIX
ModelAndView
BindTag
-
getRawFieldValue
Extract the raw field value for the given field.
Typically used for comparison purposes.- Parameters:
field
— the field to check- Returns:
- the current value of the field in its raw form, or
null
if not known
-
findEditor
Find a custom property editor for the given type and property.
- Parameters:
field
— the path of the property (name or nested path), or
null
if looking for an editor for all properties of the given typevalueType
— the type of the property (can benull
if a property
is given but should be specified in any case for consistency checking)- Returns:
- the registered editor, or
null
if none
-
getPropertyEditorRegistry
Return the underlying PropertyEditorRegistry.
- Returns:
- the PropertyEditorRegistry, or
null
if none
available for this BindingResult
-
resolveMessageCodes
Resolve the given error code into message codes.
Calls the configured
MessageCodesResolver
with appropriate parameters.- Parameters:
errorCode
— the error code to resolve into message codes- Returns:
- the resolved message codes
-
resolveMessageCodes
Resolve the given error code into message codes for the given field.
Calls the configured
MessageCodesResolver
with appropriate parameters.- Parameters:
errorCode
— the error code to resolve into message codesfield
— the field to resolve message codes for- Returns:
- the resolved message codes
-
addError
- See Also:
-
ObjectError
FieldError
BindingErrorProcessor
-
recordFieldValue
Record the given value for the specified field.
To be used when a target object cannot be constructed, making
the original field values available throughErrors.getFieldValue(java.lang.String)
.
In case of a registered error, the rejected value will be exposed
for each affected field.- Parameters:
field
— the field to record the value fortype
— the type of the fieldvalue
— the original value- Since:
- 5.0.4
-
recordSuppressedField
default void recordSuppressedField(String field)
Mark the specified disallowed field as suppressed.
The data binder invokes this for each field value that was
detected to target a disallowed field.- See Also:
-
DataBinder.setAllowedFields(java.lang.String...)
-
getSuppressedFields
default String[] getSuppressedFields()
Return the list of fields that were suppressed during the bind process.
Can be used to determine whether any field values were targeting
disallowed fields.- See Also:
-
DataBinder.setAllowedFields(java.lang.String...)
-
One of the main blocker for solving this problem is the default eagerly-failing nature of the jackson data binder; one would have to somehow convince it to continue parsing instead of just stumble at first error. One would also have to collect these parsing errors in order to ultimately convert them to BindingResult
entries. Basically one would have to catch, suppress and collect parsing exceptions, convert them to BindingResult
entries then add these entries to the right @Controller
method BindingResult
argument.
The catch & suppress part could be done by:
- custom jackson deserializers which would simply delegate to the default related ones but would also catch, suppress and collect their parsing exceptions
- using
AOP
(aspectj version) one could simply intercept the default deserializers parsing exceptions, suppress and collect them - using other means, e.g. appropriate
BeanDeserializerModifier
, one could also catch, suppress and collect the parsing exceptions; this might be the easiest approach but requires some knowledge about this jackson specific customization support
The collecting part could use a ThreadLocal
variable to store all necessary exceptions related details. The conversion to BindingResult
entries and the addition to the right BindingResult
argument could be pretty easily accomplished by an AOP
interceptor on @Controller
methods (any type of AOP
, Spring variant including).
What’s the gain
By this approach one gets the data binding errors (in addition to the validation ones) into the BindingResult
argument the same way as would expect for getting them when using an e.g. @ModelAttribute
. It will also work with multiple levels of embedded objects — the solution presented in the question won’t play nice with that.
Solution Details (custom jackson deserializers approach)
I created a small project proving the solution (run the test class) while here I’ll just highlight the main parts:
/**
* The logic for copying the gathered binding errors
* into the @Controller method BindingResult argument.
*
* This is the most "complicated" part of the project.
*/
@Aspect
@Component
public class BindingErrorsHandler {
@Before("@within(org.springframework.web.bind.annotation.RestController)")
public void logBefore(JoinPoint joinPoint) {
// copy the binding errors gathered by the custom
// jackson deserializers or by other means
Arrays.stream(joinPoint.getArgs())
.filter(o -> o instanceof BindingResult)
.map(o -> (BindingResult) o)
.forEach(errors -> {
JsonParsingFeedBack.ERRORS.get().forEach((k, v) -> {
errors.addError(new FieldError(errors.getObjectName(), k, v, true, null, null, null));
});
});
// errors copied, clean the ThreadLocal
JsonParsingFeedBack.ERRORS.remove();
}
}
/**
* The deserialization logic is in fact the one provided by jackson,
* I only added the logic for gathering the binding errors.
*/
public class CustomIntegerDeserializer extends StdDeserializer<Integer> {
/**
* Jackson based deserialization logic.
*/
@Override
public Integer deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
try {
return wrapperInstance.deserialize(p, ctxt);
} catch (InvalidFormatException ex) {
gatherBindingErrors(p, ctxt);
}
return null;
}
// ... gatherBindingErrors(p, ctxt), mandatory constructors ...
}
/**
* A simple classic @Controller used for testing the solution.
*/
@RestController
@RequestMapping("/errormixtest")
@Slf4j
public class MixBindingAndValidationErrorsController {
@PostMapping(consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
public Level1 post(@Valid @RequestBody Level1 level1, BindingResult errors) {
// at the end I show some BindingResult logging for a @RequestBody e.g.:
// {"nr11":"x","nr12":1,"level2":{"nr21":"xx","nr22":1,"level3":{"nr31":"xxx","nr32":1}}}
// ... your whatever logic here ...
With these you’ll get in BindingResult
something like this:
Field error in object 'level1' on field 'nr12': rejected value [1]; codes [Min.level1.nr12,Min.nr12,Min.java.lang.Integer,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [level1.nr12,nr12]; arguments []; default message [nr12],5]; default message [must be greater than or equal to 5]
Field error in object 'level1' on field 'nr11': rejected value [x]; codes []; arguments []; default message [null]
Field error in object 'level1' on field 'level2.level3.nr31': rejected value [xxx]; codes []; arguments []; default message [null]
Field error in object 'level1' on field 'level2.nr22': rejected value [1]; codes [Min.level1.level2.nr22,Min.level2.nr22,Min.nr22,Min.java.lang.Integer,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [level1.level2.nr22,level2.nr22]; arguments []; default message [level2.nr22],5]; default message [must be greater than or equal to 5]
where the 1th line is determined by a validation error (setting 1
as the value for a @Min(5) private Integer nr12;
) while the 2nd is determined by a binding one (setting "x"
as value for a @JsonDeserialize(using = CustomIntegerDeserializer.class) private Integer nr11;
). 3rd line tests binding errors with embedded objects: level1
contains a level2
which contains a level3
object property.
Note how other approaches could simply replace the usage of custom jackson deserializers while keeping the rest of the solution (AOP
, JsonParsingFeedBack
).
Errors Spring Boot Starter
A Bootiful, Consistent and Opinionated Approach to Handle all sorts of Exceptions.
Table of Contents
- Make Error Handling Great Again!
- Getting Started
- Download
- Prerequisites
- Overview
- Error Codes
- Error Message
- Exposing Arguments
- Exposing Named Arguments
- Named Arguments Interpolation
- Validation and Binding Errors
- Custom Exceptions
- Spring MVC
- Spring Security
- Reactive
- Servlet
- Error Representation
- Fingerprinting
- Customizing the Error Representation
- Default Error Handler
- Refining Exceptions
- Logging Exceptions
- Post Processing Handled Exceptions
- Registering Custom Handlers
- Test Support
- Appendix
- Configuration
- License
Make Error Handling Great Again!
Built on top of Spring Boot’s great exception handling mechanism, the errors-spring-boot-starter
offers:
- A consistent approach to handle all exceptions. Doesn’t matter if it’s a validation/binding error or a
custom domain-specific error or even a Spring related error, All of them would be handled by aWebErrorHandler
implementation (No moreErrorController
vs@ExceptionHandler
vsWebExceptionHandler
) - Built-in support for application specific error codes, again, for all possible errors.
- Simple error message interpolation using plain old
MessageSource
s. - Customizable HTTP error representation.
- Exposing arguments from exceptions to error messages.
- Supporting both traditional and reactive stacks.
- Customizable exception logging.
- Supporting error fingerprinting.
Getting Started
Download
Download the latest JAR or grab via Maven:
<dependency> <groupId>me.alidg</groupId> <artifactId>errors-spring-boot-starter</artifactId> <version>1.4.0</version> </dependency>
or Gradle:
compile "me.alidg:errors-spring-boot-starter:1.4.0"
If you like to stay at the cutting edge, use our 1.5.0-SNAPSHOT
version. Of course you should define the following
snapshot repository:
<repositories> <repository> <id>Sonatype</id> <url>https://oss.sonatype.org/content/repositories/snapshots/</url> </repository> </repositories>
or:
repositories {
maven {
url 'https://oss.sonatype.org/content/repositories/snapshots/'
}
}
Prerequisites
The main dependency is JDK 8+. Tested with:
- JDK 8, JDK 9, JDK 10 and JDK 11 on Linux.
- Spring Boot
2.2.0.RELEASE
(Also, should work with any2.0.0+
)
Overview
The WebErrorHandler
implementations are responsible for handling different kinds of exceptions. When an exception
happens, the WebErrorHandlers
(A factory over all WebErrorHandler
implementations) catches the exception and would
find an appropriate implementation to handle the exception. By default, WebErrorHandlers
consults with the
following implementations to handle a particular exception:
- An implementation to handle all validation/binding exceptions.
- An implementation to handle custom exceptions annotated with the
@ExceptionMapping
. - An implementation to handle Spring MVC specific exceptions.
- And if the Spring Security is on the classpath, An implementation to handle Spring Security specific exceptions.
After delegating to the appropriate handler, the WebErrorHandlers
turns the handled exception result into a HttpError
,
which encapsulates the HTTP status code and all error code/message combinations.
Error Codes
Although using appropriate HTTP status codes is a recommended approach in RESTful APIs, sometimes, we need more information
to find out what exactly went wrong. This is where Error Codes comes in. You can think of an error code as a Machine Readable
description of the error. Each exception can be mapped to at least one error code.
In errors-spring-boot-starter
, one can map exceptions to error codes in different ways:
-
Validation error codes can be extracted from the Bean Validation‘s constraints:
public class User { @NotBlank(message = "username.required") private final String username; @NotBlank(message = "password.required") @Size(min = 6, message = "password.min_length") private final String password; // constructor and getter and setters }
To report a violation in password length, the
password.min_length
would be reported as the error code. As you may guess,
one validation exception can contain multiple error codes to report all validation violations at once. -
Specifying the error code for custom exceptions using the
@ExceptionMapping
annotation:@ExceptionMapping(statusCode = BAD_REQUEST, errorCode = "user.already_exists") public class UserAlreadyExistsException extends RuntimeException {}
The
UserAlreadyExistsException
exception would be mapped touser.already_exists
error code. -
Specifying the error code in a
WebErrorHandler
implementation:public class ExistedUserHandler implements WebErrorHandler { @Override public boolean canHandle(Throwable exception) { return exception instanceof UserAlreadyExistsException; } @Override public HandledException handle(Throwable exception) { return new HandledException("user.already_exists", BAD_REQUEST, null); } }
Error Message
Once the exception mapped to error code(s), we can add a companion and Human Readable error message. This can be done
by registering a Spring MessageSource
to perform the code-to-message translation. For example, if we add the following
key-value pair in our message resource file:
user.already_exists=Another user with the same username already exists
Then if an exception of type UserAlreadyExistsException
was thrown, you would see a 400 Bad Request
HTTP response
with a body like:
{ "errors": [ { "code": "user.already_exists", "message": "Another user with the same username already exists" } ] }
Since MessageSource
supports Internationalization (i18n), our error messages can possibly have different values based
on each Locale.
Exposing Arguments
With Bean Validation you can pass parameters from the constraint validation, e.g. @Size
, to its corresponding
interpolated message. For example, if we have:
password.min_length=The password must be at least {0} characters
And a configuration like:
@Size(min = 6, message = "password.min_length") private final String password;
The min
attribute from the @Size
constraint would be passed to the message interpolation mechanism, so:
{ "errors": [ { "code": "password.min_length", "message": "The password must be at least 6 characters" } ] }
In addition to support this feature for validation errors, we extend it for custom exceptions using the @ExposeAsArg
annotation. For example, if we’re going to specify the already taken username in the message:
user.already_exists=Another user with the '{0}' username already exists
We could write:
@ExceptionMapping(statusCode = BAD_REQUEST, errorCode = "user.already_exists") public class UserAlreadyExistsException extends RuntimeException { @ExposeAsArg(0) private final String username; // constructor }
Then the username
property from the UserAlreadyExistsException
would be available to the message under the
user.already_exists
key as the first argument. @ExposeAsArg
can be used on fields and no-arg methods with a
return type. The HandledException
class also accepts the to-be-exposed arguments in its constructor.
Exposing Named Arguments
By default error arguments will be used in message interpolation only. It is also possible to additionally get those
arguments in error response by defining the configuration property errors.expose-arguments
.
When enabled, you might get the following response payload:
{ "errors": [ { "code": "password.min_length", "message": "The password must be at least 6 characters", "arguments": { "min": 6 } } ] }
The errors.expose-arguments
property takes 3 possible values:
NEVER
— named arguments will never be exposed. This is the default setting.NON_EMPTY
— named arguments will be exposed only in case there are any. If error has no arguments,
result payload will not have"arguments"
element.ALWAYS
— the"arguments"
element is always present in payload, even when the error has no arguments.
In that case empty map will be provided:"arguments": {}
.
Checkout here for more detail on how we expose arguments for different exception categories.
Named Arguments Interpolation
You can use either positional or named argument placeholders in message templates. Given:
@Size(min = 6, max = 20, message = "password.length") private final String password;
You can create message template in messages.properties
with positional arguments:
password.length=Password must have length between {1} and {0}
Arguments are sorted by name. Since lexicographically max
< min
, placeholder {0}
will be substituted
with argument max
, and {1}
will have value of argument min
.
You can also use argument names as placeholders:
password.length=Password must have length between {min} and {max}
Named arguments interpolation works out of the box, regardless of the errors.expose-arguments
value.
You can mix both approaches, but it is not recommended.
If there is a value in the message that should not be interpolated, escape the first {
character with a backslash:
password.length=Password \{min} is {min} and \{max} is {max}
After interpolation, this message would read: Password {min} is 6 and {max} is 20
.
Arguments annotated with @ExposeAsArg
will be named by annotated field or method name:
@ExposeAsArg(0) private final String argName; // will be exposed as "argName"
This can be changed by the name
parameter:
@ExposeAsArg(value = 0, name = "customName") private final String argName; // will be exposed as "customName"
Validation and Binding Errors
Validation errors can be processed as you might expect. For example, if a client passed an empty JSON to a controller method
like:
@PostMapping public void createUser(@RequestBody @Valid User user) { // omitted }
Then the following error would be returned:
{ "errors": [ { "code": "password.min_length", "message": "corresponding message!" }, { "code": "password.required", "message": "corresponding message!" }, { "code": "username.required", "message": "corresponding message!" } ] }
Bean Validation’s ConstraintViolationException
s will be handled in the same way, too.
Custom Exceptions
Custom exceptions can be mapped to status code and error code combination using the @ExceptionMapping
annotation:
@ExceptionMapping(statusCode = BAD_REQUEST, errorCode = "user.already_exists") public class UserAlreadyExistsException extends RuntimeException {}
Here, every time we catch an instance of UserAlreadyExistsException
, a Bad Request
HTTP response with user.already_exists
error would be returned.
Also, it’s possible to expose some arguments from custom exceptions to error messages using the ExposeAsArg
:
@ExceptionMapping(statusCode = BAD_REQUEST, errorCode = "user.already_exists") public class UserAlreadyExistsException extends RuntimeException { @ExposeAsArg(0) private final String username; // constructor @ExposeAsArg(1) public String exposeThisToo() { return "42"; } }
Then the error message template can be something like:
user.already_exists=Another user exists with the '{0}' username: {1}
During message interpolation, the {0}
and {1}
placeholders would be replaced with annotated field’s value and
method’s return value. The ExposeAsArg
annotation is applicable to:
- Fields
- No-arg methods with a return type
Spring MVC
By default, a custom WebErrorHandler
is registered to handle common exceptions thrown by Spring MVC:
Exception | Status Code | Error Code | Exposed Args |
---|---|---|---|
HttpMessageNotReadableException |
400 | web.invalid_or_missing_body |
— |
HttpMediaTypeNotAcceptableException |
406 | web.not_acceptable |
List of acceptable MIME types |
HttpMediaTypeNotSupportedException |
415 | web.unsupported_media_type |
The unsupported content type |
HttpRequestMethodNotSupportedException |
405 | web.method_not_allowed |
The invalid HTTP method |
MissingServletRequestParameterException |
400 | web.missing_parameter |
Name and type of the missing query Param |
MissingServletRequestPartException |
400 | web.missing_part |
Missing request part name |
NoHandlerFoundException |
404 | web.no_handler |
The request path |
MissingRequestHeaderException |
400 | web.missing_header |
The missing header name |
MissingRequestCookieException |
400 | web.missing_cookie |
The missing cookie name |
MissingMatrixVariableException |
400 | web.missing_matrix_variable |
The missing matrix variable name |
others |
500 | unknown_error |
— |
Also, almost all exceptions from the ResponseStatusException
hierarchy, added in Spring Framework 5+ , are handled compatible
with the Spring MVC traditional exceptions.
Spring Security
When Spring Security is present on the classpath, a WebErrorHandler
implementation would be responsible to handle
common Spring Security exceptions:
Exception | Status Code | Error Code |
---|---|---|
AccessDeniedException |
403 | security.access_denied |
AccountExpiredException |
400 | security.account_expired |
AuthenticationCredentialsNotFoundException |
401 | security.auth_required |
AuthenticationServiceException |
500 | security.internal_error |
BadCredentialsException |
400 | security.bad_credentials |
UsernameNotFoundException |
400 | security.bad_credentials |
InsufficientAuthenticationException |
401 | security.auth_required |
LockedException |
400 | security.user_locked |
DisabledException |
400 | security.user_disabled |
others |
500 | unknown_error |
Reactive Security
When the Spring Security is detected along with the Reactive stack, the starter registers two extra handlers to handle
all security related exceptions. In contrast with other handlers which register themselves automatically, in order to use these
two handlers, you should register them in your security configuration manually as follows:
@EnableWebFluxSecurity public class WebFluxSecurityConfig { // other configurations @Bean public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http, ServerAccessDeniedHandler accessDeniedHandler, ServerAuthenticationEntryPoint authenticationEntryPoint) { http .csrf().accessDeniedHandler(accessDeniedHandler) .and() .exceptionHandling() .accessDeniedHandler(accessDeniedHandler) .authenticationEntryPoint(authenticationEntryPoint) // other configurations return http.build(); } }
The registered ServerAccessDeniedHandler
and ServerAuthenticationEntryPoint
are responsible for handling AccessDeniedException
and AuthenticationException
exceptions, respectively.
Servlet Security
When the Spring Security is detected along with the traditional servlet stack, the starter registers two extra handlers to handle
all security related exceptions. In contrast with other handlers which register themselves automatically, in order to use these
two handlers, you should register them in your security configuration manually as follows:
@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { private final AccessDeniedHandler accessDeniedHandler; private final AuthenticationEntryPoint authenticationEntryPoint; public SecurityConfig(AccessDeniedHandler accessDeniedHandler, AuthenticationEntryPoint authenticationEntryPoint) { this.accessDeniedHandler = accessDeniedHandler; this.authenticationEntryPoint = authenticationEntryPoint; } @Override protected void configure(HttpSecurity http) throws Exception { http .exceptionHandling() .accessDeniedHandler(accessDeniedHandler) .authenticationEntryPoint(authenticationEntryPoint); } }
The registered AccessDeniedHandler
and AuthenticationEntryPoint
are responsible for handling AccessDeniedException
and AuthenticationException
exceptions, respectively.
Error Representation
By default, errors would manifest themselves in the HTTP response bodies with the following JSON schema:
{ "errors": [ { "code": "the_error_code", "message": "the_error_message" } ] }
Fingerprinting
There is also an option to generate error fingerprint
. Fingerprint is a unique hash of error
event which might be used as a correlation ID of error presented to user, and reported in
application backend (e.g. in detailed log message). To generate error fingerprints, add
the configuration property errors.add-fingerprint=true
.
We provide two fingerprint providers implementations:
UuidFingerprintProvider
which generates a random UUID regardless of the handled exception.
This is the default provider and will be used out of the box if
errors.add-fingerprint=true
property is configured.Md5FingerprintProvider
which generates MD5 checksum of full class name of original exception
and current time.
Customizing the Error Representation
In order to change the default error representation, just implement the HttpErrorAttributesAdapter
interface and register it as Spring Bean:
@Component public class OopsDrivenHttpErrorAttributesAdapter implements HttpErrorAttributesAdapter { @Override public Map<String, Object> adapt(HttpError httpError) { return Collections.singletonMap("Oops!", httpError.getErrors()); } }
Default Error Handler
By default, when all registered WebErrorHandler
s refuse to handle a particular exception, the LastResortWebErrorHandler
would catch the exception and return a 500 Internal Server Error
with unknown_error
as the error code.
If you don’t like this behavior, you can change it by registering a Bean of type WebErrorHandler
with
the defaultWebErrorHandler
as the Bean Name:
@Component("defaultWebErrorHandler") public class CustomDefaultWebErrorHandler implements WebErrorHandler { // Omitted }
Refining Exceptions
Sometimes the given exception is not the actual problem and we need to dig deeper to handle the error, say the actual
exception is hidden as a cause inside the top-level exception. In order to transform some exceptions before handling
them, we can register an ExceptionRefiner
implementation as a Spring Bean:
@Component public class CustomExceptionRefiner implements ExceptionRefiner { @Override Throwable refine(Throwable exception) { return exception instanceof ConversionFailedException ? exception.getCause() : exception; } }
Logging Exceptions
By default, the starter issues a few debug
logs under the me.alidg.errors.WebErrorHandlers
logger name.
In order to customize the way we log exceptions, we just need to implement the ExceptionLogger
interface and register it
as a Spring Bean:
@Component public class StdErrExceptionLogger implements ExceptionLogger { @Override public void log(Throwable exception) { if (exception != null) System.err.println("Failed to process the request: " + exception); } }
Post Processing Handled Exceptions
As a more powerful alternative to ExceptionLogger
mechanism, there is also WebErrorHandlerPostProcessor
interface. You may declare multiple post processors which implement this interface and are exposed
as Spring Bean. Below is an example of more advanced logging post processors:
@Component public class LoggingErrorWebErrorHandlerPostProcessor implements WebErrorHandlerPostProcessor { private static final Logger log = LoggerFactory.getLogger(LoggingErrorWebErrorHandlerPostProcessor.class); @Override public void process(@NonNull HttpError error) { if (error.getHttpStatus().is4xxClientError()) { log.warn("[{}] {}", error.getFingerprint(), prepareMessage(error)); } else if (error.getHttpStatus().is5xxServerError()) { log.error("[{}] {}", error.getFingerprint(), prepareMessage(error), error.getOriginalException()); } } private String prepareMessage(HttpError error) { return error.getErrors().stream() .map(HttpError.CodedMessage::getMessage) .collect(Collectors.joining("; ")); } }
Registering Custom Handlers
In order to provide a custom handler for a specific exception, just implement the WebErrorHandler
interface for that
exception and register it as a Spring Bean:
@Component @Order(Ordered.HIGHEST_PRECEDENCE) public class CustomWebErrorHandler implements WebErrorHandler { @Override public boolean canHandle(Throwable exception) { return exception instanceof ConversionFailedException; } @Override public HandledException handle(Throwable exception) { return new HandledException("custom_error_code", HttpStatus.BAD_REQUEST, null); } }
If you’re going to register multiple handlers, you can change their priority using @Order
. Please note that all your custom
handlers would be registered after built-in exception handlers (Validation, ExceptionMapping
, etc.). If you don’t like
this idea, provide a custom Bean of type WebErrorHandlers
and the default one would be discarded.
Test Support
In order to enable our test support for WebMvcTest
s, just add the @AutoConfigureErrors
annotation to your test
class. That’s how a WebMvcTest
would look like with errors support enabled:
@AutoConfigureErrors @RunWith(SpringRunner.class) @WebMvcTest(UserController.class) public class UserControllerIT { @Autowired private MockMvc mvc; @Test public void createUser_ShouldReturnBadRequestForInvalidBodies() throws Exception { mvc.perform(post("/users").content("{}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.errors[0].code").value("username.required")); } }
For WebFluxTest
s, the test support is almost the same as the Servlet stack:
@AutoConfigureErrors @RunWith(SpringRunner.class) @WebFluxTest(UserController.class) @ImportAutoConfiguration(ErrorWebFluxAutoConfiguration.class) // Drop this if you're using Spring Boot 2.1.4+ public class UserControllerIT { @Autowired private WebTestClient client; @Test public void createUser_ShouldReturnBadRequestForInvalidBodies() { client.post() .uri("/users") .syncBody("{}").header(CONTENT_TYPE, APPLICATION_JSON_UTF8_VALUE) .exchange() .expectStatus().isBadRequest() .expectBody().jsonPath("$.errors[0].code").isEqualTo("username.required"); } }
Appendix
Configuration
Additional configuration of this starter can be provided by configuration properties — the Spring Boot way.
All configuration properties start with errors
. Below is a list of supported properties:
Property | Values | Default value |
---|---|---|
errors.expose-arguments |
NEVER , NON_EMPTY , ALWAYS |
NEVER |
errors.add-fingerprint |
true , false |
false |
Check ErrorsProperties
implementation for more details.
License
Copyright 2018 alimate
Licensed under the Apache License, Version 2.0 (the «License»);
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an «AS IS» BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Create global or application-level Exception handlers and return Custom Error Messages in Spring REST APIs.
Overview
Effective communication is the key to healthy and efficient relationships. Interestingly, the same applies to any Client and Server relationships. The client’s request may succeed or fail on the server. However, the server should provide the most appropriate status code in either of the outcomes.
Although sending a correct status code is enough for a client to take real action based on the outcome of a request, in case of failures, the client may need more details about what went wrong. For example, failure details like the exception type and an error message can help clients log the error or provide appropriate failure messages to their clients.
This article will teach How to handle different failures and return Custom Error Messages from a Spring REST API. If you don’t know how to handle exceptions in Spring REST API, please read Spring Rest Service Exception Handling.
Return a Generic Error Message using @ResponseStatus
The most basic way of returning an error message from a REST API is to use the @ResponseStatus annotation. We can add the error message in the annotation’s reason field. Although we can only return a generic error message, we can specify exception-specific error messages.
Next is an example of a @ControllerAdvice using @ResponseStatus annotations to return exception-specific error messages.
Code language: Java (java)
@ControllerAdvice public class ApplicationExceptionHandler { @ResponseStatus( value = HttpStatus.NOT_FOUND, reason = "Requested Student Not Found") @ExceptionHandler(StudentNotFoundException.class) public void handleException(StudentNotFoundException e) { } @ResponseStatus( value = HttpStatus.BAD_REQUEST, reason = "Received Invalid Input Parameters") @ExceptionHandler(InputValidationException.class) public void handleException(InputValidationException e) { } @ResponseStatus( value = HttpStatus.GATEWAY_TIMEOUT, reason = "Upstream Service Not Responding, Try Again") @ExceptionHandler(ServiceUnavailableException.class) public void handleException(ServiceUnavailableException e) { } }
The Exception handler class has three exception handlers, each of which returns a specific HTTP Response Status. Each response status specifies a reason field with a particular error message.
To view the error message in the response, ensure you have turned on include-messages in the server configuration. To learn more about Spring Boot server configurations, please visit Spring Boot Embedded Tomcat Configuration.
Code language: YAML (yaml)
server: error: include-message: always
Next is an example of a response object the REST API returns. Note that the response object has the specified error message.
Code language: JSON / JSON with Comments (json)
{ "timestamp": "", "status": 404, "error": "Not Found", "message": "Requested Student Not Found", "path": "/students/Jack" }
Although we can specify exception-specific error messages, it is still not informative. Therefore in the following sections, we will learn how to return a more specific error message from Spring REST API.
Return Error Message Using Custom Error Object
Let’s create a class representing the error message and the status code. We will return an instance of that in case of errors.
Next is the Error class representing the status code and a String message. We use a few Lombok annotations that introduce regular getter and setter methods and a constructor using the final fields.
Custom Response Error Class
Code language: Java (java)
@Data @RequiredArgsConstructor public class Error { private final HttpStatus httpStatus; private final String message; }
Now that we have an error model created, we will use it to return a detailed error message from Controller Advice.
@ExceptionHandler(StudentNotFoundException.class) public ResponseEntity handleException( StudentNotFoundException e) { Error error = new Error(HttpStatus.NOT_FOUND, e.getLocalizedMessage()); return new ResponseEntity<>(error, error.getHttpStatus()); }
Code language: Java (java)
The exception handler returns an instance of the Error class populated with the exception message and HTTP Status Code.
Now, we can throw our Not Found Exception with a custom error message.
Code language: Java (java)
throw new StudentNotFoundException ("Student service failed, studentId : " + studentId);
When the REST API cannot find the requested resource, we get a detailed error as a response.
Code language: JSON / JSON with Comments (json)
{ "httpStatus": "NOT_FOUND", "message": "Student service failed, studentId : Jack" }
Return Error Message Using HashMap
Also, instead of creating a dedicated error class, we can return a detailed error message using a simple HashMap. Next is an example of producing and returning a Custom Error Message using Java HashMap.
Code language: Java (java)
@ExceptionHandler(StudentNotFoundException.class) public ResponseEntity<Map<String, String>> handleException(StudentNotFoundException e) { Map<String, String> errorResponse = Map.of( "message", e.getLocalizedMessage(), "status", HttpStatus.NOT_FOUND.toString() ); return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND); }
Handle Bad Request Exceptions
The Bad Request errors are the Client errors where the client’s request doesn’t meet the requirements of the target server. This section will see how to handle Bad Request exceptions and provide a custom or detailed error response.
Type Mismatch Exceptions
The Type Mismatch Exceptions occur when Spring Controller cannot map the request parameters, path variables, or header values into controller method arguments. This section covers the handling of MethodArgumentTypeMismatchException and TypeMismatchException.
Spring throws MethodArgumentTypeMismatchException when the controller argument doesn’t have a required type. On the other hand, Spring throws TypeMismatchException when there is a type mismatch while setting Bean properties. Also, both exceptions provide a detailed error message that we can use to prepare the Error object.
To demonstrate that, next is an example of Handling MethodArgumentTypeMismatchException and TypeMismatchException and returning a detailed error message in Controller Advice.
Code language: Java (java)
@ExceptionHandler({ MethodArgumentTypeMismatchException.class, TypeMismatchException.class }) public ResponseEntity<Map<String, String>> handleException(TypeMismatchException e) { Map<String, String> errorResponse = Map.of( "message", e.getLocalizedMessage(), "status", HttpStatus.BAD_REQUEST.toString() ); return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); }
Note that the controller advice catches both exceptions; however, the method arguments accept an exception of type TypeMismatchException because it is the parent of the other exception.
Next, the snippet shows a detailed error message when we call a rest endpoint with an incompatible path variable leading to MethodArgumentTypeMismatchException.
Code language: JSON / JSON with Comments (json)
{ "httpStatus": "BAD_REQUEST", "message": "Failed to convert value of type 'java.lang.String' to required type 'java.lang.Long'; nested exception is java.lang.NumberFormatException: For input String: "Jack"" }
Bean Validation Exceptions
The Bean Validation exceptions occur when the request contents do not pass the provided validations.
The BindException occurs when the binding errors are fatal. While the MethodArgumentNotValidException occurs when validations specified by @Valid fail. Note that the MethodArgumentNotValidException is a subclass of BindException. Thus, we can handle them using the same Spring REST API’s exception handler.
Code language: Java (java)
@ExceptionHandler({ BindException.class, MethodArgumentNotValidException.class }) public ResponseEntity<Map<String, Object>> handleException(BindException e) { List<String> errors = new ArrayList<>(); e.getFieldErrors() .forEach(err -> errors.add(err.getField() + ": " + err.getDefaultMessage())); e.getGlobalErrors() .forEach(err -> errors.add(err.getObjectName() + ": " + err.getDefaultMessage())); Map<String, Object> errorResponse = Map.of( "error", errors, "message", e.getLocalizedMessage(), "status", HttpStatus.BAD_REQUEST.toString() ); return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); }
Here we have created a List<String> to represent individual binding errors and add that to the response Map. Instead, we can add a List<String>
field to the Error class we created in the previous section and populate the list with individual errors.
Spring throws HttpMediaTypeNotSupportedException, when a POST, PUT, or PATCH endpoint on the server cannot handle the content type sent by the client. The REST Controllers on the server specify the content type they can support. When the media type that a client sends doesn’t match, the client gets this exception back.
To demonstrate, next is an example of handling HttpMediaTypeNotSupportedException and returning a custom error response.
Code language: Java (java)
@ExceptionHandler(HttpMediaTypeNotSupportedException.class) public ResponseEntity<Map<String, String>> handleException( HttpMediaTypeNotSupportedException e) { String provided = e.getContentType().toString(); List<String> supported = e.getSupportedMediaTypes().stream() .map(MimeType::toString) .collect(Collectors.toList()); String error = provided + " is not one of the supported media types (" + String.join(", ", supported) + ")"; Map<String, String> errorResponse = Map.of( "error", error, "message", e.getLocalizedMessage(), "status", HttpStatus.UNSUPPORTED_MEDIA_TYPE.toString() ); return new ResponseEntity<>(errorResponse, HttpStatus.UNSUPPORTED_MEDIA_TYPE); }
As seen in the exception handler above, the instance of HttpMediaTypeNotSupportedException provides detailed information about the incorrect media type we provided and a list of actually supported media types. Thus, we create a custom error message based on the available information.
Code language: JSON / JSON with Comments (json)
{ "error":"text/plain;charset=UTF-8 is not one of the supported media types ( application/octet-stream, text/plain, application/xml, text/xml, application/x-www-form-urlencoded, application/*+xml, multipart/form-data, multipart/mixed, application/json, application/*+json, */*)", "message":"Content type 'text/plain;charset=UTF-8' not supported", "status":"415 UNSUPPORTED_MEDIA_TYPE" }
The above snippet shows a client’s sample error response when it sends a request with an invalid media type.
Handle Request Body Not Readable Exception
Now we will see an example of handling HttpMessageNotReadableException and returning a custom error response. The HttpMessageNotReadableException occurs when the request body is missing or unreadable.
Code language: Java (java)
@ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity<Map<String, String>> handleException( HttpMessageNotReadableException e) throws IOException { Map<String, String> errorResponse = Map.of( "message", e.getLocalizedMessage(), "status", HttpStatus.BAD_REQUEST.toString() ); return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); }
Handle HTTP Request Method Not Supported Exception
The HttpMethodNotSupportedException occurs when the HTTP endpoint on the REST API does not support the HTTP request method. Let’s write an exception handler for HttpMethodNotSupportedException and return a detailed error message.
Code language: Java (java)
@ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ResponseEntity<Map<String, String>> handleException( HttpRequestMethodNotSupportedException e) throws IOException { String provided = e.getMethod(); List<String> supported = List.of(e.getSupportedMethods()); String error = provided + " is not one of the supported Http Methods (" + String.join(", ", supported) + ")"; Map<String, String> errorResponse = Map.of( "error", error, "message", e.getLocalizedMessage(), "status", HttpStatus.METHOD_NOT_ALLOWED.toString() ); return new ResponseEntity<>(errorResponse, HttpStatus.METHOD_NOT_ALLOWED); }
As seen in the exception handler above, the exception instance provides detailed information about the provided HTTP Method and an array of Supported HTTP Methods. We use it to form a clear error message.
Code language: JSON / JSON with Comments (json)
{ "error": "GET is not one of the supported Http Methods (POST)", "message": "Request method 'GET' not supported", "status": "405 METHOD_NOT_ALLOWED" }
The snippet showed an example response when the client attempted to execute a GET endpoint, while the REST API supports only POST.
Default Exception Handler
Similarly, we can create a default exception handler advice that handles all Exception types. Spring attempts to find the most specific handler when we have multiple exception handlers and falls back to the default handler if there is no suitable handler.
Code language: Java (java)
@ExceptionHandler(Exception.class) public ResponseEntity<Map<String, String>> handleException( Exception e) throws IOException { Map<String, String> errorResponse = Map.of( "message", e.getLocalizedMessage(), "status", HttpStatus.INTERNAL_SERVER_ERROR.toString() ); return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); }
Above is an example of writing a default exception handler that returns an error message by the exception instance and an HTTP Status of 500.
Summary
This detailed tutorial taught us how to Return Custom Error Messages in Spring REST API. Firstly, we understood that Spring returns a generic error message and the most suitable HTTP Status Code by default. However, we can write our exception handlers for specific exceptions using @ControllerAdvice and produce a custom and detailed error response.
For more on Spring and Spring Boot Tutorials, please visit Spring Tutorials.
This
is continuation to my previous posts. In my previous post, I explained how
spring binds the request parameters to a java model object using
@ModelAttribute annotation.
But
in some cases, user may enter wrong inputs. For example, you developed a
registration form, where you are expecting below information.
Information |
Type |
userName |
String |
age |
int |
hobbies |
Collection |
If
user enter age as ‘Twenty Five’, where you are expecting an integer. Spring
throws an exception.
As
you see above registration form, I given ‘Twenty Five’ as the value for the
field ‘Age’. When I click on submit button, I got below exception.
As
you see above error page, I do not get any meaningful error message. To solve
this problem, spring provides ‘BindingResult’ interface.
BindingResult
interface
General
interface that represents binding results. If any error occurs, at the time of
binding the request parameters to java model object, we can use this interface
to retrieve the number of errors, error messages.
@RequestMapping(«/registerMe»)
public
ModelAndView getHelloMessage(@ModelAttribute(«studentInfo») Student
student, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
ModelAndView modelAndView =
new ModelAndView(«registration»);
return modelAndView;
}
…..
…..
…..
}
As
you see above snippet, I am using bindingResult.hasErrors() method to check
whether there is any errors.
you
can use the <form:errors /> tag to
render those field error messages in an HTML page.
Example
<form:errors
path=»studentInfo.*» />
Above
tag displays the error message, if the binding of the request parameters to the
studentInfo object is not successful.
Find
the below working application.
HelloWorldController.java
package com.sample.myApp.controllers; import org.springframework.stereotype.Controller; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.servlet.ModelAndView; import com.sample.myApp.model.Student; @Controller public class HelloWorldController { @RequestMapping("/registration") public String getRegistrationpage() { return "registration"; } @RequestMapping("/registerMe") public ModelAndView getHelloMessage(@ModelAttribute("studentInfo") Student student, BindingResult bindingResult) { if (bindingResult.hasErrors()) { ModelAndView modelAndView = new ModelAndView("registration"); return modelAndView; } ModelAndView modelAndView = new ModelAndView("welcome"); modelAndView.addObject("message", "Dear User, your details are registered"); return modelAndView; } }
Student.java
package com.sample.myApp.model; import java.util.List; public class Student { private String userName; private int age; private List<String> hobbies; public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } public List<String> getHobbies() { return hobbies; } public void setHobbies(List<String> hobbies) { this.hobbies = hobbies; } }
Create
registration.jsp, welcome.jsp files under WEB-INF/jsp folder.
registration.jsp
<%@taglib prefix="form" uri="http://www.springframework.org/tags/form"%> <html> <head> <title>User Information Page</title> </head> <body> <h2 style="color:red;"><form:errors path="studentInfo.*" /></h2> <form method="post" action="/springdemo/registerMe" id="f1"> <table> <tr> <td>User Name :</td> <td><input type="text" name="userName" value="" /></td> </tr> <tr> <td>Age :</td> <td><input type="text" name="age" value="" /></td> </tr> <tr> <td>Hobbies :</td> <td><select multiple name="hobbies"> <option value="cricket">Cricket</option> <option value="chess">Chess</option> <option value="football">Football</option> <option value="tennis">Tennis</option> </select></td> </tr> <tr> <td><input type="submit" name="submit" value="submit" style="font-size: 18px;" /></td> </tr> </table> </form> </body> </html>
welcome.jsp
<%@ page language="java" contentType="text/html; charset=ISO-8859-1" pageEncoding="ISO-8859-1"%> <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=ISO-8859-1"> <title>Hello World Spring Web MVC</title> </head> <body> <h1>${message}</h1> <h3> User Name : ${studentInfo.userName} <br /> Age : ${studentInfo.age} <br /> Hobbies: ${studentInfo.hobbies} </h3> </body> </html>
Create
web.xml, HelloWorld-servlet.xml files under WEB-INF folder.
web.xml
<web-app id="WebApp_ID" version="2.4" xmlns="http://java.sun.com/xml/ns/j2ee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd"> <display-name>Spring MVC Hello WorldApplication</display-name> <servlet> <servlet-name>HelloWorld</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>HelloWorld</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> </web-app>
HelloWorld-servlet.xml
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:context="http://www.springframework.org/schema/context" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation=" http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.0.xsd"> <context:component-scan base-package="com.sample.myApp" /> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/jsp/" /> <property name="suffix" value=".jsp" /> </bean> </beans>
Create
index.jsp file under webapp folder.
index.jsp
<%@taglib prefix="form" uri="http://www.springframework.org/tags/form"%> <html> <head> <title>Welcome Page</title> </head> <body> <h1>Hello World</h1> </body> </html>
Project
structure looks like below.
Run
the application on server.
I
entered ‘Twenty Five’ in the Age field, When I click on submit button, it shows
the error information.
Java
Рекомендация: подборка платных и бесплатных курсов Java — https://katalog-kursov.ru/
Нередко пользователи пытаются передать в приложение некорректные данные. Это происходит либо из злого умысла, либо по ошибке. Поэтому стоит проверять данные на соответствие бизнес-требованиям.
Эту задачу решает Bean Validation. Он интегрирован со Spring и Spring Boot. Hibernate Validator считается эталонной реализацией Bean Validation.
Основы валидации Bean
Для проверки данных используются аннотации над полями класса. Это декларативный подход, который не загрязняет код.
При передаче размеченного таким образом объекта класса в валидатор, происходит проверка на ограничения.
Настройка
Добавьте следующие зависимости в проект:
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId>
<version>7.0.0.Final</version>
</dependency>
dependencies {
compile group: 'javax.validation', name: 'validation-api', version: '2.0.1.Final'
compile group: 'org.hibernate', name: 'hibernate-validator', version: '7.0.0.Final'
}
Валидация в Spring MVC Controller
Сначала данные попадают в контроллер. У входящего HTTP-запроса возможно проверить следующие параметры:
- тело запроса
- переменные пути (например, id в /foos/{id})
- параметры запроса
Рассмотрим каждый из них подробнее.
Валидация тела запроса
Тело запроса POST и PUT обычно содержит данные в формате JSON. Spring автоматически сопоставляет входящий JSON с объектом Java.
Проверяем соответствует ли входящий Java объект нашим требованиям.
class Input {
@Min(1)
@Max(10)
private int numberBetweenOneAndTen;
@Pattern(regexp = "^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$")
private String ipAddress;
// ...
}
- Поле
numberBetweenOneAndTen
должно быть от 1 до 10, включительно. - Поле
ipAddress
должно содержать строку в формате IP-адреса.
Контроллер REST принимает объект Input
и выполняет проверку:
@RestController
class ValidateRequestBodyController {
@PostMapping("/validateBody")
public ResponseEntity<String> validateBody(@Valid @RequestBody Input input) {
return ResponseEntity.ok("valid");
}
}
Достаточно добавить в параметр input
аннотацию @Valid
, чтобы сообщить спрингу передать объект Валидатору, прежде чем делать с ним что-либо еще.
Если класс содержит поле с другим классом, который тоже необходимо проверить — это поле необходимо пометить аннотацией Valid.
Исключение MethodArgumentNotValidException
выбрасывается, когда объект не проходит проверку. По умолчанию, Spring переведет это исключение в HTTP статус 400.
Проверка переменных пути и параметров запроса
Проверка переменных пути и параметров запроса работает по-другому.
Не проверяются сложные Java-объекты, так как path-переменные и параметры запроса являются примитивными типами, такими как int
, или их аналогами: Integer
или String
.
Вместо аннотации поля класса, как описано выше, добавляют аннотацию ограничения (в данном случае @Min
) непосредственно к параметру метода в контроллере Spring:
@Validated
@RestController
class ValidateParametersController {
@GetMapping("/validatePathVariable/{id}")
ResponseEntity<String> validatePathVariable(
@PathVariable("id") @Min(5) int id
) {
return ResponseEntity.ok("valid");
}
@GetMapping("/validateRequestParameter")
ResponseEntity<String> validateRequestParameter(
@RequestParam("param") @Min(5) int param
) {
return ResponseEntity.ok("valid");
}
}
Обратите внимание, что необходимо добавить @Validated
Spring в контроллер на уровне класса, чтобы сказать Spring проверять ограничения на параметрах метода.
В этом случае аннотация @Validated
устанавливается на уровне класса, даже если она присутствует на методах.
В отличии валидации тела запроса, при неудачной проверки параметра вместо метода MethodArgumentNotValidException
будет выброшен ConstraintViolationException
. По умолчанию последует ответ со статусом HTTP 500 (Internal Server Error), так как Spring не регистрирует обработчик для этого исключения.
Вернем HTTP статус 400, так как клиент предоставил недействительный параметр. Для этого добавляем пользовательский обработчик исключений в контоллер:
@RestController
@Validated
class ValidateParametersController {
// request mapping method omitted
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity<String> handleConstraintViolationException(ConstraintViolationException e) {
return new ResponseEntity<>("not valid due to validation error: " + e.getMessage(), HttpStatus.BAD_REQUEST);
}
}
Позже рассмотрим, как вернуть структурированный ответ об ошибке, содержащий подробности обо всех неудачных подтверждениях для проверки клиентом.
Валидация в сервисном слое
Можно проверять данные на любых компонентах Spring. Для этого используется комбинация аннотаций @Validated
и @Valid
.
@Service
@Validated
class ValidatingService{
void validateInput(@Valid Input input){
// do something
}
}
Аннотация @Validated
устанавливается только на уровне класса, так что не ставьте ее на метод в данном случае.
Валидация сущностей JPA
Persistence Layer это последняя линия проверки данных. По умолчанию Spring Data использует Hibernate, который поддерживает Bean Validation из коробки.
Обычно мы не хотим делать проверку так поздно, поскольку это означает, что бизнес-код работал с потенциально невалидными объектами, что может привести к непредвиденным ошибкам.
Допустим, необходимо хранить объекты нашего класса Input
в базе данных. Сначала добавляем нужную JPA аннотацию @Entity
, а так же поле id
:
@Entity
public class Input {
@Id
@GeneratedValue
private Long id;
@Min(1)
@Max(10)
private int numberBetweenOneAndTen;
@Pattern(regexp = "^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$")
private String ipAddress;
// ...
}
Когда репозиторий пытается сохранить невалидный Input
, чьи аннотации ограничений нарушаются, выбрасывается ConstraintViolationException
.
Bean Validation запускается Hibernate только после того как EntityManager вызовет flush.
Чтобы отключить Bean Validation в репозиториях Spring, достаточно установить свойство Spring Boot spring.jpa.properties.javax.persistence.validation.mode
равным null
.
Валидация конфигурации приложения
Spring Boot аннотация @ConfigurationProperties
используется для связывания свойств из application.properties с Java объектом.
Данные из application необходимы для стабильной работы приложения. Bean Validation поможет обнаружить ошибку в этих данных при старте приложения.
Допустим имеется следующий конфигурационный класс:
@Validated
@ConfigurationProperties(prefix="app.properties")
class AppProperties {
@NotEmpty
private String name;
@Min(value = 7)
@Max(value = 30)
private Integer reportIntervalInDays;
@Email
private String reportEmailAddress;
// getters and setters
}
При попытке запуска с недействительным адресом электронной почты получаем ошибку:
***************************
APPLICATION FAILED TO START
***************************
Description:
Binding to target org.springframework.boot.context.properties.bind.BindException:
Failed to bind properties under 'app.properties' to
io.reflectoring.validation.AppProperties failed:
Property: app.properties.reportEmailAddress
Value: manager.analysisapp.com
Reason: must be a well-formed email address
Action:
Update your application's configuration
Стандартные ограничения
Библиотека javax.validation
имеет множество аннотаций для валидации.
Каждая аннотация имеет следующие поля:
message
— указывает на ключ свойства вValidationMessages.properties
, который используется для отправки сообщения в случае нарушения ограничения.groups
— позволяет определить, при каких обстоятельствах будет срабатывать эта проверка (о группах проверки поговорим позже).payload
— позволяет определить полезную нагрузку, которая будет передаваться сс проверкой.@Constraint
— указывает на реализацию интерфейсаConstraintValidator
.
Рассмотрим популярные ограничения.
@NotNull
и @Null
@NotNull
— аннотированный элемент не должен быть null. Принимает любой тип.
@Null
— аннотированный элемент должен быть null. Принимает любой тип.
@NotBlank
и @NotEmpty
@NotBlank
— аннотированный элемент не должен быть null и должен содержать хотя бы один непробельный символ. Принимает CharSequence
.
@NotEmpty
— аннотированный элемент не должен быть null или пустым. Поддерживаемые типы:
CharSequence
Collection
. Оценивается размер коллекцииMap
. Оценивается размер мапыArray
. Оценивается длина массива
@NotBlank
применяется только к строкам и проверяет, что строка не пуста и не состоит только из пробелов.
@NotNull
применяется к CharSequence
, Collection
, Map
или Array
и проверяет, что объект не равен null
. Но при этом он может быть пуст.
@NotEmpty
применяется к CharSequence
, Collection
, Map
или Array
и проверяет, что он не null
имеет размер больше 0.
Аннотация @Size(min=6)
пропустит строку состоящую из 6 пробелов и/или символов переноса строки, а @NotBlank
не пропустит.
@Size
Размер аннотированного элемента должен быть между указанными границами, включая сами границы. null
элементы считаются валидными.
Поддерживаемые типы:
CharSequence
. Оценивается длина последовательности символовCollection
. Оценивается размер коллекцииMap
. Оценивается размер мапыArray
. Оценивается длина массива
Добавление пользовательского валидатора
Если имеющихся аннотаций ограничений недостаточно, то создайте новые.
В классе Input
использовалось регулярное выражение для проверки того, что строка является IP адресом. Регулярное выражение не является полным: оно позволяет сокеты со значениями больше 255, таким образом «111.111.111.333» будет считаться действительным.
Давайте напишем валидатор, который реализует эту проверку на Java. Потому что как говорится, до решения проблемы регулярным выражением у вас была одна проблема, а теперь стало двe
Сначала создаем пользовательскую аннотацию @IpAddress
:
@Target({ FIELD })
@Retention(RUNTIME)
@Constraint(validatedBy = IpAddressValidator.class)
@Documented
public @interface IpAddress {
String message() default "{IpAddress.invalid}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}
Реализация валидатора выглядит следующим образом:
class IpAddressValidator implements ConstraintValidator<IpAddress, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
Pattern pattern =
Pattern.compile("^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$");
Matcher matcher = pattern.matcher(value);
try {
if (!matcher.matches()) {
return false;
} else {
for (int i = 1; i <= 4; i++) {
int octet = Integer.valueOf(matcher.group(i));
if (octet > 255) {
return false;
}
}
return true;
}
} catch (Exception e) {
return false;
}
}
}
Теперь можно использовать аннотацию @IpAddress
, как и любую другую аннотацию ограничения.
class InputWithCustomValidator {
@IpAddress
private String ipAddress;
// ...
}
Принудительный вызов валидации
Для принудительного вызова проверки, без использования Spring Boot, создайте валидатор вручную.
class ProgrammaticallyValidatingService {
void validateInput(Input input) {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Set<ConstraintViolation<Input>> violations = validator.validate(input);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
}
Тем не менее, Spring Boot предоставляет предварительно сконфигурированный экземпляр валидатора. Внедрив этот экземпляр в сервис не придется создавать его вручную.
@Service
class ProgrammaticallyValidatingService {
private Validator validator;
public ProgrammaticallyValidatingService(Validator validator) {
this.validator = validator;
}
public void validateInputWithInjectedValidator(Input input) {
Set<ConstraintViolation<Input>> violations = validator.validate(input);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
}
Когда этот сервис внедряется Spring, в конструктор автоматически вставляется экземпляр валидатора.
Группы валидаций
Некоторые объекты участвуют в разных вариантах использования.
Возьмем типичные операции CRUD: при обновлении и создании, скорее всего, будет использоваться один и тот же класс. Тем не менее, некоторые валидации должны срабатывать при различных обстоятельствах:
- только перед созданием
- только перед обновлением
- или в обоих случаях
Функция Bean Validation, которая позволяет нам внедрять такие правила проверки, называется «Validation Groups».
Все аннотации ограничений должны иметь поле groups
. Это поле быть использовано для передачи любых классов, каждый из которых определяет группу проверки.
Для нашего примера CRUD определим два маркерных интерфейса OnCreate
и OnUpdate
:
interface OnCreate {}
interface OnUpdate {}
Затем используем эти интерфейсы с любой аннотацией ограничения:
class InputWithGroups {
@Null(groups = OnCreate.class)
@NotNull(groups = OnUpdate.class)
private Long id;
// ...
}
Это позволит убедиться, что id пуст при создании и заполнен при обновлении.
Spring поддерживает группы проверки только с аннотацией @Validated
@Service
@Validated
class ValidatingServiceWithGroups {
@Validated(OnCreate.class)
void validateForCreate(@Valid InputWithGroups input){
// do something
}
@Validated(OnUpdate.class)
void validateForUpdate(@Valid InputWithGroups input){
// do something
}
}
Обратите внимание, что аннотация @Validated
применяется ко всему классу. Чтобы определить, какая группа проверки активна, она также применяется на уровне метода.
Использование групп проверки может легко стать анти-паттерном. При использовании групп валидации сущность должна знать правила валидации для всех случаев использования (групп), в которых она используется.
Возвращение структурных ответов на ошибки
Когда проверка не удается, лучше вернуть клиенту понятное сообщение об ошибке. Для этого необходимо вернуть структуру данных с сообщением об ошибке для каждой проверки, которая не прошла валидацию.
Сначала нужно определить эту структуру данных. Назовем ее ValidationErrorResponse
и она содержит список объектов Violation
:
public class ValidationErrorResponse {
private List<Violation> violations = new ArrayList<>();
// ...
}
public class Violation {
private final String fieldName;
private final String message;
// ...
}
Затем создадим глобальный ControllerAdvice
, который обрабатывает все ConstraintViolationExventions
, которые пробрасываются до уровня контроллера. Чтобы отлавливать ошибки валидации и для тел запросов, мы также будем работать с MethodArgumentNotValidExceptions
:
@ControllerAdvice
class ErrorHandlingControllerAdvice {
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
ValidationErrorResponse onConstraintValidationException(
ConstraintViolationException e) {
ValidationErrorResponse error = new ValidationErrorResponse();
for (ConstraintViolation violation : e.getConstraintViolations()) {
error.getViolations().add(
new Violation(violation.getPropertyPath().toString(), violation.getMessage()));
}
return error;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
ValidationErrorResponse onMethodArgumentNotValidException(
MethodArgumentNotValidException e) {
ValidationErrorResponse error = new ValidationErrorResponse();
for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
error.getViolations().add(
new Violation(fieldError.getField(), fieldError.getDefaultMessage()));
}
return error;
}
}
Здесь информацию о нарушениях из исключений переводится в нашу структуру данных ValidationErrorResponse
.
Обратите внимание на аннотацию @ControllerAdvice
, которая делает методы обработки исключений глобально доступными для всех контроллеров в контексте приложения.
Spring integrates Bean Validation and gives you more
01. Validations in Java world
Validating data is a common task that occurs throughout an application, from the presentation layer to the persistence layer. Often the same validation logic is implemented in each layer, proving to be time-consuming and error-prone.
JSR (Java Specification Requests) has developed a Java Bean validation specification. Javax and Hibernate have implemented the specification.
Spring supports Bean validation and integrates Javax and Hibernate-validation and also adds more helper class to allow better validation.
I’ll use the following DTO as an example:
@Data
public class ToDoDto implements Serializable {
private static final long serialVersionUID = 1L;
private UUID uuid;
@Email(message = "Please provide a valid Email")
private String email;
@Range(min = 13, max = 13, message = "Please provide a valid phone number. E.g., 0064-01-234-5678")
private Integer phoneNumber;
private String postAddress;
@Digits(integer = 4, fraction =0,message = "Please provide a valid post code" )
private String postCode;
@NotBlank(message = "Content cannot be empty")
private String content;
@Future()
private LocalDateTime due;
}
Bean Validation API
Bean validation annotation constraints can be applied on types, fields, methods, constructors, parameters, container elements, and other container annotations. Validation is applied not only to the object level, but it can also be inherited from super classes. Entire object graphs can be validated, meaning that if a class declares a field that has the type of a separate class containing validation, cascading validation can occur.
It is specifically not tied to either the web tier or the persistence tier, and is available for both server-side application programming. It evolves as to 3 versions now:
- JSR-303 : Bean Validation
Hibernate Validator 4.3.1.Final
- JSR 349 : Bean Validation 1.1
Hibernate Validator 5.1.1.Final
- JSR 380 : Bean Validation 2.0 (Jakarta Bean Validation)
Hibernate Validator 6.0.17.Final
Official package
javax.validation is JavaBean Validation official package
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
Hibernate implementation
You have noticed, hibernate has implemented it and uses it in different versions.
Hibernate-Validator
extends javax.validation
, e.g., it adds @Range
and @Length
.
You can mix the use of javax.validation.constraints
and org.hibernate.validator.constraints
.
for example, for our case, we imported both of them
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Email;
import javax.validation.constraints.Future;
import javax.validation.constraints.NotEmpty;
Hibernate-Validator Unit tests
You can get a validator by a ValidatorFactory
and you can also just get it by @Autowire
because Spring already provides a default validator.
class ToDoDtoValidationTest {
//@Autowired Validator validate
private Validator validator;
@BeforeEach
void setUp() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
}
@Test
public void tesAllGood() {
ToDoDto dto = new ToDoDto();
dto.setEmail("example@example.com");
dto.setPostCode(1234);
dto.setContent("example");
Set<ConstraintViolation<ToDoDto>> violations = validator.validate(dto);
Assertions.assertTrue(violations.isEmpty(), "should be empty, however we got violations, size: " + violations.size());
}
@Test
public void testContentIsEmpty() {
ToDoDto dto = new ToDoDto();
dto.setEmail("example@example.com");
dto.setPostCode(1234);
Set<ConstraintViolation<ToDoDto>> violations = validator.validate(dto);
Assertions.assertEquals(1, violations.size(), "should be 1 exception");
}
}
Spring Validation
Spring supports Java’s Bean Validation API, This makes it easy to declare validation rules as opposed to explicitly writing declaration logic in your application code.
Spring provides a built-in validation API by way of the Validator
interface. This interface allowing you to encapsulate your validation logic into a class responsible for validating the target object. In addition to the target object, the validate method takes an Errors
object, which is used to collect any validation errors that may occur.
Spring also provides a handy utility class, ValidationUtils, which provides convenience methods for invoking other validators, checking for common problems such as empty strings, and reporting errors back to the provided Errors object.
LocalValidatorFactoryBean is a Spring-managed bean since Spring 3.0.
How to import validation package
- If your spring-boot version is less than 2.3.x,
spring-boot-starter-web
has includedhibernate-validator
so you don’t need to import other packages. - otherwise, you need to manually import either of the following packages:
hibernate-validator
spring-boot-starter-validation
Where should the validation happen
Generally speaking, there are three layers we would like to implement validation:
- presentation layer, we must validate the DTO
- Service layer, something we need to do validation
- persistence layer, defitely you don’t want to save some bad data
02. DTO Bean Validation in Spring, using @Valid
For now, we would like to know how Java Bean validation works in Spring.
Later, in the next chapter, we’ll introduce the parameter validation in Spring. Parameters are not java beans, so you cannot use bean validation against them.
The above two validations would throw different kinds of exceptions, we’ll cover it in a separate chapter.
Note that Spring MVC validates ViewModels (VM is not DTO) and put results to a BindResult, I don’t have a plan to mention it in the following parts.
Request body Validation with Bean Validation and Spring Validation
-
use Bean Validation, add
@Valid
in front of@RequestBody
works. It validates all the fields before throwing exceptions. Invalid Request body throwsMethodArgumentNotValidException
.@PostMapping ResponseEntity<ToDoDto> createTodo(@Valid @RequestBody ToDoDto toDoDto) { return ResponseEntity.ok(toDoDto); } @ExceptionHandler({MethodArgumentNotValidException.class}) public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { HttpHeaders headers = new HttpHeaders(); String ResponseBody = "Response Body, should be a JSON"; return new ResponseEntity(ResponseBody, headers, HttpStatus.BAD_REQUEST); }
-
use Spring Validation to validate Request Body
- class level
@Validated
doesn’t help with Request Body validation - Add
@Valdaited
in front of@RequestBody
works, we’ll explain why both of them work - Without Spring data-binding and validation errors, it throws
MethodArgumentNotValidException
and goes to 400 error
@PostMapping ResponseEntity<ToDoDto> createTodo(@Validated @RequestBody ToDoDto toDoDto) { return ResponseEntity.ok(toDoDto); } @ExceptionHandler({MethodArgumentNotValidException.class}) public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { HttpHeaders headers = new HttpHeaders(); String ResponseBody = "Response Body, should be a JSON"; return new ResponseEntity(ResponseBody, headers, HttpStatus.BAD_REQUEST); }
- class level
-
Use Spring validation error binding and validation errors. You can take advantage of the errors object, it cotnains the filed and error message, so that you can return the error message to the API caller. It works for both Bean validation and Spring validation. It’s from package
org.springframework.validation
@PostMapping
ResponseEntity<ToDoDto> createTodo(@Valid @RequestBody ToDoDto toDoDto, Errors errors) {
if (errors.hasErrors()) {
throw new RuntimeException("Please handle these validation exceptions");
}
return ResponseEntity.ok(toDoDto);
}
@PostMapping
ResponseEntity<ToDoDto> createTodo(@Validated @RequestBody ToDoDto toDoDto, Errors errors) {
if (errors.hasErrors()) {
throw new RuntimeException("Please handle these validation exceptions");
}
return ResponseEntity.ok(toDoDto);
}
Testing with DTO Bean Validation
Note that you should customize the error message. We will do this in another chapter.
@PostMapping
ResponseEntity<ToDoDto> createTodo(@Valid @RequestBody ToDoDto toDoDto) {
return ResponseEntity.ok(toDoDto);
}
@ExceptionHandler({MethodArgumentNotValidException.class})
public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
HttpHeaders headers = new HttpHeaders();
String ResponseBody = "Response Body, should be a JSON";
return new ResponseEntity(ResponseBody, headers, HttpStatus.BAD_REQUEST);
}
@Test
void createToDoWithInvalidEmail() throws Exception {
ToDoDto dto = new ToDoDto();
dto.setEmail("example@@");
dto.setPostCode(1234);
dto.setContent("exdample");
String body = objectMapper.writeValueAsString(dto);
mockMvc.perform(post("/")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(content().string("Response Body, should be a JSON"));
}
@Test
void createToDoWithValidEmail() throws Exception {
ToDoDto dto = new ToDoDto();
dto.setEmail("example@example.com");
dto.setPostCode(1234);
dto.setContent("example");
String body = objectMapper.writeValueAsString(dto);
mockMvc.perform(post("/")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk());
}
03. Spring Parameter validation, introducing @Validated
Parameters are not JavaBeans, so Bean Validation doesn’t help.
Spring implements parameter validation by itself.
The keys are:
- add
@Validated
to the class you want to validate parameters. Without it, parameter validation is not enabled - you don’t need to add
@Valid
to the parameter - it throws
ConstraintViolationException
if the parameter is invalid - it works for both
RequestParam
andPathVariable
-
you cannot use Errors Binding here, otherwise, you’ll get an
IllegalStateException
java.lang.IllegalStateException: An Errors/BindingResult argument is expected to be declared immediately after the model attribute, the @RequestBody or the @RequestPart arguments to which they apply: org.springframework.http.ResponseEntity com.mg.todo.ToDoController.fetchByEmail( java.lang.String,org.springframework.validation.Errors)
Example and test case
import org.springframework.validation.annotation.Validated;
@Validated
@RestController
public class ToDoController {
private final ToDoService toDoService;
public ToDoController(ToDoService toDoService) {
this.toDoService = toDoService;
}
@GetMapping
ResponseEntity<List<ToDoDto>> fetchByEmail(@RequestParam("email") @Email(message = "should be a valid email") String email) {
return ResponseEntity.ok(new ArrayList<ToDoDto>());
}
@ExceptionHandler({ConstraintViolationException.class})
public ResponseEntity handleConstrainViolationException() {
HttpHeaders headers = new HttpHeaders();
String ResponseBody = "should be a valid email";
return new ResponseEntity(ResponseBody,headers, HttpStatus.BAD_REQUEST );
}
}
@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = ToDoController.class)
class ToDoControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
ToDoService toDoService;
@Test
void fetchByValidEmail() throws Exception {
mockMvc.perform(get("/")
.param("email","valid@example.com")
.contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk());
}
@Test
void fetchByInvalidEmail() throws Exception {
mockMvc.perform(get("/")
.param("email","invalid@@")
.contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isBadRequest())
.andExpect(content().string("should be a valid email"));
}
}
You can also use @Validated for Request Body DTO validation
In the background, Spring wraps Hibernate-Validator
and does the validation work.
Note that resolveArgument()
method calls validateIfApplicable(binder, parameter);
and this is the key.
It picks up @Validate
parameters first and it triggers validation.
If it’s absent, it looks for any parameters’ annotation that start from Valid
.
That’s why @Validated
works for both DTO and parameters and DTO here.
package org.springframework.web.servlet.mvc.method.annotation;
/**
* Resolves method arguments annotated with {@code @RequestBody} and handles return
* values from methods annotated with {@code @ResponseBody} by reading and writing
* to the body of the request or response with an {@link HttpMessageConverter}.
*/
public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {
/**
* Throws MethodArgumentNotValidException if validation fails.
* @throws HttpMessageNotReadableException if {@link RequestBody#required()}
* is {@code true} and there is no body content or if there is no suitable
* converter to read the content with.
*/
@Override
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
parameter = parameter.nestedIfOptional();
Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
String name = Conventions.getVariableNameForParameter(parameter);
if (binderFactory != null) {
WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
if (arg != null) {
validateIfApplicable(binder, parameter);
if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
}
}
if (mavContainer != null) {
mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
}
}
return adaptArgumentIfNecessary(arg, parameter);
}
}
package org.springframework.web.servlet.mvc.method.annotation;
/**
* A base class for resolving method argument values by reading from the body of
* a request with {@link HttpMessageConverter HttpMessageConverters}.
*/
public abstract class AbstractMessageConverterMethodArgumentResolver implements HandlerMethodArgumentResolver {
/**
* Validate the binding target if applicable.
* <p>The default implementation checks for {@code @javax.validation.Valid},
* Spring's {@link org.springframework.validation.annotation.Validated},
* and custom annotations whose name starts with "Valid".
*/
protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
Annotation[] annotations = parameter.getParameterAnnotations();
for (Annotation ann : annotations) {
Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) {
Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));
Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints});
binder.validate(validationHints);
break;
}
}
}
}
04. Handle Validation Errors
In the above examples, we would know:
- Bean validation throws
MethodArgumentNotValidException
- If we use parameter Error Binding, we can make use of the
errors
object. - You can get each error by
ex.getBindingResult().getFieldErrors()
- If we use parameter Error Binding, we can make use of the
- Spring parameter validation throws
ConstraintViolationException
,- it cannot use parameter Error Binding.
- You can get each error by
e.getConstraintViolations()
- There actually is another exception
org.springframework.validation.BindException
, thrown in MVC form submitContent-Type: multipart/form-data
, we didn’t mention this but you should know it exists.
A new issue comes out. How can we format/unify our error response body, so that the API caller would have a meaningful error message.
- We should always return a unified Response entity for exceptions. Use
ResponseStatusException
- We could make use of
@RestControllerAdvice
to handle exception globally
Returns Unified error response
this content is from All You Need To Know About Bean Validation With Spring Boot
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ValidationErrorResponse {
private List<Violation> violations = new ArrayList<>();
}
@Data
@AllArgsConstructor
class Violation {
private String fieldName;
private String message;
}
@ControllerAdvice
public class GlobalRestExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
ValidationErrorResponse onMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
ValidationErrorResponse error = new ValidationErrorResponse();
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
error.getViolations().add(
new Violation(fieldError.getField(), fieldError.getDefaultMessage()));
}
return error;
}
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
ValidationErrorResponse onConstraintValidationException(
ConstraintViolationException e) {
ValidationErrorResponse error = new ValidationErrorResponse();
for (ConstraintViolation violation : e.getConstraintViolations()) {
error.getViolations().add(
new Violation(violation.getPropertyPath().toString(), violation.getMessage()));
}
return error;
}
}
@Test
void createToDoWithInvalidEmail() throws Exception {
ToDoDto dto = new ToDoDto();
dto.setEmail("example@@");
dto.setPostCode("0060");
String body = objectMapper.writeValueAsString(dto);
final MvcResult mvcResult = mockMvc.perform(post("/")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andReturn();
final String content = mvcResult.getResponse().getContentAsString();
Assertions.assertNotNull(content);
Assertions.assertTrue(content.contains(""fieldName":"content","message":"Content cannot be empty""),"should contain this text");
Assertions.assertTrue(content.contains(""fieldName":"email","message":"Please provide a valid Email""),"should contain this text");
}
05. @Valid vs @Validated, validation groups using @Validated
Oftentimes, you need to validate a filed differently based on the scenario. A common case, we don’t have an Id for an object on creating, however, we do need a valid id while updating. The javax.validation
@Valid
doesn’t support Groups. The Spring @Validated
does.
Steps:
- Add groups to the Model
- Use
@Validated
and Groups in Controller - MockMVC testing
Use Validation Groups
@Data
public class ToDoDto implements Serializable {
private static final long serialVersionUID = 1L;
@NotNull(message = "Id should not be null on updating", groups = Update.class)
private UUID uuid;
@Email(message = "Please provide a valid Email", groups = {Create.class, Update.class})
private String email;
@Range(min = 13, max = 13, message = "Please provide a valid phone number. E.g., 0064-01-234-5678", groups = {Create.class, Update.class})
private Integer phoneNumber;
@Digits(integer = 4, fraction = 0, message = "Please provide a valid post code", groups = {Create.class, Update.class})
private String postCode;
@NotBlank(message = "Content cannot be empty", groups = {Create.class, Update.class})
private String content;
@Future(groups = {Create.class, Update.class})
private LocalDateTime due;
//on creating
public interface Create { }
// on updating
public interface Update { }
}
@PostMapping
ResponseEntity<ToDoDto> createTodo(@Validated(ToDoDto.Create.class) @RequestBody ToDoDto toDoDto) {
return ResponseEntity.ok(toDoDto);
}
@Test
void updateToDoWithUUID() throws Exception {
ToDoDto dto = new ToDoDto();
dto.setEmail("example@example.com");
dto.setPostCode("0060");
dto.setContent("example");
dto.setUuid(UUID.randomUUID());
String body = objectMapper.writeValueAsString(dto);
mockMvc.perform(put("/")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON));
}
@Test
void updateToDoWithoutUUID() throws Exception {
ToDoDto dto = new ToDoDto();
dto.setEmail("example@example.com");
dto.setPostCode("0060");
dto.setContent("example");
String body = objectMapper.writeValueAsString(dto);
final MvcResult mvcResult = mockMvc.perform(put("/")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andReturn();
String content = mvcResult.getResponse().getContentAsString();
Assertions.assertTrue(content.contains( "violations"));
Assertions.assertTrue(content.contains("Id should not be null on updating"));
}
06. @Valid vs @Validated, Collection validation using @Valid
In the Demo, all fields we validate are Java provided types, e.g., String, Long or LocalDateTime. However, it’s not always the case.
- we may need to validate another object nested in your DTO
- We may need to validate a list of your DTOs
Validate Nested object
@Data
public class ToDoDto implements Serializable {
// .... other fields
@NotNull(groups = {Create.class, Update.class})
@Valid
private MetaData metaData;
@Data
public static class MetaData {
@Min(value = 1, groups = Update.class)
private Long Id;
@NotNull(groups = {Create.class, Update.class})
@Length(min = 2, max = 10, groups = {Create.class, Update.class})
private String Name;
@NotNull(groups = {Create.class, Update.class})
@Length(min = 2, max = 10, groups = {Create.class, Update.class})
private String position;
}
public interface Create { }
public interface Update { }
In our case, if you have an empty Meta data object, you’ll get the following error mesasge:
{"violations":[
{"fieldName":"metaData.Name","message":"must not be null"},
{"fieldName":"metaData.position","message":"must not be null"}
]}
Validate a list of DTOs
- In
java.util.Collection
,List
andSet
don’t work for validation - We need to implement our own collection, e.g.,
myValidationList
to accept the data and do validation
Bean Validation doesn’t work in List of DTOs
@PostMapping(value = "/todos")
ResponseEntity<List<ToDoDto>> createTodos(@Validated(ToDoDto.Create.class) @RequestBody List<ToDoDto> toDoDtos) {
return ResponseEntity.ok(toDoDtos);
}
@Test
void createToDosWithInvalidEmailShouldBe400ButIs200() throws Exception {
ToDoDto dto = new ToDoDto();
dto.setEmail("example@@");
dto.setPostCode("0060");
ToDoDto dto2 = new ToDoDto();
dto2.setEmail("example@@");
dto2.setPostCode("0060");
String body = objectMapper.writeValueAsString(List.of(dto, dto2));
mockMvc.perform(post("/todos")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk());
}
Implement MyValidationList for List of DTOs
It throws org.springframework.beans.NotReadablePropertyException
if there is any violation
public class MyValidationList<E> implements List<E> {
@Delegate // lombok annotation
@Valid
public List<E> list = new ArrayList<>();
}
@PostMapping(value = "/todos2")
ResponseEntity<List<ToDoDto>> createTodos2(@Validated(ToDoDto.Create.class) @RequestBody MyValidationList<ToDoDto> toDoDtos) {
return ResponseEntity.ok(toDoDtos);
}
@ExceptionHandler({NotReadablePropertyException.class})
public ResponseEntity handleMethodArgumentNotValidException(NotReadablePropertyException e) {
HttpHeaders headers = new HttpHeaders();
String ResponseBody = e.getMessage();
return new ResponseEntity(ResponseBody, headers, HttpStatus.BAD_REQUEST);
}
@Test
void createToDosWithMyValidationListOfDtosWithInvalidEmail() throws Exception {
ToDoDto dto = new ToDoDto();
dto.setEmail("example@@");
dto.setPostCode("0060");
ToDoDto dto2 = new ToDoDto();
dto2.setEmail("example@@");
dto2.setPostCode("0060");
String body = objectMapper.writeValueAsString(List.of(dto, dto2));
mockMvc.perform(post("/todos2")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
@Test
void createToDosWithMyValidationListOfDtos() throws Exception {
ToDoDto dto = new ToDoDto();
dto.setEmail("example@example.com");
dto.setPostCode("0060");
dto.setContent("example");
dto.setUuid(UUID.randomUUID());
final ToDoDto.MetaData metaData = new ToDoDto.MetaData();
metaData.setName("name");
metaData.setPosition("position");
dto.setMetaData(metaData);
ToDoDto dto2 = new ToDoDto();
dto2.setEmail("example@example.com");
dto2.setPostCode("0060");
dto2.setContent("example");
dto2.setUuid(UUID.randomUUID());
final ToDoDto.MetaData metaData2 = new ToDoDto.MetaData();
metaData2.setName("name");
metaData2.setPosition("position");
dto2.setMetaData(metaData2);
String body = objectMapper.writeValueAsString(List.of(dto));
mockMvc.perform(post("/todos2")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk());
}
07. Declarative customized annotation validation
Again, in a real scenario, we may need more complicated logic than what Spring provides us.
Steps:
- Add the new annotation interface
- Add a customized validator and implement
ConstraintValidator
- Add this new annotation to the DTO
- No need to do anything to the Controller or Service layer
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {EncryptIdValidator.class})
public @interface NotGmail {
String message() default "We don't support Gmail";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class NotGmailValidator implements ConstraintValidator<NotGmail, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value != null) {
return !value.toLowerCase().contains("@gmail");
}
return true;
}
}
@Data
public class ToDoDto implements Serializable {
@Email(message = "Please provide a valid Email", groups = {Create.class, Update.class})
@com.mg.todo.dto.NotGmail(message = "Sorry, no Google", groups = {Create.class, Update.class})
private String email;
}
@Test
void createToDoWithGoogleEmailShouldBeInvalid() throws Exception {
ToDoDto dto = new ToDoDto();
dto.setEmail("xiguadawang@gmail.com");
dto.setPostCode("0060");
dto.setContent("example");
final ToDoDto.MetaData metaData = new ToDoDto.MetaData();
metaData.setName("name");
metaData.setPosition("position");
dto.setMetaData(metaData);
String body = objectMapper.writeValueAsString(dto);
final MvcResult mvcResult = mockMvc.perform(post("/")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andReturn();
final String content = mvcResult.getResponse().getContentAsString();
Assertions.assertTrue(content.contains("{"violations":[{"fieldName":"email","message":"Sorry, no Google"}]}"),"should contain this text");
}
08. Programmatic validator validation
Spring Boot provides us with a pre-configured Validator instance, and you can always explicitly reference the javax.validation.Validator
.
It doesn’t need the @Validated
key world on the class name.
A scenario like when a new user registers. When all fields are good, we still need to validate the username is occupied or not.
We use declarative validation for the correctness of the email and we need to issue a database call to check the username which should be a programmatic validation.
Spring Validator and Javax Validator works the same way
@RestController
public class ToDoController {
@Qualifier("defaultValidator")
@Autowired
private org.springframework.validation.Validator springValidator;
@Autowired
private javax.validation.Validator globalValidator;
@PostMapping(value = "/javaxValidator")
ResponseEntity<ToDoDto> createTodos3(@RequestBody ToDoDto toDoDto) {
Set<ConstraintViolation<ToDoDto>> violations = globalValidator.validate(toDoDto, ToDoDto.Create.class);
if (violations.isEmpty()) {
// passed the validation, free to go
} else {
for (ConstraintViolation<ToDoDto> userDTOConstraintViolation : violations) {
// Failed the validation, do your business here. E.g., send the error to a Queue
}
}
return new ResponseEntity("Failed for javaxValidator", new HttpHeaders(), HttpStatus.BAD_REQUEST);
}
@PostMapping(value = "/springValidator")
ResponseEntity<ToDoDto> createTodos4(@RequestBody ToDoDto toDoDto) {
Set<ConstraintViolation<ToDoDto>> violations = globalValidator.validate(toDoDto, ToDoDto.Create.class);
if (violations.isEmpty()) {
// passed the validation, free to go
} else {
for (ConstraintViolation<ToDoDto> userDTOConstraintViolation : violations) {
// Failed the validation, do your business here. E.g., send the error to a Queue
}
}
return new ResponseEntity("Failed for spring Validator", new HttpHeaders(), HttpStatus.BAD_REQUEST);
}
}
@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = ToDoController.class)
class ToDoControllerTest {
@Autowired
private javax.validation.Validator globalValidator;
@Qualifier("defaultValidator")
@Autowired
private org.springframework.validation.Validator springValidator;
@Test
void createToDosWithJavaxValidatorAndGoogleEmailShouldReturnBadRequest() throws Exception {
ToDoDto dto = new ToDoDto();
dto.setEmail("xiguadawang@gmail.com");
dto.setPostCode("0060");
dto.setContent("example");
dto.setUuid(UUID.randomUUID());
final ToDoDto.MetaData metaData = new ToDoDto.MetaData();
metaData.setName("name");
metaData.setPosition("position");
dto.setMetaData(metaData);
String body = objectMapper.writeValueAsString(dto);
final MvcResult mvcResult = mockMvc.perform(post("/javaxValidator")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andReturn();
final String content = mvcResult.getResponse().getContentAsString();
Assertions.assertEquals("Failed for javaxValidator", content);
}
@Test
void createToDosWithSpringValidatorGmailShouldReturnBadRequest() throws Exception {
ToDoDto dto = new ToDoDto();
dto.setEmail("xiguadawang@gmail.com");
dto.setPostCode("0060");
dto.setContent("example");
dto.setUuid(UUID.randomUUID());
final ToDoDto.MetaData metaData = new ToDoDto.MetaData();
metaData.setName("name");
metaData.setPosition("position");
dto.setMetaData(metaData);
String body = objectMapper.writeValueAsString(dto);
final MvcResult mvcResult = mockMvc.perform(post("/springValidator")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andReturn();
final String content = mvcResult.getResponse().getContentAsString();
Assertions.assertEquals("Failed for spring Validator", content);
}
}
Configure Validator Bean in Spring
You can configure a Validator bean by yourself. By default, it uses HibernateValidator.class
, there are other options like ApacheValidationProvider.class
.
A benefit of configuring it is to make it fail fast.
In failFast mode, it throws an exception on the first violation.
@Bean
public Validator validator() {
ValidatorFactory validatorFactory = Validation.byProvider( HibernateValidator.class)
.configure()
.failFast(true)
.buildValidatorFactory();
return validatorFactory.getValidator();
}
ValidatorFactory validatorFactory = Validation.byProvider( HibernateValidator.class )
.configure()
.addProperty( "hibernate.validator.fail_fast", "true" )
.buildValidatorFactory();
return validatorFactory.getValidator();
09. Service layer validation needs both @Validate and @Valid
@Service
@Validated
class ValidatingService{
void validateInput(@Valid DTO dto){
// do something
}
}
10. Persistence layer validation
- By default, Spring uses Hibernate-Validator by default so that it supports Bean Validation out of the box
- The validation happends on the repository save or update method
- No need to add any annotation to the Repository
- It throws
ConstraintViolationException
on violations
References
- Spring Validation最佳实践及其实现原理
- Complete Guide to Validation With Spring Boot
- Jakarta Bean Validation — Constrain once, validate everywhere
Во время работы вашего приложения часто будут возникать исключительные ситуации. Когда у вас простое консольное приложение, то все просто – ошибка выводится в консоль. Но как быть с веб-приложением?
Допустим у пользователя отсутсвует доступ, или он передал некорректные данные. Лучшим вариантом будет в ответ на такие ситуации, отправлять пользователю сообщения с описанием ошибки. Это позволит клиенту вашего API скорректировать свой запрос.
В данной статье разберём основные возможности, которые предоставляет SpringBoot для решения этой задачи и на простых примерах посмотрим как всё работает.
@ExceptionHandler
@ExceptionHandler
позволяет обрабатывать исключения на уровне отдельного контроллера. Для этого достаточно объявить метод в контроллере, в котором будет содержаться вся логика обработки нужного исключения, и пометить его аннотацией.
Для примера у нас будет сущность Person
, бизнес сервис к ней и контроллер. Контроллер имеет один эндпойнт, который возвращает пользователя по логину. Рассмотрим классы нашего приложения:
Сущность Person
:
package dev.struchkov.general.sort;
import java.text.MessageFormat;
public class Person {
private String lastName;
private String firstName;
private Integer age;
//getters and setters
}
Контроллер PersonController
:
package dev.struchkov.example.controlleradvice.controller;
import dev.struchkov.example.controlleradvice.domain.Person;
import dev.struchkov.example.controlleradvice.service.PersonService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@Slf4j
@RestController
@RequestMapping("api/person")
@RequiredArgsConstructor
public class PersonController {
private final PersonService personService;
@GetMapping
public ResponseEntity<Person> getByLogin(@RequestParam("login") String login) {
return ResponseEntity.ok(personService.getByLoginOrThrown(login));
}
@GetMapping("{id}")
public ResponseEntity<Person> getById(@PathVariable("id") UUID id) {
return ResponseEntity.ok(personService.getById(id).orElseThrow());
}
}
И наконец PersonService
, который будет возвращать исключение NotFoundException
, если пользователя не будет в мапе persons
.
package dev.struchkov.example.controlleradvice.service;
import dev.struchkov.example.controlleradvice.domain.Person;
import dev.struchkov.example.controlleradvice.exception.NotFoundException;
import lombok.NonNull;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@Service
public class PersonService {
private final Map<UUID, Person> people = new HashMap<>();
public PersonService() {
final UUID komarId = UUID.randomUUID();
people.put(komarId, new Person(komarId, "komar", "Алексей", "ertyuiop"));
}
public Person getByLoginOrThrown(@NonNull String login) {
return people.values().stream()
.filter(person -> person.getLogin().equals(login))
.findFirst()
.orElseThrow(() -> new NotFoundException("Пользователь не найден"));
}
public Optional<Person> getById(@NonNull UUID id) {
return Optional.ofNullable(people.get(id));
}
}
Перед тем, как проверить работу исключения, давайте посмотрим на успешную работу эндпойнта.
Все отлично. Нам в ответ пришел код 200, а в теле ответа пришел JSON нашей сущности. А теперь мы отправим запрос с логином пользователя, которого у нас нет. Посмотрим, что сделает Spring по умолчанию.
Обратите внимание, ошибка 500 – это стандартный ответ Spring на возникновение любого неизвестного исключения. Также исключение было выведено в консоль.
Как я уже говорил, отличным решением будет сообщить пользователю, что он делает не так. Для этого добавляем метод с аннотацией @ExceptionHandler
, который будет перехватывать исключение и отправлять понятный ответ пользователю.
@RequestMapping("api/person")
@RequiredArgsConstructor
public class PersonController {
private final PersonService personService;
@GetMapping
public ResponseEntity<Person> getByLogin(@RequestParam("login") String login) {
return ResponseEntity.ok(personService.getByLoginOrThrown(login));
}
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorMessage> handleException(NotFoundException exception) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorMessage(exception.getMessage()));
}
}
Вызываем повторно наш метод и видим, что мы стали получать понятное описание ошибки.
Но теперь вернулся 200 http код, куда корректнее вернуть 404 код.
Однако некоторые разработчики предпочитают возвращать объект, вместо ResponseEntity<T>
. Тогда вам необходимо воспользоваться аннотацией @ResponseStatus
.
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
@ExceptionHandler(NotFoundException.class)
public ErrorMessage handleException(NotFoundException exception) {
return new ErrorMessage(exception.getMessage());
}
Если попробовать совместить ResponseEntity<T>
и @ResponseStatus
, http-код будет взят из ResponseEntity<T>
.
Главный недостаток @ExceptionHandler
в том, что он определяется для каждого контроллера отдельно. Обычно намного проще обрабатывать все исключения в одном месте.
Хотя это ограничение можно обойти если @ExceptionHandler
будет определен в базовом классе, от которого будут наследоваться все контроллеры в приложении, но такой подход не всегда возможен, особенно если перед нами старое приложение с большим количеством легаси.
HandlerExceptionResolver
Как мы знаем в программировании магии нет, какой механизм задействуется, чтобы перехватывать исключения?
Интерфейс HandlerExceptionResolver
является общим для обработчиков исключений в Spring. Все исключений выброшенные в приложении будут обработаны одним из подклассов HandlerExceptionResolver
. Можно сделать как свою собственную реализацию данного интерфейса, так и использовать существующие реализации, которые предоставляет нам Spring из коробки.
Давайте разберем стандартные для начала:
ExceptionHandlerExceptionResolver
— этот резолвер является частью механизма обработки исключений помеченных аннотацией @ExceptionHandler
, которую мы рассмотрели выше.
DefaultHandlerExceptionResolver
— используется для обработки стандартных исключений Spring и устанавливает соответствующий код ответа, в зависимости от типа исключения:
Exception | HTTP Status Code |
---|---|
BindException | 400 (Bad Request) |
ConversionNotSupportedException | 500 (Internal Server Error) |
HttpMediaTypeNotAcceptableException | 406 (Not Acceptable) |
HttpMediaTypeNotSupportedException | 415 (Unsupported Media Type) |
HttpMessageNotReadableException | 400 (Bad Request) |
HttpMessageNotWritableException | 500 (Internal Server Error) |
HttpRequestMethodNotSupportedException | 405 (Method Not Allowed) |
MethodArgumentNotValidException | 400 (Bad Request) |
MissingServletRequestParameterException | 400 (Bad Request) |
MissingServletRequestPartException | 400 (Bad Request) |
NoSuchRequestHandlingMethodException | 404 (Not Found) |
TypeMismatchException | 400 (Bad Request) |
Мы можем создать собственный HandlerExceptionResolver
. Назовем его CustomExceptionResolver
и вот как он будет выглядеть:
package dev.struchkov.example.controlleradvice.service;
import dev.struchkov.example.controlleradvice.exception.NotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.handler.AbstractHandlerExceptionResolver;
import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
public class CustomExceptionResolver extends AbstractHandlerExceptionResolver {
@Override
protected ModelAndView doResolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception e) {
final ModelAndView modelAndView = new ModelAndView(new MappingJackson2JsonView());
if (e instanceof NotFoundException) {
modelAndView.setStatus(HttpStatus.NOT_FOUND);
modelAndView.addObject("message", "Пользователь не найден");
return modelAndView;
}
modelAndView.setStatus(HttpStatus.INTERNAL_SERVER_ERROR);
modelAndView.addObject("message", "При выполнении запроса произошла ошибка");
return modelAndView;
}
}
Мы создаем объект представления – ModelAndView
, который будет отправлен пользователю, и заполняем его. Для этого проверяем тип исключения, после чего добавляем в представление сообщение о конкретной ошибке и возвращаем представление из метода. Если ошибка имеет какой-то другой тип, который мы не предусмотрели в этом обработчике, то мы отправляем сообщение об ошибке при выполнении запроса.
Так как мы пометили этот класс аннотацией @Component
, Spring сам найдет и внедрит наш резолвер куда нужно. Посмотрим, как Spring хранит эти резолверы в классе DispatcherServlet
.
Все резолверы хранятся в обычном ArrayList
и в случае исключнеия вызываются по порядку, при этом наш резолвер оказался последним. Таким образом, если непосредственно в контроллере окажется @ExceptionHandler
обработчик, то наш кастомный резолвер не будет вызван, так как обработка будет выполнена в ExceptionHandlerExceptionResolver
.
Важное замечание. У меня не получилось перехватить здесь ни одно Spring исключение, например MethodArgumentTypeMismatchException
, которое возникает если передавать неверный тип для аргументов @RequestParam
.
Этот способ был показан больше для образовательных целей, чтобы показать в общих чертах, как работает этот механизм. Не стоит использовать этот способ, так как есть вариант намного удобнее.
@RestControllerAdvice
Исключения возникают в разных сервисах приложения, но удобнее всего обрабатывать все исключения в каком-то одном месте. Именно для этого в SpringBoot предназначены аннотации @ControllerAdvice
и @RestControllerAdvice
. В статье мы рассмотрим @RestControllerAdvice
, так как у нас REST API.
На самом деле все довольно просто. Мы берем методы помеченные аннотацией @ExceptionHandler
, которые у нас были в контроллерах и переносим в отдельный класс аннотированный @RestControllerAdvice
.
package dev.struchkov.example.controlleradvice.controller;
import dev.struchkov.example.controlleradvice.domain.ErrorMessage;
import dev.struchkov.example.controlleradvice.exception.NotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
@RestControllerAdvice
public class ExceptionApiHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorMessage> notFoundException(NotFoundException exception) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorMessage(exception.getMessage()));
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorMessage> mismatchException(MethodArgumentTypeMismatchException exception) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorMessage(exception.getMessage()));
}
}
За обработку этих методов класса точно также отвечает класс ExceptionHandlerExceptionResolver
. При этом мы можем здесь перехватывать даже стандартные исключения Spring, такие как MethodArgumentTypeMismatchException
.
На мой взгляд, это самый удобный и простой способ обработки возвращаемых пользователю исключений.
Еще про обработку
Все написанное дальше относится к любому способу обработки исключений.
Запись в лог
Важно отметить, что исключения больше не записываются в лог. Если помимо ответа пользователю, вам все же необходимо записать это событие в лог, то необходимо добавить строчку записи в методе обработчике, например вот так:
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorMessage> handleException(NotFoundException exception) {
log.error(exception.getMessage(), exception);
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorMessage(exception.getMessage()));
}
Перекрытие исключений
Вы можете использовать иерархию исключений с наследованием и обработчики исключений для всей своей иерархии. В таком случае обработка исключения будет попадать в самый специализированный обработчик.
Допустим мы бросаем NotFoundException
, как в примере выше, который наследуется от RuntimeException
. И у вас будет два обработчика исключений для NotFoundException
и RuntimeException
. Исключение попадет в обработчик для NotFoundException
. Если этот обработчик убрать, то попадет в обработчик для RuntimeException
.
Резюмирую
Обработка исключений это важная часть REST API. Она позволяет возвращать клиентам информационные сообщения, которые помогут им скорректировать свой запрос.
Мы можем по разному реализовать обработку в зависимости от нашей архитектуры. Предпочитаемым способом считаю вариант с @RestControllerAdvice
. Этот вариант самый чистый и понятный.