Подключаем каталог к внешнему сервису по подписанному вебхуку

Подключаем каталог к внешнему сервису по подписанному вебхуку

Библиотека выросла из демо-каталога. У неё есть закрытая админка, читательские аккаунты, фильтры, обложки и два языка. Остался последний сосед — внешний мир. Печатный цех, куда уходит заказ на новый экземпляр; служба доставки, которой сообщают о выдаче; учётная система, которая присылает свой статус. Каждая интеграция состоит из двух половин: наш сценарий зовёт чужой API и чужой сервис зовёт наш сценарий обратно.

В этой части замыкаем оба направления на примере типографии. Добавили книгу — сценарий отправляет в цех заказ на печатный экземпляр, приложив служебный ключ в заголовке. Цех отвечает номером заказа, а когда экземпляр готов — присылает подписанный вебхук, по которому каталог переводит книгу в «Готова к выдаче». Предыдущие части цикла: сборка закрытой админки, читательские аккаунты, фильтры и честные ошибки, обложки из медиатеки, два языка из одной записи.

Секреты обеих половин мы заранее вынесли из документов сценариев — для того у платформы есть отдельное хранилище.

Два секрета в хранилище

В разделе «Сайт» появилась вкладка «Секреты»: список имён и форма добавления. Имя придумываете вы, значение — это ваш настоящий ключ (printshop_key) и общий секрет для подписи (printshop_signing). У демо-проекта они учебные — в боевом их выдаёт партнёр.

Раздел «Сайт», вкладка «Секреты»: список из biblio-smtp-password, printshop_key и printshop_signing, ниже форма «Добавить или обновить секрет» с предупреждением о шифровании

Хранилище одностороннее. Значение шифруется платформенным ключом, и после сохранения его нельзя посмотреть ни в интерфейсе, ни через API — можно перезаписать, удалить или сослаться. Ссылка выглядит как secret://printshop_key и ставится целым значением в документе сценария. Движок подставляет настоящий ключ в последний момент, перед исполнением, а в журналах и dry-run подменяет его обратно на ссылку. Не нашлось имя — действие падает с SECRET_NOT_FOUND, и запрос к партнёру не уходит.

Сценарий зовёт типографию

В books_add за созданием книги стоит новый узел HTTP POST. После того как createBook вернул запись и пометил её print: "ordered", сценарий собирает заказ:


{

  "action_type": "http_post",

  "config": {

    "url": "https://biblio.dynapi.ru/api/printshop/orders",

    "headers": { "X-Printshop-Key": "secret://printshop_key" },

    "body_template": "{\"book_id\": \"{{results.created.createBook.id}}\", \"title\": {{results.created.createBook.title | json}}}",

    "result_key": "printshop",

    "timeout_sec": 10

  }

}

Liquid работает во всех трёх полях: в URL, в заголовках и в теле. Значение заголовка — целиком ссылка на секрет, подставленный ключ уходит на провод только в момент запроса. Тело собирается из ответа graphql-узла, а фильтр | json экранирует название, чтобы кавычки в «Мёртвых душах» не сломали JSON. Content-Type: application/json сценарий ставит сам, таймаут по умолчанию десять секунд.

Сегодня тело заказа собирает body_expr — значением, а не текстом:

{ "book_id": results.created.createBook.id, "title": results.created.createBook.title }

Значение кодируется как данные: кавычки в названии экранируются сами, число остаётся числом, битого JSON не бывает. Liquid-форма body_template с | json у «HTTP POST» — помеченный legacy-путь: она работает и осталась в демо-ветке (у «JSON-ответа» тот же путь с v1.4.16 удалён из движка — там тело собирает только body_expr), но новые тела собирают body_expr-ом. Если секрет понадобится внутри expr-поля, там он выглядит вызовом — secret("printshop_key"); в Liquid-полях (как этот заголовок) действует ссылка secret:// целым значением.

result_key решает судьбу ответа. С ним разобранное тело партнёра попадает в results.printshop — следующие узлы могут по нему ветвиться. Без него ответ выбрасывается — в журнале останется сниппет, но в сценарий ничего не приедет.

Редактор сценария books_add: справа панель узла HTTP POST с URL приёмника, заголовком X-Printshop-Key со значением secret://printshop_key и телом заказа, слева граф с узлом между GraphQL и JSON-ответом

Приёмник пускает по ключу

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

Приёмник заказов — маршрут /api/printshop/orders с двумя исходами. Условие сравнивает заголовок с секретом из хранилища — это вторая позиция, где разрешена ссылка secret://:


{

  "field": "headers.X-Printshop-Key",

  "operator": "eq",

  "value": "secret://printshop_key"

}

Совпало — приёмник отвечает {"accepted": true, "order_id": "P-…"}, номер собирается из свежего UUID. Не совпало — 403 {"error": "BAD_KEY"}. Имена заголовков в условиях пишутся в каноническом виде, headers.X-Printshop-Key, ровно как их отдаёт HTTP-сервер.

Ответ приходит с подписью

Когда экземпляр готов, типография вызывает POST /api/printshop/webhook. Верить слову нельзя: маршрут публичный, значит подпись обязана доказать, что тело собрано держателем общего секрета и не искажено по дороге. Весь гейт — одно условие:


{

  "field": "headers.X-Printshop-Signature",

  "operator": "digest_matches",

  "value": "hmac-sha256|secret://printshop_signing|{{raw_body}}"

}

Разбирать стоит по частям. raw_body — точные байты запроса, до одного мегабайта, у JSON- и XML-тел; подпись сверяют с ними, а не с распарсенной картой, у которой порядок и экранирование могли пережить переезд. Спека читается как алгоритм|секрет|вход, и secret:// внутри неё — единственное место, где ссылка на секрет разрешена не целым значением. Сигнатура едет в заголовке и может выглядеть как hex или base64 — регистр и паддинг не важны.

Набор алгоритмов покрывает sha256, sha512, легаси-md5/sha1 для старых схем поставщиков и HMAC-варианты всех четырёх. Составные подписи вроде подписей Stripe t=169…,v1=… и префиксы sha256=/v1= разворачиваются автоматически, из списка сравнивается любой подходящий элемент. Всё, что не сошлось, не распарсилось или ссылается на неизвестный секрет, уходит в else — вебхук-гейт не имеет права пройти на мусоре.

У гейта есть и expr-форма: в predicate_expr подпись сравнивают сырым hex-дайджестом — hmac_sha256_hex(secret("printshop_signing"), raw_body) == headers["X-Signature"] (дефис в имени заголовка требует скобочной записи). Составные подписи вроде Stripe t=…,v1=…, base64-кодирование и префиксы sha256= остаются в клаузе digest_matches — она разворачивает их сама; expr-сравнение принимает только сырой hex.

На then стоит graphql-узел: updateBook переводит книгу в print: "ready" и сценарий отвечает {"ok": true, "book": {…}}. На else — 403 {"error": "BAD_SIGNATURE"}. Подделать статус подделкой подписи нельзя, менять книгу может только держатель секрета.

Канва сценария printshop_webhook: от «Начало» к условию с заголовком X-Printshop-Signature, ветка да ведёт ко второму условию и graphql-узлу, ветка нет — к JSON-ответу с BAD_SIGNATURE

Ответ в каталоге и в журнале

Клиенту сценарий отдаёт обе вещи одним ответом: книгу и результат внешнего вызова. Форма показывает номер заказа отдельной строкой, а под пилюлей статуса появляется тихая пометка «В типографии» — состояние чужого процесса поверх прежнего статуса книги:

Каталог с поиском по слову «Алиса»: сверху синяя строка «Типография приняла заказ P-01a29576», в строке книги пилюля «В наличии» и серая пометка «В типографии»

Если типография не ответила, книга всё равно создаётся. Ошибке http_post достаточно испортить свой узел — цепочка продолжит, в ответ уедет "printshop": null, а интерфейс честно скажет «заказ не отправлен». Наружная интеграция не должна стоить каталогу записи.

Вторая половина отладки живёт во вкладке «Запуски». Шаг http_post раскрывается в дамп запроса и ответа, и в дампе вместо ключа снова стоит secret://printshop_key — движок вернул ссылку на место значения:

Вкладка «Запуски» сценария books_add: раскрыт шаг http_post, слева блок «Запрос (секреты скрыты)» с заголовком X-Printshop-Key: secret://printshop_key, справа ответ приёмника с order_id

Сниппет ответа хранит первые пятьсот символов от партнёра — и это касается не только удачных вызовов. На 4xx и 5xx шаг падает, но тело ошибки остаётся в журнале — разбирать «почему партнёр недоволен» можно без доступа к его логам. Запрос маскируется до записи, секрет не попадает даже в текст упавшего прогона.

После вебхука та же книга выглядит так — пометка позеленела, а интерфейс из кадра переключён на EN переключателем из прошлой части:

Каталог в режиме EN: у книги «Маленький принц» пилюля «In stock» и зелёная пометка «Ready for pickup», интерфейс переведён на английский

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

Dry-run умеет репетировать вызовы: узел без result_key в репетиции подавляется и не стреляет, узел с ним берёт ответ из мока httpResults в контексте, а условия с секретами считают против настоящих значений из хранилища — ветка в репетиции возьмёт тот же курс, что и в живом прогоне.

Подписывать исходящие запросы можно и самому: в шаблонах доступны фильтры sha256 и hmac_sha256 (плюс md5, sha1, sha512 и их HMAC-варианты), они возвращают hex. Пустой вход или пустой ключ дают пустую строку. notify_telegram — готовый вызов для чат-бота, если API партнёра оказался Telegram.

Секрет в URL — плохая идея даже с маской в журнале: адрес целиком уезжает в логи пира и промежуточных прокси. Ключ живёт в заголовке или теле.

За кадром остался последний житель демо — сама сборка. Следующая часть цикла про то, как отдать админку ИИ-агенту: пакет инструкций и flowctl, по которым внешний ассистент сам заводит типы, сценарии и страницы.

Все .dynflow.json этой части лежат в ветке step-9-external: обновлённый books_add плюс приёмник и вебхук, публикуемые командой flowctl apply flows/printshop_webhook.dynflow.json --publish.


Все записи