Are you still using If/else validation in Spring 6.0+ / SpringBoot 3.0+? / Hebrew

Are you still using If/else validation in Spring 6.0+ / SpringBoot 3.0+? / Hebrew

If so, you should update your code using the recommendations below.

To avoid unauthorized parameters affecting your business, your web services must implement parameter validation at the controller level! In most cases, query parameters can be divided into the following two types:

  • POST and PUT requests using requestBody to pass parameters.

  • GET requests using requestParam/PathVariable to pass parameters.

Minimum requirements:

  • Spring 6.0+

  • SpringBoot 3.0+

The Java API specification (JSR303) defines the Validation Api standard for Beans, but does not provide its implementation. Hibernate Validation is an implementation of this standard, adding a number of validation annotations such as @ Email, @ Length, etc. Spring Validation is a secondary encapsulation of Hibernate Validation used to support automatic Spring MVC parameter validation.

Without further ado, let’s get acquainted with the use of Spring Validation on the example of a Spring Boot project.

In SpringBoot 3.0+

the validation library has been moved to jakarta.validation

<dependency>
            <groupId>jakarta.validation</groupId>
            <artifactId>jakarta.validation-api</artifactId>
</dependency>

which can be imported for

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

RequestBody

In POST and PUT requests, passing parameters is usually used requestBody. In this case, the backend uses a DTO object to receive them. If the DTO object is marked with the @Validated instruction, automatic parameter validation can be implemented.

For example, there is an interface for storing user data that requires that the length userName was 2-10, the length of the account and password fields was 6-20. If the parameters do not pass the check, an error will be thrown MethodArgumentNotValidExceptionand Spring will send a 400 (Bad Request) request by default.

@Data
public class UserDTO {

    private Long userId;

    @NotNull
    @Length(min = 2, max = 10)
    private String userName;

    @NotNull
    @Length(min = 6, max = 20)
    private String account;

    @NotNull
    @Length(min = 6, max = 20)
    private String password;
}

Declaration of verification annotations for method parameters:

@PostMapping("/save")
public Result saveUser(@RequestBody @Validated UserDTO userDTO) {
    // ...
    return Result.ok();
}


// А НЕ(!!!)

@PostMapping("/save")
public Result saveUser(UserDTO userDTO) {
    
  if(userDTO.getUserName().getLength >=2 && userDTO.getUserName().getLength <=10)
  {
    ...
  }
  
  if(...)
  {
    ...
  }
  else{
    ...
  }
  ...
    
  return Result.ok();
}

RequestParam/PathVariable

GET requests are usually used to pass parameters requestParam/PathVariable. If there are many parameters (for example, more than 6), DTO objects should be used to receive them. Otherwise, it is recommended to put one parameter in the input parameters of the method. In this case, the Controller class must be annotated with @Validated and the input parameters declared with the constraint annotation (eg @Min, etc.). If the check fails, an error will be thrown ConstraintViolationException. An example code looks like this:

@RequestMapping("/api/user")
@RestController
@Validated
public class UserController {

    @GetMapping("{userId}")
    public Result detail(@PathVariable("userId") @Min(10000000000000000L) Long userId) {

        UserDTO userDTO = new UserDTO();
        userDTO.set...
        return Result.ok(userDTO);
    }


    @GetMapping("getByAccount")
    public Result getByAccount(@Length(min = 6, max = 20) @NotNull String  account) {

        UserDTO userDTO = new UserDTO();
        userDTO.set...
        return Result.ok(userDTO);
    }
}

In real-world project development, it is common to use unified exception handling to return the friendlier messages we are already familiar with.

Before we delve deeper, we need to understand the relationship and the difference

@Valid and @Validated

Both @Valid and @Validated are used to trigger the validation process when processing a Spring request. However, there are several key differences between the two:

  • Origin: @Valid is a standard directive from the Java Bean Validation specification, also known as JSR-303. It is not specific to Spring and can be used in any Java application. On the other hand, @Validated is a Spring-specific annotation provided by Spring itself.

  • Function: @Valid is used to validate a method object or method parameter. Most often, it is used when an object is received in an HTTP request, and you want to check the fields of this object. @Validated is used to validate method parameters on the Spring Bean. It is most often used when a Spring component method has parameters that need to be validated.

  • Grouping: Only @Validated supports restrictions. This is useful when different sets of checks are required for the same object under different circumstances.

In summary, we can say that when working within the Spring framework, the best solution would be to use @Validated because of its additional features. Use @Valid outside of Spring or when the additional capabilities of @Validated are not needed.

OK, now we can get to the fun parts.

Group validation

In real projects, multiple methods may need to use the same DTO class to obtain parameters, and the validation rules for different methods will likely be different. Currently, simply adding restrictive annotations to the fields of the DTO class will not solve this problem. That’s why Spring Validation offers a batch validation option specifically to solve this kind of problem.

In this example, for example, when saving User, UserId can be null, and when updating Uservalue UserId must be >=10000000000000000L; the validation rules for other fields are the same in both cases. An example code using group validation currently looks like this:

@Data
public class UserDTO {

    @Min(value = 10000000000000000L, groups = Update.class)
    private Long userId;

    @NotNull(groups = {Save.class, Update.class})
    @Length(min = 2, max = 10, groups = {Save.class, Update.class})
    private String userName;

    @NotNull(groups = {Save.class, Update.class})
    @Length(min = 6, max = 20, groups = {Save.class, Update.class})
    private String account;

    @NotNull(groups = {Save.class, Update.class})
    @Length(min = 6, max = 20, groups = {Save.class, Update.class})
    private String password;

    /**
     * группа проверок для сохранения
     */
    public interface Save {
    }

    /**
     * группа проверок для обновления 
     */
    public interface Update {
    }
}

@PostMapping("/save")
public Result saveUser(@RequestBody @Validated(UserDTO.Save.class) UserDTO userDTO) {
    // ...
    return Result.ok();
}

@PostMapping("/update")
public Result updateUser(@RequestBody @Validated(UserDTO.Update.class) UserDTO userDTO) {
    // ...
    return Result.ok();
}

As you can see, the grouping happens in the constraint instructions.

Nested validation

In the previous examples, all fields in the DTO class were basic data types or String types. However, in real-world scenarios, it is quite possible that the field could be an object, in which case nested validation could be used.

For example, when saving the information about the user given, we also receive information about his work. It should be noted that this time the corresponding field of the DTO class must be marked with the @Valid instruction.

@Data
public class UserDTO {

    @Min(value = 10000000000000000L, groups = Update.class)
    private Long userId;

    @NotNull(groups = {Save.class, Update.class})
    @Length(min = 2, max = 10, groups = {Save.class, Update.class})
    private String userName;

    @NotNull(groups = {Save.class, Update.class})
    @Length(min = 6, max = 20, groups = {Save.class, Update.class})
    private String account;

    @NotNull(groups = {Save.class, Update.class})
    @Length(min = 6, max = 20, groups = {Save.class, Update.class})
    private String password;

    @NotNull(groups = {Save.class, Update.class})
    @Valid
    private Job job;

    @Data
    public static class Job {

        @Min(value = 1, groups = Update.class)
        private Long jobId;

        @NotNull(groups = {Save.class, Update.class})
        @Length(min = 2, max = 10, groups = {Save.class, Update.class})
        private String jobName;

        @NotNull(groups = {Save.class, Update.class})
        @Length(min = 2, max = 10, groups = {Save.class, Update.class})
        private String position;
    }


    public interface Save {
    }


    public interface Update {
    }
}

Nested validation can be used in conjunction with group validation. Additionally, nested collection validation will validate each element in the collection, such as a field List will check each object Job in the list

Collection validation

For example, the request body directly sends a JSON array to the backend and relies on the validation of each element in the array. In this case, if we use a list or set directly java.util.Collection to receive data, the parameters will not be checked! We can use our own List collection to accept parameters:

Wrap type List and declare a @Valid instruction.

An exception will be thrown if the check fails NotReadablePropertyExceptionwhich can also be handled using the unified Exception exception.

For example, if we need to save several objects at once Usermethod in the Controller layer can be written as follows:

public class ValidationList<E> implements List<E> {

    @Delegate
    @Valid // обязательно
    public List<E> list = new ArrayList<>();

    @Override
    public String toString() {
        return list.toString();
    }
}

@PostMapping("/saveList")
public Result saveList(@RequestBody @Validated(UserDTO.Save.class) ValidationList<UserDTO> userList) {
    // ...
    return Result.ok();
}

User validation

Business requirements are always much more complex than the simple checks provided by the framework, so we can define our own checks to meet those needs.

Customizing Spring Validation is very easy. Let’s say we’re configuring an encrypted check id (consisting of numbers or letters from af and length 32-256). There are two main steps:

Define a custom constraint statement and implement the interface ConstraintValidatorin which the constraint validator will be written:

@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {EncryptIdValidator.class})
public @interface EncryptId {

    String message() default "id format error";

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

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


public class EncryptIdValidator implements ConstraintValidator<EncryptId, String> {

    private static final Pattern PATTERN = Pattern.compile("^[a-f\\d]{32,256}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {

        if (value != null) {
            Matcher matcher = PATTERN.matcher(value);
            return matcher.find();
        }
        return true;
    }
}


@Data
public class XXXDTO {

    @EncryptId
    private Long id;

    ...
}

This way we can use @EncryptId to validate parameters!

Program validation

The above examples are based on instructions for implementing automatic validation, but in some cases we may need to invoke the validation programmatically. And here we can implement the object javax.validation.Validator and then call its API.

@Autowired
private javax.validation.Validator globalValidator;

@PostMapping("/saveWithCodingValidate")
public Result saveWithCodingValidate(@RequestBody UserDTO userDTO) {
    Set<ConstraintViolation<UserDTO>> validate = globalValidator.validate(userDTO, UserDTO.Save.class);

    if (validate.isEmpty()) {
        // ...

    } else {
        for (ConstraintViolation<UserDTO> userDTOConstraintViolation : validate) {
            // ...
            System.out.println(userDTOConstraintViolation);
        }
    }
    return Result.ok();
}

Fail Fast

By default, Spring Validation will check all fields before throwing an exception. By adding a few simple settings, you can enable Fail Fast mode, which immediately returns an exception when a check fails.

@Bean
public Validator validator() {
    ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
            .configure()
            .failFast(true)
            .buildValidatorFactory();
    return validatorFactory.getValidator();
}

And last but not least

Implementation principle

@RequestBody

In Spring MVC RequestResponseBodyMethodProcessor used to parse parameters annotated with @RequestBody and to process return values ​​of methods annotated with @ResponseBody. Obviously, the logic for checking the parameters should be in the parameter enable method resolveArgument().

 public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {

 ...

 /**
  * При неудачной проверке выбрасывает MethodArgumentNotValidException.
  * @throws HttpMessageNotReadableException если {@link RequestBody#required()}
  * является {@code true} и там нет содержимого, или если нет подходящего
  * конвертера для чтения содержимого.
  */
 @Override
 public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
   NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {

  parameter = parameter.nestedIfOptional();
  
  //Инкапсулируйте данные запроса в DTO-объект 
  Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());

  if (binderFactory != null) {
   String name = Conventions.getVariableNameForParameter(parameter);
   WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
   if (arg != null) {
    validateIfApplicable(binder, parameter);
    if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
     throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
    }
   }
   if (mavContainer != null) {
    mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
   }
  }

  return adaptArgumentIfNecessary(arg, parameter);
 }

 ...

}

As you can see, resolveArgument() causes validateIfApplicable() to validate parameters.

protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
  Annotation[] annotations = parameter.getParameterAnnotations();
  for (Annotation ann : annotations) {
   //определяем подсказки валидации
   Object[] validationHints = ValidationAnnotationUtils.determineValidationHints(ann);
   if (validationHints != null) {
    binder.validate(validationHints);
    break;
   }
  }
 }

From here you should understand why the instructions @Validated and @Valid can be mixed in this scenario. Let’s move on to the implementation WebDataBinder.validate():

/**
  * Вызваем указанные валидаторы, если таковые имеются, с заданными подсказками валидации.
  * <p>Примечание: подсказки валидации могут быть проигнорированы реальным целевым валидатором.
  * @param validationHints один или несколько объектов подсказок для передачи в {@link SmartValidator}
  * @since 3.1
  * @see #setValidator(Validator)
  * @see SmartValidator#validate(Object, Errors, Object...)
  */
 public void validate(Object... validationHints) {
  Object target = getTarget();
  Assert.state(target != null, "No target to validate");
  BindingResult bindingResult = getBindingResult();
  // Вызываем каждый валидатор с одним и тем же результатом привязки
  for (Validator validator : getValidators()) {
   if (!ObjectUtils.isEmpty(validationHints) && validator instanceof SmartValidator smartValidator) {
    smartValidator.validate(target, bindingResult, validationHints);
   }
   else if (validator != null) {
    validator.validate(target, bindingResult);
   }
  }
 }

It turns out that the base layer eventually calls the Hibernate Validator to do the actual validation processing.

Validation of parameters at the method level

This method of validation involves assigning parameters to method parameters in turn and declaring constraint annotations in front of each parameter, which is parameter validation only at the method level. In fact this method can be used for any Spring Bean methods like Controller/Service etc. The implementation is based on the principle of AOP (aspect-oriented programming). In particular, it is involved here MethodValidationPostProcessorwhich dynamically registers the AOP aspect and then uses it MethodValidationInterceptor for weaving extensions in pointcut.

public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor
  implements InitializingBean {

  ...


 @Override
 public void afterPropertiesSet() {
  // Создаем аспект для всех бинов аннотированных '@Validated'. 
  Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
  // Создаем советника для расширений
  this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
 }

 /**
  * Создание рекомендаций AOP для целей валидации метода, которые будут применяться
  * с указателем для указанной аннотации "validated".
  * @param validator поставщик для используемого валидатора
  * @return перехватчик (обычно, но не обязательно,
  * {@link MethodValidationInterceptor} или его подкласс)
  * @since 6.0
  */
 protected Advice createMethodValidationAdvice(Supplier<Validator> validator) {
  return new MethodValidationInterceptor(validator);
 }

}

Let’s take a look at MethodValidationInterceptor:

public class MethodValidationInterceptor implements MethodInterceptor {

 private final Supplier<Validator> validator;


 ...


 @Override
 @Nullable
 public Object invoke(MethodInvocation invocation) throws Throwable {
  // Избегайте вызова валидатора на FactoryBean.getObjectType/isSingleton
  if (isFactoryBeanMetadataMethod(invocation.getMethod())) {
   return invocation.proceed();
  }

  Class<?>[] groups = determineValidationGroups(invocation);

  // Стандартный API Bean Validation 1.1
  ExecutableValidator execVal = this.validator.get().forExecutables();
  Method methodToValidate = invocation.getMethod();
  Set<ConstraintViolation<Object>> result;

  Object target = invocation.getThis();
  if (target == null && invocation instanceof ProxyMethodInvocation methodInvocation) {
   // Разрешаем проверку для AOP-прокси без таргета
   target = methodInvocation.getProxy();
  }
  Assert.state(target != null, "Target must not be null");

  try {
   result = execVal.validateParameters(target, methodToValidate, invocation.getArguments(), groups);
  }
  catch (IllegalArgumentException ex) {
   // Вероятно, имеет место несовпадение типов между интерфейсом и реализацией, как сообщалось в SPR-12237 / HV-1011
   // Давайте попробуем найти связанный метод в классе реализации...
   methodToValidate = BridgeMethodResolver.findBridgedMethod(
     ClassUtils.getMostSpecificMethod(invocation.getMethod(), target.getClass()));
   result = execVal.validateParameters(target, methodToValidate, invocation.getArguments(), groups);
  }
  if (!result.isEmpty()) {
   throw new ConstraintViolationException(result);
  }

  Object returnValue = invocation.proceed();

  result = execVal.validateReturnValue(target, methodToValidate, returnValue, groups);
  if (!result.isEmpty()) {
   throw new ConstraintViolationException(result);
  }

  return returnValue;
 }

 ...

}

That’s how it turns out, whether it’s parameter validation requestBody or method-level validation, eventually Hibernate Validator will do the work.

Thank you for attention! Come to the free open classes that will be held on the eve of the start of the “Developer on Spring Framework” course:

  • March 13: let’s talk about JHipster, touch on Rapid Application Development and look at some use cases. Sign up

  • March 20: let’s try to understand the Controller, Service, Repository patterns – what benefit can they bring us? And we will also discuss the features of their use in Spring. Sign up

Related posts