Фильтрация и сортировка API
Предыдущий урок разделил устойчивый снимок каталога на страницы. Теперь читатель хочет выбрать направление. Если сначала нарезать данные, а потом убрать неподходящие записи, первая страница может стать пустой, хотя дальше есть нужные курсы. Поэтому фильтр должен иметь определённое место в процессе выборки.
В этом уроке закрепим последовательность: проверка параметров, выбор снимка, фильтрация, полная сортировка, выделение страницы. Результат — ответ с topic frontend содержит два соответствующих курса, а метаданные относятся именно к выбранному набору. Все результаты ожидаемые; база и обработчик здесь не выполнялись.
Фильтр относится ко всему набору
Исходные данные s1 не меняются: c001 и c002 относятся к frontend, c003 — quality, c004 — tools. Параметр topic принимает одно объявленное машинное значение. Для чтения фронтенд-курсов используем такой запрос:
GET /api/courses?topic=frontend&limit=2 HTTP/1.1
Host: catalog.example
Accept: application/json
Ожидаемый полный ответ содержит два курса и точный total после фильтра. Продолжения нет, поскольку вся выбранная коллекция поместилась в страницу:
{
"items":[
{"id":"c001","title":"Современный JavaScript","topic":"frontend","lessons":40},
{"id":"c002","title":"Современный HTML и CSS","topic":"frontend","lessons":32}
],
"total":2,
"nextCursor":null,
"snapshot":"s1"
}
total равен двум, а не четырём. Четыре — размер всего исходного снимка, два — размер выбранного набора. Клиенту важно знать, о каком количестве речь: подпись «найдено четыре» рядом с двумя фронтенд-курсами создала бы ложное ожидание следующей страницы. Если нужно общее число каталога, оно должно иметь отдельное имя и назначение.
Теперь мысленно задайте limit один. Первая страница содержит c001, а курсор приводит к c002 в том же снимке и с прежним фильтром. Клиент не должен фильтровать уже полученную нефильтрованную страницу и считать результат эквивалентным серверной выборке. Это разные операции над разными наборами.
Полный порядок при одинаковых значениях
Наш sort имеет единственное принимаемое значение lessons:desc,id:asc, которое используется и по умолчанию. Первая часть располагает большее число уроков раньше, вторая разрывает равенство по уникальному id. Не оставляем итоговый порядок на усмотрение случайной выдачи базы.
В отдельном мысленном варианте снимка изменим lessons курса c002 на 40. Теперь c001 и c002 равны по первому ключу. Порядок всё равно определён:
c001 (40), c002 (40), c003 (24), c004 (12)
Этот вариант не подменяет сохранённый s1, а объясняет правило сортировки при новых данных. По id c001 предшествует c002. Если клиент выбрал страницу в одну запись, продолжение имеет однозначную границу. Без второго ключа база могла бы менять взаимное положение равных записей, и даже неизменяемый набор не давал бы предсказуемую пагинацию.
Сортировка по заголовку потребовала бы отдельных правил языка, регистра и Unicode. Два сервиса могут иначе располагать кириллицу, латиницу и буквы с диакритикой. Поэтому не добавляем title:asc как якобы очевидный вариант без соглашения. Начальная числовая сортировка с id даёт понятный пример, а новые порядки должны сопровождаться своей семантикой и версиями курсора.
Сервер принимает заранее объявленные поля и направления. Строку sort нельзя напрямую вставить в SQL как готовый фрагмент выражения. Даже при параметризованных значениях выбор имён столбцов обычно требует разрешённого отображения. Контракт с закрытым набором вариантов уменьшает пространство неопределённости и помогает будущей реализации на любом хранилище.
Проверка до чтения страницы
Параметры topic=unknown и sort=lessons:sideways не относятся к пустой корректной выборке. Они нарушают договор и получают 400. Значение topic backend, напротив, корректно, хотя в s1 таких курсов нет. Оно даёт успешный пустой список:
{"items":[],"total":0,"nextCursor":null,"snapshot":"s1"}
Это различие сообщает клиенту, что настройка принята, но данных нет. Если сервис вместо отказа молча проигнорирует unknown, человек может получить весь каталог и решить, что фильтр действует. Если сервис обозначит отсутствие backend как ошибку, интерфейс не сможет нормально показать направление, в котором материалы ещё готовятся.
Повторяющийся одиночный параметр отвергается, как определено в уроке об адресах. Запрос с двумя topic не превращается автоматически в объединение направлений. Для такого расширения потребовались бы правила: OR или AND, порядок значений, повторы и каноническое представление. Пока лучше иметь небольшой полностью описанный фильтр, чем множество неоднозначных форм.
Число limit проверяем до выделения страницы. Отрицательное значение не превращается в выбор с конца, дробное не округляется, а слишком большое не создаёт неограниченный ответ. Можно ввести политику мягкого ограничения, но наш API объявляет отказ вне диапазона. Клиент должен знать фактическое правило, чтобы корректно отображать размер и продолжение.
Пустой набор сохраняет форму ответа
Для правильного topic backend без данных оболочка остаётся такой же, как для frontend. Клиент не получает вместо массива строку «ничего не найдено» и не теряет типы полей. Пустота относится к данным, а не к изменению схемы. Поэтому одна функция отображения может понять items, total и nextCursor, затем выбрать подходящее сообщение человеку.
Если фильтр когда-нибудь станет текстовым поиском, потребуется определить нормализацию, подстроку или слова, язык и порядок результатов. Эти правила не появляются автоматически из query-поля q. Курсор такого поиска должен связываться с фактическими условиями и версией результата так же, как нынешний topic. Иначе небольшое изменение запроса между страницами создаст смешанный набор.
У сортировки также есть цена выбора. Система может не иметь подходящего индекса для каждого произвольного поля, и обещание всех возможных комбинаций быстро усложняет сервер. Закрытый начальный sort облегчает причинный разбор: мы знаем полный порядок и можем объяснить каждую границу. Новые варианты следует добавлять вместе с их гарантией устойчивости, а не только с параметром формы.
Простота данного набора намеренная: она позволяет видеть контракт выборки целиком до реализации хранилища.
Контекст курсора
Курсор сохраняет не только последнюю позицию. Он связан с topic, sort, limit и snapshot. Поэтому смена фильтра требует нового обхода без прежнего курсора. Программа не может взять continuation фронтенд-выборки и продолжить ею каталог quality: это другая последовательность, даже если у некоторых записей совпадают числовые ключи.
Пользовательское изменение формы должно сбрасывать накопленные страницы и начать запрос с новыми настройками. Если сохранить старые карточки и дописать новую выборку снизу, получится смесь, которая не соответствует ни одному договору API. Это интерфейсный эффект серверной семантики, а не просто эстетика списка.
Снимок фиксирует и значения, по которым фильтруют. Если c003 переведён в frontend после первой страницы, прежний s1 продолжает видеть его как quality. Новый обход показывает актуальное направление. Без такой границы изменение фильтрующего поля способно добавить или убрать элемент посреди чтения, даже если числовой порядок остальных записей не менялся.
Все эти параметры не заменяют проверку разрешений. В будущем личный список сначала ограничивается доступными пользователю данными по серверным правилам, а затем применяет разрешённую выборку. Клиентский фильтр с чужим id не должен расширять доступ. Для текущего публичного каталога такого измерения ещё нет; его нельзя молча переносить на закрытый API.
Теперь вы можете объяснить три разных пустых результата: правильный фильтр без данных, неправильный параметр с отказом и истёкшее продолжение снимка. Они требуют разных действий интерфейса. Структура параметров URL помогает формировать адрес, но прикладную семантику задаёт наш договор. Следующий урок вернётся к единичному представлению и уменьшит лишнюю передачу при повторном чтении через валидатор.