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 достаточен для дневника и что происходит при конфликте двух устройств.
Все записи