JavaRush /Курси /Spring REST & MVC /@RequestParam для філ...

@RequestParam для фільтрів і значень за замовчуванням

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

1. Query-параметри для list-endpoint

З адресацією ми вже розібралися: конкретний ресурс живе в path, а список майже відразу потребує додаткових умов. GET /api/v1/tasks без фільтрів — нормальний старт, але щойно потрібні status, assigneeName, page або size, заводити окремий URI для кожної варіації стає боляче.

Для таких уточнень у HTTP є рядок запиту. Він не змінює адресу колекції, а лише налаштовує вибірку: які елементи показати, у якому обсязі й з якими обмеженнями. Тому GET /api/v1/tasks?status=TODO&page=0 залишається запитом до тієї самої колекції задач, а не перетворюється на новий ресурс.

Сьогодні ми свідомо зосередимося на блоці Query + @RequestParam: як зробити контракт списку читабельним, передбачуваним і не перетворити контролер на метод на пів екрана.

2. Як працює @RequestParam

У HTTP query-параметри живуть після ? і розділяються &. Наприклад:

/api/v1/tasks?status=TODO

/api/v1/tasks?page=0&size=20

/api/v1/tasks?status=IN_PROGRESS&assigneeName=Anna&page=1

З погляду браузера або клієнта це просто рядок. З погляду Spring MVC це вхідні дані, які треба дістати із запиту, акуратно перетворити в потрібний тип і передати в метод контролера. Саме цю звʼязку «значення в query-рядку → аргумент методу» і виражає @RequestParam.

Найпростіший мінімальний приклад виглядає так: ми хочемо вміти приймати фільтр за статусом.

import com.example.tasktracker.domain.model.TaskStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@GetMapping("/api/v1/tasks")
public void findTasks(@RequestParam TaskStatus status) {
    // Важливо: параметр за замовчуванням обовʼязковий (required = true).
    // Spring візьме значення з query-рядка (?status=...) і сконвертує його в TaskStatus.
    // Якщо параметр не передали, запит не пройде звʼязування на рівні Spring MVC.
}

Тут Spring очікує, що клієнт викличе endpoint ось так:

GET http://localhost:8080/api/v1/tasks?status=TODO
Accept: application/json

Важливий момент для мислення backend-розробника: сам факт наявності @RequestParam — це частина публічного контракту API. Клієнту не потрібно читати ваш сервісний код або репозиторій, щоб зрозуміти, як «налаштувати» запит: він бачить це в URL і (у майбутньому) в документації.

Тут уже видно ще одну важливу річ: в URL живе рядок status=TODO, а в методі — TaskStatus. Отже, між сирим значенням у query та аргументом контролера є шар перетворення, через який некоректне значення може відсіятися ще до сервісної логіки. Саму механіку цього звʼязування ми далі розберемо на enum, числах і датах.

У нашому проєкті Task Tracker API list-endpoint — це GET /api/v1/tasks, і саме через query-параметри ми поступово розширюватимемо його можливості (сьогодні — лише каркас входу, не «вся пагінація світу»).

3. Обовʼязковий і необовʼязковий query-параметр

Якщо бездумно додавати @RequestParam до методу, можна випадково зробити ваш endpoint крихким: він почне вимагати параметри навіть тоді, коли це не має сенсу. Для list-endpointʼа це особливо небезпечно, бо «список усіх задач» — нормальний сценарій. І він має працювати без того, щоб клієнт був зобовʼязаний надсилати status або assigneeName.

За замовчуванням @RequestParam вважається обовʼязковим. Тобто якщо ви написали @RequestParam String q, Spring чекатиме ?q=.... Не надіслали — запит, найімовірніше, не пройде (у деталі помилок сьогодні не заглиблюємося, але сам принцип важливий).

Як зробити параметр необовʼязковим: required = false

Щоб сказати Spring: «Цей параметр може бути, а може й не бути», ви ставите required = false. Тоді за відсутності параметра Spring передасть у метод null (або порожню обгортку — залежно від типу).

import com.example.tasktracker.domain.model.TaskStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@GetMapping("/api/v1/tasks")
public void findTasks(@RequestParam(required = false) TaskStatus status) {
    // Якщо ?status=... не передали, Spring підставить null.
    // Це зручно для фільтрів: null зазвичай означає, що фільтр вимкнено.
}

Це добра базова модель для фільтрів. Фільтр за статусом — як галочка в інтерфейсі: користувач може її увімкнути, а може й ні.

Чому “необовʼязковий параметр” погано дружить із примітивами

Тут є типова пастка новачка, яка здається смішною рівно до того моменту, поки ви не витратите пів години на налагодження.

Якщо параметр може бути відсутнім, він може бути null. А примітиви (int, boolean) null не вміють. У результаті код на кшталт «ну нехай буде необовʼязковий int» часто закінчується неприємно.

@GetMapping("/api/v1/tasks")
public void findTasks(@RequestParam(required = false) int page) {
    // page: як ти збирався бути null, друже?
}

Правильніше або використовувати wrapper-тип (Integer), або використовувати defaultValue (про нього — у наступному розділі), або взагалі визнати: page і size зазвичай не мають бути «невідомими» — у них є значення за замовчуванням.

Три способи описати “може бути відсутнім”

Щоб не потонути у варіантах, ось компактна таблиця. Це не «найкраще у світі», це «щоб ви не гадали, чому код поводиться саме так».

Що ви пишете в методі Що приходить, якщо параметр не надіслали Коли зручно
@RequestParam(required = false) String q null Коли «немає параметра» = «немає фільтра»
@RequestParam(required = false) Integer size null Коли число справді може бути невідомим
@RequestParam Optional<String> q Optional.empty() Коли ви хочете явно показати «може бути відсутнім» без null

Optional виглядає красиво, але для початківців часто додає зайве когнітивне навантаження: вам потрібно памʼятати про q.isPresent(), q.orElse(...). Тому в навчальному проєкті зазвичай простіше почати з required = false і зрозумілого null (так, ми свідомо використовуємо null; іноді це нормальний інженерний інструмент, якщо тримати його під контролем).

“Параметр відсутній” і “параметр порожній” — це не одне й те саме

Є ще один нюанс, який корисно тримати в голові, хоча сьогодні ми не будемо будувати навколо нього складну логіку.

Якщо клієнт викликав /api/v1/tasks і не надіслав assigneeName, то параметра немає взагалі.

Якщо клієнт викликав /api/v1/tasks?assigneeName= — параметр є, але значення порожнє.

Для String це може означати "" (порожній рядок), і ви вже самі вирішуєте, вважати це «фільтра немає» чи «фільтр за порожнім імʼям» (зазвичай другий варіант не має сенсу). У реальних API це важливо, бо такі нюанси впливають на передбачуваність контракту.

4. defaultValue: передбачувана поведінка без ручних if

Коли в endpointʼі зʼявляються «службові» параметри списку (сторінка, розмір видачі), майже завжди хочеться, щоб сервер працював адекватно навіть без них. Тобто клієнт не зобовʼязаний памʼятати про «за замовчуванням page=0&size=20». Сервер сам може це зафіксувати — і це буде частиною контракту.

defaultValue — це спосіб сказати: «Якщо параметр відсутній (або прийшов порожнім), візьми ось це значення». І тут відбувається маленька магія без магії: якщо ви вказуєте defaultValue, параметр автоматично перестає бути обовʼязковим, бо в нього є значення за замовчуванням.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@GetMapping("/api/v1/tasks")
public void findTasks(@RequestParam(defaultValue = "0") int page,
                      @RequestParam(defaultValue = "20") int size) {
    // page і size завжди будуть числами:
    // - якщо клієнт передав ?page=...&size=... — візьмемо їх,
    // - якщо не передав — підставимо значення за замовчуванням із defaultValue.
    //
    // І так, тому тут уже безпечно використовувати примітиви int.
}

Тут є кілька практичних плюсів.

По-перше, ви можете використовувати примітиви int, бо page і size завжди матимуть значення: або із запиту, або з дефолту.

По-друге, значення за замовчуванням стають явними на межі API. Це сильно покращує читабельність: ви відкрили контролер — і вже розумієте, як поводитиметься сервер, якщо клієнт нічого не надіслав.

По-третє, ви менше спокушаєтеся зробити ось так:

int pageToUse = page == null ? 0 : page;

і почати розносити значення за замовчуванням по сервісах, репозиторіях і навіть по фронтенду. Дефолт — це частина контракту. Отже, логічно тримати його на межі контракту.

Є й нюанс: defaultValue — це рядок. Spring спочатку підставить рядок, а потім спробує конвертувати його в потрібний тип аргументу. Якщо ви випадково напишете "banana", а тип — int, то «банан» у число не перетвориться. Так, навіть якщо дуже просити.

5. Проєктування query-параметрів

Query-параметри — це не дрібні деталі. Це публічні імена полів вашого API. Клієнти писатимуть їх у .http, у Postman, у документації, а іноді й у коді (наприклад, у мобільному застосунку). Тому в цих імен є дві важливі характеристики: зрозумілість і стабільність.

Зрозумілість зазвичай перемагає короткість заради короткості. assigneeName читається краще, ніж a. dueBefore — краще, ніж d1. Ми проєктуємо API як контракт для людини, а не як чемпіонат з економії символів.

Стабільність означає, що ви не хочете випадково зламати контракт лише тому, що перейменували змінну в методі контролера. Тому в реальних командах часто дотримуються принципу: імена query-параметрів мають бути зафіксовані так само серйозно, як імена полів у JSON. На рівні синтаксису в @RequestParam для цього є атрибут name (або value).

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@GetMapping("/api/v1/tasks")
public void findTasks(@RequestParam(name = "assigneeName", required = false) String assignee) {
    // Контракт (URL): параметр називається assigneeName
    // Внутрішній код: змінна може називатися як завгодно (тут — assignee)
    // Це допомагає не ламати API під час рефакторингу імен у Java-коді.
}

Я спеціально показав різні імена, щоб було видно: контракт живе окремим життям від внутрішнього рефакторингу. На практиці, звісно, краще не ускладнювати й називати змінну так само, як параметр (assigneeName). Але корисно знати, що контракт можна зафіксувати.

Орієнтир щодо параметрів для GET /api/v1/tasks

Сьогодні ми не реалізуємо всю дорослу фільтрацію й пагінацію, але вже можемо вибрати імена та загальний стиль, щоб не плодити хаос.

У межах Task Tracker API для list-endpointʼа природні query-параметри на рівні контракту виглядають приблизно так:

Параметр Значення Типовий приклад
status фільтр за статусом задачі ?status=TODO
assigneeName фільтр за виконавцем ?assigneeName=Anna
q текстовий пошук (простий рядок) ?q=docs
page номер сторінки (зазвичай 0-based) ?page=0
size розмір сторінки ?size=20

Навіть якщо деякі з цих параметрів поки що лише каркасні, уже зараз важливо тримати їх в одному стилі: camelCase, передбачувані імена, без раптових скорочень «бо так швидше».

І ще один практичний сигнал: якщо ви помітили, що сигнатура методу вже починає нагадувати телефонний довідник, це не привід страждати. Це привід подумати про критерії пошуку як про один обʼєкт — але до цього ми дійдемо трохи пізніше в цьому ж дні, у лекції про @ModelAttribute. Сьогодні ми чесно подивимося на @RequestParam як на базовий інструмент.

6. Приклад: GET /api/v1/tasks

Зараз у нас є list-endpoint GET /api/v1/tasks. На старті він міг виглядати максимально просто: «поверни всі задачі». Це нормально для першого кроку. Але нам уже час навчити його приймати фільтри та службові параметри — так, щоб контролер залишався тонким, а контракт — читабельним.

Нижче приклад того, як може виглядати метод контролера, якщо ми додамо два фільтри (status, assigneeName) і два службові параметри (page, size). Зверніть увагу: всередині методу ми не робимо «розумну» логіку. Ми лише приймаємо вхід і делегуємо в сервіс.

import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.model.TaskStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;

@GetMapping("/api/v1/tasks")
public List<Task> findTasks(
        @RequestParam(defaultValue = "0") int page,          // службовий параметр: номер сторінки
        @RequestParam(defaultValue = "20") int size,         // службовий параметр: розмір сторінки
        @RequestParam(required = false) TaskStatus status,   // фільтр: може бути відсутнім (null => «не фільтрувати»)
        @RequestParam(required = false) String assigneeName, // фільтр: може бути відсутнім
        @RequestParam(required = false) String q             // фільтр/пошук: може бути відсутнім
) {
    // Контролер: приймає HTTP-вхід і передає далі.
    // Важливо: тут не влаштовуємо stream-оркестр — тримаємо контролер тонким.
    return taskService.findTasks(page, size, status, assigneeName, q);
}

Так, тут уже пʼять параметрів. Це багато? Для першої версії list-endpointʼа — так. Але саме тому ми й вчимося: сьогодні ви маєте відчути, що query-параметри — це нормально, але в такого підходу є межа зручності. На поточному етапі це корисно як міст: спочатку навчилися приймати query-параметри, потім навчимося збирати їх в єдиний критерій.

Щоб руками перевірити, як це працює, зручно мати кілька запитів у .http-стилі:

### 1) Просто список без фільтрів
GET http://localhost:8080/api/v1/tasks
Accept: application/json
### 2) Фільтр за статусом
GET http://localhost:8080/api/v1/tasks?status=TODO
Accept: application/json
### 3) Фільтр за статусом + виконавець + сторінка
GET http://localhost:8080/api/v1/tasks?status=IN_PROGRESS&assigneeName=Anna&page=1&size=10
Accept: application/json

Зверніть увагу ще на одну дрібницю, яка економить нерви: порядок query-параметрів не важливий. Це не positional arguments у методі Java. Це іменовані параметри. Ваш endpoint має однаково розуміти і ?page=1&size=10, і ?size=10&page=1.

Мініреалізація в сервісі

Сервісний метод можна зробити дуже простим: поки не реалізуємо справжню пагінацію або «розумний» пошук, але хоча б покажемо, що фільтри можуть застосовуватися. Це допомагає відчути, що @RequestParam — не просто синтаксис, а реальний вхід у поведінку API.

Приклад максимально навчальної реалізації (фільтри застосовуємо, page/size/q поки ігноруємо без драматичних оркестровок):

import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.model.TaskStatus;

import java.util.List;

public List<Task> findTasks(int page, int size, TaskStatus status, String assigneeName, String q) {
    // Поки це навчальний приклад: пагінацію та q-пошук свідомо не реалізуємо,
    // але показуємо, як «опціональні» параметри перетворюються на фільтрацію.
    return tasks.stream()
            // status == null => фільтр вимкнено
            .filter(t -> status == null || t.status() == status)
            // assigneeName == null або порожній => фільтр вимкнено
            .filter(t -> assigneeName == null || assigneeName.isBlank()
                    || (t.assigneeName() != null && t.assigneeName().equalsIgnoreCase(assigneeName)))
            .toList();
}

Зверніть увагу на важливий стиль: контролер не повинен «вбирати» цей stream-код. Нехай він живе в сервісі. Контролер — це про HTTP-контракт і оркестрацію, а не про бізнес-правила та фільтрацію.

7. Типові помилки під час роботи з @RequestParam

Помилка №1: перетворювати list-endpoint на «обовʼязковий квест» із параметрів.
Дуже легко написати метод із пʼятьма @RequestParam, забути про required = false і раптово отримати API, яке без ?status= взагалі не працює. Для фільтрів майже завжди правильніше робити параметр необовʼязковим: відсутність фільтра має означати «не фільтрувати», а не «зламати запит».

Помилка №2: використовувати примітиви (int, boolean) для параметрів, які можуть бути відсутніми.
Примітив — це «значення завжди є». А необовʼязковий query-параметр — це «значення може не прийти». Якщо ви намагаєтеся поєднати ці два твердження, вийде або дивна поведінка, або помилка. Для необовʼязкових чисел беріть Integer, а для параметрів зі зрозумілим значенням за замовчуванням — примітив + defaultValue.

Помилка №3: зберігати значення за замовчуванням «де завгодно, тільки не в контролері».
Якщо size за замовчуванням дорівнює 20, це частина поведінки API. Якщо ви тримаєте дефолт десь у сервісі, десь у контролері, а десь «на фронті так заведено», ви отримаєте контракт, який неможливо зрозуміти без археологічних розкопок. defaultValue у @RequestParam часто дає найбільш читабельну фіксацію значень за замовчуванням прямо на межі.

Помилка №4: робити параметри з неясними іменами, бо «так швидше друкувати».
?a=Ann&s=TODO&p=1 виглядає економно, але економія зазвичай хибна: за тиждень ніхто не памʼятає, що таке a. Публічний контракт API живе довше, ніж ваш сьогоднішній настрій. assigneeName, status, page, size — нудно, зате зрозуміло.

Помилка №5: намагатися «сховати» фільтри в path замість query.
Коли дуже хочеться красивий URI, виникає спокуса зробити /api/v1/tasks/status/TODO. Але це швидко ламає масштабованість: далі зʼявиться /priority/HIGH, потім /assignee/Anna, і ви раптово будуєте вкладену ієрархію з фільтрів, хоча це не ресурси. Фільтр — це налаштування запиту до колекції, а не адреса ресурсу. Отже, йому місце в query.

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