javaspring-bootspring-validator

Spring boot mvc validate enum by value


In my spring boot application I want to validate enum by custom value: I have my DTO like following :

@Data
public class PayOrderDTO {
    @NotNull
    @EnumValidator(enumClass = TransactionMethod.class)
    private TransactionMethod method;
}

And my enum validator annotation defined like bellow:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.METHOD})
@Constraint(validatedBy = EnumValidatorImpl.class)
public @interface EnumValidator {
    String message() default "is invalid";

    /**
     * @return Specify group
     */
    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    /**
     * @return Specifies the enumeration type. The parameter value must be a value in this enumeration type
     */
    Class<? extends EnumBase> enumClass();

    /**
     * @return Can it be null
     */
    boolean nullable() default false;

    /**
     * @return Values to exclude
     */
    int[] exclusion() default {};

}

This is the implementation of my enum validator annotation

public class EnumValidatorImpl implements ConstraintValidator<EnumValidator, EnumBase> {
    private boolean nullable;

    private Set<String> values;

    @Override
    public void initialize(EnumValidator constraintAnnotation) {
        this.nullable = constraintAnnotation.nullable();
        Class<? extends EnumBase> enumClass = constraintAnnotation.enumClass();
        int[] exclusion = constraintAnnotation.exclusion();

        values = new HashSet<>();
        EnumBase[] enumConstants = enumClass.getEnumConstants();
        for (EnumBase iEnum : enumConstants) {
            values.add(iEnum.getValue());
        }
        if (exclusion.length > 0)
            for (int i : exclusion) {
                values.remove(i);
            }
    }

    @Override
    public boolean isValid(EnumBase param, ConstraintValidatorContext constraintValidatorContext) {
        if (nullable && param == null) {
            return true;
        }
        else if(param == null)
            return false;
        return values.contains(param.getValue());
    }
}

this is my enum:

public enum TransactionMethod implements EnumBase {
    CREDIT_CARD("creditcard"),
    DEBIT_CARD("debitcard");
    public String label;

    TransactionMethod(String label) {
        this.label = label;
    }

    @Override
    public String getValue() {
        return this.label;
    }

    @JsonCreator(mode = JsonCreator.Mode.DELEGATING)
    public static TransactionMethod fromString(String value) {
        return TransactionMethod.valueOf(value);
//        return Arrays.stream(TransactionMethod.values())
//                .filter(el -> el.getValue().equals(value))
//                .findFirst()
//                .orElseThrow(() -> {
//                    throw new IllegalArgumentException("Not valid method");
//                });
    }
}

when I'm sending my http request to that rest controller :

@RequiredArgsConstructor
@RestController
@RequestMapping("/orders")
@Validated
public class PaymentRestController {
    
    public ResponseEntity<?> createPayment(
            @Valid @RequestBody PayOrderDTO payOrderDTO
    ) {
        return ResponseEntity.ok("Worked");
    }
}

request example:

POST /orders/ HTTP/1.1
Host: store.test
Content-Type: application/json
Content-Length: 152

{
    "method":"creditcard",
}

I'm expecting to get invalidation exception or error message defined in my enum validator, instead I get an exception in the console that contains :

JSON parse error: Cannot construct instance of `x.TransactionMethod`, problem: No enum constant x.TransactionMethod.creditcard

But if I sent this request :

POST /orders/ HTTP/1.1
Host: store.test
Content-Type: application/json
Content-Length: 152

{
    "method":"CREDIT_CARD",
}

the application works normal

I want to validate enum using label instead of constant value of the enum, if it doesn't exists, a validation error will be thrown like :

HTTP 422 : field 'method' is not valid, expected values are ['creditcard','debitcard']

I tried some solutions as well like the convertor

public class TransactionMethodStringEnumConverter implements Converter<String, TransactionMethod> {
    @Override
    public TransactionMethod convert(String source) {
        Optional<TransactionMethod> first = Arrays.stream(TransactionMethod.values()).filter(e -> e.label.equals(source)).findFirst();
        return first.orElseThrow(() -> {
            throw new IllegalArgumentException();
        });
    }

}

but seems like I does nothing. I would really appreciate is someone has a good solution for this, Thank you 🙏


Solution

  • To deserialize an enum by label value you can use @JsonValue annotation on a getter:

    public enum TransactionMethod implements EnumBase {
    
        CREDIT_CARD("creditcard"),
        DEBIT_CARD("debitcard");
    
        public String label;
    
        TransactionMethod(String label) {
            this.label = label;
        }
    
        @Override
        @JsonValue
        public String getValue() {
            return this.label;
        }
    }
    

    How To Serialize and Deserialize Enums with Jackson

    Also, pay attention to the facts:

    1. You have values.remove(i) in the initialize() method of EnumValidatorImpl, although elements of Set are not indexed and Set<String> values has generic type String.
    2. In your EnumValidator you can set nullable=true using boolean nullable(), but in PayOrderDTO you still check the method field for null using @NotNull, which can lead to an undesirable result.

    EDIT:

    You can define locale-specific messages with MessageSource, look at this article.

    In your message.properties: invalid.transaction.method=is not valid, expected values are ['creditcard','debitcard']

    Then add message to annotation:

    @EnumValidator(enumClass = TransactionMethod.class, message = "{invalid.transaction.method}")
    

    By failed validation catchMethodArgumentNotValidException in @ExceptionHandler method in @RestController or @RestControllerAdvice and format message as you want, e.g.:

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
    public void handleException(MethodArgumentNotValidException e) {
    
        String errorMessage = e.getBindingResult().getFieldErrors().stream()
                .map(fieldError -> fieldError.getField() + " " + fieldError.getDefaultMessage()) 
                .collect(Collectors.joining(";"));
    
    
        LOG.error(e.getMessage()); // method is not valid, expected values are ['creditcard','debitcard']
    }