Search code examples
javaspringspring-bootspring-validatorspring-validation

SpringBoot @Valid on one field, based on the value of another field


I would like to use the SpringBoot @Valid to validate a http request field, but based on another field of the same http request.

I have the following code:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.1</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <artifactId>question</artifactId>

    <properties>
        <maven.compiler.source>23</maven.compiler.source>
        <maven.compiler.target>23</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
class FieldValidationApplication {

    public static void main(String[] args) {
        SpringApplication.run(FieldValidationApplication.class, args);
    }

}
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
class FieldValidationController {

    @PostMapping("/validate")
    String question(@Valid @RequestBody SomeRequest someRequest) {
        return "please validate the field";
    }

}

record SomeRequest(int score,
                   String fieldPositive,
                   String fieldZeroAndNegative
                   ) 
{ }

The validation rules are quite simple:

The request payload has a field score. If the value of the field score is strictly positive, then I need to check the field fieldPositive is a valid string, and also, that fieldZeroAndNegative is null.

For instance:

{
  "score": 1,
  "fieldPositive": "thisisok"
}

But those are not:

{
  "score": 1
}

{
  "score": 1,
  "fieldPositive": ""
}

{
  "score": 1,
  "fieldPositive": "below fieldZeroAndNegative should be null",
  "fieldZeroAndNegative": "not ok"
}

Similar rule for the other field (code just below).

This is what I tried, I created custom annotation:

record SomeRequest(int score,
                   @ValidateThisFieldOnlyIfScoreIsPositive String fieldPositive,
                   @ValidateThisFieldOnlyIfScoreIsZeroOrNegative String fieldZeroAndNegative
                   ) 
{ }

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Constraint(validatedBy = ValidateThisFieldOnlyIfScoreIsPositiveValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@interface ValidateThisFieldOnlyIfScoreIsPositive
{
    String message() default "Field is invalid";

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

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

}


import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

class ValidateThisFieldOnlyIfScoreIsPositiveValidator implements ConstraintValidator<ValidateThisFieldOnlyIfScoreIsPositive, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        System.out.println("hello, the value of the field fieldPositive is " + value);
        System.out.println("However, I cannot get the value of the field score");
        if (" SomeRequest score " > 0) { //how to get the value of the field score here?
            return value != null && !value.isEmpty() && value.length() > 3;
        }
        if (" SomeRequest score"  <= 0) {
            return value == null;
        }
        ...
    }

}

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Constraint(validatedBy = ValidateThisFieldOnlyIfScoreIsZeroOrNegativeValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@interface ValidateThisFieldOnlyIfScoreIsZeroOrNegative
{
    String message() default "Field is invalid";

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

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

}

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

class ValidateThisFieldOnlyIfScoreIsZeroOrNegativeValidator implements ConstraintValidator<ValidateThisFieldOnlyIfScoreIsZeroOrNegative, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        System.out.println("hello, the value of the field fieldZeroAndNegative is " + value);
        System.out.println("However, I cannot get the value of the field score");
        if (" SomeRequest score " <= 0) { //how to get the value of the field score here?
            return value != null && !value.isEmpty() && value.length() > 3;
        }
        if (" SomeRequest score" > 0) {
            return value == null;
        }

    }

}

I am not sure using one annotation per field is the way to go to begin with.

Question:

How to get both fields (or multiple fields) of the same request in the validator?


Solution

  • To validate one field based on the value of another field in the same request object, you need to apply validation at the class level rather than the field level. This way, the validator has access to all fields of the object.

    Solution: Use a Class-Level Constraint

    1. Create a Custom Validation Annotation

    Create a custom annotation that can be applied to the entire class:

    import jakarta.validation.Payload;
    import java.lang.annotation.ElementType;
    import java.lang.annotation.Retention;
    import java.lang.annotation.RetentionPolicy;
    import java.lang.annotation.Target;
    
    @Constraint(validatedBy = SomeRequestValidator.class)
    @Target({ElementType.TYPE})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface ValidSomeRequest {
        String message() default "Invalid request data";
        Class<?>[] groups() default {};
        Class<? extends Payload>[] payload() default {};
    }
    
    1. Implement the Validator

    Create a validator that can access all fields of SomeRequest:

    import jakarta.validation.ConstraintValidator;
    import jakarta.validation.ConstraintValidatorContext;
    
    public class SomeRequestValidator implements ConstraintValidator<ValidSomeRequest, SomeRequest> {
    
        @Override
        public boolean isValid(SomeRequest request, ConstraintValidatorContext context) {
            if (request == null) {
                return true; // Let @NotNull handle null cases
            }
    
            boolean isValid = true;
            context.disableDefaultConstraintViolation(); // Prevent default message
    
            if (request.score > 0) {
                // If score is positive, fieldPositive must be non-empty, and fieldZeroAndNegative must be null
                if (request.fieldPositive == null || request.fieldPositive.isEmpty()) {
                    isValid = false;
                    context.buildConstraintViolationWithTemplate("fieldPositive must not be empty when score is positive")
                            .addPropertyNode("fieldPositive")
                            .addConstraintViolation();
                }
                if (request.fieldZeroAndNegative != null) {
                    isValid = false;
                    context.buildConstraintViolationWithTemplate("fieldZeroAndNegative must be null when score is positive")
                            .addPropertyNode("fieldZeroAndNegative")
                            .addConstraintViolation();
                }
            } else {
                // If score is zero or negative, fieldZeroAndNegative must be non-empty, and fieldPositive must be null
                if (request.fieldZeroAndNegative == null || request.fieldZeroAndNegative.isEmpty()) {
                    isValid = false;
                    context.buildConstraintViolationWithTemplate("fieldZeroAndNegative must not be empty when score is zero or negative")
                            .addPropertyNode("fieldZeroAndNegative")
                            .addConstraintViolation();
                }
                if (request.fieldPositive != null) {
                    isValid = false;
                    context.buildConstraintViolationWithTemplate("fieldPositive must be null when score is zero or negative")
                            .addPropertyNode("fieldPositive")
                            .addConstraintViolation();
                }
            }
    
            return isValid;
        }
    }
    
    1. Apply the Annotation to the Record

    Modify SomeRequest to use the new validation annotation:

    @ValidSomeRequest
    public record SomeRequest(int score, String fieldPositive, String fieldZeroAndNegative) { }
    
    1. Validate in the Controller

    Spring Boot will automatically validate SomeRequest using the annotation when handling the request:

    import jakarta.validation.Valid;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    class FieldValidationController {
    
        @PostMapping("/validate")
        String validate(@Valid @RequestBody SomeRequest someRequest) {
            return "Request is valid";
        }
    
    }
    

    Why This Works

    • The validation is performed at the class level, so all fields are accessible.
    • The validation logic checks both score and the dependent fields in one place.
    • Custom error messages are assigned to specific fields using addPropertyNode(), improving clarity in API responses.

    This approach ensures that your request is properly validated based on score, without needing separate annotations for each field.