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.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ