Фильтрация, сортировка, поиск

Урок о том, как дать клиенту фильтровать, сортировать и искать через query-параметры, не превращая API в неуправляемого монстра.

Query-параметры — пары ключ=значение после ? в URL, которыми клиент уточняет, какие именно записи коллекции он хочет получить, в каком порядке и с какими полями.

Эндпоинт GET /v1/orders возвращает все заказы — но клиенту почти никогда не нужны «все». Ему нужны заказы со статусом active, дороже 1000 рублей, отсортированные по дате убывания, и только поля id и total. Хорошо спроектированные query-параметры позволяют выразить это в URL, не плодя десятки специализированных эндпоинтов вроде /orders/active-expensive-recent. Плохо спроектированные — превращаются в мини-язык запросов, который невозможно валидировать и который открывает дыры в производительности и безопасности.

Фильтрация по точному совпадению

Базовый случай — фильтр по равенству. Имя поля становится ключом параметра:

GET /v1/orders?status=active
GET /v1/orders?status=active&currency=RUB

Несколько параметров комбинируются через логическое И: первый запрос вернёт активные заказы, второй — активные и в рублях. Под капотом это превращается в WHERE status = 'active' AND currency = 'RUB'. Простой, предсказуемый, легко документируемый паттерн.

Диапазоны

Равенства мало — часто нужно «дороже», «не позже». Распространённое соглашение — суффиксы операторов:

GET /v1/orders?price_gte=1000            -- цена >= 1000
GET /v1/orders?price_lte=5000            -- цена <= 5000
GET /v1/orders?price_gte=1000&price_lte=5000   -- диапазон
GET /v1/orders?created_after=2026-01-01  -- по дате

Суффиксы _gte/_lte/_gt/_lt (greater/less than or equal) читаются однозначно и ложатся на SQL price >= 1000 AND price <= 5000. Альтернатива — отдельные имена вроде min_price/max_price: они нагляднее для людей, но хуже масштабируются на много полей. Выберите одно соглашение и применяйте единообразно.

Сортировка

Удобная и компактная конвенция — один параметр sort с префиксом направления: минус означает по убыванию, его отсутствие — по возрастанию.

GET /v1/orders?sort=created_at     -- по дате, по возрастанию
GET /v1/orders?sort=-created_at    -- по дате, по убыванию (новые сверху)
GET /v1/orders?sort=-priority,created_at   -- сначала по priority убыв., затем по дате возр.

Перечисление через запятую задаёт многоуровневую сортировку. Это компактнее пары параметров sort_by=created_at&order=desc и хорошо расширяется. Критично: сортировать можно только по белому списку полей — об этом ниже, в валидации.

Выбор полей (sparse fieldsets)

Если клиенту нужны лишь пара полей из жирного объекта, заставлять его качать всё — расточительно. Параметр fields позволяет запросить подмножество:

GET /v1/orders?fields=id,total,status
{
  "data": [
    { "id": 41, "total": 1500, "status": "active" },
    { "id": 42, "total": 3200, "status": "active" }
  ]
}

Это экономит трафик и ускоряет мобильных клиентов. Цена — усложнение сервера: нужно проверять, что запрошенные поля существуют и разрешены, и аккуратно строить ответ. Не перегружайте этим API на старте — добавляйте, когда трафик реально станет проблемой.

Полнотекстовый поиск

Фильтры — это точные условия; поиск — это «найди что-то похожее на текст». Их стоит разделять. Конвенция — параметр q (query):

GET /v1/products?q=беспроводные наушники
GET /v1/products?q=наушники&category=audio&sort=-rating

За q обычно стоит не LIKE '%...%' (он не масштабируется и не ранжирует), а полнотекстовый движок: встроенный full-text в PostgreSQL/MySQL или внешний Elasticsearch/OpenSearch. Поиск и фильтры отлично сочетаются: q сужает по релевантности, остальные параметры — по точным условиям.

Проектирование без взрыва сложности

Соблазн велик: разрешить фильтровать по любому полю любым оператором, вложенные условия, OR-группы. Так рождается самодельный язык запросов в URL — его невозможно ни задокументировать, ни обезопасить. Держите дисциплину:

  • Белый список. Заранее решите, по каким полям можно фильтровать и сортировать. Остальное — игнорируйте или отвечайте 400.
  • Плоскость. Избегайте вложенных условий и произвольных OR. Если нужны сложные запросы — это сигнал к отдельному search-эндпоинту с телом, а не к нагромождению query-параметров.
  • Стабильные имена. Имена параметров — часть контракта API; менять их так же больно, как ломать поля ответа.

Валидация и дефолты

Каждый входящий параметр — недоверенный ввод. Правила гигиены:

ПараметрПравило
statusтолько из набора (active/paid/cancelled); иное → 400
sortполя только из белого списка; неизвестное поле → 400
price_gteдолжно парситься в число; price_gte=abc400
limitдефолт 20, максимум 100; отрицательное → 400
fieldsтолько существующие и разрешённые поля

Дефолты важны не меньше валидации: если клиент не указал sort, выберите осмысленный порядок по умолчанию (например, -created_at) — без него БД вернёт строки в недетерминированном порядке, и пагинация поедет. Дефолтный и максимальный limit защищают от случайной выгрузки всей таблицы.

Как работает под капотом

Сервер не подставляет query-значения в SQL напрямую — это прямой путь к SQL-инъекции. Слой обработки сначала валидирует каждый параметр против схемы (тип, диапазон, принадлежность белому списку), затем строит запрос через параметризованные плейсхолдеры: имя столбца берётся из доверенного белого списка, а значение уходит отдельным параметром драйвера БД. Поэтому белый список полей сортировки — не только про чистоту API, но и про безопасность: имя столбца нельзя параметризовать как значение, его можно только выбрать из заранее известного множества. Производительность тоже определяется здесь: фильтр по неиндексированному полю заставляет БД делать полный скан таблицы. Грамотный API разрешает фильтровать ровно по тем полям, под которые есть индексы, — иначе безобидный с виду ?some_field=x на большой таблице станет тормозом. Сортировка по неиндексированному полю аналогично требует дорогой сортировки всего результата в памяти.

Частые ошибки

  • Фильтр по чему угодно. Разрешить произвольные поля — открыть полный скан по неиндексированным столбцам и риск инъекции.
  • Нет дефолтной сортировки. Без ORDER BY порядок строк недетерминирован, и пагинация показывает дубли/пропуски.
  • Поиск и фильтр в одном параметре. Смешивать полнотекстовый q с точными условиями в одном поле — путаница; разделяйте их.
  • Молчаливое игнорирование опечаток. Если ?statuss=active тихо вернёт всё, клиент решит, что фильтр сработал. Либо валидируйте строго (400), либо чётко документируйте политику игнора.
  • Самодельный язык запросов. Вложенные OR/AND в URL невозможно безопасно разобрать — для сложного выноси́те поиск в отдельный POST-эндпоинт с телом запроса.

Итоги

  • Фильтры — точные условия (?status=active), диапазоны — суффиксы (?price_gte=1000), комбинируются через И.
  • Сортировка — компактный ?sort=-created_at с минусом для убывания и запятыми для нескольких ключей.
  • fields экономит трафик, q — отдельный полнотекстовый поиск; не смешивайте поиск с фильтрами.
  • Держите белый список полей и плоскую структуру — не растите язык запросов в URL.
  • Валидируйте каждый параметр, задавайте дефолты (особенно сортировку и limit) и фильтруйте только по индексированным полям.
Проверьте себя
1. Что означает query-параметр ?sort=-created_at?
AСортировать по полю created_at по возрастанию
BСортировать по полю created_at по убыванию (минус = descending)
CИсключить поле created_at из ответа
DОтфильтровать записи, где created_at отрицателен
2. Почему важно разрешать сортировку и фильтрацию только по полям из белого списка?
AИначе ответ придёт в XML вместо JSON
BЭто и про безопасность (имя столбца нельзя параметризовать как значение), и про производительность (фильтр по неиндексированному полю даёт полный скан)
CБелый список нужен только для красоты документации
DБез него невозможно вернуть статус 200
3. Зачем API нужна дефолтная сортировка, если клиент не передал sort?
AЧтобы ответ был валидным JSON
BБез явного ORDER BY порядок строк недетерминирован, из-за чего пагинация показывает дубли и пропуски
CДефолтная сортировка ускоряет content negotiation
DИначе сервер вернёт 500
4. Что обычно стоит за параметром полнотекстового поиска ?q=...?
AВсегда простой SQL LIKE '%...%' по одному столбцу
BПолнотекстовый движок (встроенный в СУБД или внешний типа Elasticsearch), умеющий ранжировать по релевантности
CПеренаправление на отдельный микросервис кеширования
DТочный фильтр по равенству, как ?status=active