JSON API из флоу: девять эндпоинтов без сервера

JSON API из флоу: девять эндпоинтов без сервера

Дневниковый API состоит из девяти HTTP-эндпоинтов, и ни один из них не написан на серверном языке. Каждый эндпоинт — это флоу платформы, граф из узлов condition, graphql и respond_json, привязанный к маршруту; Liquid в нём шаблонизирует документы, а значения сегодня считают expr-поля (body_expr, variables_expr, predicate_expr).

Флоу как эндпоинт

Сам маршрут умещается в JSON на десяток строк:

{
  "path": "/api/entries/save",
  "methods": ["POST"],
  "cache": { "defaultTtl": 0 },
  "membersOnly": true,
  "loginPath": "/login"
}

membersOnly отдаёт анониму 403 с кодом AUTHENTICATION_REQUIRED; defaultTtl: 0 при этом отключает кэш ответов — для member-зон это обязательная пара. Тело запроса флоу читает по транспорту: JSON-клиент получает поля в json.*, HTML-форма получает их в form.*. Ответ формирует узел respond_json, который умеет и status: 200 на успех, 400 с {"ok":false,"error":"INVALID_REQUEST"} на мусорный запрос.

Одна деталь контракта легко обходится, если знать о ней заранее. Закрытая часть платформы ведёт себя по заголовку Accept: браузер без куки получает 302 на /login, а для клиента с Accept: application/json ответом будет честный 403 JSON. PWA-клиент поэтому шлёт этот заголовок с каждым запросом и по 403 сам ведёт пользователя на логин.

Контракт дневникового API

Метод и путь Что делает
POST /api/entries/save создать/обновить запись (апсет по client_uuid)
POST /api/entries/delete удалить запись по client_uuid
GET /api/entries?from=&to= дневник за период, до 500 записей
POST /api/foods добавить свой продукт
GET /api/foods?q= поиск: публичный каталог плюс свои, по 50 на страницу
GET /api/profile профиль без пароля
POST /api/profile частичное обновление профиля
POST /api/weights взвешивание (апсет)
GET /api/weights последние 100 взвешиваний

Входные данные проверяют condition-узлы ещё до любых graphql-запросов. client_uuid не пуст, date соответствует ^[0-9]{4}-[0-9]{2}-[0-9]{2}$, и meal равен одному из четырёх приёмов пищи.

Апсет по client_uuid — ядро синхронизации

Клиент генерирует client_uuid при создании записи и не меняет его никогда. Флоу ищет запись по ключу и владельцу:

entries(where: {client_uuid: {eq: $uuid},
                owner: {eq: $member}}, limit: 1) { id }
entriesCount(where: {client_uuid: {eq: $uuid},
                     owner: {eq: $member}})

Если count больше нуля, ветка then ведёт в updateEntry. Если count равен нулю, флоу уходит в ветку else и попадает в createEntry с owner: $member. Переменную $member флоу заполняет на сервере из подписанной куки, клиент идентификатор не присылает. Ветку обновления мы собрали как частичное обновление: в мутацию попадают только поля, пришедшие в теле запроса. Liquid собирает graphql-аргументы по частям, и запятая при этом стоит внутри условия. Вот update-ветка из реального флоу:

mutation($id: ID!) {
  updateEntry(id: $id, input: {
    client_uuid: {{ json.client_uuid | json }},date: {{ json.date | json }},meal: {{ json.meal | json }}
    {%- assign fd = json.food_id | strip -%}{% if fd != "" %},food: {{ fd | json }}{% endif %}
    {%- assign nm = json.name | strip -%}{% if nm != "" %},name: {{ nm | json }}{% endif %}
    {%- assign g = json.grams | number -%}{% if g != nil %},grams: {{ g | json }}{% endif %}
    {%- assign k = json.kcal | number -%}{% if k != nil %},kcal: {{ k | json }}{% endif %}
    {%- assign p = json.protein | number -%}{% if p != nil %},protein: {{ p | json }}{% endif %}
    {%- assign ft = json.fat | number -%}{% if ft != nil %},fat: {{ ft | json }}{% endif %}
    {%- assign cb = json.carb | number -%}{% if cb != nil %},carb: {{ cb | json }}{% endif %}
  }) { id }
}

Если клиент не прислал поле, оно остаётся на сервере как было. То же правило работает в POST /api/profile: можно прислать любое подмножество полей, каждое под своим assign+strip-guard, и нормы с весом не затрут друг друга.

Сама сборка аргументов Liquid-ом в тексте мутации осталась в прошлом: с v1.4.16 Liquid из текста запроса удалён из движка, мутация стала статичной, а карту переменных целиком собирает variables_expr. Ветку с опциональными полями теперь можно выразить и там: хелпер stringmap собирает карту из toPairs(json), а filter отбрасывает отсутствующие, null и пустые поля — «поле не трогать» наконец выразимо:

stringmap(fromPairs(map(filter(toPairs(json),
  #[1] != nil && (type(#[1]) != "string" || trim(#[1]) != "")),
  [#[0], type(#[1]) == "string" ? trim(#[1]) : #[1]])))

Именно это выражение стоит в variables_expr сохранения профиля: прислали поле — оно поедет в мутацию, не прислали — сервер не увидит ключ и оставит значение как было.

За счёт этого save идемпотентен. Повтор с тем же uuid возвращает тот же id и не создаёт дублей: e2e-проверка выполняла save дважды с разными граммами, id совпал, счётчик записей не вырос. Удаление несуществующего uuid тоже отвечает {"ok":true}, так что повтор DELETE безопасен. Именно эти свойства позволяют офлайн-очереди отправлять одну и ту же запись сколько угодно раз.

Запросы к дневнику мы держим в библиотеке запросов. У graphql-узла для этого есть метка library_name: на исполнении платформа подставляет текст из записи. После правки записи и публикации все связанные флоу работают с новой версией, перевыпускать их не нужно. Поначалу не все запросы попадали в библиотеку. Валидатор библиотеки принимал только чистый GraphQL, а JSON для update-мутаций мы собирали Liquid-ом, поэтому мутации держали прямо в узлах. Потом развязка пришла с другой стороны: с v1.4.16 Liquid из текста запроса удалён из движка, мутации стали статичными — и теперь они спокойно живут в библиотеке наравне с выборками. Осталось одно правило: запись нельзя удалить, пока на неё ссылается живой флоу. Так что при чистке каталога запросов сначала отвязывают узлы.

Грабли

JSON собирается руками в Liquid — legacy-сборка. Форму ответа задаёт body_template, и канон, который тогда сложился: каждое значение проходит через фильтр | json (он обернёт строку в кавычки, число оставит числом, а пустоту превратит в null), опциональные ключи несут запятую внутри условия, хвост списка отсекает forloop.last:

{"ok":true,"items":[{% for i in results.lst.entries %}
{"id":{{ i.id | json }},"date":{{ i.date | json }},
"food_id":{{ i.food.id | json }},...}
{% unless forloop.last %},{% endunless %}{% endfor %}]}

Если пропустить запятую снаружи {% if %}, первая же запись без названия даст битый JSON.

Сегодня тот же ответ собирает body_expr узла respond_json:

{ "ok": true, "items": results.lst.entries }

Список встраивается как данные: массив остаётся массивом, числа — числами, | json не нужен, а невалидный JSON невозможен в принципе. Liquid-рецепт выше — история: с v1.4.16 Liquid в body_template «JSON-ответа» удалён из движка (ключ стал статичной строкой), так что старых шаблонных тел с подстановками больше нет — вычисляемый ответ собирает только body_expr.

Entity-поле разворачивается в объект. Первая версия списка выбирала поле food без сабселекции, и платформа вложила в ответ весь продукт целиком, с owner и служебными датами и без нужного id. PWA не могла получить id продукта из дневника. Исправляется это в двух местах: food { id } в запросе и i.food.id | json в шаблоне. Для записей без продукта ответ с "food_id": null валиден и ожидаем.

orderBy — это ENUM’ы. Сортировка пишется как orderBy: [{field: DATE, direction: ASC}], заглавными буквами и без кавычек. Строка "date" флоу не подойдёт.

Пагинация каталога. Поиск продуктов отвечает максимум 50 записями за запрос, и для подсказки в строке поиска этого хватает. Полный каталог при этом загружается страницами: сервер отдаёт не больше 100 записей за запрос, клиент запрашивает limit=100 со сдвигом offset. Каталог с 131 продуктом занимает две страницы.

Фантом с owner=null. Публичный каталог в нашей схеме состоит из продуктов с пустым владельцем. Однажды в выдаче поиска у тестовых пользователей появился «продукт» с именем из кракозябр. Это был мусор тестового прогона, созданный без owner, и пустой владелец молча сделал его публичным. С тех пор мы удаляем тестовый мусор сразу: он попадает в выдачу, пока лежит в базе. Фантомы в локальном индексе самого клиента и чистка pruneFoods() описаны отдельно, в четвёртой части.

Дневник на мобильном: светлая и тёмная темы

Развёрнутый приём пищи: светлая и тёмная темы

Что дальше

Следующая статья — «Офлайн и синхронизация»: как устроена очередь в IndexedDB, почему LWW по client_uuid достаточен для дневника и что происходит при конфликте двух устройств.


Все записи