'Springboot: Better handling of error messages
I'm developing an API with Spring Boot and currently, I'm thinking about how to handle error messages in an easily internationalizable way. My goals are as follows:
- Define error messages in resource files/bundles
- Connect constraint annotation with error messages (e.g.,
@Length) in a declarative fashion - Error messages contain placeholders, such as
{min}, that are replaced by the corresponding value from the annotation, if available, e.g.,@Length(min = 5, message = msg)would result in something likemsg.replace("{min}", annotation.min()).replace("{max}", annotation.max()). - The JSON property path is also available as a placeholder and automatically inserted into the error message when a validation error occurs.
- A solution outside of an error handler is preferred, i.e., when the exceptions arrive in the error handler, they already contain the desired error messages.
- Error messages from a resource bundle are automatically registered as constants in Java.
Currently, I customized the methodArgumentNotValidHandler of my error handler class to read ObjectErrors from e.getBindingResult().getAllErrors() and then try to extract their arguments and error codes to decide which error message to choose from my resource bundle and format it accordingly. A rough sketch of my code looks as follows:
Input:
@Data
@RequiredArgsConstructor
public class RequestBody {
@NotNull
@NotBlank(message = ErrorConstants.NOT_BLANK)
@Length(min = 5, max = 255, message = ErrorConstants.LENGTH_MIN_MAX) // LENGTH_MIN_MAX = validation.length.min-max
private String greeting;
}
Error handler:
@ResponseBody
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
ErrorMessage methodArgumentNotValidHandler(MethodArgumentNotValidException e) {
ObjectError objectError = e.getBindingResult().getAllErrors().get(0);
Object[] arguments = objectError.getArguments();
String messageCode = objectError.getDefaultMessage(); // e.g., "validation.length.min-max" (key in resource bundle)
ResourceBundle errMsgBundle = ResourceBundle.getBundle("errorMsg");
String message;
if (objectError.getCode().equals("Length")) {
String messageTemplate = errMsgBundle.getString(messageCode);
message = String.format(messageTemplate, arguments[2], arguments[1]);
} else {
message = "Bad input, but I cannot tell you the problem because the programmer hasn't handled this yet. Sorry :'(";
}
return new ErrorMessage(message);
}
Unfortunately, I suppose this approach is not maintainable. In the error handler, I will end up with a huge if-else block that has to probe several different situations (error codes, number of arguments, ...) and format error messages accordingly. Changing error messages will possibly result in having to change the code (e.g., the order of arguments). Each property key must be present as a constant in ErrorConstants, which I find undesirable. This code also doesn't query the name or path of the faulty property, e.g., "name".
Hence,
- is there a solution that can satisfy some or all of the above-mentioned requirements?
- At which place would I implement this?
- Is there at least a better solution to the above one?
- Are there recipes or patterns in SpringBoot to handle validation errors (I'm definitely not the first one thinking about this)?
Solution 1:[1]
I'm not a big fan of javax.validation annotations. Mostly because objects whose classes are annotated with these cannot be unit tested easily.
What I recommend is registering a org.springframework.validation.Validator implementation in your @RestController annotated handler class as follows:
@InitBinder
void initBinder(WebDataBinder binder) {
if (binder.getTarget() == null) {
return;
}
final var validator1 = // your validator1 instance
//check if specific validator is eligible to validate request and its body
if (validator1.supports(binget.getTarget().getClass()) {
binder.setValidator(validator);
}
}
After such registration, Spring invokes such validator for matching request and it's body and throws MethodArgumentNotValidException if validator rejected any of the given object fields.
In your exception handler annotated with @ControllerAdvice (keep in mind that it's scope is only for http requests) you can handle such exception as follows:
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
ErrorMessage handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
final var errors = e.getAllErrors()
return new ErrorMessage(/* populate your error message based on given errors */);
}
While validator implementation could have looked like that:
@Override
public void validate(Object target, Errors errors) {
final var credentials = (Credentials) target;
//reject username field with given c000 code if it's null
if (isNull(credentials.getUsername())) {
errors.rejectValue("username", "c000", "username cannot be null");
return;
}
if (credentials.getUsername().trim().isEmpty()) {
errors.rejectValue("username", "c001", "username cannot be empty");
return;
}
if (credentials.getUsername().length() > 256) {
errors.rejectValue("username", "c002", "username cannot be longer than 256 characters");
return;
}
}
The advantage of such solution is:
- you can unit-test such validator without setting up application's context - which is fast
- when validator rejects reuqest body it(actually you provide that ;)) provides an error code and message so you can map it directly to your ErrorMessage response without digging any further.
- you externalise validation logic to dedicated class - that corresponds to S in SOLID ([S]ingle object responsibility principle), which is desired by some developers
If you have any questions or doubts - just ask.
Solution 2:[2]
Spring Boot supports handling of error messages in multiple languages using internationalization. To handle Validation errors using i18n we can use MessageSource
Whenever validation exception occurs, the code message set in the exception is passed to the MessageSource instance to get a localized message explaining the error. The current locale is passed alongside with the message code so that Spring Boot can interpolate the localized final message replacing any placeholders.
Example: If we define a property as @NotNull and a user sends an instance that contains null as the value, a MethodArgumentNotValidException is thrown saying that this situation is not valid. We can handle the exception and set the message, error codes to messages defined in messages.properties files
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorMessage> handleArgumentNotValidException(MethodArgumentNotValidException ex, Locale locale) {
BindingResult result = ex.getBindingResult();
List<String> errorMessages = result.getAllErrors()
.stream()
.map(err-> messageSource.getMessage(err, locale))
.collect(Collectors.toList());
return new ResponseEntity<>(new ErrorMessage(errorMessages), HttpStatus.BAD_REQUEST);
}
Now for information on locale you refer this link. Whenever a user wants to display messages in a specific language, Spring Boot will search the messages.properties (default) or messages_<locale-name>.properties (locale) file to get the appropriate message.
sample: message_es_MX.properties
All the locale files can be stored in resources folder.
Whenever a validation fails, Spring Boot generates a code that starts with the annotation name (e.g. NotNull), then it adds the entity where the validation failed (e.g. objectname), and lastly it adds the property (e.g. greeting).
Properties can be like NotNull.<object-name>.greeting
Sample
message.properties
NotNull.obj.greeting=Please, provide a valid greeting message.
Size.obj.greeting=Greeting message must contain between {2} and {1} characters.
Note: {2} and {1} are placeholders of the minimum and maximum length
message_es_MX.properties
NotNull.obj.greeting=Por favor, proporcione un mensaje de saludo vĂ¡lido.
Size.obj.greeting=El mensaje de bienvenida debe contener entre {2} y {1} caracteres.
We can always get the locale from the request header Accept-Language
Example : Accept-Language: en-US or request.getLocale().
curl -X POST -H "Content-Type: application/json" -H "Accept-Language: es-MX" -d '{
"description": "employee info"
}' http://localhost:8080/api/employee
Solution 3:[3]
If I understood your question correctly....
Below is example of exception handling in better way
Microsoft Graph API - ERROR response - Example :
{
"Error": {
"Code": "401",
"Message": "Unauthorized",
"Target": null,
"InnerError": {
"Code": "System.UnauthorizedAccessException",
"Message": "Exception occured in AppAssertedAuth Handler",
"Target": "MoveNext"
}
}
}
Below code may help you implemented in Similar way with message translation[Expect 'accept-language'].
ErrorCodeEnum.java
public enum ErrorCodeEnum {
USER_NOT_SIGNED_IN(HttpStatus.BAD_REQUEST, "UserNotSignedIn"),
FILE_SIZE_EXCEEDED(HttpStatus.PAYLOAD_TOO_LARGE, "FileSizeExceeded"),
FILE_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "FileType"),
SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "ServiceUnavailable"),
SERVICE_TEMPORARY_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "ServiceTemporaryUnavailable"),
ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "OrderNotFound"),
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "Unauthorized"),
UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "UnexpectedError"),
HEADER_MISSING(HttpStatus.BAD_REQUEST, "HeaderMissing"),
ErrorResponse.java
public class ErrorResponse {
@JsonIgnore
private String timestamp;
private String error;
private String message;
private String code;
@JsonIgnore
private Integer status;
@JsonIgnore
private String path;
@JsonAlias("error_description")
private String errorDescription;
//Create constructer as per your requirements
GlobalExceptionHandler.java
@ControllerAdvice
public class GlobalExceptionHandler {
private final MessageSource messageSource;
public GlobalExceptionHandler(MessageSource messageSource) {
this.messageSource = messageSource;
}
@ExceptionHandler(MissingRequestHeaderException.class)
public ResponseEntity<ErrorResponse> handleMissingHeader(ResponseStatusException ex) {
Sentry.captureException(ex);
return ResponseEntity.badRequest().body(new ErrorResponse(ex.getMessage(), ex));
}
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ErrorResponse> handleMissingParameterException(Exception ex) {
log.error("Exception: Class {}|{}", ex.getClass().getCanonicalName(), ex.getMessage(), ex);
return ResponseEntity.badRequest().body(new ErrorResponse(ex.getMessage(), ErrorCodeEnum.PARAMETER_MISSING));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
final var errors = e.getAllErrors();
return ResponseEntity.badRequest().body(new ErrorMessage(/* error message based errors */);
}
@ExceptionHandler(CustomWebClientException.class)
public ResponseEntity<ErrorResponse> handleSalesAssistantException(CustomWebClientException ex) {
log.error(
"CustomWebClientException from WebClient - Status {}, Body {}",
ex.getErrorCodeEnum().getHttpStatus(),
ex);
Sentry.captureException(ex);
String errorMessage = getTranslatedMessage(ex.getErrorCodeEnum(), ex.getMessage());
return new ResponseEntity<>(
new ErrorResponse(errorMessage, ex), ex.getErrorCodeEnum().getHttpStatus());
}
private String getTranslatedMessage(ErrorCodeEnum errorCodeEnum, String defaultMessage) {
return messageSource.getMessage(
errorCodeEnum.getErrorCode(), null, defaultMessage, LocaleContextHolder.getLocale());
}
}
/* class CustomWebClientException extends WebClientException {
private final ErrorCodeEnum errorCodeEnum;
private ErrorResponse errorResponse;
public CustomWebClientException(ErrorCodeEnum errorCodeEnum, ErrorResponse errorResponse) {
super(errorResponse.getMessage());
this.errorCodeEnum = errorCodeEnum;
this.errorResponse = errorResponse;
}
} */
Solution 4:[4]
May be you can have
@ExceptionHandler(ConstraintViolationException.class)
protected ResponseEntity<Object> handleConstraintViolation(ConstraintViolationException e, WebRequest request){
return Optional.ofNullable(e).map(ConstraintViolationException::getConstraintViolations).map(this::createException).orElseGet(this::generateGenericError);
}
From which you can have
private ErrorBody createException(FieldError fieldError) {
return ErrorBody.builder()
.code(fieldError.getCode())
.message(fieldError.getDefaultMessage())
.field(fieldError.getField())
.value(fieldError.getRejectedValue())
.build();
}
So that you can use
fieldError.getCode()
for mapping key value from properties file
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|---|
| Solution 1 | bpawlowski |
| Solution 2 | |
| Solution 3 | |
| Solution 4 | nithin prasad |
