Перейти к основному содержимому

Ошибки REST — @Valid и @ControllerAdvice

Разработчику Архитектору

Ошибки REST — @Valid и @ControllerAdvice

Рабочий REST на Spring Boot возвращает предсказуемые ошибки: JSON с кодом и описанием, а не HTML-страницу Whitelabel с трассировкой.

@ControllerAdvice — класс «рядом с контроллерами», который перехватывает исключения по всему приложению и формирует ответ. Один раз настроили — все @RestController получают единый формат.

Связано: Spring Security · JWT.

Словарь

ТерминОбъяснение
HTTP 400 Bad RequestКлиент прислал неверные данные (пустое поле, слишком длинный текст)
HTTP 404 Not FoundРесурс с таким id отсутствует
HTTP 401 / 403Нет входа / нет прав (часто от Security до контроллера)
Bean ValidationАннотации @NotBlank, @Size на полях DTO; проверка до бизнес-логики
@Valid«Проверь этот объект перед вызовом метода»
ProblemDetailСтандарт RFC 7807: поля type, title, status, detail в JSON
DTOОбъект запроса/ответа API (CreateNoteRequest), отделён от entity БД

Практическое задание
Отправьте POST /api/notes с пустым text — получите 400 и список полей. Затем исправьте тело и убедитесь, что приходит 201.


Что получится

ЗапросОтвет
POST /api/notes с пустым text400 + список полей
GET /api/notes/999404 + ProblemDetail
Успешный POST201 + тело

Зависимости

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

(Часто уже транзитивно из spring-boot-starter-web.)


DTO с валидацией

package com.example.notes;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record CreateNoteRequest(
@NotBlank(message = "text обязателен")
@Size(max = 500, message = "не длиннее 500 символов")
String text
) {}

Разбор аннотаций:

АннотацияСмысл
@NotBlankСтрока не null, не пустая, не только пробелы
@Size(max = 500)Длина строки не больше 500
message = "..."Текст попадёт в FieldError и в detail ответа

Record в Spring Boot 3 — обычный DTO: валидация работает на компонентах конструктора.

Что произойдёт без @Valid: пустой text дойдёт до сервиса; клиент получит 500 или мусор в данных вместо понятного 400.


Простой сервис (в памяти)

import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Service;

@Service
public class NoteService {
private final Map<Long, String> store = new ConcurrentHashMap<>();
private final AtomicLong seq = new AtomicLong();

public record Note(long id, String text) {}

public Note create(String text) {
long id = seq.incrementAndGet();
store.put(id, text);
return new Note(id, text);
}

public Optional<Note> find(long id) {
return Optional.ofNullable(store.get(id)).map(t -> new Note(id, t));
}
}

Контроллер

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/notes")
public class NoteController {

private final NoteService service;

public NoteController(NoteService service) {
this.service = service;
}

@PostMapping
public ResponseEntity<NoteService.Note> create(@Valid @RequestBody CreateNoteRequest req) {
NoteService.Note saved = service.create(req.text());
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}

@GetMapping("/{id}")
public NoteService.Note get(@PathVariable long id) {
return service.find(id).orElseThrow(() -> new NoteNotFoundException(id));
}
}

Разбор контроллера:

ЭлементРоль
@PostMappingHTTP POST на /api/notes
@Valid @RequestBody CreateNoteRequestРазбор JSON + валидация; при ошибке метод не вызывается
ResponseEntity.status(CREATED)Статус 201 и тело созданной заметки
orElseThrow(() -> new NoteNotFoundException(id))Нет id → исключение → advice вернёт 404

Доменное исключение

public class NoteNotFoundException extends RuntimeException {
public NoteNotFoundException(long id) {
super("Note not found: " + id);
}
}

@ControllerAdvice

package com.example.notes;

import org.springframework.http.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import java.net.URI;
import java.util.stream.Collectors;

@RestControllerAdvice
public class ApiExceptionHandler {

@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
String details = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.collect(Collectors.joining("; "));

ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, details);
problem.setTitle("Validation failed");
problem.setType(URI.create("about:blank"));
return problem;
}

@ExceptionHandler(NoteNotFoundException.class)
public ProblemDetail handleNotFound(NoteNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Not found");
return problem;
}
}

Разбор @RestControllerAdvice:

МетодИсключениеОтвет
handleValidationMethodArgumentNotValidException400, в detail — список полей
handleNotFoundNoteNotFoundException404, сообщение из исключения

ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, details) — фабрика Spring 6; сериализуется в JSON:

{
"type": "about:blank",
"title": "Validation failed",
"status": 400,
"detail": "text: text обязателен"
}

Фронтенд может показать detail пользователю или разобрать поля из строки; для сложных форм позже добавляют массив errors[] в кастомном теле.

Порядок обработки запроса:

POST /api/notes + JSON
→ Security (если включён)
→ DispatcherServlet
→ валидатор (@Valid) → 400 через advice
→ NoteController.create
→ 201 + тело

Проверка

curl -s -X POST http://localhost:8080/api/notes \
-H "Content-Type: application/json" \
-d '{"text":""}' | jq .

curl -s http://localhost:8080/api/notes/99999 | jq .

Ожидайте status: 400 и 404 без HTML Whitelabel.


Security + ошибки

При JWT неавторизованный запрос даёт 401 от Security до контроллера. Чтобы формат совпадал, можно добавить AuthenticationEntryPoint, возвращающий ProblemDetail — опционально для углубления.


Частые ошибки

СимптомПричина
500 вместо 400Нет @Valid или нет starter-validation
Advice не срабатываетКласс вне scan-пакета @SpringBootApplication
Пустой detailНет message в аннотациях @NotBlank

Что попробовать

  1. @Email, @Min на других полях.
  2. @ControllerAdvice + ResponseEntity с кастомным телом { code, errors[] } для legacy API.
  3. Интеграционный тест MockMvcstatus().isBadRequest() и jsonPath("$.detail").

Дальше

JWT · JPA · JUnit / MockMvc


См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).