DEV Community

Noe Lopez
Noe Lopez

Posted on

Spring Rest Custom Data Validation

Introduction

This short tutorial is a continuation of the previous article Spring Rest/MVC Validation where Bean Validation was introduced.

In this part, we will explore a few more use cases which are quite common in java web apps. You will learn how to do the following validations using JSR 380 from Jakarta EE:

  1. Validate Request Params.
  2. Validate Request Headers.
  3. Validate URL paths.
  4. Create a custom constrain.

Without any further delay, let's start with the first item on the list.

Validate Request Params

We are going to add validation to the DocumentController presented in the Http Client mini series. What we are going to do is implement new functionality to search documents filtered by name. The steps are described here:

  1. Add request param to findDocuments method
  2. Validate min and max length of the name parameter.
  3. Test method via http request.

Updated method code is shown here

@GetMapping
public List<DocumentResponse> findDocuments(@RequestParam
    @Min(value = 3, message = "Name must be at least 3 
                               characters")
    @Max(value = 100, message = "Name must be at most 100 
                                 characters") String name) {
...
}    
Enter fullscreen mode Exit fullscreen mode

Calling the endpoint http://localhost:8080/api/v1/documents?name=na will return documents successfully. Why were the validations not checked?
To enable validation at method level it is necessary to annotate the class with @Validated. This indicates Spring that the class is validated at the method level (parameters as well as return types) using AOP techniques. Also, a MethodValidationPostProcessor bean has to be configured in Spring. If you are using SpringBoot, then it is registered automatically and no extra setup is needed.

@Validated
@RestController
@RequestMapping("api/v1/documents")
public class DocumentController {
Enter fullscreen mode Exit fullscreen mode

Once the annotation is applied to the Controller class, method validation will be triggered. The expected output is generated when the name parameter length is less than 3 characters.

{
    "status": 400,
    "message": "findDocuments.name: Name must be at least 3 
     characters",
    "timestamp": "2023-05-13T18:03:25.4586965"
}
Enter fullscreen mode Exit fullscreen mode

JSR 380 provides @Size annotation, a convinient constrain to enforce min and max length. It can be applied to the below types:

  • CharSequence: length of character sequence is evaluated.
  • Collection: collection size is evaluated.
  • Map: map size is evaluated.
  • Array: array length is evaluated.

Replacing the two annotations with the @Size annotation, the code looks as follows

@GetMapping
public List<DocumentResponse> findDocuments(@RequestParam 
    @Size(min = 3, max = 10, message = "Name must be have at least 
          3 characters and no more than 100.") String name) 
{
Enter fullscreen mode Exit fullscreen mode

Running a new test sends back the response

{
    "status": 400,
    "message": "findDocuments.name: Name must be have at least 3 
characters and no more than 100.",
    "timestamp": "2023-05-13T18:21:11.0345341"
}
Enter fullscreen mode Exit fullscreen mode

Validate Request Headers

The same procedure applies for request headers validation. We can use the annotations from JSR 380. Again, method validation must be enabled so that the constrains declared before the parameter are executed. This step was done in the previous sections.
Lets look at the upload documents use case where the filename header is a mandatory piece of information and must comply with certain rules.

  1. Must not exceed 255 characters.
  2. Must have one dot.
  3. Allow only alphanumeric, numbers, underscore and hyphen.
  4. Extension is alphanumeric between 2 and 4 characters.

This is the perfect case for the @Pattern constrain which employs regular expresions.

@PostMapping()
ResponseEntity<Void> uploadDocument(@RequestBody byte[] data
    ,@RequestHeader("Content-Type") String type
    ,@RequestHeader("fileName") @Pattern(
         regexp = "^([a-zA-Z0-9_-]{2,250})\\.([a-z]{2,4})$", 
         message = "FileName is invalid.") String fileName) {
    ...
}    
Enter fullscreen mode Exit fullscreen mode

Sending the filename header value texttxt will cause the app to return

{
    "status": 400,
    "message": "uploadDocument.fileName: FileName is invalid.",
    "timestamp": "2023-05-13T19:18:04.5942804"
}
Enter fullscreen mode Exit fullscreen mode

Validate URL paths

The setup is just as the previous two sections. Then constrains can be placed right after the @PathVariable annotation. Lets add some validations to the download document method. This method extracts the id from the URL into a Long. Ids are positive and cannot exceed a max value.

ResponseEntity<Resource> downloadDocument(@PathVariable
    @Positive(message = "Document id must be positive.")
    @Max(value = 99999999, message = "Document id cannot exceed 
        value 99999999.") Long documentId) {
...
}    
Enter fullscreen mode Exit fullscreen mode

Making a http request to http://localhost:8080/api/v1/documents/-1 produces

{
    "status": 400,
    "message": "downloadDocument.documentId: Document id must be 
positive.",
    "timestamp": "2023-05-13T19:44:21.8745695"
}
Enter fullscreen mode Exit fullscreen mode

Creating a custom constrain

In this section, we are going to create a custom constrain which performs custom validation based on our business rules.
As an example (do not this in production), lets suppose we want to only permit to upload a specific set of extensions. We could create our own annotation @AllowedExtensions. The procedure is explained below

  1. Create @AllowedExtensions annotation.
  2. Create AllowedExtensionsConstrainValidator.
  3. Setting the annotation in the parameter.

Create @AllowedExtensions annotation

An annotation is defined with the @interface keyword.

public @interface AllowedExtensions {}
Enter fullscreen mode Exit fullscreen mode

Next is to setup some meta-annotations on top of it. They are needed to speficy some properties and behaviour of our annotation.

@Documented
@Target({ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint( 
     validatedBy = AllowedExtensionsConstrainValidator.class)
public @interface AllowedExtensions {}
Enter fullscreen mode Exit fullscreen mode

Lets see what each meta-annotation does in the above code:

  • @Documented ensures that our custom annotations are shown in the documentation.
  • @Retention annotation sets the scope of the annotation. How long the annotation will be stored. RetentionPolicy.RUNTIME will keep the annotation in the bytecode (class file) and in runtime (JVM) as the annotation is run by our application.
  • @Target annotation indicates where it can be applied. In our case, the annotation will be applied on parameters and fields.
  • @Constraint annotation marks an annotation as being a Jakarta Bean Validation constraint. It defines the validator class implementing our own custom rules to pass the validation.

Next step is to define the attributes the annotation can accept. There will be two attributes, the value containing the extensions and the message in case the validation fails.

@Documented
@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = AllowedExtensionsConstrainValidator.class)
public @interface AllowedExtensions {
    public String[] value() default {"pdf","txt","png"};

    public String message() default "File extension must be pdf, 
    txt or png";

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

    public Class<? extends Payload>[] payload() default {};
}
Enter fullscreen mode Exit fullscreen mode

Value is a just an String array with the default extensions. Likewise, message defines the default message. Annotation default values have the default clause and can return a value of the same type.
It is also needed two more entries for groups and payload. Groups can group validation constrains together. Payload can give additional information about the validation error such as severity level or error code.

That is all for the annotation AllowedExtensions.

Create AllowedExtensionsConstrainValidator class

This class will perform the validation for our annotation. It has to implement the interface ConstraintValidator and also the isValid method. In this method our own logic for validations are defined.

ConstraintValidator defines the logic to validate a given constraint A for a given object type T. Implementations must comply to the following restriction:

T must resolve to a non parameterized type
or generic parameters of T must be unbounded wildcard types

In our case, the first type is the annotation AllowedExtensions and the second is String which is the input data to be validated (the filename).

Class code is available in the snippet below

public class AllowedExtensionsConstrainValidator implements ConstraintValidator<AllowedExtensions, String> {
    private String[] extensions;

    @Override
    public void initialize(AllowedExtensions allowedExtensions) {
        this. extensions = allowedExtensions.value();
    }

    @Override
    public boolean isValid(String fileName, 
        ConstraintValidatorContext constraintValidatorContext) {
        return Arrays.asList(extensions). 
        contains(fileName.substring(fileName.lastIndexOf(".")) 
        .toLowerCase());
    }
}
Enter fullscreen mode Exit fullscreen mode

Initialize method initializes the validator in preparation for isValid. The parameter is the constrain annotation. The allowed extensions are assigned to a member variable so that they are available when the isValid method is invoked.

The isValid method verifies the file extension is on the list of valid extensions.

We are done with the constrain validator. Now it is time to use the validation.

Setting the annotation in the parameter

At this point, all that is needed is to place the annotation in the filename parameter.

@PostMapping()
ResponseEntity<Void> uploadDocument(@RequestBody byte[] data,
    @RequestHeader("Content-Type") String type,
    @RequestHeader("fileName") 
    @Pattern(regexp = "^([a-zA-Z0-9_-]\{2,200})\\.([a-z]{3,4})$", 
             message = "FileName is invalid.")
    @AllowedExtensions String fileName) {
...
}
Enter fullscreen mode Exit fullscreen mode

Time to test it out sending text.doc in the header fileName of the http request.

{
    "status": 400,
    "message": "uploadDocument.fileName: File extension must be 
pdf, txt or png",
    "timestamp": "2023-05-13T22:51:13.6404863"
}
Enter fullscreen mode Exit fullscreen mode

Another test, this time passing the value text.txt. We get back status 201 and the location of the newly created file.

201
http://localhost:8080/api/v1/documents/4
Enter fullscreen mode Exit fullscreen mode

Summary

In this tutorial, we have learned how to validate request params, request headers, dynamic paths and create our own custom constrains. Now you have the tools to start using Bean Validation in any layer of your application and benefit from its clean and simple approach.

As usual the source code used in this article can be found in the github repo by cliking here

Top comments (0)