JavaRush /Курси /Spring REST & MVC /Валідація критеріїв через @...

Валідація критеріїв через @ModelAttribute

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

1. Роль TaskSearchCriteria

Коли ви пишете перший list-endpoint, дуже легко спокуситися: «Ну це ж просто список задач: додам page, size, sort, status, priority, q…» — і раптом сигнатура методу стає довшою, ніж ваше резюме. Проблема не в тому, що «багато параметрів — це погано естетично». Проблема в іншому: такий код важко читати, складно розширювати й незручно однаково валідовувати в усіх місцях.

Поки параметрів мало, тримати page, size і sort прямо в @RequestParam навіть корисно: так легше побачити, де саме спрацьовують @Min, @Max і @Pattern. Але для фінального вигляду GET /api/v1/tasks це не найкращий варіант. Щойно до нього додаються фільтри та поля пошуку, сигнатуру вже зручніше згорнути в один criteria DTO.

Уявіть: ви підтримуєте GET /api/v1/tasks. Сьогодні до нього додають фільтр assigneeName, завтра — dueAfter, післязавтра — archived. Якщо все тримати набором @RequestParam, контролер починає жити окремим життям. Ще гірше — ви почнете копіювати однакові обмеження між методами, і «validation» тихо перетвориться на «вгадайку»: десь size обмежено до 100, десь до 1000, а десь узагалі без ліміту, бо «забули».

Критерії пошуку — чудовий кандидат на criteria DTO: один об’єкт, один контрактний вхід, одна точка, де читаються й перевіряються query-параметри.

2. @ModelAttribute: складання criteria з query

Коли ви бачите @RequestBody, інтуїтивно зрозуміло: приходить JSON, перетворюється на об’єкт. А от із query-параметрами в початківців часто виникає відчуття, що це «просто рядки десь у URL». Насправді Spring робить цілком конкретну роботу: бере query string, знаходить параметри за іменами та заповнює поля об’єкта. @ModelAttribute — це як «коробка для параметрів», у яку Spring акуратно складає все, що стосується пошуку.

Для GET-endpointʼів це особливо зручно, бо request body тут немає, а вхід усе одно може бути складним. Типовий сценарій: GET /api/v1/tasks?page=0&size=20&sort=updatedAt,desc&q=report&assigneeName=Alice. Ми хочемо, щоб це перетворювалося на об’єкт TaskSearchCriteria, а не на набір окремих рядків і чисел, розкиданих по методу.

Мінімальний приклад TaskSearchCriteria може виглядати так:

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

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public record TaskSearchCriteria(
        // Wrapper-типи потрібні, щоб "параметр не передано" залишався `null`, а не перетворювався на 0/false
        @Min(0) Integer page,
        // За замовчуванням обмеження Bean Validation ігнорують `null`: перевіряємо лише тоді, коли параметр справді передали
        @Min(1) @Max(100) Integer size,
        @Pattern(regexp = "^(createdAt|updatedAt|dueDate|priority|status|title),(asc|desc)$") String sort,
        @Size(max = 100) String q,
        @Size(max = 80) String assigneeName
) {}

Зверніть увагу на Integer, а не int. Це не «прискіпування до стилю», а практичний нюанс: якщо параметр не передали, у Integer буде null, і Bean Validation не сваритиметься на @Min. А ось int без значення перетворюється на 0 — і ви раптово отримуєте неявні «значення за замовчуванням» прямо в типі, які можуть конфліктувати з вашими правилами, особливо для size, де мінімум зазвичай 1.

Такий формат і далі вважатимемо робочим для search-контракту: query-параметри живуть в одному TaskSearchCriteria, а відсутність параметра лишається null. Значення за замовчуванням на кшталт page=0, size=20 і sort=updatedAt,desc зручніше підставляти вже після binding, в одному місці, а не розмазувати по довгій сигнатурі.

Тепер контролер. Ми явно говоримо Spring: «зібери мені цей об’єкт із query-параметрів»:

package com.example.tasktracker.api.controller;

import com.example.tasktracker.api.dto.request.TaskSearchCriteria;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TaskController {

    @GetMapping("/api/v1/tasks")
    public String list(@Valid @ModelAttribute TaskSearchCriteria criteria) {
        // @Valid тут — тригер: без нього анотації @Min/@Max/@Size/@Pattern на criteria просто "лежать" і не перевіряються
        return "ок";
    }
}

Тут поки що «повертаємо ок», тому що зараз нас цікавить саме складання та валідація входу. У реальному проєкті ви будете повертати PagedResponse<TaskSummaryResponse>, але механіка входу така сама.

3. @Valid на @ModelAttribute

Дуже типова пастка: ви красиво розмітили TaskSearchCriteria анотаціями @Min, @Max, @Size, упевнені, що «валідатор усе перевірить», запускаєте запит ?page=-1, а контролер усе одно радісно відповідає 200 OK. Це той момент, коли хочеться сказати: «Spring, ти взагалі на чиєму боці?» — але винні ми самі.

Bean Validation не вмикається сама собою лише через те, що ви написали анотації в класі. Їй потрібен тригер. Для web-шару тригером зазвичай слугує @Valid (або @Validated) на аргументі методу контролера. Тобто анотації на полях — це «правила», а @Valid — це «вмикач світла». Правила без вмикача залишаються в темряві.

Порівняймо два варіанти.

Варіант А: забули @Valid:

@GetMapping("/api/v1/tasks")
public String list(@ModelAttribute TaskSearchCriteria criteria) {
    return "ок";
}

Варіант Б: додали @Valid:

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.ModelAttribute;

@GetMapping("/api/v1/tasks")
public String list(@Valid @ModelAttribute TaskSearchCriteria criteria) {
    return "ок";
}

Тепер page=-1 уже не «просто значення», а порушення контракту. І важливо, що це відбувається до сервісного шару: ви не пишете ручне if (page < 0), не розмазуєте перевірки по коду і не перетворюєте контролер на «мініпарсер запитів». Ви просто описали контракт і сказали Spring: «перевір».

Є ще один практичний нюанс: обмеження типу @Min і @Size за стандартом Bean Validation ігнорують null. Це дуже зручно для criteria DTO, тому що «параметр не передано» — це зазвичай допустимий сценарій, який має вести до поведінки за замовчуванням (наприклад, page=0, size=20, sort=updatedAt,desc). Тобто ви отримуєте просту модель: якщо параметр є — він має бути коректним; якщо параметра немає — застосуємо значення за замовчуванням.

4. Criteria DTO та прості параметри можуть жити поруч

На цьому місці легко вирішити, ніби після появи TaskSearchCriteria будь-які query/path параметри мають зникнути всередину нього. Не мають. @ModelAttribute добре підходить для групи пов’язаних query-параметрів одного search-контракту. Але в методі цілком може залишитися окремий @PathVariable taskId або інший простий параметр зі своїми constraints.

Наприклад, список коментарів задачі може одночасно приймати taskId із path і criteria з query:

package com.example.tasktracker.api.controller;

import com.example.tasktracker.api.dto.request.TaskSearchCriteria;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Pattern;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class CommentController {

    @GetMapping("/api/v1/tasks/{taskId}/comments")
    public String listComments(
            @PathVariable @Pattern(regexp = "^[0-9a-fA-F-]{36}$") String taskId,
            @Valid @ModelAttribute TaskSearchCriteria criteria
    ) {
        return "ок";
    }
}

Тут відбуваються дві різні, але сумісні речі:

@Valid @ModelAttribute TaskSearchCriteria criteria перевіряє поля самого criteria.

@Pattern(...) String taskId перевіряє окремий path-параметр.

Це не конкуруючі підходи. В одного endpointʼа цілком можуть одночасно жити і object input, і прості параметри.

@Valid і @Validated: відмінності

Плутанина між цими анотаціями зазвичай виникає, коли їх намагаються використовувати як взаємозамінні. Насправді ролі різні:

Анотація Куди зазвичай ставимо Що важливо в межах цього дня
@Valid на @RequestBody, @ModelAttribute і вкладені поля DTO запускає перевірку об’єкта й каскадну валідацію
@Validated на Spring-компоненти в сценаріях, де потрібні validation groups або інший Spring-specific method-validation context не замінює @Valid для criteria DTO і не є обов’язковим щоденним перемикачем для базових прикладів контролера цього дня

Тому робоче правило просте: якщо у вас є TaskSearchCriteria або інший DTO-аргумент, його явно валідовуємо через @Valid. А прямі constraints на taskId, page, size та інші прості параметри — це вже окрема частина контракту контролера.

5. Приклад у TaskController

Тепер давайте приземлимо все на наш Task Tracker API. Ми хочемо, щоб GET /api/v1/tasks приймав TaskSearchCriteria, перевіряв його і далі спокійно передавав у сервіс, не займаючись ручним розбором query string.

Скелет контролера може виглядати так:

package com.example.tasktracker.api.controller;

import com.example.tasktracker.api.dto.request.TaskSearchCriteria;
import com.example.tasktracker.api.dto.response.PagedResponse;
import com.example.tasktracker.api.dto.response.TaskSummaryResponse;
import com.example.tasktracker.domain.service.TaskService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {

    private final TaskService taskService;

    public TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

    @GetMapping
    public PagedResponse<TaskSummaryResponse> list(@Valid @ModelAttribute TaskSearchCriteria criteria) {
        // criteria збирається з query-параметрів, а @Valid відсікає некоректні значення ще до входу в сервісний шар
        return taskService.list(criteria);
    }
}

Саме такий формат GET /api/v1/tasks і варто далі тримати в голові: query-параметри збираються в TaskSearchCriteria, а контролер передає далі вже один пошуковий об’єкт.

А що всередині сервісу? Навіть якщо пагінація і сортування в нас поки що можуть бути «навчальними» (або ще не повністю реалізованими), при nullable-полях уже зараз корисно мати одне місце, де null перетворюється на робочі значення. Там і живуть значення за замовчуванням page=0, size=20, sort=updatedAt,desc.

Наприклад, для size це виглядає так:

package com.example.tasktracker.domain.service;

import com.example.tasktracker.api.dto.request.TaskSearchCriteria;
import java.util.Objects;
import org.springframework.stereotype.Service;

@Service
public class TaskService {

    public int normalizeSize(TaskSearchCriteria criteria) {
        Integer rawSize = criteria.size();
        // Якщо параметр size не передано, явно підставляємо значення за замовчуванням, щоб контракт був передбачуваним
        return Objects.requireNonNullElse(rawSize, 20);
    }
}

За тим самим принципом підставляються page=0 і sort=updatedAt,desc.

6. Ручна перевірка: .http запити

Дуже важливо не вірити валідації на слово. Валідація — це частина контракту, а контракт потрібно вміти перевіряти. Найпростіший спосіб — зробити кілька запитів, які свідомо порушують правила, і подивитися, що контролер до сервісу не доходить.

Приклад запиту з неправильною сторінкою:

### некоректна сторінка: має бути >= 0
### очікувано: 400 Bad Request
GET http://localhost:8080/api/v1/tasks?page=-1&size=20
Accept: application/json

В ідеалі ви побачите 400 Bad Request. Формат відповіді з помилкою зараз може бути «значенням за замовчуванням» для Spring Boot, тому що ми ще не побудували єдиний error contract (і це нормально на цій точці курсу). Але ключова поведінка має бути такою: запит не повинен, ніби нічого не сталося, повертати список задач.

Тепер приклад запиту, де параметри не передано — і це нормально:

### page, size і sort не передано => пізніше застосуються значення за замовчуванням
GET http://localhost:8080/api/v1/tasks?q=report
Accept: application/json

Такий запит має проходити, тому що q обмежений за довжиною, а page/size/sort як null не порушують @Min, @Max і @Pattern.

І ще приклад для path variable, якщо ви валідовуєте UUID як рядок:

### некоректний taskId, схожий на UUID
GET http://localhost:8080/api/v1/tasks/NOT-A-UUID/comments
Accept: application/json

Якщо taskId позначений @Pattern, то це теж має завершуватися 400. Інакше ви можете зіткнутися із ситуацією, коли endpoint спробує працювати з NOT-A-UUID, а помилка вилізе вже пізніше — або взагалі не там, де ви очікували.

7. Типові помилки при валідації criteria

Помилка №1: написати constraints у TaskSearchCriteria, але забути @Valid на аргументі контролера.
Це одна з найобразливіших помилок, тому що код виглядає «ніби правильно», а насправді контракт дірявий. Сервіс отримує page=-1, size=9999, sort=unknownField,asc і починає або повертати дивні результати, або падати десь посеред логіки. Сенс validation саме в тому, щоб відсікати такі речі на вході.

Помилка №2: використовувати примітиви (int page, int size) у criteria DTO і дивуватися несподіваним значенням за замовчуванням.
Примітиви не можуть бути null, тому відсутність параметра перетворюється на 0. Для page це ще може випадково збігтися з вашим значенням за замовчуванням, але для size це майже завжди ламає поведінку: 0 не проходить @Min(1), і ви отримаєте помилку навіть тоді, коли клієнт узагалі не передавав size. Для query-критеріїв найчастіше зручніше використовувати wrapper-типи (Integer, Boolean): «не передано» залишається null, а значення за замовчуванням ви задаєте явно.

Помилка №3: чекати, що @Valid на TaskSearchCriteria заодно провалідовує і окремий taskId.
@Valid запускає перевірку полів самого criteria. Якщо поруч є @PathVariable taskId, його обмеження описуються окремо. Проблема не в тому, що параметр «не потрапив у DTO», а в тому, що це інший тип входу.

Помилка №4: перетворювати TaskSearchCriteria на універсальний «мішок для всього на світі».
Criteria DTO має бути про один сценарій — пошук і список задач. Якщо ви починаєте пхати туди поля, які не стосуються списку, або намагаєтеся перевикористовувати його в непов’язаних endpointʼах, DTO перестає бути контрактом і стає «складом». Краще мати два маленькі criteria DTO, ніж один гігантський, у якому ніхто не пам’ятає, що означає половина полів.

Помилка №5: валідовувати sort регуляркою «на все одразу» і випадково зробити контракт нечитабельним.
Так, @Pattern на sort може бути корисним, але дуже легко переборщити й написати такий регулярний вираз, який потім страшно відкривати навіть вам самим. У навчальному проєкті краще тримати sort або простим і зрозумілим, або валідовувати його мінімально розумно. Наша мета — ясний контракт, а не конкурс на найзакрученіший regex-рядок.

1
Задача
Spring REST & MVC, 16 рівень, 3 лекція
Недоступна
Пошук фільмів через criteria DTO
Пошук фільмів через criteria DTO
1
Задача
Spring REST & MVC, 16 рівень, 3 лекція
Недоступна
Список тікетів проєкту з path-параметром і criteria DTO
Список тікетів проєкту з path-параметром і criteria DTO
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ