728x90
반응형

사용하는 Dto Class에 Enum Type이 존재하고, 타입 별로 필드를 검증하고 싶을 때 Annotation과 ConstraintValidator 인터페이스를 활용하여 Validaton을 구현할 수 있다.

 

1. ConditinalValidator Class

Conditional Class는 2번에서 만들 Annotation을 의미한다.

isValid 메소드에서 Required Value가 null이거나 ""일 때 에러를 반환해준다.

@Slf4j
public class ConditionalValidator implements ConstraintValidator<Conditional, Object> {

    private String selected;
    private String[] required;
    private String message;
    private String[] values;

    @Override
    public void initialize(Conditional requiredIfChecked) {
        selected = requiredIfChecked.selected();
        required = requiredIfChecked.required();
        message = requiredIfChecked.message();
        values = requiredIfChecked.values();
    }

    @Override
    public boolean isValid(Object objectToValidate, ConstraintValidatorContext context) {
        Boolean valid = true;
        try {
            Object actualValue = BeanUtils.getProperty(objectToValidate, selected);
            if (Arrays.asList(values).contains(actualValue)) {
                for (String propName : required) {
                    Object requiredValue = BeanUtils.getProperty(objectToValidate, propName);
                    // value가 null이거나 ""인지 판별한다.
                    valid = requiredValue != null && !isEmpty(requiredValue);
                    if (!valid) {
                        context.disableDefaultConstraintViolation();
                        context.buildConstraintViolationWithTemplate(message).addPropertyNode(propName).addConstraintViolation();
                    }
                }
            }
        } catch (IllegalAccessException e) {
            log.error("Accessor method is not available for class : {}, exception : {}", objectToValidate.getClass().getName(), e);
            e.printStackTrace();
            return false;
        } catch (NoSuchMethodException e) {
            log.error("Field or method is not present on class : {}, exception : {}", objectToValidate.getClass().getName(), e);
            e.printStackTrace();
            return false;
        } catch (InvocationTargetException e) {
            log.error("An exception occurred while accessing class : {}, exception : {}", objectToValidate.getClass().getName(), e);
            e.printStackTrace();
            return false;
        }
        return valid;
    }
}

 

2. Conditional Annotaton

Dto에 쓰이며 위에서 만든 ConditionalValidator Class를 활용하는 Annotaton이다.

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {ConditionalValidator.class})
public @interface Conditional {

    String message() default "This field is required.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    String selected();
    String[] required();
    String[] values();
}

 

여기서 아래와 같이 @Repeatable 어노테이션으로 여러 개의 Conditianl Annotation을 같이 선언할 수도 있다.

@Repeatable(Conditionals.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {ConditionalValidator.class})
public @interface Conditional {

    String message() default "This field is required.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    String selected();
    String[] required();
    String[] values();
}

 

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface Conditionals {
    Conditional[] value();
}

 

3. DTO

사용이 필요한 DTO에 어노테이션을 추가해준다.

@Builder
@Getter
@Setter
@Conditional(selected = "locationOption", values = {"SFTP", "FTP"}, required = {"host", "user", "password", "port"})
@Conditional(selected = "locationOption", values = {"EMAIL"}, required = {"emailAddress"})
public class ExportLocationDTO {

    @NotEmpty
    private String name;
    @NotNull
    private ExportLocationOption locationOption;
    private String emailAddress;
    private String host;
    @Min(1)
    private Integer port;
    @NotBlank
    @Length(min = 1,max = 255)
    private String user;
    @NotBlank
    @Length(min = 1,max = 100)
    private String password;
}

 

 

2번에서 알려준 @Conditionals를 활용하면 다음과 같이 쓸 수 있다.

@Builder
@Getter
@Setter
@Conditionals(
	{
		@Conditional(selected = "locationOption", values = {"SFTP", "FTP"}, required = {"host", "user", "password", "port"}),
		@Conditional(selected = "locationOption", values = {"EMAIL"}, required = {"emailAddress"})
	}
)
public class ExportLocationDTO {

    @NotEmpty
    private String name;
    @NotNull
    private ExportLocationOption locationOption;
    private String emailAddress;
    private String host;
    private Integer port;
    private String user;
    private String password;
}

 

4. @Valid 적용

마지막으로 해당 DTO가 사용되는 곳에 @Valid 어노테이션을 추가해준다.

    @PutMapping
    public CommonResponse<Boolean> exportLocation(
        @Valid @RequestBody ExportLocationDTO dto
    ) {
        service.exportLocation(dto);
        return new CommonResponse<>(true);
    }

 

 

5. Test Code

@Valid 유닛 테스트 코드는 다음과 같이 작성할 수 있다.

Assertions.assertFalse(violations.isEmpty()); 메소드를 사용하면 @Valid 검증이 실패할 경우 테스트가 통과된다.

import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;

public class ExportLocationDTOTest {

    private Validator validator;

    private ExportLocationDTO dto;

    @Before
    public void setUp() throws Exception {
        ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
        validator = validatorFactory.getValidator();

        dto = ExportLocationDTO.builder()
                .name("validName")
                .build();
    }

    @Test
    public void ShouldFailOnMissingEmailAddressField() {
        dto.setLocationOption(ExportLocationOption.EMAIL);
        Set<ConstraintViolation<ExportLocationDTO>> violations = validator.validate(dto);
        assertEquals(1, violations.size());
    }

    @Test
    public void ShouldFailOnMissingFTPCredentials() {
        dto.setLocationOption(ExportLocationOption.FTP);
        Set<ConstraintViolation<ExportLocationDTO>> violations = validator.validate(dto);
        assertEquals(4, violations.size());
    }

    @Test
    public void ShouldFailOnMissingSFTPCredentials() {
        dto.setLocationOption(ExportLocationOption.SFTP);
        Set<ConstraintViolation<ExportLocationDTO>> violations = validator.validate(dto);
        assertEquals(4, violations.size());
    }
}

Assertions.assertFalse(violations.isEmpty());

 

 

아래는 Conditional 어노테이션 및 ConditionalValidator 클래스의 동작을 테스트한다.

LocaltionOption이 EMAIL일 경우 emailAddress 필드 값이 null이거나 빈 값이면 안되기 때문에 False를 검증하는 테스트이다.

public class ExportLocationDTOTest {

    private final ConstraintValidatorContext constraintValidatorContext = mock(
        ConstraintValidatorContext.class);

    private ExportLocationDTO dto;
    
    private conditionalValidator = new ConditionalValidator();

    @BeforeEach
    public void setUp(){

        dto = ExportLocationDTO.builder()
            .name("validName")
            .build();

        ConstraintAnnotationDescriptor.Builder<Conditional> descriptorBuilder = new ConstraintAnnotationDescriptor.Builder<>(
            Conditional.class);
        descriptorBuilder.setAttribute("selected", "locationOption");
        descriptorBuilder.setAttribute("values", new String[]{"EMAIL"});
        descriptorBuilder.setAttribute("required", new String[]{"emailAddress"});
        Conditional conditional = descriptorBuilder.build()
            .getAnnotation();
        conditionalValidator.initialize(conditional);
    }

    @Test
    public void ShouldFailOnMissingEmailAddressField() {
        dto.setLocationOption(ExportLocationOption.EMAIL);
        boolean valid = conditionalValidator.isValid(dto,
            constraintValidatorContext);
        assertFalse(valid);
    }
}
728x90
반응형

+ Recent posts