1. Filtering как часть 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 | задача должна быть назначена на исполнителя с таким именем; сравнение делаем case-insensitive |
| tag | String | tag=backend | у задачи должен быть ровно такой тег (не “похоже”, не “начинается с”), тоже case-insensitive |
| 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 and archived contradict each other");
}
}
Да, сообщение здесь “по-английски и сухо” — это нормально для учебного примера. В вашем 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 нет. Лучше выбрать одно правило (в нашем дне — case-insensitive exact match) и держаться его везде, где это логично.
Ошибка №5: реализуют “умные совпадения” там, где обещали equality filter.
Самый частый пример — использовать contains вместо equalsIgnoreCase. Тогда tag=back внезапно начинает матчить backend, и вы уже сделали не фильтр, а поиск по подстроке. Это не “плохо” как идея, но это другой тип поведения, который должен быть отдельной договорённостью (и обычно отдельным параметром, например q).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ