Dernière modification : 24/08/2022

Spring REST - Validation

Dans ce ticket nous allons voir comment utiliser les validations natives avec Spring Boot. Les technologies utilisées sont les suivantes :

  • Spring Boot 2
  • Maven
  • Java 11

Prérequis : Il est nécessaire de générer un projet Spring Boot via le site https://start.spring.io . Ajouter Spring web, JPA, h2 pour la persistance des données puis validation pour la validation des données.

Nous créerons une entité voiture, contenant des informations de validation qui seront testées lors de l'appel au controller. Nous en profiterons pour stocker les informations des voitures dans une base de données h2 pour pouvoir être récupérées.

1. Création de l'entité d'exemple Car

@Entity
public class Car {
	@Id
	@GeneratedValue
	private Long id;
	
    @NotEmpty(message = "Please provide a model")
	private String model;
	
    @NotEmpty(message = "Please provide a registration")
	private String registration;

	public Long getId() {
		return id;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public String getModel() {
		return model;
	}

	public void setModel(String model) {
		this.model = model;
	}

	public String getRegistration() {
		return registration;
	}

	public void setRegistration(String registration) {
		this.registration = registration;
	}
}

Remarque : Des annotations @NotEmpty sont présentes. Elles seront utiles pour la validation des champs. Elles décrivent un champ comme ne devant pas être vide.

2. Création du repository

Repository de l'entité Car :

public interface CarRepository extends JpaRepository{}

3. Création du service

Service appelant le repository de l'entité Car :

@Service
public class CarService {
	@Autowired
	private CarRepository carRepository;
	
	public List findAll(){
		return this.carRepository.findAll();
	}
	
	public Car save(Car theCar){
		return this.carRepository.save(theCar);
	}
	
	public Optional findById(Long theId){
		return this.carRepository.findById(theId);
	}
}

4. Création du controller

@Controller
public class CarController {
	@Autowired
	private CarService carService;

	@GetMapping("/cars")
	public ResponseEntity> cars() {
		return ResponseEntity.ok(this.carService.findAll());
	}

	@PostMapping("/car")
	@ResponseStatus(HttpStatus.CREATED)
	public ResponseEntity car(@Valid @RequestBody Car car) {
		return ResponseEntity.ok(this.carService.save(car));
	}
}

5. Test et @ControllerAdvice

Si nous tentons de vérifier le bon fonctionnement des validations, nous observerons une erreur 400 en cas d'absence de valeur lors de l'enregistrement d'une voiture.

$ curl -v -X POST localhost:8080/car -H "Content-type:application/json" -d "{\"model\":\"ABC\"}"
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> POST /car HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.64.1
> Accept: */*
> Content-type:application/json
> Content-Length: 15
> 
* upload completely sent off: 15 out of 15 bytes
< HTTP/1.1 400 
< Content-Length: 0
< Date: Fri, 08 Jan 2021 17:47:10 GMT
< Connection: close
< 
* Closing connection 0

Afin d'avoir un résultat JSON sur les problèmes rencontrés lors de l'enregistrement, nous allons nous aider d'un controller qui sera appelé dans le cas d'une exception du type MethodeArgumentNotValidException :

@ControllerAdvice
public class CustomGlobalExceptionHandler extends ResponseEntityExceptionHandler {

	@Override
	protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
			HttpHeaders headers, HttpStatus status, WebRequest request) {

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

		// Get all errors
		List errors = ex.getBindingResult().getFieldErrors().stream().map(FieldError::getDefaultMessage)
				.collect(Collectors.toList());

		body.put("errors", errors);

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

Si nous réessayons d'insérer une voiture dans la base de données, avec des paramètres erronés, nous obtenons un JSON en sortie avec les erreurs associées :

$ curl -v -X POST localhost:8080/car -H "Content-type:application/json" -d "{\"model\":\"ABC\"}"
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> POST /car HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.64.1
> Accept: */*
> Content-type:application/json
> Content-Length: 15
> 
* upload completely sent off: 15 out of 15 bytes
< HTTP/1.1 400 
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Fri, 08 Jan 2021 17:58:28 GMT
< Connection: close
< 
* Closing connection 0
{"timestamp":"2021-01-08T17:58:27.990+00:00","status":400,"errors":["Please provide a registration"]}%

6. Validation dans les paramètres du controller

Il est possible de créer une validation directement en paramètre du controller :

@GetMapping("/car/{id}")
public ResponseEntity car(@PathVariable @Min(1) Long id) {
	return ResponseEntity.ok(this.carService.findById(id).orElse(null));
}

Remarque : Il est nécessaire de metre l'annotation @Validated au controller.

Dans le cas d'une récupération d'une voiture, si le paramètre id est inférieur à 1, alors une erreur 500 est levée (et non 400). Une exception est également disponible dans la console lorsque le paramètre ne valide pas la validation.

Pour modifier ce comportement, nous allons ajouter une méthode pour capturer les exceptions du type ConstraintViolationException dans la classe CustomGlobalExceptionHandler :

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity> constraintViolationException(
		ConstraintViolationException constraintViolationException) {
	Map body = new LinkedHashMap<>();
	body.put("timestamp", new Date());
	body.put("status", HttpStatus.BAD_REQUEST.value());

	// Get all errors
	List errors = constraintViolationException.getConstraintViolations().stream()
			.map(x -> x.getPropertyPath() + " " + x.getMessage()).collect(Collectors.toList());

	body.put("errors", errors);
	return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}

Désormais, dans le cas d'un échec à cause du paramètre, le code d'erreur retourné sera 400 et non 500.

7. Test

Test avec TestRestTemplate :

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class CarControllerRestTemplateTest {

	@Autowired
	private TestRestTemplate restTemplate;

	@Test
	void save_emptyModel_emptyRegistration_400() throws JSONException {
		// GIVEN
		String CarJson = "{}";
		String expectedJson = "{\"status\":400,\"errors\":[\"Please provide a model\",\"Please provide a registration\"]}";

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

		// WHEN
		// send json with POST
		ResponseEntity response = restTemplate.postForEntity("/car", entity, String.class);

		// THEN
		assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
		JSONAssert.assertEquals(expectedJson, response.getBody(), false);
	}

	@Test
	void save_fullModel_200() throws JSONException {
		// GIVEN
		String CarJson = "{\"model\":\"ABC\",\"registration\":\"ABC\"}";

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

		// WHEN
		// send json with POST
		ResponseEntity response = restTemplate.postForEntity("/car", entity, String.class);

		// THEN
		assertEquals(HttpStatus.OK, response.getStatusCode());
	}
}