fieldErrors у ProblemDetail

Spring REST & MVC
Рівень 21 , Лекція 2
Відкрита

1. Єдиний формат для помилок валідації

Коли API лише зʼявляється, дуже легко зробити так: «Якщо сталася помилка валідації — поверну якийсь {"errors":[...]}; якщо ресурс не знайдено — поверну ProblemDetail». На маленькому проєкті це навіть може здаватися цілком безневинним. Але щойно зʼявляються друга кінцева точка й другий тип клієнта, починається катавасія: клієнтський код перетворюється на набір if/else за форматом відповіді, а документація стає суперечливою. У підсумку замість одного зрозумілого контракту ми отримуємо дві реальності, які доводиться синхронізувати вручну. А це, як відомо, навичка рівня «професійний страждалець».

Ключова ідея тут така: валідація — це лише один із видів помилок клієнта, який природно вкладається в загальний каркас problem details. Зверху в нас залишається звичайний ProblemDetail із status=400 і нашим стабільним code=INVALID_INPUT, а конкретика за полями додається в fieldErrors. Тоді клієнту не потрібно вгадувати, який сьогодні формат у відповіді: він завжди спершу читає верхній рівень (code, title, status), а потім — додаткові деталі (fieldErrors, якщо вони є).

Якщо сказати простіше, то ProblemDetail — це «конверт», а fieldErrors — «аркуш усередині конверта». Ми не друкуємо два різні види конвертів для різних життєвих сценаріїв. Ми просто кладемо всередину різні вкладення, але зберігаємо одну форму.

2. Модель fieldErrors: мінімум полів

Зробити fieldErrors можна по-різному: туди можна покласти все, аж до rejectedValue, імені DTO-класу, списку всіх constraints і філософського пояснення, чому життя — біль. Але в API-контракті корисніший мінімалізм: чим менше шуму, тим простіше клієнту використовувати відповідь, тим стабільніший контракт і тим менший ризик витоку чутливих даних. Наприклад, якщо хтось випадково надіслав у поле пароль, а ми його «любʼязно» повернули назад у помилці — таке трапляється.

Мінімальний field-error зазвичай відповідає на три запитання: яке поле, яке правило порушено, що сказати людині. Цього достатньо і фронтенду — підсвітити поле й показати повідомлення, — і тестам, щоб перевірити код порушення, і вам, щоб швидко зрозуміти, що зламалося, не вмикаючи режим «археолог із JSON-черепків».

Домовімося про такий формат елемента fieldErrors:

Поле Приклад Навіщо потрібно
field "title", "tags[1]", "metadata.description", "_global" Адреса помилки в запиті (шлях до поля)
code "NotBlank", "Size", "Pattern" Машиночитабельний код порушення (зазвичай імʼя constraintʼа)
message "title не має бути порожнім" Повідомлення, яке можна показати людині

І оформімо це як окремий DTO у нашому проєкті.

package com.example.tasktracker.api.dto.error;

// Один елемент масиву fieldErrors у ProblemDetail.
// Цей DTO — частина публічного контракту, тому тримаємо його мінімальним і стабільним.
public record FieldErrorResponse(
        // Шлях до проблемного місця в запиті: поле, вкладеність, індекс колекції або "_global"
        String field,
        // Машиночитабельний код порушення (зазвичай імʼя constraintʼа)
        String code,
        // Повідомлення, яке можна показати людині (або залогувати в клієнті)
        String message
) {
}

Зверніть увагу на важливу «дворівневу» модель: code зверху (INVALID_INPUT) описує усю проблему цілком, а code всередині fieldErrors описує локальні порушення конкретних полів. Клієнту це зручно: спочатку він розуміє загальний сценарій, потім — деталі.

3. Шлях до поля: індекси, вкладеність, cross-field

Шлях до поля — це та частина контракту, яку найлегше «випадково зіпсувати», бо здається: «Ну, поле й поле». На практиці шлях — це міст між вашим JSON-контрактом і UI клієнта. Якщо шлях незручний, непередбачуваний або «інколи з індексом, інколи без», то фронтенду складно правильно показати помилки: наприклад, підсвітити конкретний тег у списку або конкретний елемент вкладеного обʼєкта.

У нашому курсі ми дотримуємося зрозумілих шляхів: для простих полів це просто імʼя (title), для колекцій — імʼя плюс індекс (tags[1]), для вкладених обʼєктів — крапка (metadata.description). Spring сам зазвичай формує такі шляхи в BindingResult, і це приємний бонус: нам не потрібно вигадувати власну систему адресації з нуля.

Є ще один нюанс: помилки рівня «не одне поле, а правило між полями». Наприклад, «dueAfter має бути раніше за dueBefore». У такого порушення немає одного-єдиного поля, але клієнту все одно потрібна зрозуміла точка в контракті. У цьому курсі фіксуємо один канон: для object-level і cross-field помилок використовуємо field = "_global".

Це означає: проблема стосується всього обʼєкта запиту, а не одного конкретного властивості. Така конвенція проста для клієнта і не змушує кожного разу вигадувати нові штучні назви. Саме так class-level validators із блоку валідації потрапляють у той самий масив fieldErrors, не створюючи другого формату помилок.

4. Помилки валідації в Spring MVC: які винятки ловити

На рівні «я написав @Valid, і це якось працює» усе виглядає просто. Але коли ми будуємо єдиний error contract, нам потрібно розуміти: які саме винятки потрапляють до global error handler, тому що від цього залежить, де брати список помилок і як дістати шлях до поля. Гарна новина: у звичайному Spring MVC застосунку ці сценарії доволі повторювані.

Спростімо картину до трьох типових джерел помилок валідації:

Сценарій Приклад у контролері Що зазвичай прилітає Звідки беремо помилки
Валідація @RequestBody DTO public ... create(@Valid @RequestBody TaskCreateRequest req) MethodArgumentNotValidException ex.getBindingResult()
Валідація @ModelAttribute / criteria DTO public ... list(@Valid TaskSearchCriteria criteria) BindException ex.getBindingResult()
Валідація параметрів методу (@RequestParam, @PathVariable) у controller method validation public ... get(@Min(0) int page) HandlerMethodValidationException Помилки параметрів з обʼєкта винятку

Чому нам так подобається BindingResult? Тому що він дає майже готовий список FieldError, де вже є field, code, defaultMessage. Тобто ніби Spring сам підготував нам інгредієнти для fieldErrors, а ми просто акуратно їх подаємо.

Найчастіший і найважливіший випадок — MethodArgumentNotValidException для body DTO. Саме його ви побачите на POST /api/v1/tasks, PUT, PATCH, POST comment і так далі.

Для MVC-контролерів у нашому базовому сценарії орієнтир саме HandlerMethodValidationException. Він живе окремо від BindingResult, але назовні має лягати в той самий INVALID_INPUT + fieldErrors. ConstraintViolationException корисно знати як більш загальний Jakarta Validation-контекст, але не як основний exception-path контролера.

5. GlobalExceptionHandler: BindingResultfieldErrors

Тепер переходимо до приємної частини: беремо все, що Spring уже зібрав, і вкладаємо в наш контракт. Важливо зробити це так, щоб обробники не перетворилися на копіпасту, а структура відповіді була однаковою за будь-якої помилки валідації.

Саме тут закривається діра з cross-field validation: в один список потраплять і звичайні помилки полів, і object-level помилки з field="_global".

Почнемо з маленького helperʼа, який перетворює BindingResult на список FieldErrorResponse. Це буде наш «перекладач із мови Spring на мову API-контракту». І так, це чудове місце для лаконічного Streamʼа — але рівно до тієї міри, поки він не починає виглядати як заклинання виклику демона.

import com.example.tasktracker.api.dto.error.FieldErrorResponse;
import org.springframework.validation.BindingResult;

import java.util.List;
import java.util.stream.Stream;

private static final String GLOBAL_FIELD = "_global";

private List<FieldErrorResponse> toFieldErrors(BindingResult bindingResult) {
    // Spring уже розклав помилки за полями й побудував зручні шляхи на кшталт tags[1] або metadata.description
    List<FieldErrorResponse> fieldErrors = bindingResult.getFieldErrors().stream()
            .map(e -> new FieldErrorResponse(
                    e.getField(),          // шлях до поля в запиті
                    e.getCode(),           // імʼя constraintʼа (зазвичай NotBlank/Size/Pattern/...)
                    e.getDefaultMessage()  // текст для людини
            ))
            .toList();

    // Class-level / cross-field помилки теж включаємо в той самий масив.
    // Для них у контракті фіксуємо один канон: field = "_global".
    List<FieldErrorResponse> globalErrors = bindingResult.getGlobalErrors().stream()
            .map(e -> new FieldErrorResponse(
                    GLOBAL_FIELD,
                    e.getCode(),
                    e.getDefaultMessage()
            ))
            .toList();

    return Stream.concat(fieldErrors.stream(), globalErrors.stream())
            .toList();
}

Тепер нам потрібен спосіб створити “базовий” ProblemDetail для invalid input, щоб усюди був один title, один type, один code. Це продовження ідеї з попереднього кроку: єдиність краща за творчість, навіть якщо дуже хочеться імпровізувати.

import com.example.tasktracker.api.dto.error.ApiErrorCode;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;

import java.net.URI;

private ProblemDetail invalidInputProblem(HttpStatusCode status, String instance) {
    // Це не окрема схема помилок, а просто фіксована конфігурація
    // спільного builder'а для сценарію INVALID_INPUT.
    return appProblem(
            status,
            URI.create("/problems/invalid-input"),
            "Неприпустимі вхідні дані",
            "Перевірка запиту не вдалася",
            ApiErrorCode.INVALID_INPUT,
            URI.create(instance)
    );
}

Залишилося зрозуміти, звідки взяти instance — шлях запиту. В обробниках ResponseEntityExceptionHandler нам зазвичай доступний WebRequest, і в MVC-світі його можна привести до ServletWebRequest, щоб отримати HttpServletRequest. Це звучить трохи страшніше, ніж є насправді.

import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.context.request.WebRequest;

private String requestPath(WebRequest request) {
    // У Spring MVC WebRequest зазвичай є ServletWebRequest
    ServletWebRequest servlet = (ServletWebRequest) request;

    // Для ProblemDetail.instance нам потрібен URI запиту (без домену)
    return servlet.getRequest().getRequestURI();
}

І нарешті — збираємо ProblemDetail для MethodArgumentNotValidException, додаючи fieldErrors як extension field. Зверніть увагу: ми не робимо окремий DTO і не змінюємо форму відповіді — ми просто додаємо ще одне поле до того ж ProblemDetail.

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.context.request.WebRequest;

@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
        MethodArgumentNotValidException ex, HttpHeaders headers,
        HttpStatusCode status, WebRequest request
) {
    // 1) Створюємо базовий ProblemDetail для сценарію INVALID_INPUT
    ProblemDetail pd = invalidInputProblem(status, requestPath(request));

    // 2) Додаємо "вкладення в конверт": список помилок за полями
    pd.setProperty("fieldErrors", toFieldErrors(ex.getBindingResult()));

    // 3) Повертаємо відповідь у стандартному для ResponseEntityExceptionHandler вигляді
    return new ResponseEntity<>(pd, headers, status);
}

Якщо ви зараз подумали «усе?» — так, на базовому рівні усе. Найкрасивіше тут те, що Spring уже зробив половину роботи: перевірив, зібрав помилки, побудував шляхи, а ми просто запакували результат.

6. Query/criteria validation: той самий INVALID_INPUT

Дуже важливо, щоб fieldErrors працював не лише для @RequestBody, а й для query/criteria-сценаріїв. Інакше клієнту знову доведеться тримати в голові: «якщо помилка в body — один формат, якщо помилка в query — інший». А ми якраз намагаємося від цього піти.

Для @ModelAttribute/criteria DTO типовий випадок — BindException. Там теж є BindingResult, а отже, наш helper toFieldErrors(...) знову стане в пригоді без змін. Ми просто повертаємо такий самий INVALID_INPUT, тільки джерело помилок інше.

import org.springframework.validation.BindException;

@Override
protected ResponseEntity<Object> handleBindException(
        BindException ex, HttpHeaders headers,
        HttpStatusCode status, WebRequest request
) {
    // Логіка рівно та сама: один і той самий сценарій INVALID_INPUT, тільки інший виняток
    ProblemDetail pd = invalidInputProblem(status, requestPath(request));

    // У BindException теж є BindingResult, тому повторно використовуємо спільний helper
    pd.setProperty("fieldErrors", toFieldErrors(ex.getBindingResult()));
    return new ResponseEntity<>(pd, headers, status);
}

А що щодо валідації параметрів (page, size, taskId)? У MVC-контролері нашого базового сценарію основний шлях — HandlerMethodValidationException. Його API трохи багатослівніший, ніж у BindingResult, але сенс не змінюється: дістати помилки параметрів і вкласти їх у той самий INVALID_INPUT + fieldErrors.

Тобто для клієнта немає різниці, звідки саме прийшла помилка валідації — із body DTO, criteria object чи обмежень на параметри методу. Верхній code залишається INVALID_INPUT, а далі клієнт читає fieldErrors.

Якщо ж ви поза controller pipeline окремо стикаєтеся з ConstraintViolationException, логіка перетворення залишається такою самою:

import com.example.tasktracker.api.dto.error.FieldErrorResponse;
import jakarta.validation.ConstraintViolationException;

import java.util.List;

private List<FieldErrorResponse> toFieldErrors(ConstraintViolationException ex) {
    return ex.getConstraintViolations().stream()
            // propertyPath зазвичай містить імʼя параметра або шлях усередині обʼєкта, що валідується
            .map(v -> new FieldErrorResponse(
                    v.getPropertyPath().toString(),
                    v.getConstraintDescriptor().getAnnotation().annotationType().getSimpleName(),
                    v.getMessage()
            ))
            .toList();
}

7. Приклад підсумкової JSON-відповіді

Коли ми зробили ProblemDetail + code + fieldErrors, результат стає передбачуваним і зручним. Клієнт отримує один і той самий каркас помилки, а відмінності — лише в деталях. Саме це і називається «єдиний error contract»: однакова форма, різні значення.

Уявімо, що клієнт викликав POST /api/v1/tasks і надіслав невалідний JSON:

{
  "title": "  ",
  "tags": ["ok", "this-tag-is-way-too-long-for-our-contract-because-it-is-ridiculous"]
}

Відповідь може виглядати так:

{
  "type": "/problems/invalid-input",
  "title": "Неприпустимі вхідні дані",
  "status": 400,
  "detail": "Перевірка запиту не вдалася",
  "instance": "/api/v1/tasks",
  "code": "INVALID_INPUT",
  "fieldErrors": [
    {
      "field": "title",
      "code": "NotBlank",
      "message": "title не має бути порожнім"
    },
    {
      "field": "tags[1]",
      "code": "Size",
      "message": "довжина тегу має бути від 1 до 30"
    }
  ]
}

Чому це зручно клієнту? Тому що тепер фронтенд може діяти просто: якщо code == INVALID_INPUT, він бере fieldErrors, перетворює їх на map field -> message і підсвічує потрібні поля. Йому не потрібно парсити рядок detail, не потрібно вгадувати, де лежить список помилок, і не потрібно розрізняти «валідацію body» та «валідацію query» за форматом відповіді.

Якщо спрацює class-level validator, у цей самий масив потрапить елемент із field = "_global". Тобто cross-field правило теж не створює новий формат помилки — воно просто живе в тому ж fieldErrors.

І ще один приємний бонус: ваші автотести та ручні перевірки стають простішими. Перевіряти code=INVALID_INPUT і наявність fieldErrors значно стабільніше, ніж перевіряти, що у detail міститься слово blank.

8. Типові помилки під час роботи з fieldErrors

Помилка №1: зробити другий формат помилок лише для валідації.
Найпоширеніший провал — створити окремий ValidationErrorResponse, повертати його при MethodArgumentNotValidException, а для інших помилок і далі повертати ProblemDetail. У цей момент контракт API перестає бути єдиним: клієнту потрібні два парсери, дві схеми документації й дві гілки обробників. Правильніше залишити один каркас ProblemDetail і додати fieldErrors як extension field.

Помилка №2: повернути лише один рядок «validation failed».
Іноді здається, що detail="Validation failed" достатньо, адже «клієнт же людина, зрозуміє». Але клієнт — це ще й код, який має підсвітити конкретне поле. Без fieldErrors фронтенд змушений або показувати помилку загалом, або вгадувати, що саме пішло не так. Навіть якщо UI зараз примітивний, контракт краще проєктувати так, щоб він автоматично був корисним.

Помилка №3: втратити індекси й вкладеність у шляхах.
Якщо ви в fieldErrors пишете просто "tags" замість "tags[1]", клієнт не зможе підсвітити конкретний проблемний елемент списку. Те саме з вкладеними обʼєктами: "metadata" замість "metadata.description" перетворює точну помилку на «десь там щось не так». Краще зберегти шлях максимально точним, як його віддає BindingResult.

Помилка №4: змішати верхній code помилки і поле code всередині fieldErrors.
Іноді розробники кладуть NotBlank у верхній code, а INVALID_INPUT — усередину fieldErrors. Виходить перевернутий світ: загальна помилка описується локальним правилом одного поля. Зручніше тримати верхній code як ідентифікатор сценарію (INVALID_INPUT), а всередині fieldErrors — причини за конкретними полями.

Помилка №5: засунути в fieldErrors занадто багато «діагностики».
Дуже хочеться додати rejectedValue, імʼя DTO-класу, stack trace і «ще трішки, і буде лог». Але error response — це публічний контракт. Він має бути компактним і безпечним. Якщо вам потрібна діагностика — вона живе в логах і моніторингу, а в API-відповіді достатньо адреси помилки, коду правила і повідомлення.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ