Custom Annotations To Validate Input Requests in Spring Boot - Part I
In this article, we will discuss how we can implement custom annotations on input request for conditionally mandatory fields in Spring Boot.
Join the DZone community and get the full member experience.
Join For FreeIn our day-to-day programming, we would have used many Spring Boot default annotations that can be used for validation like @NotNull, @Size, @NotBlank, @Digits, and many more, which is a cool way to validate any incoming request.
Consider a scenario, there are some fields that are optional by default and it has to be mandatory if some other field is populated by a specific value.
Spring doesn't have predefined annotation for this kind of validation.
Let us take some examples and see how we can simplify the validation process, make it reusable code, and bring abstraction at annotation level.
In a typical sale platform, there will be sale operations and void sale operations. The amount would be mandatory in a sale operation, and reversal type would be mandatory in case of a void sale operation.
Our dto classes are as follows:
public class IncomingRequestDto { public TransactionType transactionType; public ReversalType reversalType; public String reversalId; public AmountDto amountDto; }
IncomingRequestDto has several properties like transactionType, reversalType as ENUMS.
public enum TransactionType { SALE { public String toString() { return "Sale"; } }, VOIDSALE { public String toString() { return "VoidSale"; } }, }
public enum ReversalType { TIMEDOUT { public final String toString() { return "Timedout"; } }, CANCELLED { public final String toString() { return "Cancelled"; } } }
And amountDto as:
public class AmountDto { public String value; }
Scenario 1: amountDto.value is conditional. When we received a request that has transactionType="SALE", amountDto.value should be mandatory.
Scenario 2: reversalType is conditional. When we received a request that has transactionType="VOIDSALE", reversalType should be mandatory.
Let us define an annotation first with required properties for validation process:
@Target({TYPE, ANNOTATION_TYPE}) @Retention(RUNTIME) @Documented public @interface NotNullIfAnotherFieldHasValue { String fieldName(); String fieldValue(); String dependFieldName(); String message(); Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; @Target({TYPE, ANNOTATION_TYPE}) @Retention(RUNTIME) @Documented @interface List { NotNullIfAnotherFieldHasValue[] value(); } }
fieldName and fieldValue will be defined on which we have to search for a specific value. Here it is "Sale."
dependFieldName will be defined on which we have to search for a value.
Lets us implement the above interface now:
public class NotNullIfAnotherFieldHasValueValidator implements ConstraintValidator<NotNullIfAnotherFieldHasValue, Object> { private String fieldName; private String expectedFieldValue; private String dependFieldName; @Override public void initialize(NotNullIfAnotherFieldHasValue annotation) { fieldName = annotation.fieldName(); expectedFieldValue = annotation.fieldValue(); dependFieldName = annotation.dependFieldName(); } @Override public boolean isValid(Object value, ConstraintValidatorContext ctx) { String fieldValue = ""; String dependFieldValue = ""; if (value == null) { return true; } try { fieldValue = BeanUtils.getProperty(value, fieldName); dependFieldValue = BeanUtils.getProperty(value, dependFieldName); return validate(fieldValue, dependFieldValue, ctx); } catch (NestedNullException ex) { dependFieldValue = StringUtils.isNotBlank(dependFieldValue) ? dependFieldValue : ""; try { return validate(fieldValue, dependFieldValue, ctx); } catch (NumberFormatException exception) { return false; } } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException | NumberFormatException | NullPointerException ex) { return false; } } private boolean validate(String fieldValue, String dependFieldValue, ConstraintValidatorContext ctx) { if (!StringUtils.isBlank(fieldValue)) { if (expectedFieldValue.equals(fieldValue) && (StringUtils.isBlank(dependFieldValue))) { ctx.disableDefaultConstraintViolation(); ctx.buildConstraintViolationWithTemplate(ctx.getDefaultConstraintMessageTemplate()) .addNode(dependFieldName) .addConstraintViolation(); return false; } } else { return false; } return true; } }
Here we need to go back and decorate our interface with its implementation class as below:
@Target({TYPE, ANNOTATION_TYPE}) @Retention(RUNTIME) @Constraint(validatedBy = NotNullIfAnotherFieldHasValueValidator.class) @Documented public @interface NotNullIfAnotherFieldHasValue {
That's it! We are finished with implementation! Let us decorate our IncomingRequestDto class with our custom annotation:
@JsonDeserialize(as = IncomingRequestDto.class) @NotNullIfAnotherFieldHasValue.List({ @NotNullIfAnotherFieldHasValue( fieldName = "transactionType", fieldValue = "Sale", dependFieldName = "amountDto.value", message = " - amount is mandatory for Sale requests"), }) @JsonInclude(JsonInclude.Include.NON_NULL) public class IncomingRequestDto { public TransactionType transactionType; public ReversalType reversalType; public String reversalId; public AmountDto amountDto; }
By adding the annotation as above, requests will be rejected as BAD with HTTP 400 where amountDto.value is not populated for Sale type requests. We can add as many as validations we want inside the List without changing any code as follows:
@JsonDeserialize(as = IncomingRequestDto.class) @NotNullIfAnotherFieldHasValue.List({ @NotNullIfAnotherFieldHasValue( fieldName = "transactionType", fieldValue = "Sale", dependFieldName = "amountDto.value", message = " - amount is mandatory for Sale requests"), @NotNullIfAnotherFieldHasValue( fieldName = "transactionType", fieldValue = "VoidSale", dependFieldName = "reversalType", message = " - Reversal Type is mandatory for VoidSale requests"), }) @JsonInclude(JsonInclude.Include.NON_NULL) public class IncomingRequestDto { public TransactionType transactionType; public ReversalType reversalType; public String reversalId; public AmountDto amountDto; }
Refer to the GitHub page for complete implementation.
Similarly, in another scenario, there are some fields that are optional by default and it has to be mandatory if the other two fields are populated by specific value (two field validation). We will discuss this in part 2.
Cheers!
Opinions expressed by DZone contributors are their own.
Comments