9

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:

  1. Define error messages in resource files/bundles
  2. Connect constraint annotation with error messages (e.g., @Length) in a declarative fashion
  3. 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 like msg.replace("{min}", annotation.min()).replace("{max}", annotation.max()).
  4. The JSON property path is also available as a placeholder and automatically inserted into the error message when a validation error occurs.
  5. 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.
  6. 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,

  1. is there a solution that can satisfy some or all of the above-mentioned requirements?
  2. At which place would I implement this?
  3. Is there at least a better solution to the above one?
  4. Are there recipes or patterns in SpringBoot to handle validation errors (I'm definitely not the first one thinking about this)?
Green绿色
  • 1,620
  • 1
  • 16
  • 43

5 Answers5

3

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
TriS
  • 3,668
  • 3
  • 11
  • 25
1

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;
        }
    } */
Ravi Parekh
  • 5,253
  • 9
  • 46
  • 58
  • This is not quite what I'm looking for. Maybe my question is not clear. I was primarily looking for a more declarative way to handle constraint exceptions, such as when `@NotBlank` is not satisfied. Your proposal would require me to translate them somehow to a `CustomWebClientException` and assign a code to them. Maybe, you point to the right direction, but I cannot see it yet. I'm surprised that other people seemingly haven't had this problem yet. – Green绿色 Jan 29 '22 at 06:21
  • 1
    @Green绿色, `CustomWebClientException` is just exception, it is no what you have to translate to. – Ravi Parekh Feb 03 '22 at 10:03
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.

bpawlowski
  • 1,025
  • 2
  • 11
  • 22
  • 2
    Isn't it possible to unit test validations by instantiating a `Validation` object from a `ValidationFactory` and then running `validate` on the test instance? I guess, this should be rather independent from Spring's application context. Furthermore, I personally prefer declarative solutions and therefore prefer those validation annotations over manually written validation algorithms. – Green绿色 Feb 01 '22 at 05:49
  • Well, maybe it is possible. The only implementation of javax.validation I know is provided by hibernate and it's not very straightforward. I fully understand that you prefer declarative validation definition - that's totally ok. For me it isn't flexible enough. If you have to implement complex string validation javax.validation won't be enough until you implement your own annotation which actually needs custom validation logic implementation :) Moreover, validated object may require validation rules depending on context it is used in. Here javax.validation again might be not enough. – bpawlowski Feb 01 '22 at 10:47
  • Yes, Java is an imperative language and in the end, you'll always come back to imperative programming. What I like with the annotations is that you just open a class and you immediately see what an object contains and what constraints it exposes on its members. It's very clear and concise and covers most use cases. – Green绿色 Feb 03 '22 at 02:39
1

After some digging around, I found the confirmation that what I was looking for was indeed built-in already as this is a question I expect every developer who want to acquit oneself well would ask. And indeed, this question has been asked already (I could have found it earlier if I would have verbalized my requirements correctly). I was just asking to customize my localized error messages via resource bundles.

When I create the resource bundle in the resources folder containing my custom error messages and name it "validation_errors.properties", then I can make the validator using these messages by creating a corresponding bean:

@Bean
public Validator validatorFactory (MessageSource messageSource) {
    LocalValidatorFactoryBean validator =  new LocalValidatorFactoryBean();
    validator.setValidationMessageSource(messageSource);
    return validator;
}

@Bean
public MessageSource messageSource() {
    ReloadableResourceBundleMessageSource bean = new ReloadableResourceBundleMessageSource();
    bean.addBasenames("classpath:org.hibernate.validator.ValidationMessages", "classpath:validation_errors"); // validation_errors.properties is my resource bundle
    bean.setDefaultEncoding("UTF-8");
    return bean;
}

My custom validator retrieves the validation messages from an instance of ReloadableResourceBundleMessageSource, which in turn retrieves them from a properties file.

The properties file contains the qualified path of the "message" parameter of a validation annotation as keys and values arbitrary strings, where strings in curly brackets are replaced by arguments from the validation annotation and SpEL expressions are evaluated.

javax.validation.constraints.NotNull.message = Not null please!
javax.validation.constraints.NotBlank.message = Not empty please!
org.hibernate.validator.constraints.Length.message = String length between {min} and {max} please!

Next, in my error handler, I need to detect and unpack if the ObjectError instance in MethodArgumentNotValidException contains a ConstraintViolation (to simplify this example, I ignore other error sources):

@ResponseBody
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
List<ErrorMessage> methodArgumentNotValidHandler(MethodArgumentNotValidException e) {
    return e.getBindingResult().getAllErrors().stream()
            .filter(objectError -> objectError.contains(ConstraintViolation.class))
            .map(objectError -> objectError.unwrap(ConstraintViolation.class))
            .map(ConstraintViolation::getMessage)
            .map(message -> new ErrorMessage("VE-400", message))
            .collect(Collectors.toList());
}

This solution meets requirements 1, 3, 5 and 6. Requirement 2 is considered invalid as it's tied to a specific solution that I had in mind when I asked this question. Requirement 4 remains open, SpEL might be a possibility to look further, otherwise I would continue exploring Tris answer.

Green绿色
  • 1,620
  • 1
  • 16
  • 43
  • How you manage to handle custom exception? Can you provice an example? – john triantafillakis May 20 '23 at 08:51
  • You can write `@ExceptionHandler` methods for your custom exceptions and fetch the messages from a map of the containing advice class that got the messages from a resource bundle similar to the ones above. – Green绿色 May 21 '23 at 09:56
0

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

nithin prasad
  • 168
  • 1
  • 9
  • A good suggestion to handle `ConstraintViolationException`, but unfortunately, an instance of `MethodArgumentNotValidException` is generated instead, so the exception handler for `ConstraintViolationException`s doesn't catch it. – Green绿色 Jan 31 '22 at 09:23