Учим API каталога фильтрам, пагинации и честным ошибкам

Каталог живёт по сценарию уже две статьи: в первой он получил JSON-API и закрытую админку на Vue, во второй — аккаунты читателей с подтверждением email и ролями. Сам API при этом остался прежним: один эндпоинт, который отдаёт все книги сразу. Четыре записи это выдерживали спокойно, одиннадцать — уже с натяжкой, а интерфейс к тому же просит поиск, фильтр по статусу и страницы. Четыре задачи — поиск, фильтр, страницы и честные ошибки — решаются в том же сценарии сайта: фильтры и пагинация — внутри graphql-узла, валидация — условиями, а отладка — dry-run’ом до публикации и журналом прогонов после.
Все файлы — в репозитории dynapi-vue-admin, ветка step-6-api. Она продолжает step-5-accounts: сначала пройдите шаги первых двух статей.
Шаг 1. Контракт вместо массива
В первой статье эндпоинт /api/books отвечал голым массивом — интерфейс брал его как есть. Теперь у эндпоинта есть контракт. Вход — строка запроса:
GET /api/books?status=on_loan&page=1
Выход — конверт:
{
"items": [
{ "id": "...", "title": "Двенадцать стульев", "author": "Илья Ильф, Евгений Петров", "year": 1928, "status": "on_loan" }
],
"total": 4,
"pages": 1
}
items — страница данных, total — число книг с учётом фильтров, pages — сколько всего страниц, по 5 книг на каждой. Конверт нужен потому, что из массива текущей страницы интерфейс не узнает общее число записей, а без него не нарисуешь пагинатор.

Шаг 2. Фильтры внутри graphql-узла
За фильтры отвечает тот же graphql-узел из первой статьи, только теперь запрос получает переменные из строки запроса страницы:
{
"id": "a_q",
"type": "action",
"data": {
"action_type": "graphql",
"on_error": "stop",
"config": {
"query": "query($status: String, $q: String) {\n books(limit: 5, offset: {{ offset }}, orderBy: [{field: {{ field }}, direction: DESC}], search: $q, where: {status: {eq: $status}}) {\n id title author year status\n }\n booksCount(search: $q, where: {status: {eq: $status}})\n}",
"variables": {
"status": "{{query.status}}",
"q": "{{query.q}}"
},
"result_key": "q",
"secret_vars": []
}
}
}
Здесь работают три механизма. query.<имя> — это параметры строки запроса GET-сценария: открыли /api/books?status=on_loan, и в {{query.status}} оказалось on_loan. Аргумент search ищет по строковым полям записи без учёта регистра, так что q=булгаков находит книги и по фамилии, и по названию. И главное — пустая переменная не становится фильтром: если status в строке запроса не было, переменная с пустой строкой не уходит в запрос вовсе, и условие where не применяется. Запрос остаётся один на все случаи, без ветвления на «фильтр задан — один запрос, не задан — другой».
Значения {{ offset }} и {{ field }} в тексте запроса пока не определены — ими займутся следующие два шага: пагинация и сортировка.
Несуществующий статус в адресе — это пустой items с нулевым total: честный ответ на запрос, а не ошибка.

Шаг 3. Страница в offset
Пагинация собирается из двух чисел: сколько записей на странице (в демо зафиксировано 5) и какую страницу просят. GraphQL-аргумент offset — это номер страницы, пересчитанный в сдвиг, и считает его Liquid прямо в тексте запроса:
{% assign page = query.page | plus: 0 %}{% if page < 1 %}{% assign page = 1 %}{% endif %}{% assign offset = page | minus: 1 | times: 5 %}
plus: 0 превращает строковый параметр в число; отсутствие параметра и мусор вроде ?page=abc после этой операции становятся нулем, а условие поднимает его до первой страницы. Отрицательные и дробные номера страниц до GraphQL не доезжают.
Вторую половину считает respond-узел, собирающий конверт из результата graphql-узла:
{% assign total = results.q.booksCount | plus: 0 %}{"items": {{ results.q.books | json }}, "total": {{ total }}, "pages": {{ total | plus: 4 | divided_by: 5 }}}
booksCount — автосгенерированное поле-спутник списка: оно принимает те же where и search, поэтому считает отфильтрованные книги, а не весь каталог. Число страниц выводится целочисленным делением total + 4 на размер страницы — округление вверх без отдельных фильтров.
Шаг 4. Сортировка по белому списку
Аргумент orderBy принимает не строку, а перечисление — TITLE, AUTHOR, YEAR. Перечисление защищает от опечаток, но чужое значение в запросе даст ошибку GraphQL вместо данных, поэтому параметр sort из строки запроса проходит через белый список до подстановки:
{% if query.sort == "title" %}{% assign field = "TITLE" %}{% elsif query.sort == "author" %}{% assign field = "AUTHOR" %}{% else %}{% assign field = "YEAR" %}{% endif %}
Любое неизвестное значение сворачивается к сортировке по году. Такой узел приятно проверять в редакторе: весь шаблон виден целиком, assigns сверху, запрос снизу.

Шаг 5. Ошибки, которые можно показать
На POST /api/books/add встают условия-гейты. Первые два требуют непустые название и автора:
{
"id": "c_title",
"type": "condition",
"data": {
"predicate": {
"combinator": "and",
"clauses": [
{ "field": "json.title", "operator": "matches", "value": "\\S" }
]
}
}
}
Регулярное выражение \S требует хотя бы один непробельный символ, так что и пустое поле, и строка из пробелов уходят в ветку else. Следующий гейт проверяет год, если он пришёл: операторы gt и lt сравнивают численно, и диапазон 1800–2100 отсеивает опечатки и фантастические годы. Каждый отказ отвечает кодом 400 с именем поля и сообщением:
{
"id": "a_err_year",
"type": "action",
"data": {
"action_type": "respond_json",
"config": {
"status": 400,
"body_template": "{\"error\": \"VALIDATION\", \"field\": \"year\", \"message\": \"Год должен быть числом от 1800 до 2100\"}"
}
}
}
Интерфейс при этом освобожден от догадок: атрибут required с полей убран, форма отправляет что есть, а сценарий возвращает конкретику. Приложение читает field и message из ответа и подсвечивает нужное поле:
if (!res.ok) {
const data = await res.json().catch(() => null);
if (res.status === 400 && data?.field) {
invalidField.value = data.field;
serverMessage.value = data.message ?? "Проверьте заполнение полей.";
}
return;
}
Контракт валидации живёт на сервере, в сценарии. Интерфейс, мобильный клиент или телеграм-бот получат те же сообщения, не дублируя проверки.

Шаг 6. Отладка до и после публикации
Сценарий с ветвлением надо проверять до публикации — тем же flowctl dryrun, что в первых статьях. В контексте кладём мок результата graphql-узла, чтобы respond-узел собрал конверт из настоящих чисел:
{
"method": "GET",
"graphqlResults": {
"q": { "books": [{ "title": "Мы", "year": 1920 }], "booksCount": 8 }
}
}
Отчёт показывает оба узла зелёными и собранный ответ:
directives (1):
- {"body": "{\"items\": [{...}], \"total\": 8, \"pages\": 2}", "status": 200}
GraphQL внутри dry-run не исполняет — вместо запроса подставляется мок. Синтаксис сгенерированного запроса покажет только живой запуск — его принимает журнал прогонов.
Журнал живёт на вкладке «Запуски» (или в flowctl runs <slug>) и хранит каждый запуск: какие условия в какую ветку ушли и где узел ошибся. Вот история, которая случилась при отладке демо: в поле года отправили 1990.5. Условия диапазона пропустили значение — дробное число в границах. Следующий узел упал:
node type runs errors avg last
a_create graphql 2 1 14ms ✗
last error per failing node:
a_create: graphql.variables: variable "year" is not a valid Int: "1990.5"
Хитрость в том, что клиент увидел успех: 201 и {"ok": true, "id": ...}. Когда сценарий обрывается без respond-узла, платформа отвечает стандартным эхо отправки формы — а книга в каталоге так и не появилась. Расхождение между ответом и данными нашлось в журнале: красный узел с текстом ошибки. Лечение — одна клауза в условие года:
{ "field": "json.year", "operator": "matches", "value": "^\\d{4}$" }

Что осталось за кадром
Маршрут удаления книги не изменился со второй статьи: гейт роли и respond_json 403 построены на тех же условиях, и валидация запроса встраивается в него по образцу пятого шага. Ограничение частоты запросов тоже живёт не в сценарии: это настройка проекта из лимитов тарифа, отдельного лимита на маршрут нет.
Дальше по циклу — файлы и медиа: как дать каталогу обложки и не выйти за квоту хранилища. Все .dynflow.json этой статьи лежат в ветке step-6-api, применяются командой flowctl apply flows/books_list.dynflow.json --publish.
Все записи