1. Фільтрація як частина collection endpoint
Фільтрація в REST API часто здається оманливо простою: «ну додамо кілька query-параметрів — і готово». Але саме тут зазвичай і починається хаос: параметрів стає дедалі більше, правила порівняння в кожного свої, а клієнту доводиться вгадувати, чому один фільтр працює «за підрядком», інший — «за точним збігом», а третій — «якось дивно». Тому почнемо з правильної рамки: filtering — це операція звуження вже наявної колекції, а не окрема бізнес-операція і не нова сутність.
GET /api/v1/tasks уже вміє page/size/sort. Тепер до цього самого collection endpoint додаються критерії відбору. Ми не відкриваємо новий URI і не змінюємо природу запиту: просто уточнюємо, які саме задачі мають потрапити до вибірки.
Якщо сформулювати чесно, GET /api/v1/tasks — це «прочитати подання колекції задач». Параметри фільтрації не змінюють природу запиту: ми все ще читаємо задачі, просто з додатковими умовами. Дуже корисна метафора тут — «сито»: колекція задач у вас уже є, а фільтри — це змінні сітки. Сітка з дрібнішими отворами пропускає менше об’єктів. Але це все ще те саме сито, а не окрема кухня з окремим кухарем.
У цій лекції ми говоритимемо про equality filters — фільтри за точним значенням поля (з поправкою на регістр для рядків). Це найзрозуміліший і найпередбачуваніший клас фільтрів: один параметр відповідає одному полю, значення параметра — очікуваному значенню поля, а правило порівняння фіксоване й повторюване.
2. Контракт фільтрів: параметри та семантика
Коли ми додаємо фільтри, найважливіше — не написати код, а домовитися про зміст. Зміст — це частина контракту, і якщо його не проговорити, клієнти почнуть будувати здогадки. А здогадки, як відомо, у продакшені дуже швидко перетворюються на баги, тому що в клієнта і на сервері зазвичай різні «очевидності». Тому ми фіксуємо мінімальний набір фільтрів і їхні правила так, щоб вони читалися однаково і людиною, і кодом клієнта.
Нижче — канонічний набір базових equality filters. Тут немає текстового пошуку та діапазонів дат, тому що в них інша семантика: там уже важливі підрядок і межі діапазону. Спочатку корисно зафіксувати найпередбачуваніший випадок — точний збіг значення поля.
| Query-параметр | Тип (у контролері) | Приклад | Семантика (як читаємо) |
|---|---|---|---|
| status | TaskStatus | status=IN_PROGRESS | задача має мати рівно цей статус |
| priority | TaskPriority | priority=HIGH | задача має мати рівно цей пріоритет |
| assigneeName | String | assigneeName=alice | задача має бути призначена на виконавця з таким імʼям; порівняння робимо без урахування регістру |
| tag | String | tag=backend | у задачі має бути рівно такий тег (не «схожий» і не «починається з»), теж без урахування регістру |
| archived | Boolean | archived=true | це зручний фільтр «показати архівні / неархівні»; у нашому проєкті archived = похідний від status == ARCHIVED |
Особливо важливо не дати archived стати другим «світом статусів». Ми не створюємо паралельну реальність «status один, archived інший». archived — це просто зручний boolean-прапорець для UI-клієнта (прапорець «Показувати архівні»), але джерелом істини залишається статус.
3. Spring MVC: приймання фільтрів у контролері
Контролер — це місце, де ми оголошуємо web-контракт: які параметри приймає endpoint, які типи очікує, які значення вважаються допустимими, і який формат відповіді повертається. Але контролер не повинен перетворюватися на «фільтрувальний комбайн», де половина бізнес-логіки живе в методі @GetMapping. Інакше у вас неминуче почнеться «копіпаста» й розходження правил між ендпоінтами, а далі — смуток, журба і вічне «а чому тут інакше?».
Для першого кроку фільтрації найпрозоріший варіант — прийняти параметри як @RequestParam(required = false). Якщо параметр не передано, Spring покладе null, і це зручно: null читається як «фільтр не активний». На такому вході найпростіше побачити сам контракт. Коли критеріїв стає більше, їх уже зручно збирати в один object, але базова логіка не змінюється.
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@GetMapping("/api/v1/tasks")
public PagedResponse<TaskSummaryResponse> getTasks(
// null = фільтр вимкнено (параметр у query не передали)
@RequestParam(required = false) TaskStatus status,
@RequestParam(required = false) TaskPriority priority,
@RequestParam(required = false) String assigneeName,
@RequestParam(required = false) String tag,
@RequestParam(required = false) Boolean archived
) {
// Контролер сам не фільтрує: він лише приймає контракт і делегує в сервіс
return taskService.findTasks(status, priority, assigneeName, tag, archived);
}
Головне тут — не форма виклику сервісу, а межа відповідальності: контролер оголошує query-контракт, а відбір задач відбувається далі. page/size/sort при цьому нікуди не зникають — вони просто не потрібні, щоб побачити сам механізм фільтрації.
Перевірити такий endpoint можна звичайним .http запитом. Ідея проста: параметрів може бути скільки завгодно, а сервер застосує всі активні одночасно.
### Активні задачі з високим пріоритетом
GET http://localhost:8080/api/v1/tasks?status=IN_PROGRESS&priority=HIGH
Accept: application/json
4. Логіка фільтрації: сервіс і тонкий контролер
Одна з найчастіших причин, чому list-endpoint з часом «ламається», — це коли фільтри починають жити прямо в контролері. Спочатку це навіть виглядає красиво: один метод, усе поруч, працює швидко. Потім додається ще один параметр, потім ще два, потім якась «особлива обробка archived», і в підсумку ви отримуєте контролер, який читати страшно, а тестувати — боляче. Тому правило в нас просте: контролер приймає параметри й передає їх у сервіс, а сервіс уже вирішує, які задачі підходять.
У Task Tracker API це особливо важливо, тому що проєкт навчальний і in-memory. Ми легко можемо спокуситися: «ну фільтруємо список прямо тут, що такого?». Проблема не в тому, що так не можна в принципі, а в тому, що так ви ламаєте архітектурну дисципліну, яка пізніше має плавно перейти в курс по JPA. Сьогодні в нас List<Task> у памʼяті, завтра — запит до БД. Якщо вся логіка фільтрації розмазана по контролерах, перехід буде болісним.
Сервісний шар також зручний тим, що саме там можна тримати маленькі методи на кшталт matchesTag(...) або matchesArchived(...). Вони виглядають як «правила відбору», і це саме те, що ми хочемо читати, коли намагаємося зрозуміти поведінку API.
5. Композиція фільтрів: вимкнені умови
Усередині сервісу нам потрібна проста, але залізобетонна логіка: кожен фільтр — це додаткова умова, а всі активні фільтри мають застосовуватися спільно, тобто через логічне І. Найзручніший спосіб зробити код читабельним — зібрати «головну умову» в один метод, а деталі винести в маленькі matches...-функції. Це схоже на хороший if у валідації: замість одного монстра на 20 рядків — кілька маленьких перевірок, кожна зі зрозумілою назвою.
Почнемо з «компоновника»: він читається як декларація контракту. Навіть якщо ви завтра забудете деталі, за цим методом ви відновите картину.
private boolean matchesBasicFilters(
Task task,
TaskStatus status,
TaskPriority priority,
String assigneeName,
String tag,
Boolean archived
) {
// Кожен метод зіставлення має бути "нейтральним", якщо його фільтр вимкнено (null/blank)
// І лише коли фільтр увімкнено — реально відкидати невідповідні задачі
return matchesStatus(task, status)
&& matchesPriority(task, priority)
&& matchesAssignee(task, assigneeName)
&& matchesTag(task, tag)
&& matchesArchived(task, archived);
}
А ось як це зазвичай підключається до in-memory колекції — теж максимально «плоско» й читабельно:
import java.util.List;
List<Task> filtered = repository.findAll().stream()
// Застосовуємо всі активні фільтри одним предикатом: логічне І всередині matchesBasicFilters
.filter(task -> matchesBasicFilters(task, status, priority, assigneeName, tag, archived))
.toList();
Зверніть увагу: ми не обговорюємо тут пагінацію й сортування — вони у вас уже є, і вони залишаться. Зараз наш фокус — щоб фільтри працювали передбачувано.
6. Реалізація фільтрів за полями
Enum-фільтри: status і priority
Enum-фільтри — це найприємніший тип фільтрації. По-перше, клієнт не може надіслати «майже статус» — він або надіслав допустиме значення enum, або отримав помилку привʼязки, яку ваша global error handling уже красиво запакує. По-друге, порівняння enum у Java можна робити через ==, і це не «брудний хак», а нормальна практика: enum — це singleton-значення, і == порівнює посилання, що коректно.
Два маленькі методи — і у вас уже читається як документація:
private boolean matchesStatus(Task task, TaskStatus status) {
// Фільтр вимкнено: не обмежуємо вибірку за status
if (status == null) {
return true;
}
// Enum порівнюємо через == (це коректно для enum)
return task.getStatus() == status;
}
private boolean matchesPriority(Task task, TaskPriority priority) {
// Фільтр вимкнено: не обмежуємо вибірку за priority
if (priority == null) {
return true;
}
return task.getPriority() == priority;
}
Якщо клієнт не передав status, метод повертає true, тобто «не відкидаємо задачу за цим критерієм». Це дуже важливий патерн: умови не повинні заважати одна одній, і кожен фільтр має бути нейтральним, коли він вимкнений.
Рядкові фільтри: assigneeName і tag
Рядки — підступна річ. З enum усе зрозуміло: або збіглося, або ні. З рядками одразу починається «а давайте за підрядком», «а давайте за префіксом», «а давайте ігнорувати пробіли», «а давайте ще й крапку з комою». І ось ви вже пишете мініпошуковик, хоча хотіли просто фільтр. Тому в базових фільтрах ми вводимо суворе правило: строкові equality filters — це точний збіг значення, але без урахування регістру. Цього вистачає майже для будь-якого UI і не перетворює API на пошукову платформу.
Фільтр assigneeName має враховувати, що задача може бути взагалі не призначена (усередині Task.assigneeName може бути null). Отже, за активного фільтра такі задачі мають просто не проходити.
private boolean matchesAssignee(Task task, String assigneeName) {
// Порожнє або пробільне значення = вважаємо, що фільтр не вмикали
if (assigneeName == null || assigneeName.isBlank()) {
return true;
}
String actual = task.getAssigneeName();
// Важливо: якщо задача не призначена (actual == null), за активного фільтра вона не проходить
return actual != null && actual.equalsIgnoreCase(assigneeName.trim());
}
Фільтр tag схожий за змістом, але тут ми не порівнюємо одне поле, а перевіряємо, чи є значення в колекції. І саме тут дуже легко випадково зробити фільтр «схожості», якщо почати використовувати contains. Нам це не потрібно. Нам потрібно рівно: тег збігається або ні.
private boolean matchesTag(Task task, String tag) {
// Порожнє або пробільне значення = вважаємо, що фільтр не вмикали
if (tag == null || tag.isBlank()) {
return true;
}
String expected = tag.trim();
return task.getTags().stream()
// equality filter: суворий збіг тега (але без урахування регістру)
.anyMatch(t -> t.equalsIgnoreCase(expected));
}
Семантика виходить гранично прозора. Клієнт передав tag=backend — отже, ми шукаємо задачу, у якої в списку тегів є backend у будь-якому регістрі (Backend, BACKEND, backend — усе ок). Але backend-core і back не збігатимуться, і це добре: передбачуваність дорожча за «розумність».
Фільтр archived і модель статусів
archived — це параметр, який люблять фронтенди. У UI зручно мати чекбокс «Показувати архівні», а бізнес-статуси на кшталт DONE/ARCHIVED користувачу часто взагалі не хочеться пояснювати в деталях. Але на боці API ми зобовʼязані бути акуратними: archived не має перетворюватися на паралельний «другий статус», який живе окремо від TaskStatus. У нашому проєкті правило просте: задача архівна тоді і тільки тоді, коли її status == ARCHIVED.
Код фільтра виходить маленьким і майже математичним:
private boolean matchesArchived(Task task, Boolean archived) {
// archived == null означає: клієнт не вмикав цей фільтр
if (archived == null) {
return true;
}
boolean isArchived = task.getStatus() == TaskStatus.ARCHIVED;
// Порівнюємо очікуване (з query) з фактом (зі status)
return archived == isArchived;
}
Тут є маленька тонкість: archived — обʼєкт Boolean, а не boolean. Це спеціально, щоб параметр міг бути «не заданий» (тобто null). В інтерфейсі API це читається як «фільтр вимкнено».
А ось семантична тонкість уже більш контрактна: що робити, якщо клієнт передав одночасно status=ARCHIVED і archived=false? Це суперечність, яку краще вважати помилкою входу, а не «ну сервер якось здогадається». У нас уже є validation і єдиний error contract, тому ми можемо дозволити собі чесну поведінку: за суперечливих фільтрів віддавати 400 INVALID_INPUT, щоб клієнт виправив запит.
Мініперевірка може жити в сервісі до фільтрації:
private void validateStatusAndArchived(TaskStatus status, Boolean archived) {
// Перевіряємо лише тоді, коли обидва фільтри реально задано
if (status == null || archived == null) {
return;
}
boolean statusArchived = status == TaskStatus.ARCHIVED;
// Якщо фільтри суперечать один одному — це помилка вхідних даних, а не "дивна фільтрація"
if (statusArchived != archived) {
throw new InvalidInputException("status і archived суперечать одне одному");
}
}
Так, повідомлення тут «англійською і сухе» — це нормально для навчального прикладу. У вашому ProblemDetail назовні піде структурований код INVALID_INPUT і зрозумілий detail.
Тут цю перевірку показано поруч із фільтрацією просто тому, що так легше побачити сам конфлікт. За змістом це таке саме cross-field rule вхідних параметрів, як і перевірка діапазону дат: коли всі query-критерії зібрані в один обʼєкт, такі обмеження зручно тримати поруч з іншою валідацією входу.
7. Порожній результат: 200 OK для колекції
Коли ви додаєте фільтри, дуже хочеться інтерпретувати ситуацію «не знайдено жодної задачі» як «ну, значить, задач немає». І тут багато API починають повертати 404 на list endpoint. Це одна з тих помилок, яка спочатку здається безпечною, а потім змушує клієнтів писати дивні умови на кшталт «якщо 404 — показуємо порожній список, але взагалі-то це успіх». Тобто ви самі зламали семантику HTTP, а потім разом із клієнтом імітуєте, що так і задумано.
Для GET /api/v1/tasks правило залишається тим самим, що й для пагінації: якщо вибірка порожня, ми повертаємо нормальну успішну відповідь зі сторінкою, в якій items порожній. Колекція як ресурс існує, просто всередині неї зараз немає елементів, що задовольняють умови.
Якщо у вас PagedResponse<T>, це особливо зручно: клієнту повертаються метадані сторінки, і йому не потрібно вгадувати, «чи успішний запит». Він успішний — просто порожньо.
Схематично, без деталей обчислення сторінок, це виглядає так:
GET /api/v1/tasks?status=DONE&priority=CRITICAL
→ 200 OK
→ PagedResponse {
items: [],
page: 0,
size: 20,
totalElements: 0,
totalPages: 0,
sort: "updatedAt,desc"
}
Це чесна й зручна модель: UI-клієнт може спокійно показувати «нічого не знайдено», не перемикаючись у режим «помилка».
8. Типові помилки під час додавання базових фільтрів
Коли фільтри з’являються вперше, помилки зазвичай не «зламали компіляцію», а «зламали зміст». Код працює, але контракт стає каламутним. Нижче — набір граблів, на які найчастіше наступають, і яких краще уникнути одразу, поки проєкт ще не розрісся.
Помилка № 1: переносять фільтрацію в path замість query.
Шлях виду /api/v1/tasks/status/DONE виглядає «красиво» лише до першого другого фільтра. Потім ви захочете priority, потім tag, потім archived, і в результаті отримаєте або комбінаторний вибух URI, або дивні гібриди. Для відбору елементів колекції в REST-підході природне місце — query-параметри.
Помилка № 2: використовують неоднозначні імена параметрів.
Якщо назвати фільтр просто name, ви самі створюєте загадку: це імʼя задачі, автора коментаря, виконавця, тега? У нашому контракті assigneeName спеціально довге: воно неприємне лише в момент написання, зате приємне щоразу, коли ви читаєте запит.
Помилка № 3: не фіксують семантику archived поруч зі status.
Без явного правила клієнт неминуче спробує передати обидва параметри й отримати несподіванку. Сьогодні «status важливіший», завтра «archived важливіший», післязавтра «обидва застосовуються якось разом». Контракт має бути нудним: або забороняємо суперечності через 400, або явно описуємо пріоритет. У навчальному API чесний 400 зазвичай простіший і чистіший.
Помилка № 4: роблять різні правила порівняння для різних рядкових фільтрів.
Якщо assigneeName у вас case-insensitive, а tag раптом case-sensitive, то клієнту буде складно пояснити користувачеві, чому Backend працює, а backend — ні. Краще вибрати одне правило, у нашому випадку — exact match без урахування регістру, і триматися його всюди, де це логічно.
Помилка № 5: реалізують «розумні збіги» там, де обіцяли equality filter.
Найчастіший приклад — використовувати contains замість equalsIgnoreCase. Тоді tag=back раптом починає матчити backend, і ви вже зробили не фільтр, а пошук за підрядком. Це не «погано» як ідея, але це інший тип поведінки, який має бути окремою домовленістю, зазвичай ще й окремим параметром, наприклад q.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ