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:
@Length) in a declarative fashion{min}, that are replaced by the corresponding value from the annotation, if available, e.g., @Length(min = 5, message = msg) would result in something like msg.replace("{min}", annotation.min()).replace("{max}", annotation.max()).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,
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
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:
If you have any questions or doubts - just ask.