JavaRush /Курсы /Java Server /Фильтры по status ...

Фильтры по status и title

Java Server
24 уровень , 3 лекция
Открыта

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 поиск, особенно в учебном проекте.

1
Задача
Java Server, 24 уровень, 3 лекция
Недоступна
Фильтр списка по `status`
Фильтр списка по `status`
1
Задача
Java Server, 24 уровень, 3 лекция
Недоступна
Фильтр по `title` и комбинирование фильтров
Фильтр по `title` и комбинирование фильтров
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ