JavaRush /Курсы /Spring REST & MVC /@RequestParam для фил...

@RequestParam для фильтров и дефолтов

Spring REST & MVC
6 уровень , 1 лекция
Открыта

1. Query-параметры для list-endpoint

С адресацией мы уже разобрались: конкретный ресурс живёт в path, а список почти сразу требует дополнительных условий. GET /api/v1/tasks без фильтров — нормальный старт, но как только нужны status, assigneeName, page или size, плодить под каждую вариацию новый URI становится больно.

Для таких уточнений у HTTP есть query-строка. Она не меняет адрес коллекции, а настраивает выборку: какие элементы показать, в каком объёме и с какими ограничениями. Поэтому 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. Значит, между raw query-значением и аргументом контроллера есть слой преобразования, из-за которого некорректное значение может отвалиться ещё до сервисной логики. Саму механику этого binding'а мы дальше разберём на 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 обычно трактуем как "фильтр выключен".
}

Это хорошая базовая модель для фильтров. Фильтр “по статусу” — это как галочка в UI: пользователь может включить, а может не включать.

Почему “необязательный параметр” плохо дружит с примитивами

Здесь есть типичная ловушка новичка, которая выглядит смешно ровно до момента, пока вы не потратите полчаса на отладку.

Если параметр может отсутствовать, он может быть 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.

1
Задача
Spring REST & MVC, 6 уровень, 1 лекция
Недоступна
Поиск заметок по обязательной метке
Поиск заметок по обязательной метке
1
Задача
Spring REST & MVC, 6 уровень, 1 лекция
Недоступна
Список фильмов с необязательным жанром и значениями по умолчанию
Список фильмов с необязательным жанром и значениями по умолчанию
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ