Main Tutorials

Spring REST Validation Example

In this article, we will enhance the previous Spring REST Hello World example, by adding bean validation and custom validator.

Technologies used :

  • Spring Boot 2.1.2.RELEASE
  • Spring 5.1.4.RELEASE
  • Maven 3
  • Java 8

1. Controller

Review the previous REST Controller again :

BookController.java

package com.mkyong;

import com.mkyong.error.BookNotFoundException;
import com.mkyong.error.BookUnSupportedFieldPatchException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
public class BookController {

    @Autowired
    private BookRepository repository;

    // Find
    @GetMapping("/books")
    List<Book> findAll() {
        return repository.findAll();
    }

    // Save
    @PostMapping("/books")
	@ResponseStatus(HttpStatus.CREATED)
    Book newBook(@RequestBody Book newBook) {
        return repository.save(newBook);
    }

	 // Find
    @GetMapping("/books/{id}")
    Book findOne(@PathVariable Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new BookNotFoundException(id));
    }
	
	//...
}

2. Bean Validation (Hibernate Validator)

2.1 The bean validation will be enabled automatically if any JSR-303 implementation (like Hibernate Validator) is available on the classpath. By default, Spring Boot will get and download the Hibernate Validator automatically.

2.2 The below POST request will be passed, we need to implement the bean validation on the book object to make sure fields like name, author and price are not empty.


	@PostMapping("/books")
    Book newBook(@RequestBody Book newBook) {
        return repository.save(newBook);
    }

curl -v -X POST localhost:8080/books -H "Content-type:application/json" -d "{\"name\":\"ABC\"}"

2.3 Annotate the bean with javax.validation.constraints.* annotations.

Book.java

package com.mkyong;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.math.BigDecimal;

@Entity
public class Book {

    @Id
    @GeneratedValue
    private Long id;

    @NotEmpty(message = "Please provide a name")
    private String name;

    @NotEmpty(message = "Please provide a author")
    private String author;

    @NotNull(message = "Please provide a price")
    @DecimalMin("1.00")
    private BigDecimal price;

    //...
}

2.4 Add @Valid to @RequestBody. Done, bean validation is enabled now.

BookController.java

import javax.validation.Valid;

@RestController
public class BookController {

    @PostMapping("/books")
    Book newBook(@Valid @RequestBody Book newBook) {
        return repository.save(newBook);
    }
	//...
}

2.5 Try to send a POST request to the REST endpoint again. If the bean validation is failed, it will trigger a MethodArgumentNotValidException. By default, Spring will send back an HTTP status 400 Bad Request, but no error detail.


 curl -v -X POST localhost:8080/books -H "Content-type:application/json" -d "{\"name\":\"ABC\"}"
 
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> POST /books HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.55.1
> Accept: */*
> Content-type:application/json
> Content-Length: 32
>
* upload completely sent off: 32 out of 32 bytes
< HTTP/1.1 400
< Content-Length: 0
< Date: Wed, 20 Feb 2019 13:02:30 GMT
< Connection: close
<

2.6 The above error response is not friendly, we can catch the MethodArgumentNotValidException and override the response like this :

CustomGlobalExceptionHandler.java

package com.mkyong.error;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@ControllerAdvice
public class CustomGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    // error handle for @Valid
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
                                                                  HttpHeaders headers,
                                                                  HttpStatus status, WebRequest request) {

        Map<String, Object> body = new LinkedHashMap<>();
        body.put("timestamp", new Date());
        body.put("status", status.value());

        //Get all errors
        List<String> errors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(x -> x.getDefaultMessage())
                .collect(Collectors.toList());

        body.put("errors", errors);

        return new ResponseEntity<>(body, headers, status);

    }

}

2.7 Try again. Done.


curl -v -X POST localhost:8080/books -H "Content-type:application/json" -d "{\"name\":\"ABC\"}"
 
{
	"timestamp":"2019-02-20T13:21:27.653+0000",
	"status":400,
	"errors":[
		"Please provide a author",
		"Please provide a price"
	]
}

3. Path Variables Validation

3.1 We also can apply the javax.validation.constraints.* annotations on the path variable or even the request parameter directly.

3.2 Apply @Validated on class level, and add the javax.validation.constraints.* annotations on path variables like this :

BookController.java

import org.springframework.validation.annotation.Validated;
import javax.validation.constraints.Min;

@RestController
@Validated // class level
public class BookController {

    @GetMapping("/books/{id}")
    Book findOne(@PathVariable @Min(1) Long id) { //jsr 303 annotations
        return repository.findById(id)
                .orElseThrow(() -> new BookNotFoundException(id));
    }

	//...
}

3.3 The default error message is good, just the error code 500 is not suitable.


curl -v localhost:8080/books/0

{
	"timestamp":"2019-02-20T13:27:43.638+0000",
	"status":500,
	"error":"Internal Server Error",
	"message":"findOne.id: must be greater than or equal to 1",
	"path":"/books/0"
}

3.4 If the @Validated is failed, it will trigger a ConstraintViolationException, we can override the error code like this :

CustomGlobalExceptionHandler.java

package com.mkyong.error;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import javax.servlet.http.HttpServletResponse;
import javax.validation.ConstraintViolationException;
import java.io.IOException;

@ControllerAdvice
public class CustomGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(ConstraintViolationException.class)
    public void constraintViolationException(HttpServletResponse response) throws IOException {
        response.sendError(HttpStatus.BAD_REQUEST.value());
    }

	//..
}

curl -v localhost:8080/books/0

{
	"timestamp":"2019-02-20T13:35:59.808+0000",
	"status":400,
	"error":"Bad Request",
	"message":"findOne.id: must be greater than or equal to 1",
	"path":"/books/0"
}

4. Custom Validator

4.1 We will create a custom validator for the author field, only allowing 4 authors to save into the database.

Author.java

package com.mkyong.error.validator;

import javax.validation.Constraint;
import javax.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 = AuthorValidator.class)
@Documented
public @interface Author {

    String message() default "Author is not allowed.";

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

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

}
AuthorValidator.java

package com.mkyong.error.validator;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.Arrays;
import java.util.List;

public class AuthorValidator implements ConstraintValidator<Author, String> {

    List<String> authors = Arrays.asList("Santideva", "Marie Kondo", "Martin Fowler", "mkyong");

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

        return authors.contains(value);

    }
}
Book.java

package com.mkyong;

import com.mkyong.error.validator.Author;

import javax.persistence.Entity;
import javax.validation.constraints.NotEmpty;
//...

@Entity
public class Book {

    @Author
    @NotEmpty(message = "Please provide a author")
    private String author;

	//...

4.2 Test it. If the custom validator is failed, it will trigger a MethodArgumentNotValidException


curl -v -X POST localhost:8080/books 
	-H "Content-type:application/json" 
	-d "{\"name\":\"Spring REST tutorials\", \"author\":\"abc\",\"price\":\"9.99\"}"

{
	"timestamp":"2019-02-20T13:49:59.971+0000",
	"status":400,
	"errors":["Author is not allowed."]
}

5. Spring Integration Test

5.1 Test with MockMvc

BookControllerTest.java

package com.mkyong;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;

import static org.hamcrest.Matchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
public class BookControllerTest {

    private static final ObjectMapper om = new ObjectMapper();

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private BookRepository mockRepository;

    /*
        {
            "timestamp":"2019-03-05T09:34:13.280+0000",
            "status":400,
            "errors":["Author is not allowed.","Please provide a price","Please provide a author"]
        }
     */
    @Test
    public void save_emptyAuthor_emptyPrice_400() throws Exception {

        String bookInJson = "{\"name\":\"ABC\"}";

        mockMvc.perform(post("/books")
                .content(bookInJson)
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.timestamp", is(notNullValue())))
                .andExpect(jsonPath("$.status", is(400)))
                .andExpect(jsonPath("$.errors").isArray())
                .andExpect(jsonPath("$.errors", hasSize(3)))
                .andExpect(jsonPath("$.errors", hasItem("Author is not allowed.")))
                .andExpect(jsonPath("$.errors", hasItem("Please provide a author")))
                .andExpect(jsonPath("$.errors", hasItem("Please provide a price")));

        verify(mockRepository, times(0)).save(any(Book.class));

    }

    /*
        {
            "timestamp":"2019-03-05T09:34:13.207+0000",
            "status":400,
            "errors":["Author is not allowed."]
        }
     */
    @Test
    public void save_invalidAuthor_400() throws Exception {

        String bookInJson = "{\"name\":\" Spring REST tutorials\", \"author\":\"abc\",\"price\":\"9.99\"}";

        mockMvc.perform(post("/books")
                .content(bookInJson)
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.timestamp", is(notNullValue())))
                .andExpect(jsonPath("$.status", is(400)))
                .andExpect(jsonPath("$.errors").isArray())
                .andExpect(jsonPath("$.errors", hasSize(1)))
                .andExpect(jsonPath("$.errors", hasItem("Author is not allowed.")));

        verify(mockRepository, times(0)).save(any(Book.class));

    }

}

5.2 Test with TestRestTemplate

BookControllerRestTemplateTest.java

package com.mkyong;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.json.JSONException;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;

import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // for restTemplate
@ActiveProfiles("test")
public class BookControllerRestTemplateTest {

    private static final ObjectMapper om = new ObjectMapper();

    @Autowired
    private TestRestTemplate restTemplate;

    @MockBean
    private BookRepository mockRepository;

    /*
        {
            "timestamp":"2019-03-05T09:34:13.280+0000",
            "status":400,
            "errors":["Author is not allowed.","Please provide a price","Please provide a author"]
        }
     */
    @Test
    public void save_emptyAuthor_emptyPrice_400() throws JSONException {

        String bookInJson = "{\"name\":\"ABC\"}";

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        HttpEntity<String> entity = new HttpEntity<>(bookInJson, headers);

        // send json with POST
        ResponseEntity<String> response = restTemplate.postForEntity("/books", entity, String.class);
        //printJSON(response);

        String expectedJson = "{\"status\":400,\"errors\":[\"Author is not allowed.\",\"Please provide a price\",\"Please provide a author\"]}";
        assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
        JSONAssert.assertEquals(expectedJson, response.getBody(), false);

        verify(mockRepository, times(0)).save(any(Book.class));

    }

    /*
        {
            "timestamp":"2019-03-05T09:34:13.207+0000",
            "status":400,
            "errors":["Author is not allowed."]
        }
     */
    @Test
    public void save_invalidAuthor_400() throws JSONException {

        String bookInJson = "{\"name\":\" Spring REST tutorials\", \"author\":\"abc\",\"price\":\"9.99\"}";

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        HttpEntity<String> entity = new HttpEntity<>(bookInJson, headers);

        //Try exchange
        ResponseEntity<String> response = restTemplate.exchange("/books", HttpMethod.POST, entity, String.class);

        String expectedJson = "{\"status\":400,\"errors\":[\"Author is not allowed.\"]}";
        assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
        JSONAssert.assertEquals(expectedJson, response.getBody(), false);

        verify(mockRepository, times(0)).save(any(Book.class));

    }

    private static void printJSON(Object object) {
        String result;
        try {
            result = om.writerWithDefaultPrettyPrinter().writeValueAsString(object);
            System.out.println(result);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
    }

}

Download Source Code

$ git clone https://github.com/mkyong/spring-boot.git
$ cd spring-rest-validation
$ mvn spring-boot:run

References

About Author

author image
Founder of Mkyong.com, love Java and open source stuff. Follow him on Twitter. If you like my tutorials, consider make a donation to these charities.

Comments

Subscribe
Notify of
18 Comments
Most Voted
Newest Oldest
Inline Feedbacks
View all comments
Israel Rodrigues
4 years ago

Thanks for this awesome tutorial Mkyong, it helped me a lot <3

Jim H
5 years ago

Starting StartBookApplication returns a java.net.BindException: Address already in use: bind exception. Do I have to use TestRestTemplate? Not finding that class in the spring-boot-master projects.

Hansen
4 years ago
Reply to  Jim H

You mentioned 2 problems:

1. The BindException
Since you probably didn’t change the properties, the application will run on port 8080 by default. The exception tells you, that another application is bound to that port. You can change the port by defining it in your application properties (e.g. server.port=8081). Alternatively you manage to figure out which application is bound to the port and stop it.

2. TestRestTemplate
The class should be part of the spring-boot-test dependency which is defined in the pom.xml. I guess you didn’t build the project. You can do that by using maven.

Mher
1 year ago

In SpringBoot version 2.7.5, only this variant worked.

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity constraintViolationException(ConstraintViolationException ex) {
var body = new HashMap();
body.put(“timestamp”, new Date());
body.put(“status”, HttpStatus.BAD_REQUEST.value());
body.put(“error”, ex.getLocalizedMessage());

return new ResponseEntity(body, HttpStatus.BAD_REQUEST);
}

Anurag
1 year ago

We’re developing a web application using Spring MVC/Boot, and we’ve created custom constraint/validators for both fields and classes. Both work, but the error message isn’t displayed correctly; in the case of a class level validator, it shows Constraint.bean as an error message, and in the case of a field level validator, it shows Constraint.bean.field as an error message. I supply hard coded error messages in the Constraint as the default message and in the bean field where we apply the validator annotation, as well as at the class level validatior annotation in the bean class, but the default error message or overlapped error message does not appear. The custom annotation is Constraint, and the class where we added validation is bean.

It show error messages like

In case of field validator ContactNumberConstraint.EmployeeBean.phone
In case of class level validator ContactNumberConstraint.EmployeeBean
Is there any property we need to set in application.properties file or any other setting we missed, please help to resolve issue.

Scott
2 years ago

I want to save the RequestBody object that has had an exception. how can I access the request body object in the exception handler to do that?

glen
3 years ago

How can I validate the request header value with Min Length >=5 and Max Length <=10?
Thank you.

Babu
4 years ago

Hi Nice explanation, I would like to know how to handle DataIntegrityViolationException in spring and response with specific field name has DataIntegrityViolationException

Sarosh
4 years ago

Hi, I needed some help. I am facing following error
Duplicate annotation for class: interface javax.validation.constraints.NotBlank:

My request class is

public class UserAuthenticationRequestBody {

@NotBlank (message = “username cannot be empty”)
private String username;

@NotBlank (message = “password cannot be empty”)
private String password;
}

and controller is

@PostMapping(path = “/authenticate”, name = “authenticate-user”)
public HttpReponse authenticateUser(HttpServletRequest request,
@Valid @RequestBody UserAuthenticationRequestBody body)
throws Exception {

As soon as I remove @Valid the error disappears but request is not validated as well,
Any suugestions??

Rahul Yadav
4 years ago

Request Body has one parameter such as int data. Where as if i pass extra parameter which is not there in the class it does not throw an error. Why its able to consume the data which is not part of request body.

Kelvin Ho
4 years ago

Thanks for your tutorial.

Eduardo Garcia-Castrillon
4 years ago

Hi! Just to let you know, I added this tutorial code on top of the Spring REST Hello World Example (https://www.mkyong.com/spring-boot/spring-rest-hello-world-example/) and the tests were failing because of the StartBookApplication.initDatabase bean method adding books to the repository, so the times(0) assertions on both test classes failed.

To fix this, I specified the reset mode to the MockBean mockRepository to be BEFORE the test method was invoked:

@MockBean(reset = MockReset.BEFORE)
private BookRepository mockRepository;

That emptied the mock repository and then ran the test successfully.

Abhilash Nair
4 years ago

Thanks. Wondering how to capture whole request body on any exception condition. Right now only respective field details are being captured. Missing price for example etc. How about I need to send info back that for “this book name {name} the prices is missing.. please provide ” . My objective is to send back with some basic info to the client so that he can trace the details later.

Muhammad Anto Berlianto
4 years ago

Hello Mr MK Young,

I’m confiused why in your project solution have file application.properties, and for what the file that? where i look tutorial about file that? because if i use that file, not working.

Mark Nuttall
4 years ago

FYI, i get JSON without the CustomGlobalExceptionHandler with a basic brand new Spring Initializer created project and any other project i have.

Mark Nuttall
4 years ago
Reply to  Mark Nuttall

BTW, i do like your examples and they are helpful! 🙂

Jacob
4 years ago

Thanks a lot!

Rahul
4 years ago

How to handle ConstraintViolationException exception during put and update mapping
javax.validation.ConstraintViolationException: Validation failed for classes [com.mkyong.Book] during update time for groups [javax.validation.groups.Default, ]
List of constraint violations:[
ConstraintViolationImpl{interpolatedMessage=’Please provide a author’, propertyPath=author, rootBeanClass=class com.mkyong.Book, messageTemplate=’Please provide a author’}