12 Spring Web Parameter Validation Common Errors

12 Spring Web Parameter Validation Common Errors #

Hello, I’m Fu Jian. In this lesson, we will talk about parameter validation in Spring Web development.

Parameter validation is often used in web programming as a technique to validate the legality of requests. It helps us intercept invalid requests effectively, thereby saving system resources and protecting the system.

Compared to other Spring technologies, Spring’s parameter validation functionality has the advantages of independence and ease of use. However, in practice, we still make some common errors. Although these errors won’t lead to fatal consequences, they will affect our user experience. For example, rejecting illegal operations only during business processing and the response codes returned may not be clear and friendly enough. Moreover, these errors are difficult to detect without testing. Next, we will analyze these common error cases and the underlying principles behind them.

Case 1: Object Parameter Validation Failure #

When building a web service, we generally validate the body content of an HTTP request. Let’s take a look at a case and the corresponding code.

When developing a student management system, we provide an API interface to add student information. The object definition is shown in the code below:

import lombok.Data;
import javax.validation.constraints.Size;

@Data
public class Student {
    @Size(max = 10)
    private String name;
    private short age;
}

Here, we use @Size(max = 10) to constrain the student’s name (maximum 10 characters) in order to intercept the addition of student information with names that are too long or do not conform to common practice.

After defining the object, we define a controller to use it. The usage is as follows:

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Slf4j
@Validated
public class StudentController {
    @RequestMapping(path = "students", method = RequestMethod.POST)
    public void addStudent(@RequestBody Student student){
        log.info("add new student: {}", student.toString());
        // omit business logic
    };
}

We provide an interface for adding student information. After starting the service, use the HTTP Client tool provided by IDEA to send the following request to add a student. Of course, this student’s name will far exceed expectations (i.e., this_is_my_name_which_is_too_long):

POST http://localhost:8080/students
Content-Type: application/json
{
 "name": "this_is_my_name_which_is_too_long",
 "age": 10
}

Clearly, sending such a request (with a name that is too long) expects Spring Validation to intercept it. Our expected response is as follows (omitting some response fields):

HTTP/1.1 400
Content-Type: application/json

{
  "timestamp": "2021-01-03T00:47:23.994+0000",
  "status": 400,
  "error": "Bad Request",
  "errors": [
      "defaultMessage": "个数必须在 0 和 10 之间",
      "objectName": "student",
      "field": "name",
      "rejectedValue": "this_is_my_name_which_is_too_long",
      "bindingFailure": false,
      "code": "Size"
    }
  ],
  "message": "Validation failed for object='student'. Error count: 1",
  "path": "/students"
}

However, the ideal and reality often differ. In actual testing, it will be found that the web service built using the above code does not intercept anything.

Case Analysis #

To find the root cause of this problem, we need to have a certain understanding of Spring Validation. First, let’s take a look at the location and conditions of the object validation that occurs when accepting the RequestBody.

Assuming that we are using Spring Boot to build the web service, we can refer to the sequence diagram below to understand its core execution steps:

sequence_diagram

As shown in the above diagram, when a request arrives, it enters the DispatcherServlet and executes its doDispatch() method. This method locates the controller method responsible for processing based on key information such as the path and method (i.e., the addStudent method), and then uses reflection to execute this method. The specific process of executing the method through reflection is as follows (refer to InvocableHandlerMethod#invokeForRequest):

public Object invokeForRequest(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
      Object... providedArgs) throws Exception {
   // Get method argument instances based on the request content and method definition
   Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);
   if (logger.isTraceEnabled()) {
      logger.trace("Arguments: " + Arrays.toString(args));
   }
   // Use the method argument instances to invoke the method through "reflection"
   return doInvoke(args);
}

To use Java reflection to execute a method, we need to get the method’s parameters. The above code precisely validates this point: getMethodArgumentValues() obtains the method execution arguments, and doInvoke() uses these obtained arguments to execute the method. When it comes to getMethodArgumentValues(), how do we obtain the method invocation parameters? We can refer to the method definition of addStudent and build an instance of the Student class from the current request (NativeWebRequest).

public void addStudent(@RequestBody Student student)

So how do we construct this method parameter instance? Spring has a variety of built-in HandlerMethodArgumentResolver options, as shown in the following diagram:

When attempting to construct a method parameter, all supported resolvers are traversed to find the most suitable resolver. The code for finding resolvers can be found in HandlerMethodArgumentResolverComposite#getArgumentResolver:

@Nullable
private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
   HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
   if (result == null) {
      // Iterate over all HandlerMethodArgumentResolvers
      for (HandlerMethodArgumentResolver resolver : this.argumentResolvers) {
         // Check if the current HandlerMethodArgumentResolver is a match
         if (resolver.supportsParameter(parameter)) {
            result = resolver;            
            this.argumentResolverCache.put(parameter, result);
            break;
         }
      }
   }
   return result;
}

For the student parameter, it is annotated with @RequestBody, and it will match with the RequestResponseBodyMethodProcessor during traversal. The matching code can be found in the supportsParameter method of RequestResponseBodyMethodProcessor:

@Override
public boolean supportsParameter(MethodParameter parameter) {
   return parameter.hasParameterAnnotation(RequestBody.class);
}

Once the resolver is found, the HandlerMethodArgumentResolver#resolveArgument method is executed. It will first assemble the Student object based on the current request (NativeWebRequest) and perform necessary validation on this object. The validation process can be found in AbstractMessageConverterMethodArgumentResolver#validateIfApplicable:

protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
   Annotation[] annotations = parameter.getParameterAnnotations();
   for (Annotation ann : annotations) {
      Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
      // Check if validation is necessary
      if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) {
         Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));
         Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints});
         // Perform validation
         binder.validate(validationHints);
         break;
      }
   }
}

As shown in the above code, to validate the student instance (by executing binder.validate(validationHints)), it must meet one of the following two conditions:

  1. It is annotated with org.springframework.validation.annotation.Validated.
  2. It is annotated with another type of annotation that starts with the keyword Valid.

Therefore, based on the example program, we know that the student method parameter does not meet either of these conditions. So even if its internal members are annotated with validation (i.e., @Size(max = 10)), it will not take effect.

Problem Resolution #

With the analysis of the source code, we can quickly find a solution for this case. For object parameters accepted by RequestBody, in order to enable validation, the object parameter must be annotated with @Validated or another annotation starting with the keyword @Valid. Therefore, we can use the corresponding strategies to resolve the issue.

  1. Annotate with @Validated

The modified key line of code is as follows:

public void addStudent(@Validated @RequestBody Student student)
  1. Annotate with an annotation starting with @Valid

Here we can directly use the familiar javax.validation.Valid annotation, which is an annotation starting with the keyword @Valid. The modified key line of code is as follows:

public void addStudent(@Valid @RequestBody Student student)

Additionally, we can also customize an annotation starting with @Valid. The definition is as follows:

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidCustomized {
}

After defining this annotation, we can annotate it to the student parameter object. The modified key line of code is as follows:

public void addStudent(@ValidCustomized @RequestBody Student student)

By using these two strategies and three specific resolution methods, we can finally make the parameter verification effective and meet expectations. However, when using the third resolution method, please pay attention to explicitly mark the @Retention(RetentionPolicy.RUNTIME) on the custom annotation; otherwise, the validation will still not take effect. This is another area that is easily overlooked. The reason is that when RetentionPolicy is not explicitly marked, RetentionPolicy.CLASS is used by default, and although the information of this type of annotation will be retained in the bytecode file (.class), it will be lost when loaded into the JVM. Therefore, when judging whether to perform validation based on this annotation at runtime, it will definitely not take effect.

Case 2: Failed Nested Validation #

Although the previous case is a classic example, it is only a common mistake made by beginners. In fact, one of the most easily overlooked aspects of validation is the validation of nested objects. Let’s use the same example as before to illustrate this.

Let’s say a student may also have a phone number, so we can define a Phone object and associate it with the Student object. The code is as follows:

public class Student {
    @Size(max = 10)
    private String name;
    private short age;   
    private Phone phone;
}

@Data
class Phone {
    @Size(max = 10)
    private String number;
}

Here, we have also specified the validation requirements for the Phone object (@Size(max = 10)). When we send the following request (with a phone number exceeding 10 characters) and test the validation, we find that this constraint is not enforced:

POST http://localhost:8080/students
Content-Type: application/json
{
  "name": "xiaoming",
  "age": 10,
  "phone": {"number":"12306123061230612306"}
}

Why is it not enforced?

Analysis of the Case #

In the analysis of Case 1, we mentioned that by adding @Valid (or @Validated, etc.) to the student object, we can enable validation for this object. However, whether the Phone member of the student object should be validated or not is determined during the validation process (in the code line binder.validate(validationHints) in Case 1).

During the validation process, all validation points are determined based on the type definition of the Student object. The validation is then performed on the instance of the Student object. You can refer to the logic process implemented by the code ValidatorImpl#validate:

@Override
public final <T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups) {
   // Omitted some non-essential code
   Class<T> rootBeanClass = (Class<T>) object.getClass();
   // Get the "metadata" (including "constraints") of the validation object type
   BeanMetaData<T> rootBeanMetaData = beanMetaDataManager.getBeanMetaData(rootBeanClass);

   if (!rootBeanMetaData.hasConstraints()) {
      return Collections.emptySet();
   }

   // Omitted some non-essential code
   // Perform validation
   return validateInContext(validationContext, valueContext, validationOrder);
}

The statement beanMetaDataManager.getBeanMetaData(rootBeanClass) retrieves the BeanMetaData based on the Student type. The BeanMetaData contains the constraints that need to be enforced.

During the assembly of the BeanMetaData, the cascading property of the field is determined based on whether the field is annotated with @Valid. You can refer to the code AnnotationMetaDataProvider#getCascadingMetaData:

private CascadingMetaDataBuilder getCascadingMetaData(Type type, AnnotatedElement annotatedElement,
      Map<TypeVariable<?>, CascadingMetaDataBuilder> containerElementTypesCascadingMetaData) {
   return CascadingMetaDataBuilder.annotatedObject(type, annotatedElement.isAnnotationPresent(Valid.class),
               containerElementTypesCascadingMetaData, getGroupConversions(annotatedElement));
}

In the code above, annotatedElement.isAnnotationPresent(Valid.class) determines whether CascadingMetaDataBuilder#cascading is true or not. If it is true, cascading validation will be performed during the actual validation process, which is similar to the validation process of the host object (Student). It first retrieves the definition based on the object type and then performs validation.

In the current example code, the phone field is not annotated with @Valid, so the cascading property of the field is definitely false. Therefore, when validating the Student object, it will not perform cascading validation on the phone field.

Problem Fix #

From the perspective of the source code, we can understand why nested validation fails. We can see that there is only one solution to make nested validation work, which is to add @Valid to the field, as shown below:

@Valid
private Phone phone;

After fixing the problem, we can see that the validation works. If we debug the fixed code, we can see that the cascading property in the metadata information of the phone field is indeed true. You can refer to the following image:

metadata screenshot

Furthermore, suppose we do not delve into the source code and try to fix this issue using other methods mentioned in Case 1. For example, attempting to fix the issue using @Validated. However, you will find that even without considering whether the source code supports it, the code itself will not compile. This is mainly because @Validated is not allowed to be used on a field:

@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Validated {

By using the above method to fix the problem, we can finally make the nested validation work. However, you may still think that this error seems unlikely to be made. Just imagine that our example only includes one level of nesting, while real-life production code often involves multiple levels of nesting. In this case, can we ensure that each level is not overlooked and @Valid is applied? Therefore, this is still a typical error that requires special attention from you.

Case 3: Misunderstanding of Validation Execution #

Through filling the gaps in the previous two cases, we generally can make parameter validation effective. However, validation itself is sometimes an endless process of improvement. While the validation itself is already in effect, whether it perfectly matches all our stringent requirements is another easily overlooked aspect. For example, we may misunderstand the use of certain validations in practice. Here, we can continue to use the previous case as an example and make a slight modification.

Previously, we defined that the name of a student object should be less than 10 bytes (i.e. @Size(max = 10)). At this point, we may want to improve the validation. For example, we hope that the name cannot be empty. In this case, you might think of modifying the critical line of code as follows:

@Size(min = 1, max = 10)
private String name;

Then, let’s test with the following JSON body:

{
  "name": "",
  "age": 10,
  "phone": {"number":"12306"}
}

The test result meets our expectations. But what if we test with the following JSON body (removing the name field)?

{
  "age": 10,
  "phone": {"number":"12306"}
}

We will find that the validation fails. This result is somewhat surprising and confusing: if @Size(min = 1, max = 10) already requires a minimum of 1 byte, can it not constrain null, but only an empty string (i.e. “”)?

Case Analysis #

If we pay a little more attention, we will notice that the Javadoc of @Size has already explicitly mentioned this situation, as shown in the following image:

Screenshot

As shown in the image, “null elements are considered valid” explains well why null cannot be constrained. Of course, what is learned from the books is always superficial, so we still need to interpret the validation process of @Size from the source code level.

Here, we found the method responsible for executing the @Size constraint, referring to the method SizeValidatorForCharSequence#isValid:

public boolean isValid(CharSequence charSequence, ConstraintValidatorContext constraintValidatorContext) {
    if (charSequence == null) {
        return true;
    }
    int length = charSequence.length();
    return length >= min && length <= max;
}

As shown in the code, when the string is null, the validation is directly passed without any further constraint checks.

Problem Resolution #

Fixing this problem is actually quite simple. We can use other annotations (@NotNull or @NotEmpty) to strengthen the constraint. The revised code is as follows:

@NotEmpty
@Size(min = 1, max = 10)
private String name;

After making this code modification, test it again, and you will find that the constraint now completely meets our requirements.

Key Review #

After reviewing the cases above, we can see that the direct result of these errors is either complete or partial validation failure, which does not cause serious consequences. However, as mentioned at the beginning of this lecture, these errors will affect our user experience. Therefore, we still need to avoid these errors and make the validation stronger!

In addition, @Valid and @Validation are often confusing concepts for us. We don’t know the exact difference between them, and we often wonder if we can use one when the other is available.

Through analysis, we can find that in many scenarios, we don’t necessarily have to rely on search engines to distinguish between them. We just need to study the code a little bit and it will be easier to understand. For example, in case 1, after studying the code, we find that they can not only be interchanged, but we can also create a custom annotation starting with @Valid. However, in case 2, only @Valid can be used to enable cascading validation.

Thought Exercise #

In the above student management system, we have another interface that is responsible for deleting a student’s information based on their student number. The code is as follows:

@RequestMapping(path = "students/{id}", method = RequestMethod.DELETE)
public void deleteStudent(@PathVariable("id") @Range(min = 1,max = 10000) String id){
    log.info("delete student: {}",id);
    //省略业务代码
};

The student number is obtained from the path of the request, and it has a range constraint that it must be between 1 and 10000. Can you identify the type of resolver (HandlerMethodArgumentResolver) responsible for parsing the student number? How is the validation triggered?

We look forward to your thoughts in the comments section!