Учим 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-узел из первой статьи, только теперь запрос получает переменные из строки запроса страницы:

{
  "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 сверху, запрос снизу.

Редактор сценария books_list: панель graphql-узла с шаблоном запроса — assigns для страницы и сортировки над текстом GraphQL и переменные из query.*

Шаг 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.


Все записи