1. Идея /search в REST API
Если вы впервые строите REST API, мозг работает по привычной логике «каждому действию — свой endpoint». Поиск кажется отдельным действием: “search”. И кажется логичным написать что-то вроде GET /api/v1/tasks/search?q=.... Это ощущается аккуратно: есть «просто список» и есть «список с поиском». На практике это почти всегда приводит к двум похожим API, которые начинают расходиться как два носка после стирки.
Давайте честно сформулируем соблазн. Когда у нас появляется q и диапазон дат, GET /api/v1/tasks начинает выглядеть «перегруженным». Кажется, что выделенный /search разгрузит URL и внесёт порядок. Но REST-порядок — это не про “чтобы URL был коротенький и красивый”, а про смысл: что именно мы читаем и что является ресурсом.
В нашем домене ресурс — это коллекция задач. И поиск с фильтрами — это не новый ресурс и не новая операция по своей транспортной семантике. Это всё ещё чтение коллекции задач, просто с условиями отбора.
Поведение выборки у нас уже зафиксировано: сначала отбираем задачи, потом фиксируем порядок, потом режем страницы. Теперь встаёт другой вопрос — нужен ли ради этого второй URI, или всё это по-прежнему остаётся одной точкой входа в коллекцию.
REST-оптика: search и filtering — это чтение tasks
Представьте, что GET /api/v1/tasks — это дверь в «комнату со всеми задачами». Фильтры и q — это не новая комната и даже не новый коридор. Это скорее “очки с фильтром”: вы по-прежнему смотрите в ту же комнату, но видите только то, что соответствует условиям. Комната не меняется, меняется только выборка из неё.
В REST-модели мы стараемся не плодить endpoint’ы под каждую “операцию”, если операция не меняет смысл ресурса. И q, status, priority, dueAfter, dueBefore не меняют смысл. Клиент не делает “search resource”. Клиент читает задачи, просто просит: “дай мне подмножество”.
Отсюда вытекает простое правило, которое хорошо держать в голове: если ответ имеет тот же response shape (PagedResponse<TaskSummaryResponse>), те же статусы, те же pagination/sorting параметры и отличается только условием отбора — это почти наверняка один и тот же collection endpoint.
Чтобы это почувствовать, сравним два запроса:
- GET /api/v1/tasks?page=0&size=20
- GET /api/v1/tasks?page=0&size=20&q=report&status=IN_PROGRESS
Во втором запросе “магически” не появляется новый тип сущности. Мы не переходим к другой предметной области. Мы просто уточняем, какие элементы коллекции нас интересуют.
Если же мы делаем /api/v1/tasks/search, мы как бы говорим клиенту: “вот тут уже другая операция”. И дальше начинается: какие параметры допустимы тут, а какие там? Можно ли сортировать тут? Должны ли быть такие же default значения? А если отличаются — почему? И вы внезапно становитесь автором двух контрактов вместо одного.
2. /search почти всегда дублирует контракт
Когда вы добавляете отдельный /search, вы почти неизбежно дублируете одно и то же поведение в двух местах. Это сначала кажется «пару строк», а потом превращается в “ой, у нас баг: в /tasks default sort один, а в /tasks/search другой”.
Здесь важно не морализаторство, а простая арифметика. В нашем GET /api/v1/tasks уже живут (или должны жить) такие правила:
- page, size, sort с default значениями и validation;
- whitelist сортируемых полей;
- пустой результат как 200 OK и пустые items;
- единый error contract через ProblemDetail для invalid query params;
- детерминированный порядок операций filtering → sorting → pagination.
Если вы делаете /search, то либо вы переносите туда эти правила целиком, либо вы заставляете клиента “прыгать” между endpoint’ами, чтобы получить всё сразу. И вот тут наступает классический момент: у клиента появляется вопрос “а где правильный список?”. Чаще всего правильный — тот, который включает всё. То есть /search. А /tasks становится каким-то “полу-эндпоинтом”, существующим больше для красоты, чем для пользы.
И самое неприятное: вы создаёте две точки, которые похожи, но не одинаковы. Похожесть — это источник багов. Полное различие проще понять, чем “почти то же самое”.
Чтобы увидеть дублирование на уровне кода, достаточно такого контраста:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
class TaskController {
@GetMapping("/api/v1/tasks")
PagedResponse<TaskSummaryResponse> list(@RequestParam(required = false) String q) {
// Один и тот же ресурс: коллекция задач.
// `q` — просто опциональный критерий отбора, а не отдельная "операция поиска".
return null;
}
@GetMapping("/api/v1/tasks/search")
PagedResponse<TaskSummaryResponse> search(@RequestParam String q) {
// Здесь меняется только "обязательность" параметра `q`,
// но по смыслу это всё равно чтение той же коллекции задач.
// Такой endpoint быстро начинает тащить за собой пагинацию/сортировку/валидацию и дублировать контракт.
return null;
}
}
Код “влезает”, но вопрос простой: чем search() принципиально отличается от list()? Если только тем, что q обязательный, то это слишком слабая причина, чтобы плодить новый endpoint и потом таскать за ним весь остальной контракт.
3. Одна точка входа и композиция параметров
В API для списков главная ценность — возможность собирать запрос как конструктор: фильтр + поиск + сортировка + пагинация. Чем меньше “веток” у клиента в голове, тем проще пользоваться API.
Расширенный collection endpoint отлично поддерживает композицию. Клиент не думает, “сейчас я в режиме списка или в режиме поиска”. Он просто добавляет query-параметры по мере необходимости. Это особенно важно, когда фильтров становится много (а у нас они как раз становятся много, и это нормально).
Например, один запрос может выглядеть так (и это хорошо):
GET /api/v1/tasks?status=IN_PROGRESS&priority=HIGH&q=report&dueAfter=2026-03-01&sort=updatedAt,desc&page=0&size=20
Это один URL, один контракт, один response shape. Клиент может сохранить ссылку, повторить запрос, логировать его, делиться им внутри команды (“вот наш проблемный кейс”), и он всегда будет означать одно и то же: “дай подмножество задач”.
Если же вы вводите /search, очень быстро появляется желание “разделить обязанности”: фильтры оставить в /tasks, а поиск — в /tasks/search. И вы теряете композицию. Клиенту становится сложнее: “а если я хочу status + поиск, я куда иду?”. И вы либо начинаете дублировать параметры между endpoint’ами, либо начинаете делать “шлюз-переадресацию” внутри сервера, что обычно выглядит как попытка вылечить симптом, а не причину.
4. Сравнение вариантов
Иногда полезно не спорить абстрактно, а просто положить варианты рядом. Ниже — прагматичное сравнение для нашего учебного проекта, где мы сознательно остаёмся в пределах “обычного task API”, без поисковых движков и без сложных DSL.
| Вариант | Пример | Что выигрываем | Что теряем / чем платим |
|---|---|---|---|
| A. Расширенный GET /tasks | GET /api/v1/tasks?q=report&status=IN_PROGRESS | Один контракт, отличная композиция параметров, единые defaults и единая обработка ошибок | URL может становиться длиннее (но у нас это не проблема) |
| B. Отдельный GET /tasks/search | GET /api/v1/tasks/search?q=report | Кажется “красивее” на старте | Дублирование пагинации/сортировки/ошибок, две двери для одного смысла, риск расхождения поведения |
| C. POST /tasks/search с body | POST /api/v1/tasks/search + JSON criteria | Удобно для очень сложных критериев, когда query-параметры уже не подходят | Теряем простую ссылку, усложняем семантику read-only операции, добавляем отдельный формат input (а у нас уровень про query, а не про JSON body) |
Обратите внимание на вариант C. Он часто появляется как «следующая ступень»: когда критериев много, люди хотят отправлять JSON. Но это уже другая лига сложности. В нашем курсе мы сознательно держим критерии в query и не превращаем “чтение коллекции” в “команду поиска с телом”, потому что это распухает и контракт, и реализацию, и документацию.
5. HTTP-прагматика и границы /search
GET как “ссылка” на выборку
Когда вы используете GET /api/v1/tasks с query-параметрами, вы получаете приятный побочный эффект: запрос становится “ссылкой на выборку”. Это удобно не только “в теории”, а буквально в повседневной разработке.
Вы можете:
- сохранить этот URL в .http файле и воспроизводить его;
- отправить ссылку коллеге (“вот запрос, который возвращает пустую страницу при таком-то фильтре”);
- легко логировать path + query и понимать, что именно запрашивали;
- проще дебажить, потому что в запросе нет тела, которое надо ещё отдельно смотреть.
Если же вы делаете поиск как отдельную сущность или начинаете использовать POST /search, вы теряете часть этой простоты. POST-поиск может быть оправдан (и иногда он реально оправдан), но в нашем проекте это будет усложнение ради усложнения. Мы пока решаем задачу уровня “обычная фильтрация и простой substring search”.
Я люблю называть это правилом “не делайте из холодильника космический корабль”. Холодильник должен охлаждать. Наш list endpoint должен детерминированно возвращать задачи. Этого достаточно, чтобы он был production-like в рамках курса.
Когда отдельный /search уместен
Чтобы не звучать догматично, давайте признаем очевидное: отдельный /search иногда реально нужен. Просто не всегда и не везде.
Он становится разумным, когда вы упираетесь в ситуации вроде этих: критерии настолько сложные, что query-параметры превращаются в мини-язык, который невозможно поддерживать без слёз; вы вводите полноценный поиск с релевантностью, рейтингом, подсветкой совпадений, “похожими результатами” и прочими штуками, которые уже не ощущаются как “фильтрация коллекции”; вам нужна асинхронная модель поиска (например, создать “поисковую задачу”, вернуть 202, потом отдавать результат по отдельному resource id).
Заметьте, что все эти случаи либо требуют отдельной инфраструктуры, либо меняют саму природу ответа. Это уже не “тот же PagedResponse<TaskSummaryResponse> только с q”. Это уже другой сценарий, часто другой домен внутри системы.
В нашем Task Tracker API по ТЗ всё проще: q — простой substring по title и description, фильтры по нескольким полям, диапазон дат по dueDate. То есть это textbook-случай “expanded collection endpoint”. Поэтому отдельный /search для нас не “улучшение дизайна”, а “лишняя развилка на дороге”.
6. Как закрепить решение в коде
Приятная часть решения “оставляем один endpoint” в том, что код контроллера становится спокойнее. У нас остаётся ровно один GET /api/v1/tasks. Все query-параметры живут в одном criteria object, а дальше контроллер просто делегирует в сервис. Тогда defaults, валидация и нормализация тоже остаются в одном месте, а не расползаются по двум почти одинаковым методам.
То есть технический признак хорошего решения очень простой: не два почти одинаковых метода getTasks()/searchTasks(), а одна точка входа для всей выборки.
Если вам хочется увидеть, как выглядело бы “плохое ветвление” (в чисто учебных целях), то оно часто начинается так:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class TaskController {
@GetMapping("/api/v1/tasks")
PagedResponse<TaskSummaryResponse> getTasks() {
// "Обычный список" — но без критериев.
// Как только появятся фильтры/сортировка/пагинация, начнётся вопрос: а где это всё должно жить?
return null;
}
@GetMapping("/api/v1/tasks/search")
PagedResponse<TaskSummaryResponse> searchTasks() {
// "Поиск" как отдельная ветка.
// На практике сюда быстро переедут те же параметры page/size/sort + те же правила ошибок,
// и вы начнёте поддерживать два почти одинаковых контракта.
return null;
}
}
И это тот момент, где вы ещё ничего не написали, но уже обязаны поддерживать консистентность двух контрактов. Это как завести два будильника: сначала кажется надёжнее, потом просыпаешься в 3:00 ночи и проклинаешь себя из прошлого.
7. Типичные ошибки при выборе endpoint для поиска
Ошибка №1: делать /search только потому, что “так делают везде”.
У новичков есть желание повторять паттерны из случайных туториалов. Проблема в том, что /search иногда появляется по причинам, которые вы не видите: исторический легаси-код, необходимость POST из‑за огромных criteria, поисковый движок со своей моделью результатов. Если у вас этого нет, /search превращается в “второй такой же endpoint”, который ничего не добавляет, кроме поддержки.
Ошибка №2: разделять фильтры и поиск по разным URI, ломая композицию.
Самый болезненный сценарий — когда status и priority живут в /tasks, а q живёт в /tasks/search. Клиенту нужно и то, и другое, а вы заставляете его выбирать, либо делать два запроса и склеивать результаты (что вообще отдельная комедия). Правильнее держать все критерии отбора в одном месте, чтобы запрос собирался как конструктор.
Ошибка №3: делать разные defaults для /tasks и /tasks/search.
Сначала кажется: “ну /search — это особый режим, пусть там будет default sort по relevance”. Но у нас нет relevance. Потом defaults начинают жить в двух местах: один endpoint сортирует по updatedAt,desc, другой — по createdAt,desc. Клиенты начинают получать “странные” результаты и думать, что сервер багует. На самом деле багует дизайн.
Ошибка №4: использовать POST /search без реальной необходимости и тем самым усложнять контракт.
POST для read-only поиска иногда нужен, но это почти всегда следующий уровень сложности: появляется JSON body как criteria, появляются дополнительные правила валидации, появляется отдельное описание формата в документации, появляются вопросы “а как это кэшировать и логировать?”. В нашем курсе мы учимся делать зрелый API без такого усложнения, поэтому держим поиск в query-параметрах и в GET.
Ошибка №5: воспринимать /search как “ресурс”, хотя это просто action-слово в URL.
URI вида /tasks/search выглядит безобидно, но семантически это “глагол” в пути. Иногда это допустимо, но в нашем проекте мы сознательно избегаем RPC-стиля. Мы не делаем /tasks/filterByStatus, /tasks/findByAssignee, /tasks/searchByText. Мы держим один ресурс и один способ его читать: GET /tasks с query-параметрами как критериями отбора.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ