Подводные камни Liquid и динамической схемы

Подводные камни Liquid и динамической схемы

У API «КБЖУ Дневника» девять эндпоинтов: сохранение, удаление и список записей, создание и поиск продуктов, чтение и запись профиля, приём и список веса. Ни один из них не написан серверным кодом. Каждый собран из флоу, то есть из графа condition- и graphql-узлов, где Liquid склеивал переменные и тела ответов. Бэкенд мы собрали за один день, и даже этого дня хватило, чтобы наловить граблей. Уточнение для новых сценариев: значения сегодня считает expr (compute.expr, body_expr, variables_expr, predicate_expr), а большинство граблей из этого списка относится к Liquid-сборке. С v1.4.16 её границы уже: Liquid-путь остался в шаблонах, письмах, переменных и «HTTP POST», но из текста graphql-запроса и из «Тела (шаблон)» «JSON-ответа» он удалён из движка.

Движок osteele/liquid

Документация Liquid описывает парсер Shopify, а платформа работает на библиотеке osteele/liquid. Синтаксис совместим, но поведение части фильтров и тегов различается, и на этих различиях мы спотыкались чаще всего.

Первое различие — проверка blank. Движок Shopify понимает blank как особое значение. osteele/liquid про него не знает, и проверку «строка непустая» приходится собирать вручную:

{%- assign nm = json.name | strip -%}{% if nm != "" %},name: {{ nm | json }}{% endif %}

Фильтр внутри {% if %} — синтаксическая ошибка, поэтому конструкция {% if x | size > 0 %} не скомпилируется: сначала значение кладут в переменную через assign, а сравнивают уже после. Пустоту проверяют через size > 0, и тут важно не перепутать направление: условие с == 0 даст перевёрнутый результат. Знакомый по Shopify фильтр trim здесь называется strip. В expr-полях вся эта семья ловушек не существует: отсутствующее поле — это nil, и проверка «непусто» пишется одним сравнением — form.lastName != nil && form.lastName != "".

Арифметика — это фильтры plus, minus, times и divided_by, и у двух из них особенности, о которых лучше знать заранее. plus: 0 молча превращает любой мусор в ноль: для подсчёта суммы это удобно, а при валидации поля маскирует опечатку, и значение тихо обнуляется. divided_by при nil падает, так что делитель проверяют до деления. Для новых сценариев арифметику считает compute-узел в expr: литеральные числа там целые, а невалидный операнд падает видимо в журнале прогонов, а не превращается молча в ноль. Фильтры остаются для Liquid-полей — текстов, писем, шаблонов страниц.

JSON из Liquid: запятая внутри if

Тело JSON-ответа пишется вручную, и каждое значение проходит через | json: фильтр экранирует строку и превращает nil в null. Запятую для опционального ключа ставят внутри {% if %}, а список собирают циклом с {% unless forloop.last %},{% endunless %}:

{"ok":true,"items":[{% for i in results.lst.foods %}
{"id":{{ i.id | json }},"name":{{ i.name | json }},
"kcal_100":{{ i.kcal_100 | json }}}
{% unless forloop.last %},{% endunless %}{% endfor %}]}

Это наследие: с v1.4.16 Liquid в body_template «JSON-ответа» удалён из движка — ключ стал статичной строкой, и рецепт с подстановками к нему больше не применим. Новому телу хватает одной строки expr в body_expr узла respond_json:

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

Значение кодируется как данные — без экранирования, без запятой-хореографии и без цикла: массив остаётся массивом, числа числами, а невалидный JSON невозможен в принципе.

Ещё одна грабля проявилась в автотесте. Entity-поле в graphql-запросе без сабселекции платформа разворачивает в объект целиком, и food_id в списке записей однажды вернулся вложенным объектом продукта без поля id и с лишними служебными полями. Починили двумя токенами: food { id } в выборке и i.food.id | json в шаблоне.

Теги вставки шаблонов в проекте не используются, хотя движок сайта знает оба: {% include %} вставляет шаблон с общими переменными, {% render %} — с изолированными. У флоу-движка таких тегов нет вовсе, поэтому переиспользуемые graphql-запросы названы в библиотеке запросов и подключаются узлом по library_name.

Есть и мелочь, на которой мы споткнулись один раз. Обычный GraphQL-клиент оборачивает каждый список в объект nodes, и шаблон читает данные как posts.nodes[0].title. Флоу-шаблоны работают без обёртки: ответ узла graphql доступен под его ключом результата (lst), внутри сразу массив с именем типа во множественном числе, и полный путь выглядит как results.lst.foods[0].name. Шаблон, переписанный по привычке с обычного клиента, ошибку не покажет: он просто не найдёт данных.

Вокруг graphql-узлов флоу-движок сам навязывает дисциплину. До любой записи стоят condition-узлы валидации. Если не пришёл client_uuid или дата не похожа на YYYY-MM-DD, отвечаем 400 с машинным кодом INVALID_REQUEST, и до graphql дело не доходит. На каждом graphql-узле висит on_error: stop. Маршруты API собраны с TTL 0, потому что кэшировать ответ для конкретного пользователя нельзя, да и платформа никогда не отдаёт кэш залогиненным. Пароль в таблице member хешируется при записи. При чтении он маскируется "***": флоу логина получает хеш для bcrypt-сравнения, любой другой клиент — звёздочки.

Схема, которая меняется под ногами

Схему данных мы описываем в админке, и GraphQL-схема перестраивается из неё сама. Перестройка при этом асинхронна и занимает до пары секунд. Поэтому после правки схемы мутацию и запрос к новому типу отправляют разными HTTP-запросами, и перед обращением опрашивают introspection. Скрипты посева каталога устроены именно так: создать тип, подождать, убедиться, что тип виден, и только потом загружать данные.

orderBy — ENUM, то есть orderBy: [{field: DATE, direction: ASC}], а не строка "date". Числовые переменные в мутациях объявляются Float. Из JSON при этом приходят строки. В update-ветках мы приводим их сами фильтром number ещё до передачи (в новых сценариях то же делает number() в variables_expr). В create-ветке переменные уходят строками как есть, а во Float их превращает уже приведение типов на стороне GraphQL. Оба пути работают, явный фильтр просто читается надёжнее. И главное для каталогов: limit/offset ограничены 100 записями за страницу. Каталог из 131 продукта так выгружается двумя запросами, offset 0 и offset 100: клиент запрашивает страницы, пока возвращается ровно 100, со страховочным стопом на 10 000 записей.

Быстрый ввод цифрами — за ним тот же флоу-бэкенд (светлая и тёмная темы)

Библиотека запросов не принимала Liquid

Запросы к дневнику мы держим в библиотеке. В graphql-узле текст хранится рядом с меткой library_name, редактор следит, чтобы они не расходились, и на исполнении платформа подставляет его из библиотечной записи. У этого правила нашлась граница. Валидатор библиотеки принимал только чистый GraphQL. Update-мутации нашего API при этом собирают входной JSON Liquid-ом, с запятой внутри {% if %} и фильтрами number на числах, и такой текст валидатор отвергал.

Поэтому мутации с Liquid-сборкой мы тогда держали прямо в узлах флоу, без library_name: читаемые выборки шли в библиотеку, а собранные тела оставались на месте. Развязка пришла с другой стороны: с v1.4.16 Liquid из текста запроса удалён из движка — валидатор снова принимает только чистый GraphQL, мутации стали статичными и вернулись в библиотеку наравне с выборками, а переменные собирает variables_expr (включая частичный сбор — рецепт из статьи про JSON-API). Правило про удаление прежнее: библиотечную запись нельзя удалить, пока на неё ссылается живой флоу, поэтому при чистке каталога сначала отвязывают узлы.

Чего этот список стоил

Больше всего усилий в этом списке стоили привычки из Shopify: проверка blank, фильтр strip и строковые enum-ы работают в osteele/liquid иначе, и каждое такое место теперь сначала проверяется маленьким примером. Schema rebuild и кэш маршрутов обновляются с задержкой, и флоу, который записывает и тут же читает данные, должен учитывать эту задержку с самого начала.

Что дальше. Бэкенд и клиент готовы, остались математика и обёртка. В финальной части цикла, «Калькулятор нормы, темы и полировка», речь про Mifflin-St Jeor, кольцо, которое обрезалось, и итоги проекта.


Все записи