Spring Boot: validarea datelor și erori HTTP
Validează cererile unui API Spring Boot cu Jakarta Validation și Valid. Învață să diferențiezi erorile 400 și 404 și să păstrezi datele corecte.
În acest material
Un API trebuie să decidă ce acceptă înainte să salveze datele. În proiectul nostru, titlul este obligatoriu și are cel mult 80 de unități UTF-16. Vom transforma cererile invalide în răspunsuri 400 și vom păstra resursele inexistente ca răspunsuri 404.
Punctul de plecare
Continuă API-ul REST în memorie. Folosim Java 21, Spring Boot 4.1.1 și dependența spring-boot-starter-validation deja prezentă în POM. Nu copia importuri javax.validation din tutoriale vechi: această serie folosește namespace-ul jakarta.validation.
Regula de lungime este deliberat precisă. Un String Java măsoară prin length() unități UTF-16, care nu corespund mereu caracterelor percepute de utilizator. Pentru titluri obișnuite diferența poate trece neobservată, dar contractul și testele nu trebuie să promită altă unitate de măsură.
Validează obiectul de intrare
Înlocuiește CreateTask.java cu:
package ro.algo25.tasks;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateTask(@NotBlank @Size(max = 80) String title) {}
@NotBlank respinge null, textul gol și textul format numai din spații albe recunoscute de validator. @Size impune limita lungimii, dar nu este un înlocuitor pentru regula de nenul. Combinația exprimă două condiții diferite.
Înlocuiește controllerul cu această versiune, care adaugă @Valid pe corpul cererii:
package ro.algo25.tasks;
import java.net.URI;
import java.util.List;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService service;
public TaskController(TaskService service) {
this.service = service;
}
@GetMapping
public List<TaskView> list() {
return service.list();
}
@GetMapping("/{id}")
public TaskView get(@PathVariable long id) {
return service.get(id);
}
@PostMapping
public ResponseEntity<TaskView> create(@Valid @RequestBody CreateTask request) {
TaskView created = service.create(request.title());
return ResponseEntity.created(URI.create("/api/tasks/" + created.id())).body(created);
}
}
Adnotările de pe record nu sunt executate de compilator și nu validează automat orice apel new CreateTask(...). În acest flux, infrastructura Spring MVC pornește validarea pentru parametrul marcat. Dacă alt cod apelează direct serviciul, granița HTTP nu îl protejează automat.
Alege o eroare publică stabilă
Înlocuiește ApiErrors.java cu:
package ro.algo25.tasks;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiErrors {
@ExceptionHandler(TaskMissing.class)
public ProblemDetail missing(TaskMissing exception) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "Sarcina nu există.");
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail invalid(MethodArgumentNotValidException exception) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
"Titlul este obligatoriu și are cel mult 80 de caractere UTF-16.");
}
}
ProblemDetail oferă o structură pentru probleme HTTP. În acest exemplu păstrăm un mesaj stabil pentru regula titlului. Nu copiem stack trace-ul și nu includem valoarea invalidă în mesaj. Clientul poate distinge statusul 400 de 404 fără să parseze propoziții destinate oamenilor.
Această clasă particularizează două tipuri de eroare. Un JSON malformat sau un ID care nu poate fi convertit în număr este tratat de infrastructura MVC. Nu pretindem că toate erorile posibile au deja exact aceeași formă. Un contract uniform pentru toate erorile ar necesita tratarea și verificarea explicită a acelor cazuri.
Verifică lipsa efectelor secundare
Trimite un POST cu {"title":" "}, apoi cere lista. Numărul sarcinilor trebuie să rămână neschimbat. Un răspuns 400 este insuficient dacă datele au fost deja salvate. Mai încearcă un corp {}, un titlu cu 81 de litere și un corp JSON incomplet.
La limita de 80, un titlu ASCII nenul trebuie acceptat. La 81 trebuie respins. Lungimea este verificată înainte de eventuala eliminare a spațiilor de la capete dintr-o etapă ulterioară. Declararea ordinii dintre normalizare și validare evită situațiile în care două implementări acceptă date diferite.
Exersează
- Compară
{}cu{"title":""}. Ambele sunt invalide, deși una omite câmpul și cealaltă trimite un text gol. - Adaugă teste pentru exact 80 și exact 81 de litere. Rezultatele trebuie să fie 201, respectiv 400.
- Explică ce se întâmplă dacă serviciul este apelat dintr-un job intern. Răspuns: trebuie stabilită validarea acelei intrări;
@Validde pe controller nu se aplică apelului intern.
Verifică înainte să continui
Poți continua dacă separi parsarea JSON, validarea datelor și regulile de business și dacă verifici că o cerere respinsă nu creează resursa. Urmează JPA, repository și tranzacții, unde schimbăm stocarea păstrând contractul HTTP.
Întrebări frecvente
Size respinge singur null?
Nu. De aceea păstrăm și regula NotBlank pentru titlul obligatoriu.
Validarea este autorizare?
Nu. Validarea verifică forma datelor. Autorizarea verifică dreptul unui utilizator de a executa operația.
Trebuie să afișez clientului mesajul excepției?
Nu automat. Alege o reprezentare publică deliberată și păstrează detaliile tehnice într-un canal de diagnostic adecvat.
Un apel direct al serviciului este validat de controller?
Nu. Sunt căi de intrare diferite. Definește unde se verifică fiecare invariant.
Avem deja aceeași structură pentru toate erorile?
Nu. Articolul particularizează validarea DTO-ului și resursa absentă; celelalte situații trebuie analizate separat.
Surse
RequestBody și validare, Bean Validation și ProblemDetail, consultate la 13 octombrie 2026.