1. Роль фильтрации в списке
Если вы сейчас смотрите на наш reading-list и думаете: “Зачем фильтровать, там же два элемента, я их и так глазами вижу”, то поздравляю — вы мыслите как разработчик на маленьком демо-данном наборе. Пользователь же мыслит как человек, у которого через пару недель будет 40 книг, часть “в планах”, часть “читаю”, часть “уже дочитал”, и ещё несколько “бросил, но стыдно признаться”.
Фильтрация в GET /api/v1/reading-list — это способ сделать endpoint полезнее, не ломая его смысл. Это всё ещё чтение коллекции, но теперь у клиента появляется возможность сказать: “Покажи только завершённые” или “Покажи те, в названии которых есть ‘java’”. Важно, что мы не создаём новый ресурс и не меняем состояние сервера — мы просто уточняем, какую часть коллекции хотим увидеть в ответе.
И ещё одна причина, почему эта тема важна именно сейчас: фильтрация на собственном сервере заставляет вас руками потрогать query params как настоящий инструмент API-контракта. В Spring MVC это потом станет “одна аннотация и один параметр в методе”, но если вы не видели механику сейчас, аннотация останется магическим заклинанием из серии “ну так надо”.
2. Query params: контракт фильтров
Query params — это часть URL после ?, где параметры идут парами key=value, а между ними стоит &. Для фильтрации коллекции это почти идеальное место: путь (/api/v1/reading-list) остаётся адресом коллекции, а query params становятся “условиями выборки”. Это ровно то разделение ответственности, за которое REST-подобные API любят: путь отвечает на вопрос “что читаем?”, а query params — “как именно читаем?”.
Мы договоримся о таком контракте:
| Запрос | Смысл | Ожидаемый статус |
|---|---|---|
| GET /api/v1/reading-list | вернуть весь список | 200 OK |
| GET /api/v1/reading-list?status=FINISHED | вернуть только по статусу | 200 OK |
| GET /api/v1/reading-list?title=java | вернуть только где title содержит “java” | 200 OK |
| GET /api/v1/reading-list?status=FINISHED&title=java | оба фильтра сразу (и это “И”, а не “ИЛИ”) | 200 OK |
| GET /api/v1/reading-list?status=UNKNOWN | клиент прислал неверный статус | 400 Bad Request |
Обратите внимание на две вещи.
Первая: отсутствие фильтра (параметр не передан) означает “не сужай выборку”. То есть GET /api/v1/reading-list и GET /api/v1/reading-list?title= в нашей реализации будут вести себя одинаково: вернут список без фильтра по title. Это делает API более “дружелюбным”: клиент не обязан идеально попадать в формат, чтобы получить полезный ответ.
Вторая: если фильтр передан и он неверный, мы не делаем вид, что “ничего не нашлось”. Это принципиально важная дисциплина. Ситуация “в списке нет книг со статусом FINISHED” и ситуация “клиент прислал статус DONEEEEE” — разные. В первом случае ответ 200 с пустым списком честный. Во втором случае 400 честнее, потому что проблема в запросе, а не в данных.
Чтобы эта дисциплина не развалилась, мы будем помнить простое правило: “невалидный параметр — это 400, а пустой результат фильтрации — это 200 с items: []”.
3. Фильтр по status: парсинг enum
Самая “подлая” часть фильтрации по статусу — статус у нас не строка “как получится”, а enum ReadingStatus. Это хорошо для внутреннего кода (никаких магических строк и опечаток), но снаружи HTTP-запрос всегда приходит строками. Значит, нам нужно превратить String из query params в ReadingStatus.
Если делать это “в лоб”, напрашивается ReadingStatus.valueOf(rawStatus). И да, это работает… ровно до первого пользователя, который напишет finished вместо FINISHED. valueOf строго чувствителен к регистру, пробелам и вообще к человеческой природе.
Поэтому мы добавим маленькую нормализацию: обрежем пробелы и приведём к верхнему регистру. Это не делает API “слишком либеральным”, зато делает его удобным. В конце концов, мы не банк и не компилятор, а учебное API. Компилятор, правда, тоже иногда банк, но это другая история.
Для контекста, enum выглядит примерно так (скорее всего он у вас уже есть с предыдущих дней):
public enum ReadingStatus {
// Книга в планах (ещё не начали)
PLANNED,
// Книга читается прямо сейчас
IN_PROGRESS,
// Книга дочитана
FINISHED
}
Теперь сделаем парсер статуса. Он будет маленьким, но очень полезным:
import com.example.readlater.readinglist.domain.ReadingStatus;
import java.util.Locale;
private ReadingStatus parseStatus(String rawStatus) {
// Нормализуем ввод: убираем пробелы и приводим к верхнему регистру,
// чтобы finished / Finished / FINISHED считались одинаковыми.
String normalized = rawStatus.trim().toUpperCase(Locale.ROOT);
// valueOf — строгий парсер: если значение неизвестно, он бросит IllegalArgumentException
return ReadingStatus.valueOf(normalized);
}
Если строка содержит неизвестное значение, valueOf бросит IllegalArgumentException. И это нормально: исключение здесь — сигнал “в контракте запроса беда”. Главное, чтобы мы потом не превратили это исключение в 500, иначе получится, что пользователь виноват, а сервер “как бы сломался”.
Лучше не плодить версии этого parser-а по разным handler-ам и сервисам: одного normalizeBlank(...) и одного parseStatus(...) уже достаточно, чтобы полностью описать поведение параметра status.
Ещё один маленький помощник — нормализация пустых значений. Она нужна и для status, и для title, поэтому пусть будет универсальной:
private String normalizeBlank(String value) {
// Если параметр не передан (null) или он пустой/из пробелов — считаем, что фильтра нет.
if (value == null || value.isBlank()) return null;
// Обрезаем пробелы по краям, чтобы " java " работало как "java".
return value.trim();
}
С этим помощником наш фильтр по статусу становится “условным”: если параметра нет — фильтр не применяется; если параметр есть — мы парсим и фильтруем; если параметр странный — это 400.
И да, здесь мы делаем маленькую методическую “подлянку”: сервис может бросить IllegalArgumentException, а handler должен правильно это превратить в HTTP-ответ. Это ровно та рутина, которую потом автоматизируют фреймворки. Но пока — руками, как в кружке “Юный backend-разработчик”.
4. Фильтр по title: частичное совпадение
Фильтрация по названию — штука более “человеческая”, чем статус. Статус — это строгий набор значений, а title — обычный текст. Здесь нельзя сказать “валидный title или нет”, потому что title может быть любым. Но нам нужно решить, как именно фильтровать.
Мы выберем простое правило: title фильтрует по частичному совпадению, без учёта регистра. То есть java должно найти “Effective Java”, “Java Concurrency in Practice”, “java для тех, кто очень устал” и даже “JAVA” (если кто-то кричит в названии, это его право).
Технически это можно сделать через toLowerCase(Locale.ROOT) + contains. Locale.ROOT — маленькая деталь, которая помогает избежать странностей с локальными правилами преобразования регистра. Это не то чтобы критично для учебного проекта, но привычка хорошая: меньше неожиданных сюрпризов.
Вот компактный хелпер:
import java.util.Locale;
private boolean matchesTitle(String title, String titlePart) {
// Ищем без учёта регистра: и книга, и кусок запроса приводятся к одному регистру.
// Locale.ROOT — безопасный вариант без "локальных сюрпризов" (типа турецкой i).
return title.toLowerCase(Locale.ROOT)
.contains(titlePart.toLowerCase(Locale.ROOT));
}
Почему мы не делаем “по словам”, “по регуляркам”, “по полнотекстовому поиску” и прочие радости? Потому что наша цель — не построить поисковик, а показать механику query params и фильтров в коллекционном GET. Частичное совпадение даёт хорошую демонстрацию и вполне полезно для маленького API.
Ещё одна практическая деталь: query params приходят URL-энкоденными. Если пользователь отправит title=clean%20code, вы должны получить “clean code”. Скорее всего, ваш helper queryParams(exchange) уже делает URL-decoding. Если нет — будет очень “весёлый” баг: фильтр вроде есть, но ничего не находится, потому что вы ищете %20 как символы.
5. Комбинация фильтров
Самая частая ошибка новичка в фильтрации — начать писать “лес из if-ов” прямо в handler’е: если есть status, если есть title, если есть оба, если нет ни одного… и через 15 минут вы уже ненавидите себя, Java и особенно людей, которые придумали HTTP (хотя они, в общем-то, ни при чём).
Нормальная стратегия такая: handler отвечает за HTTP-слой (прочитал параметры, выбрал статус ответа, вернул JSON), а сервис отвечает за прикладной смысл “дай список с учётом фильтров”. Поэтому фильтрацию мы делаем в сервисе, а handler только передаёт туда параметры.
Фильтры — это не повод заводить второй сервис специально “под поиск”. Это всё тот же ReadingListService: у коллекционного endpoint-а просто появляется параметризованный способ собрать ответ.
public ReadingListResponse getFiltered(String rawStatus, String rawTitle) {
// 1) Нормализуем входные query params: null/пусто => фильтра нет
String statusValue = normalizeBlank(rawStatus);
String titleValue = normalizeBlank(rawTitle);
// 2) Парсим status только если он реально задан
// (если не задан — status = null и фильтр не применяется)
ReadingStatus status = (statusValue == null) ? null : parseStatus(statusValue);
// 3) Фильтруем коллекцию: оба фильтра работают как "И"
List<ReadingItemResponse> items = repository.findAll().stream()
// Фильтр по статусу: если status == null — пропускаем всех
.filter(i -> status == null || i.getStatus() == status)
// Фильтр по title: если titleValue == null — пропускаем всех
.filter(i -> titleValue == null || matchesTitle(i.getTitle(), titleValue))
// Преобразуем доменную сущность в DTO ответа
.map(this::toResponse)
.toList();
// 4) count — это размер уже отфильтрованного списка, а не всего репозитория
return new ReadingListResponse(items, items.size());
}
Здесь важно прочитать код как историю.
Сначала мы нормализуем строки: null и пустота превращаются в null (то есть “фильтра нет”). Затем мы парсим статус только если он реально задан. Потом берём все элементы из репозитория и последовательно применяем фильтры. Обратите внимание, что фильтры применяются как “И”: элемент должен пройти и статус, и title, если оба фильтра есть.
Ещё один тонкий момент: count считается по итоговому списку, а не по хранилищу. Это кажется очевидным, но на практике люди часто делают repository.findAll().size() и получают “count: 2” при “items: []”. Клиент после такого начинает подозревать, что API слегка… фантазирует. А мы не хотим конкурировать с LLM, у неё в фантазиях опытнее.
И наконец: если после фильтров список пуст, это всё равно нормальный ответ. Мы просто вернём:
{
"items": [],
"count": 0
}
И статус будет 200 OK. Никакого 404 у коллекции из-за того, что она пустая, быть не должно.
Именно эти helper-правила и должны жить в рабочем коде без “облегчённой” второй версии, иначе ?status= и ?title= java начнут вести себя по-разному в соседних ветках.
6. Фильтрация в handler
Теперь самое “web-layer” место: нужно взять query params из входящего запроса и передать их в сервис. В прошлых лекциях у нас уже была идея handleList(exchange), где list-endpoint обрабатывается отдельно от get-by-id. Мы просто расширим эту ветку: теперь она читает status и title и вызывает service.getFiltered(...).
Ниже только ветка списка внутри того же handler-а; routing-скелет и общий sendJson(...) не меняются.
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.util.Map;
private void handleList(HttpExchange exchange) throws IOException {
Map<String, String> params = queryParams(exchange);
// Забираем query params (могут быть null, если параметр не передан)
String status = params.get("status");
String title = params.get("title");
try {
// Сервис решает, как применять фильтры и что вернуть
ReadingListResponse body = service.getFiltered(status, title);
sendJson(exchange, 200, body, mapper);
} catch (IllegalArgumentException e) {
// Сюда попадаем, если parseStatus(...) не смог распарсить enum
sendJson(exchange, 400, ErrorResponses.invalidStatus(status), mapper);
}
}
В тот же ErrorResponses удобно добавить ещё один factory-метод:
public static ErrorResponse invalidStatus(String rawStatus) {
return new ErrorResponse(
"INVALID_STATUS",
"Unknown status. Allowed: PLANNED, IN_PROGRESS, FINISHED",
List.of(String.valueOf(rawStatus))
);
}
Тут мы ловим IllegalArgumentException, который прилетает из ReadingStatus.valueOf(...), и превращаем его в понятный 400 с ErrorResponse. Да, сейчас это “локальная” обработка ошибки, прямо в методе. Это нормально для текущего этапа: мы ещё не строим глобальный механизм ошибок, но уже сохраняем единый формат ответа.
Ещё один момент, который полезно держать в голове — логика обработки запроса становится вполне “линейной”, и это можно представить схемой. Не потому что “мы любим диаграммы”, а потому что мозг новичка часто успокаивается, когда видит путь данных от A до B.
flowchart TD
A["HTTP запрос: GET /api/v1/reading-list?status=...&title=..."] --> B["Handler: читает query params"]
B --> C["Service: normalize, parseStatus, filter"]
C --> D["Repository: findAll()"]
D --> C
C --> E["ReadingListResponse items, count"]
E --> F["Handler: sendJson 200"]
C -->|IllegalArgumentException| G["Handler: ErrorResponse + sendJson 400"]
Если вы сейчас посмотрели на диаграмму и подумали “да это же простая штука”, то всё отлично: мы хотим, чтобы она была простой. Мы намеренно не строим “универсальный фильтрационный движок”. Наша задача — научиться читать query params, аккуратно валидировать “строгий” фильтр (status) и честно комбинировать его с “мягким” фильтром (title).
Кстати, если у вас уже есть логирование входящих запросов и итогового статуса, то в handleList() можно на уровне debug писать, какие фильтры пришли. Главное — не превращать лог в болото: мы логируем краткий контекст, а не весь список книг.
7. Типичные ошибки при фильтрации
Фильтры в API кажутся “мелочью”, поэтому на них часто наступают особенно уверенно. И это нормально: вы учитесь. Но некоторые ошибки повторяются настолько часто, что их стоит проговорить заранее, чтобы не тратить вечер на отладку “почему я ничего не нахожу”.
Ошибка №1: пытаться засунуть фильтры в path вместо query params.
Иногда появляются урлы вроде /api/v1/reading-list/status/FINISHED/title/java. Технически вы можете так сделать, но вы смешиваете адрес ресурса и условия выборки. В итоге API начинает выглядеть как самодельная головоломка. Для коллекции фильтры естественнее жить в query params: путь остаётся стабильным, а фильтры — опциональными.
Ошибка №2: молча возвращать пустой список при невалидном status.
Запрос ?status=DOOOONE и пустой список с 200 OK — это “вежливая ложь”. Клиент не понимает, он ошибся или действительно ничего нет. Правильнее вернуть 400 Bad Request и объяснить, какие статусы допустимы. Пустой список должен означать “по этим условиям ничего не нашлось”, а не “я не смог понять твой запрос, но постеснялся сказать”.
Ошибка №3: не ловить IllegalArgumentException и случайно отдавать 500.
ReadingStatus.valueOf(...) бросает исключение, и если вы его не обработали, сервер вернёт внутреннюю ошибку. Тогда клиент думает: “сервер сломан”, хотя на самом деле он просто прислал неправильный параметр. В read-only API такие вещи должны превращаться в аккуратный 400.
Ошибка №4: считать count не по отфильтрованному списку.
Если вы сначала берёте размер всего хранилища, а потом фильтруете items, можно получить “count: 10” при “items: 2”. Это быстро ломает доверие к контракту. count должен отражать именно то, что реально возвращено в items после фильтров.
Ошибка №5: делать фильтр по title регистрозависимым и удивляться результатам.
Если вы используете contains без нормализации регистра, то title=java не найдёт “Java Concurrency in Practice”. Пользователь потом решит, что сервер “иногда видит Java, иногда нет”. Проще и дружелюбнее сразу сделать case-insensitive поиск, особенно в учебном проекте.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ