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

Учим 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 книг на каждой. Конверт нужен потому, что из массива текущей страницы интерфейс не узнает общее число записей, а без него не нарисуешь пагинатор.

Админка каталога: панель фильтров с поиском и статусом, таблица на 5 книг и пагинатор «Страница 1 из 3»

Шаг 2. Фильтры внутри graphql-узла

За фильтры отвечает graphql-узел. Текст запроса статичен, а всё переменное уезжает в variables_expr — одну expr-строку, которая строит карту переменных целиком:

{
  "id": "a_q",
  "type": "action",
  "data": {
    "action_type": "graphql",
    "on_error": "stop",
    "config": {
      "query": "query($status: String, $q: String) {\n  books(limit: 5, orderBy: [{field: YEAR, 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_expr": "{ \"status\": query.status != nil && query.status != \"\" ? query.status : nil, \"q\": query.q != nil && query.q != \"\" ? query.q : nil }",
      "result_key": "q",
      "secret_vars": []
    }
  }
}

Здесь работают три механизма. query.<имя> — это параметры строки запроса GET-сценария: открыли /api/books?status=on_loan, и в query.status лежит on_loan. Аргумент search ищет по строковым полям записи без учёта регистра, так что q=булгаков находит книги и по фамилии, и по названию. И главное — пустой параметр не становится фильтром: гвард != nil && != "" превращает «не было или пусто» в nil, переменная уходит в запрос как null, а null-переменную платформа трактует как «оператор не применён» — условие where не применяется. Запрос остаётся один на все случаи, без ветвления на «фильтр задан — один запрос, не задан — другой». Аргументы offset и field появятся в следующих двух шагах: пагинация и сортировка добавят свои ключи в ту же variables_expr.

Несуществующий статус в адресе — это пустой items с нулевым total: честный ответ на запрос, а не ошибка.

Историческая справка: демо-ветка step-6-api собрана Liquid-вставками прямо в тексте запроса (offset: {{ offset }}) и Liquid-строками в variables ("status": "{{query.status}}"). С v1.4.16 это работает только для variables — Liquid из текста запроса удалён из движка: запрос со вставками не пройдёт проверку и упадёт с «query does not parse». Текст запроса держат статичным, а данные передают через variables_expr.

Поиск по фамилии Достоевского в админке: остались две его книги, счётчик каталога показывает две записи

Шаг 3. Страница в offset

Пагинация собирается из двух чисел: сколько записей на странице (в демо зафиксировано 5) и какую страницу просят. GraphQL-аргумент offset — это номер страницы, пересчитанный в сдвиг. Считает его compute-узел — чистый expr-шаг, который кладёт значение в results, и его видят все узлы ниже:

{
  "id": "k_page",
  "type": "action",
  "data": {
    "action_type": "compute",
    "config": {
      "expr": "let p = number(query.page) ?? 1; p < 1 ? 1 : p",
      "result_key": "page"
    }
  }
}

number() строго приводит строку к числу: мусор вроде ?page=abc даёт nil, и ?? 1 поднимает его до первой страницы; отрицательные и дробные номера до GraphQL не доезжают. Сдвиг подставляет variables_expr graphql-узла из шага 2 — к карте добавляется один ключ:

"variables_expr": "{ ..., \"offset\": (results.page - 1) * 5 }"

Вторую половину считает respond-узел, собирающий конверт из результата graphql-узла. Число страниц считает ещё один compute, собирает конверт body_expr:

{
  "id": "k_pages",
  "type": "action",
  "data": {
    "action_type": "compute",
    "config": {
      "expr": "let t = number(results.q.booksCount) ?? 0; int((t + 4) / 5)",
      "result_key": "pages"
    }
  }
}
{
  "id": "a_out",
  "type": "action",
  "data": {
    "action_type": "respond_json",
    "config": {
      "body_expr": "{ \"items\": results.q.books, \"total\": results.q.booksCount, \"pages\": results.pages }",
      "status": 200
    }
  }
}

booksCount — автосгенерированное поле-спутник списка: оно принимает те же where и search, поэтому считает отфильтрованные книги, а не весь каталог. Число страниц — округление (total + 4) / 5 вверх: int() обрезает дробную часть. В body_expr список встраивается как данные — без | json и без запятой-хореографии, невалидный JSON невозможен.

Историческая справка: демо-ветка считала offset Liquid-ом прямо в тексте запроса ({% assign page = query.page | plus: 0 %}…) и собирала конверт body_template-ом с | json и целочисленным делением total + 4. С v1.4.16 не осталось ни того, ни другого: Liquid из текста запроса удалён из движка, а body_template «JSON-ответа» стал статичной строкой — такая сборка уехала бы клиенту буквально, с {% if %} внутри. Математику считают compute-узлы, тела кодирует body_expr.

Шаг 4. Сортировка по белому списку

Аргумент orderBy принимает не строку, а перечисление — TITLE, AUTHOR, YEAR. Перечисление защищает от опечаток, но чужое значение в запросе даст ошибку GraphQL вместо данных, поэтому параметр sort проходит через белый список — ещё один compute-узел:

{
  "id": "k_field",
  "type": "action",
  "data": {
    "action_type": "compute",
    "config": {
      "expr": "query.sort == \"title\" ? \"TITLE\" : query.sort == \"author\" ? \"AUTHOR\" : \"YEAR\"",
      "result_key": "field"
    }
  }
}

Любое неизвестное значение сворачивается к сортировке по году. Готовое перечисление уезжает в запрос через variables_expr: "field": results.field — типизированное значение вместо сплайсинга в тексте.

Историческая справка: на скриншоте ниже — цепочка {% if %}/{% elsif %} с assign в тексте graphql-узла, так была собрана демо-ветка. С v1.4.16 Liquid в тексте запроса удалён из движка, и такой узел проверку не пройдёт; текст держат статичным, а сортировку передают переменными.

Редактор сценария books_list: панель graphql-узла с шаблоном запроса — assigns для страницы и сортировки над текстом GraphQL и переменные из query. (legacy-сборка демо-ветки; сегодня те же значения считают compute-узлы и variables_expr)*

Шаг 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}$" }

Вкладка «Запуски»: прогон с бейджем «упал», красный graphql-узел и диагноз про нецелый год рядом с соседним удачным прогоном

Что осталось за кадром

Маршрут удаления книги не изменился со второй статьи: гейт роли и respond_json 403 построены на тех же условиях, и валидация запроса встраивается в него по образцу пятого шага. Ограничение частоты запросов тоже живёт не в сценарии: это настройка проекта из лимитов тарифа, отдельного лимита на маршрут нет.

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


Все записи