2

1. What I’m Trying to Achieve?

I wanted to have a custom annotation that supports the errorCode along with the errorMessage parameter.

@NotBlank(message = "Field X can't be Blank.", errorCode = 23442)

So, I create NotBlank annotation with the below schema.

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({FIELD, PARAMETER})
@Constraint(validatedBy = {NotBlankAnnotationProcessor.class})
public @interface NotBlank {
  String message() default "{commons.validation.constraints.NotBlank.message}";

  @SuppressWarnings("unused")
  Class<?>[] groups() default {};

  @SuppressWarnings("unused")
  Class<? extends Payload>[] payload() default {};

  long errorCode() default 400;
}

and it has an annotation processor for validation NotBlankAnnotationProcessor

@Getter
public class NotBlankAnnotationProcessor implements ConstraintValidator<NotBlank, String> {
  private String message;
  private long errorCode;

  @Override
  public void initialize(NotBlank annotation) {
    message = annotation.message();
    errorCode = annotation.errorCode();
  }

  @Override
  public boolean isValid(String value, ConstraintValidatorContext context) {
    boolean isValid = !Strings.isBlank(value);

    if (isValid) {
      return true;
    }
    context.disableDefaultConstraintViolation();
    var serializedJSON = getSerializedObject(getMessage(), getErrorCode()); //marshaling 
    context.buildConstraintViolationWithTemplate(serializedJSON).addConstraintViolation();
    return false;
  }
}

In the line, var serializedJSON = getSerializedObject(getMessage(), getErrorCode());

I'm marshaling the message into a JSON payload with the message and errorCode as there seems no way of throwing the custom exception with the custom body. In the exceptionHandler I'm able to unmarshal the JSON string so that's why I'm marshaling and unmarshalling.

@ExceptionHandler
ResponseEntity<Map<String, Object>> handleMethodArgumentException(
    MethodArgumentNotValidException exception) {
  Map<String, Object> body = new LinkedHashMap<>(1, 1);

  final List<ErrorStructure> errorStructures =
      exception.getBindingResult().getFieldErrors().stream()
          .map(error -> deserialize(error.getDefaultMessage()))
          .toList();
  body.put("messages", errorStructures);
  return ResponseEntity.status(BAD_REQUEST).contentType(APPLICATION_JSON).body(body);
}

Utility class that I'm using for marshaling and unmarshalling.

public final class AnnotationProcessorToExceptionHandlerAdapter {

  private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

  //NON-INSTANTIABLE UTILITY CLASS.
  private AnnotationProcessorToExceptionHandlerAdapter() {
    throw new AssertionError("No instance for you!");
  }

  @SneakyThrows
  public static String getSerializedObject(final String errorMessage, final long errorCode) {
    ErrorStructure errorStructure = new ErrorStructure(errorMessage, errorCode);
    return OBJECT_MAPPER.writeValueAsString(errorStructure);
  }

  @SneakyThrows
  public static ErrorStructure deserialize(final String serializedValue) {
    return OBJECT_MAPPER.readValue(serializedValue, ErrorStructure.class);
  }
}

2. What Is the Problem?

While the solution works perfectly fine. However, I'm certain on the higher TPS (transaction per second) will cause performance issues as I am doing unnecessary marshaling and unmarshalling to work around.

3. Alternatively, How I Don’t Want to Do?

I don't want to include the BindingResults in hundreds of function parameters in the current codebase something like the below and check errors everywhere.

void onSomething(@RequestBody @Validated WithdrawMoney withdrawal, BindingResult errors) {
  if (errors.hasErrors()) {
           throw new ValidationException(errors);
  }
}

4. Recently Read Articles.

I've read the below answers:

A custom annotation can throw a custom exception, rather than MethodArgumentNotValidException?

How to make custom annotation throw an error instead of default message

How to throw custom exception in proper way when using @javax.validation.Valid?

Spring Boot how to return my own validation constraint error messages

Custom Validation Message in Spring Boot with internationalization?


Any help would be really appreciated!

Thanks.

Zahid Khan
  • 2,130
  • 2
  • 18
  • 31
  • Although it's a workaround (dirty one tbh), when i need to add custom error codes for an error, i tend to throw exceptions in the `ConstraintValidator` and gather data from the exception in the handler. The downside is that you can get information only for one error like this, it does not work if you need to know about all errors for example. – Chaosfire Mar 27 '23 at 09:57
  • There is too much code missing (like the serialize/deserialize methods) to be able to answer this. Why not just specify the error message as json with interpolation for the error code (the same as for instance the Min/Max or Range annotation do, although with regular text)? I don't see why you would need to deserialize all the stuff. – M. Deinum Mar 27 '23 at 10:11
  • Hi @M.Deinum, I have updated and added the missing utility class. "Why not just specify the error message as JSON" => Having JSON payloads on the top of annotation is not something advisable, it'll reduce readability and increase cognitive load. "I don't see why you would need to deserialize all the stuff" => There seems no way of throwing the custom exception in the "MethodArgumentNotValidException" – Zahid Khan Mar 27 '23 at 11:04
  • Not sure if just adding a simple json reduces the readability. It is just a small line, wrapping your current message in curly braces and appending a field. Unless you are shoehorning a complete complex JSON in there that would be different. You shouldn't be throwing exceptions from the validator in the first place. Adding to that if you know it is going to be json you know the structure, just add that as a string, and place the `message` and `erorrcode` in that string instead of using marshalling. The method you use is for applying templates. – M. Deinum Mar 27 '23 at 12:07
  • Finally the original `ConstraintViolation` contains all the information you need. As it contains all the attributes from the annotation with their values in the, through accessing the `ConstraintDescriptor` (which is basically the interpretation of the annotation on the field/class whatever you annotated). So you should convert the `COnstraintViolationException` yourself instead of letting Spring convert it to a `MethodARgumentNotValidException` (although I believe it transfers, some of, the information as well). – M. Deinum Mar 27 '23 at 12:17

1 Answers1

1

I'd suggest taking a look at the dynamic payload as part of ConstraintViolation

If you want to provide some additional context in your violation, that could be one way to do it. This payload can be added through the context of isValid(...) as follows:

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
    // First check that the context is of correct type and no exceptions will be thrown
    if (context instanceof HibernateConstraintValidatorContext ) {
        // unwrap the context and add a payload. It can be as simple as just the error code. 
        // It can be an object, or a map if multiple values are expected to be passed over:
        context.unwrap( HibernateConstraintValidatorContext.class )
                .withDynamicPayload( errorCode );
    }
    // perform the actual validation:
    return value != null && !value.trim().isEmpty();
}

Now on the handling side of things, you'd need to access this payload. That can be done through unwrapping the FieldError that you've been already working with. Something along the lines:

@ExceptionHandler
ResponseEntity<Map<String, Object>> handleMethodArgumentException(MethodArgumentNotValidException exception) {
    Map<String, Object> body = new LinkedHashMap<>(1, 1);

    final List<ErrorStructure> errorStructures =
            exception.getBindingResult().getFieldErrors().stream()
                    .map(error -> {
                        // Since we passed a simple Long in the validator side we pass Long to get it back here:
                        Long code = error.unwrap( HibernateConstraintViolation.class ).getDynamicPayload( Long.class );
                        // do other stuff ...
                        // ...
                    })
                    .toList();
    body.put("messages", errorStructures);
    return ResponseEntity.status(BAD_REQUEST).contentType(APPLICATION_JSON).body(body);
}

That should eliminate the need for marshalling JSON.

mark_o
  • 2,052
  • 1
  • 12
  • 18
  • Hi Mark, Thanks for the valuable answer, it is really what I was looking for. I've a question: 1. Is there any way of throwing any other exception rather than MethodArgumentNotValidException? – Zahid Khan Mar 28 '23 at 06:14
  • Hey Zahid, happy to help. Regarding your other question on exception type ... Validator itself doesn't throw any exceptions; instead, it returns a collection of `ConstraintViolation`s. This `MethodArgumentNotValidException` comes from the Spring framework. I think if you move the validation from the controller level to your service level, you will get a `ConstraintViolationException` instead. But since it's inside of a framework... you won't be able to throw your custom exception easily... – mark_o Mar 28 '23 at 07:19