mattsmith5
mattsmith5

Reputation: 1123

Java Request Date Validation: For any class with two variable parameters

I am creating a shared component for Request Date constraints, Begin Date is before End Date. I want to take my current Validation request, and make it common, so I type in the (Begin and EndDate class members for any Class), and it will work. How can this be done? I use annotations above the request class, in ProductRequest below .

Note: How do I set Start and End date parameters in the annotation; they may not always be "Start/End" field members, sometimes they could be "Begin/Finish" in another class .

@DatesRequestConstraint
public class ProductRequest {
    private Long productId;
    private DateTime startDate;
    private DateTime EndDate;
    private List<String> productStatus;
}

@Target({ TYPE, ANNOTATION_TYPE })
@Retention(RUNTIME)
@Constraint(validatedBy = ProductValidator.class)
@Documented
public @interface DatesRequestConstraint {
    String message() default "Invalid dates request.";
    Class <?> [] groups() default {};
    Class <? extends Payload> [] payload() default {};
}

public class ProductValidator implements ConstraintValidator<DatesRequestConstraint, ProductRequest> {

    @Override
    public void initialize(DatesRequestConstraint constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
    }

    @Override
    public boolean isValid(ProductRequest productRequest, ConstraintValidatorContext constraintValidatorContext) {

    if (productRequest.getStartDate() != null && 
        productRequest.getEndDate() != null && 
        productRequest.getStartDate().isAfter(productRequest.getEndDate())) {
        return false;
    }
    else return true;
}
 

Upvotes: 4

Views: 3102

Answers (2)

Bragolgirith
Bragolgirith

Reputation: 2238

You can:

  1. Implement ConstraintValidator<DatesMatch, Object> so that you can apply the @DatesMatch annotation on any type;
  2. Add custom String fields to the @DatesMatch annotation where you can specify the names of the fields you want to validate;
  3. Use reflection at runtime to access the field values by their specified name.

There's a similar example of class-level validation over multiple custom fields here: Baeldung: Spring MVC Custom Validation (scroll down to "9. Custom Class Level Validation").

Customized to your example, something like this should work:

@Constraint(validatedBy = DatesMatchValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface DatesMatch {
    String message() default "The dates don't match.";
    String startField();
    String endField();

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

    @Target({ElementType.TYPE})
    @Retention(RetentionPolicy.RUNTIME)
    @interface List {
        DatesMatch[] value();
    }
}

// Accept a list of items so that you can validate more than one pair of dates on the same object if needed
@DatesMatch.List({
        @DatesMatch(
                startField = "startDate",
                endField = "endDate",
                message = "The end date must be after the start date."
        )
})
public class ProductRequest {
    private Long productId;
    private Instant startDate;
    private Instant endDate;
    private List<String> productStatus;
    /* Getters and setters omitted */
}

public class DatesMatchValidator implements ConstraintValidator<DatesMatch, Object> {
    private String startField;
    private String endField;

    public void initialize(DatesMatch constraintAnnotation) {
        this.startField = constraintAnnotation.startField();
        this.endField = constraintAnnotation.endField();
    }

    public boolean isValid(Object value, ConstraintValidatorContext context) {
        Instant startFieldValue = (Instant) new BeanWrapperImpl(value)
                .getPropertyValue(startField);
        Instant endFieldValue = (Instant) new BeanWrapperImpl(value)
                .getPropertyValue(endField);

        if (startFieldValue == null || endFieldValue == null) {
            return true;
        }

        return endFieldValue.isAfter(startFieldValue);
    }
}

Update: (in response to comment):

this answer is great, allows multiple pair of dates, however isn't type-string safe, person can type in whatever for the fields in the product fields

Implementing ConstraintValidator<DatesMatch, Object> is meant as an easy catch-all solution you can apply to any class.

But you can absolutely do it in a more type-safe way by implementing a separate ConstraintValidator for each type you want to validate (i.e. ConstraintValidator<DatesMatch, ProductRequest>, ConstraintValidator<DatesMatch, AnotherRequest>, ...) and then specify all of them in the @Constraint(validatedBy={...}) attribute:

@Constraint(validatedBy = {ProductRequestDatesMatchValidator.class, AnotherRequestDatesMatchValidator.class})
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface DatesMatch {

    String message() default "Invalid dates request.";

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

@DatesMatch(message = "Start and end dates do not match!")
public class ProductRequest {
    private Long productId;
    private Instant startDate;
    private Instant endDate;
    private List<String> productStatus;
    /* Getters and setters omitted */
}

@DatesMatch(message = "Begin and finish dates do not match!")
public class AnotherRequest {
    private Long productId;
    private Instant beginDate;
    private Instant finishDate;
    private List<String> productStatus;
    /* Getters and setters omitted */
}

public class ProductRequestDatesMatchValidator implements ConstraintValidator<DatesMatch, ProductRequest> {
    @Override
    public boolean isValid(ProductRequest value, ConstraintValidatorContext context) {
        // No need to cast here
        Instant startDate = value.getStartDate();
        Instant endDate = value.getEndDate();

        // You could reuse this logic between each implementation by putting it in a parent class or a utility method
        if (startDate == null || endDate == null) {
            return true;
        }

        return startDate.isBefore(endDate);
    }
}

public class AnotherRequestDatesMatchValidator implements ConstraintValidator<DatesMatch, AnotherRequest> {
    @Override
    public boolean isValid(AnotherRequest value, ConstraintValidatorContext context) {
        Instant beginDate = value.getBeginDate();
        Instant finishDate = value.getFinishDate();

        if (beginDate == null || finishDate == null) {
            return true;
        }

        return beginDate.isBefore(finishDate);
    }
}

Do note, however, that this is still not compile-time type-safe, as you could put the @DatesMatch annotation on a class for which you haven't written an implementation and the validation will only fail at runtime.

(You could achieve compile-time type-safety using annotation processing, but this another topic for another time.)

Upvotes: 5

Moemen
Moemen

Reputation: 404

You can annotate startDate and endDate with custom annotations something like:

@StartDateField
private DateTime startDate;
@EndDateField
private DateTime endDate;

Then in your isValid(), you can access both startDate and endDate fields by their annotations by iterating over all class fields (in your case, all ProductRequest fields) and checking the following:

field.isAnnotationPresent(StartDateField.class)
field.isAnnotationPresent(EndDateField.class)

The complete code could be as follows:

import javax.validation.Constraint;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import javax.validation.Payload;
import java.lang.annotation.*;
import java.util.Arrays;
import java.util.List;

import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Target({ ANNOTATION_TYPE.TYPE })
@Retention(RUNTIME)
@Constraint(validatedBy = ProductValidator.class)
@Documented
@interface DatesRequestConstraint {
    String message() default "Invalid dates request.";
    Class <?> [] groups() default {};
    Class <? extends Payload> [] payload() default {};
}

@DatesRequestConstraint
class ProductRequest {
    private Long productId;
    @StartDateField
    private DateTime startDate;
    @EndDateField
    private DateTime EndDate;
    private List<String> productStatus;
}

@Target({ ElementType.FIELD })
@Retention(RUNTIME)
@Documented
@interface StartDateField {
}

@Target({ ElementType.FIELD })
@Retention(RUNTIME)
@Documented
@interface EndDateField {
}

public class ProductValidator implements ConstraintValidator<DatesRequestConstraint, Object> {

    @Override
    public void initialize(DatesRequestConstraint constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
    }

    @Override
    public boolean isValid(Object requestObject, ConstraintValidatorContext constraintValidatorContext) {

        DateTime startDate = getDateFieldByAnnotation(requestObject, StartDateField.class);
        DateTime endDate = getDateFieldByAnnotation(requestObject, EndDateField.class);
        if (startDate != null &&
                endDate != null &&
                startDate.isAfter(endDate)) {
            return false;
        } else return true;
    }

    private DateTime getDateFieldByAnnotation(Object requestObject, Class<? extends Annotation> annotationClass) {
        return Arrays.stream(requestObject.getClass().getDeclaredFields()).filter(field -> field.isAnnotationPresent(annotationClass)).map(field -> {
            try {
                return field.get(requestObject);
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
            return null;
        }).map(DateTime.class::cast).findAny().orElse(null);
    }
}

Upvotes: 5

Related Questions