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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ