Spring Boot: Java Data Validation Best Practices

Data validation helps screen out invalid client data and makes the code easier to maintain. Jakarta validation provides various annotations for validating data, such as NotEmpty or Digits. These annotations provide flexible parameters for custom messages. The developer can create custom validation annotations for implementation with Jakarta validation.

Scenario

The developer team creates RESTful web services for creating user profiles.

Discussion

The developer team decided to use Jakarta validation to check screen data from the request body and prevent invalid data, such as invalid email patterns or empty required fields.

Implementation

The developer team created RESTful Web Services using Spring Boot 3 and integrated them with Java data validation.

Testing

The developer team can test RESTful Web Services using Spring Boot 3 with Postman.

Spring Boot validation maven dependency.

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

Create RESTful Web Services using Spring Boot.

1. Create a controller class UserProfilesController.

import com.example.demo.bean.UserProfileBean;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserProfilesController {
    @PostMapping(path = "/user-profiles", produces = "application/json")
    public ResponseEntity<String> create(@Valid @RequestBody UserProfileBean userProfile) {
        //custom process
        return new ResponseEntity<>("created", HttpStatus.CREATED);
    }

}

Valid annotation is for this method using Java data validation.
RequestBody annotation using UserProfileBean as a request body.

2. Create a Java bean for the request body.

import com.example.demo.validator.PersonIdValidate;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Data
public class UserProfileBean {

    @PersonIdValidate
    private String personalId;

    @NotEmpty
    private String firstName;

    @NotEmpty
    private String lastName;

    @Email
    private String email;

    @Min(0)
    private long age;

    public UserProfileBean(String personalId, String firstName, String lastName, int age, String email){
        this.personalId = personalId;
        this.firstName = firstName;
        this.lastName = lastName;
        this.email =  email;
        this.age = age;
    }
}

This class contains Java data validation to screen out invalid data from clients. PersonIdValidate annotation is a custom validation.

3. Create custom validation for validating personal ID.

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
class PersonIdValidator implements ConstraintValidator<PersonIdValidate, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return validatePersonId(value);
    }

    public boolean validatePersonId(String pid) {
        //custom validation personal ID
        return !pid.isEmpty();
    }
}
import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Target({ FIELD })
@Retention(RUNTIME)
@Constraint(validatedBy = PersonIdValidator.class)
@Documented
public @interface PersonIdValidate {

    String message() default "invalid personal ID or personal ID is empty.";

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

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

}

4. Create an exception handler for MethodArgumentNotValidException

import com.example.demo.bean.CommonResponseBean;
import com.example.demo.bean.ErrorBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.ArrayList;
import java.util.Date;

@RestControllerAdvice
public class CommonExceptionHandlers  {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ResponseEntity<CommonResponseBean> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
        // Customize the response entity
        CommonResponseBean res = new CommonResponseBean();
        res.setTimestamp(new Date().getTime());
        res.setErrors(new ArrayList<>());
        res.getErrors().add(new ErrorBean("E0002",ex.getMessage()));
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(res);
    }
}

Test case scenario

1. Test case: Send a request to path /user-profiles.

Request and response on Postman.

Test result: web services response HTTP status 201 and request body “created”.

2. Test case: Send a request to path /user-profiles with an empty personal ID.

Request and response on Postman.

Test result: Web services respond to HTTP status 400 and request body with error information, not an empty personal ID.

3. Test case: Send a request to path /user-profiles with an empty first name.

Request and response on Postman.

Test result: Web services respond to HTTP status 400 and request body with error information, not an empty first name.

4. Test case: Send a request to path /user-profiles with an invalid email pattern.

Request and response on Postman.

Test result: Web services respond to an HTTP status 400 with an error message and an invalid email in the request body.

The error message from “MethodArgumentNotValidException” is complex for the client to read. The developer must customize the message error from “MethodArgumentNotValidException” in the exception handler class.

Custom an error message from “MethodArgumentNotValidException.”

The developer can customize the rest of the controller advice annotation in the CommonExceptionHandlers class.

@RestControllerAdvice
public class CommonExceptionHandlers {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ResponseEntity<CommonResponseBean> methodArgumentNotValidException(MethodArgumentNotValidException ex) {
        List<ErrorBean> message = new ArrayList<>();
        // Get field name
        ex.getFieldErrors().forEach(error -> {
                    if (null == error.getDefaultMessage()) return;
                    message.add(new ErrorBean("E0001",error.getField() +", "+ error.getDefaultMessage()));
                }
        );
        CommonResponseBean res = new CommonResponseBean();
        res.setTimestamp(new Date().getTime());
        res.setErrors(message);
        return new ResponseEntity<>(res, HttpStatus.BAD_REQUEST);
    }
}

The developer can create a custom message by reading each error message in the MethodArgumentNotValidException class and adding it to the list of error beans. Each validation can have multiple fields. Java data validation screens data in all fields in the process.

Custom error message Java data validation in UserProfileBean

The developer can use the default error message from Java data validation or a custom error message. This custom can integrate with message properties to support multiple languages.

@Getter
@Setter
public class UserProfileBean {

    @PersonIdValidate
    private String personalId;

    @NotEmpty(message = "This is a custom message.")
    private String firstName;

    @NotEmpty
    private String lastName;

    @Email
    private String email;

    @Min(0)
    private long age;

    public UserProfileBean(String personalId, String firstName, String lastName, int age, String email){
        this.personalId = personalId;
        this.firstName = firstName;
        this.lastName = lastName;
        this.email =  email;
        this.age = age;
    }
}

Test case scenario

1. Test case: Send a request to path /user profiles with an empty personal ID, first name, and an invalid email pattern.

Request and response on Postman.

Test result: Web services respond to HTTP status 400 and request body with multiple field error messages.

Custom error messages support multiple languages.

The developer can customize the error message to support multiple languages by following this step.

1. Create messages_fr.properties to support the French language.

not.empty=pas vide

2. Create messages_ja.properties to support the Japanese language.

not.empty=空ではない

3. Create MessageUtils to manipulate message codes from the error messages.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Component;

import java.util.Locale;

@Component
public class MessageUtils {

    private final MessageSource messageSource;

    public MessageUtils(MessageSource messageSource) {
        this.messageSource = messageSource;
    }

    public String getMessage(String code, Object[] args, Locale locale) {
        return messageSource.getMessage(code, args, locale);
    }
}

Modify the CommonExceptionHandlers class to support accepting language from the request header and get messages from the message code.

@RestControllerAdvice
public class CommonExceptionHandlers {

    private final MessageUtils messageUtils;

    private Locale locale;

    @Autowired
    public CommonExceptionHandlers(MessageUtils messageUtils) {
        this.messageUtils = messageUtils;
    }

    @ModelAttribute
    public void addAcceptLanguageHeader(@RequestHeader("Accept-Language") String acceptLanguage, HttpServletRequest request) {
        // Parse the Accept-Language header to get the preferred locale
        this.locale =  Locale.lookup(Locale.LanguageRange.parse(acceptLanguage),Arrays.asList(Locale.FRENCH, Locale.JAPANESE, Locale.getDefault()));
    }


    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ResponseEntity<CommonResponseBean> methodArgumentNotValidException(MethodArgumentNotValidException ex) {
        List<ErrorBean> message = new ArrayList<>();
        // Get field name
        ex.getFieldErrors().forEach(error -> {
                    if (null == error.getDefaultMessage()) return;
                    message.add(new ErrorBean("E0001", error.getField() + ", " + messageUtils.getMessage(error.getDefaultMessage(), new Object[]{}, locale)));
                }
        );
        CommonResponseBean res = new CommonResponseBean();
        res.setTimestamp(new Date().getTime());
        res.setErrors(message);
        return new ResponseEntity<>(res, HttpStatus.BAD_REQUEST);
    }
}

Test case scenario

1. Test case: Send a request to path /user-profiles with an empty first name and accept-language header value “fr-FR”.

Request and response on Postman.

Test result: Web services respond to HTTP status 400 and the request body with error messages in French.

2. Test case: Send a request to path /user profiles with an empty first name and the accept-language header value “ja-JP.”

Request and response on Postman.

Test result: Web services respond to HTTP status 400 and the request body with error messages in Japanese.

Finally

The developer should be aware of all annotations provided by Java data validation to prevent wasting time creating custom validation that is redundant with those already offered by Java.

This article was originally published on Medium.

Leave a Comment

Your email address will not be published. Required fields are marked *