Search code examples
javaspring-mvcthymeleaf

How to perform custom validations in Spring MVC?


I have the following code that allows a user to update name and year.

Model

@Entity
public class Person implements Serializable{

    private static final long serialVersionUID = 1L;

    private String name;

    private int year;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getYear() {
        return year;
    }

    public void setYear(int year) {
        this.year = year;
    }

}

Controller

@RequestMapping(value = "/person", method = RequestMethod.POST)
public ModelAndView editPerson(@ModelAttribute Person person)
{
    //perform operations

    //display results
    ModelAndView modelAndView = new ModelAndView("Person.html");
    modelAndView.addObject("personBind", person);
        
    return modelAndView;

}

View

<form action="person" method="post" th:object="${personBind}">
Name:
<input type="text" th:field="*{name}" />

Year:   
<input type="text" th:field="*{year}" />

Now I want to do some validation in the year field. For instance, if the user inputs a string instead of a number on that field, the current code will throw an exception because it doesn't allow to set a string in an integer attribute.

So how to validate the input? Don't want to use @Valid. Want to do some custom validations.

The way I found to do this is to create a string version of the year field in the model (getter/setter). Then use that strYear in the view and do the validation in the controller. Like the updated code below. Is that the correct approach or there's a better way to do this? I ask because not sure if it's correct to create a string version of getters/setters for every numeric attribute that needs to be validated. Seems a lot of duplication.

Model

@Entity
public class Person implements Serializable{

    private static final long serialVersionUID = 1L;

    private String name;

    private int year;

    @Transient
    private String strYear;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getYear() {
        return year;
    }

    public void setYear(int year) {
        this.year = year;
    }    
    
    public String getStrYear() {
        return strYear;
    }

    public void setStrYear(String strYear) {
        this.strYear = strYear;
    }

}

Controller

@RequestMapping(value = "/person", method = RequestMethod.POST)
public ModelAndView editPerson(@ModelAttribute Person person)
{
    //validate
    boolean valid = Validate(Person.getStrYear());

    if(valid==true)
    {
      Person.setYear(Integer.ParseInt(Person.getStrYear()));
      //save edit
    }
    else
    {//display validation messages}

    //display results
    ModelAndView modelAndView = new ModelAndView("Person.html");
    modelAndView.addObject("personBind", person);
        
    return modelAndView;

}

View

<form action="person" method="post" th:object="${personBind}">
Name:
<input type="text" th:field="*{name}" />

Year:   
<input type="text" th:field="*{strYear}" />

Solution

  • Validation can be tricky and difficult, some things to take into account when doing validation...

    Validation Considerations

    • The model in MVC (Model, View, Controller) does not, and generally should not, be the same as your Domain Model. See @wim-deblauwe's comment and the Q&A section below.

      • Often times what is displayed in the user interface is different than what is available inside the Domain Model.
      • Placing @Valid annotations into your Domain Model means that in every form that Domain Model is used, the same @Valid rules will apply. This is not always true. Side Note: This might not apply to super simple CRUD (Create, Read, Update, Delete) applications, but in general, most applications are more sophisticated that just pure CRUD.
    • There are SERIOUS security issues with using a real Domain Model object as the form backing object due to the way that Spring does auto setting of values during form submittal. For example, if we are using a User object that has a password field on it as our form backing object, the form could be manipulated by browser developer tools to send a new value for the password field and now that new value will get persisted.

    • All data entered via an html form is really String data that will need to get transposed to its real data type (Integer, Double, Enumeration, etc...) later.

    • There are different types of validation that, in my opinion, need to happen in different temporal order.

      • Required checks happen before type checking (Integer, Double, Enumeration, etc...), valid value ranges, and then finally persistence checks (uniqueness, previous persisted values, etc...)
      • If there are any errors in a temporal level, then don't check anything later.
        • This stops the end user from getting errors like, phone number is required, phone number isn't a number, phone number isn't formatted correctly, etc... in the same error message.
    • There shouldn't be any temporal coupling between validators. Meaning that if a field is optional, then 'data type' validator shouldn't fail validation if a value isn't present. See the validators below.

    Example

    Domain Object / Business Object:

    @Entity
    public class Person {
    
        private String identifier;
        private String name;
        private int year;
    
        public String getIdentifier() {
            return identifier;
        }
    
        public String getName() {
            return name;
        }
    
        public void setName(String name) {
            this.name = name;
        }
    
        public int getYear() {
            return year;
        }
    
        public void setYear(int year) {
            this.year = year;
        }    
    }
    

    To populate an html form via a Spring MVC Controller we would create a specific object that represents that form. This also includes all the validation rules as well:

    @GroupSequence({Required.class, Type.class, Data.class, Persistence.class, CreateOrUpdatePersonForm.class})
    public class CreateOrUpdatePersonForm {
    
        @NotBlank(groups = Required.class, message = "Name is required.")
        private String name;
    
        @NotBlank(groups = Required.class, message = "Year is required.")
        @ValidInteger(groups = Type.class, message = "Year must be a number.")
        @ValidDate(groups = Data.class, message = "Year must be formatted yyyy.")
        private String year;
    
        public CreateOrUpdatePersonForm(Person person) {
            this.name = person.getName();
            this.year = Integer.valueOf(person.getYear);
        }
    
        public String getName() {
            return name;
        }
    
        public void setName(String name) {
            this.name = name;
        }
    
        public String getYearStr() {
            this.year;
        }
    
        public void setYearStr(String year) {
            this.year = year;
        }
    
        public int getYear() {
            return Integer.valueOf(this.year);
        }
    }
    

    Then in your controller to use the new CreateOrUpdatePersonForm object:

    @Controller
    public class PersonController {
        ...
        @ModelAttribute("command")
        public CreateOrUpdatePersonForm setupCommand(@RequestParam("identifier") Person person) {
    
            return new CreateOrUpdatePersonForm(person);
        }
    
        //@PreAuthorize("hasRole('ADMIN')")
        @RequestMapping(value = "/person/{person}/form.html", method = RequestMethod.GET)
        public ModelAndView getForm(@RequestParam("person") Person person) {
    
            return new ModelAndView("/form/person");
        }
    
        //@PreAuthorize("hasRole('ADMIN')")
        @RequestMapping(value = "/person/{person}/form.html", method = RequestMethod.POST)
        public ModelAndView postForm(@RequestParam("person") Person person, @ModelAttribute("command") @Valid CreateOrUpdatePersonForm form,
                                     BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    
            ModelAndView modelAndView;
    
            if (bindingResult.hasErrors()) {
    
                modelAndView = new ModelAndView("/form/person");
    
            } else {
    
                this.personService.updatePerson(person.getIdentifier(), form);
    
                redirectAttributes.addFlashAttribute("successMessage", "Person updated.");
    
                modelAndView = new ModelAndView("redirect:/person/" + person.getIdentifier() + ".html");
            }
    
            return modelAndView;
        }
    }
    

    The @ValidInteger and @ValidDate are validators that we wrote ourselves.

    @ValidInteger:

    public class ValidIntegerValidator implements ConstraintValidator<ValidInteger, String> {
    
        @Override
        public void initialize(ValidInteger annotation) {
    
        }
    
        @Override
        public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
    
            boolean valid = true;
    
            if (StringUtils.hasText(value)) {
    
                try {
                    Integer.parseInteger
    (value);
    
                } catch (NumberFormatException e) {
    
                    valid = false;
                }
            }
    
            return valid;
        }
    }
    
    @Target({METHOD, FIELD})
    @Retention(RUNTIME)
    @Constraint(validatedBy = ValidIntegerValidator.class)
    @Documented
    public @interface ValidInteger {
    
        String message() default "{package.valid.integer}";
    
        Class<?>[] groups() default {};
    
        Class<? extends Payload>[] payload() default {};
    }
    

    @ValidDate

    public class ValidDateValidator implements ConstraintValidator<ValidDate, String> {
    
        private String format;
    
        @Override
        public void initialize(ValidDate annotation) {
            this.format = annotation.format();
        }
    
        @Override
        public boolean isValid(String inputDate, ConstraintValidatorContext constraintValidatorContext) {
    
            boolean valid = true;
    
            if (StringUtils.hasText(inputDate)) {
    
                SimpleDateFormat dateFormat = new SimpleDateFormat(format);
    
                dateFormat.setLenient(false);
    
                try {
    
                    dateFormat.parse(inputDate);
    
                } catch (ParseException e) {
    
                    valid = false;
                }
            }
    
            return valid;
        }
    }
    
    @Target({METHOD, FIELD})
    @Retention(RUNTIME)
    @Constraint(validatedBy = ValidDateValidator.class)
    @Documented
    public @interface ValidDate {
    
        String message() default "{package.dateformat}";
    
        Class<?>[] groups() default {};
    
        Class<? extends Payload>[] payload() default {};
    
        String format();
    }
    

    Then in your view jsp or template you'll need to display the errors if there are any:

    <html>
        ...
        <body>
            <common:form-errors modelAttribute="command"/>
            ...
        </body>
    </html>
    

    There is a lot more to deal with validation, like comparing two fields together, or accessing your persistence layer to verify that a person name is unique, but that takes a lot more explanation.

    Q & A

    Q: Can you provide links that explain the thought behind not using the Domain Model as the MVC Model?

    A: Sure, Entities VS Domain Models VS View Models and Entity vs Model vs View Model

    TL;DR: Using different objects for the Domain Model and the MVC Model is because it reduces coupling between application layers and it protects our UI and Domain Model from changes in either layer.

    Other Considerations

    Validation of data needs to occur at all entry points to an application: UI, API, and any external systems or files that are read in.

    An API is just a UI for computers and needs to follow the same rules as the human UI.

    Accepting data from the internet is fraught with peril. It is better to be more restrictive than less restrictive. This also includes making sure that there aren't any strange characters coughMicrosoft's 1252 character encodingcough, Sql Injection, JavaScript Injection, making sure that your database is setup for unicode and understanding that a column that is setup for 512 characters, depending on the language can only actually handle 256 characters due to codepoints.