Rest 26.900.0: встройки без приложения, чтение приложений без AppTable и заготовка MCP
В rest 26.900.0 у встроек появился владелец, и модуль может повесить встройку на свою сущность без REST-приложения. Приложение читается в типизированный DTO через ApplicationProvider::find*(), статус оплаты считает ApplicationStatusCalculator, а для REST 3.0 заложен мост в MCP, который в БУС не работает. Сломается прямой вызов CRestUtil::CleanApp() с CLIENT_ID и запись в PlacementTable с неполным владельцем или APP_ID = 0.
Новое
Читайте приложение через ApplicationProvider
У ApplicationProvider появились findById(), findByClientId() и findByCode(). Все три возвращают ?ApplicationData, readonly-DTO с типизированными геттерами вроде isInstalled(): ?bool, getStatus(): ?ApplicationStatus и getDateFinish(): ?Date. findByCode() дороже двух других, индекса по CODE в b_rest_app нет. В отличие от getByClientId($userId, $clientId) того же провайдера, новые методы не проверяют ни права пользователя, ни INSTALLED, это на вас.
Статус оплаты считает ApplicationStatusCalculator::calculate(), результат лежит в ApplicationStatusData: isPaymentAllowed(), isPaymentExpired(), isPaymentNotifyNeeded(), getDaysLeft(), isHeld(). Правила перенесены из getAppStatusInfo(): предупреждение за 5 дней, 14 льготных дней для платного приложения, потом откат в демо, если есть демо-URL и триал не использован. Время, подписку Маркета и hold калькулятор получает через интерфейсы из Bitrix\Rest\Public\Contract\Application\Status, так что в тесте «сегодня» можно зафиксировать.
<?php declare(strict_types=1);
namespace Vendor\Billing\Rest;
use Bitrix\Rest\Public\Provider\ApplicationProvider;
use Bitrix\Rest\Public\Service\ApplicationStatusCalculator;
final class AppPaymentGuard
{
public function __construct(
private readonly ApplicationProvider $apps,
private readonly ApplicationStatusCalculator $calculator,
) {}
public function canCall(string $clientId): bool
{
$app = $this->apps->findByClientId($clientId);
if ($app === null || $app->isActive() !== true || $app->isInstalled() !== true) {
return false;
}
return $this->calculator->calculate($app)->isPaymentAllowed();
}
}
Повесьте встройку на свою сущность
Владелец встройки задаётся тремя полями: MODULE_ID, ENTITY_TYPE и ENTITY_ID. У встроек приложений это rest/app/<APP_ID>, и заполняется тройка сама. Другой модуль объявляет провайдера в своём .settings.php: 'rest.embedding.ownerProviders' => ['value' => ['article' => ArticleEmbeddingOwner::class]]. Провайдер реализует EmbeddingOwnerProviderInterface: getEmbeddingAccessCodes() отдаёт коды доступа новой встройки (AU, U1, G5 и другие коды CAccess), а resolveEmbeddings() превращает встройки в EmbeddingOpenDto с URL и payload для открытия.
<?php declare(strict_types=1);
namespace Vendor\Kb\Integration\Rest;
use Bitrix\Rest\Public\Dto\Embedding\EmbeddingOwnerDto;
use Bitrix\Rest\Public\Provider\Dto\Embedding\EmbeddingDto;
use Bitrix\Rest\Public\Provider\Dto\Embedding\EmbeddingOpenDto;
use Bitrix\Rest\Public\Provider\EmbeddingOwnerProviderInterface;
final class ArticleEmbeddingOwner implements EmbeddingOwnerProviderInterface
{
public function getEmbeddingAccessCodes(EmbeddingOwnerDto $owner): array
{
return ['AU'];
}
public function resolveEmbeddings(array $embeddings, int $userId): array
{
return array_map(
static fn(EmbeddingDto $embedding): EmbeddingOpenDto => new EmbeddingOpenDto(
embeddingId: $embedding->id,
url: (string)$embedding->handler,
payload: ['ARTICLE_ID' => (int)$embedding->entityId],
),
$embeddings,
);
}
}
Привязывает встройку BindEmbeddingCommand, отвязывает UnbindEmbeddingCommand по ID встройки:
<?php declare(strict_types=1);
use Bitrix\Main\Loader;
use Bitrix\Rest\Public\Command\Embedding\BindEmbeddingCommand;
use Bitrix\Rest\Public\Dto\Embedding\EmbeddingLanguageDto;
use Bitrix\Rest\Public\Dto\Embedding\EmbeddingOwnerDto;
Loader::requireModule('rest');
$result = (new BindEmbeddingCommand(
owner: new EmbeddingOwnerDto('vendor.kb', 'article', '42'),
placement: 'VENDOR_KB_PANEL', // объявлен вашим модулем в OnRestServiceBuildDescription
handler: 'https://kb.example.ru/embed/42',
languages: [new EmbeddingLanguageDto('ru', title: 'Статья из базы знаний')],
))->run();
$embeddingId = $result->isSuccess() ? $result->getData()['embeddingId'] : null;
Команда требует существующий неприватный плейсмент и абсолютный http(s)-URL обработчика, учитывает max_count, user_mode и registerCallback плейсмента и пишет всё в транзакции. Ожидаемые отказы (EMBEDDING_ALREADY_EXISTS, EMBEDDING_BINDING_NOT_SUPPORTED, ошибки плейсмента) приходят в Result, а ошибки аргументов и ненайденный провайдер владельца вылетают из run() как CommandException.
К REST-приложению так ничего не привязать, потому что AppEmbeddingOwnerProvider::getEmbeddingAccessCodes() бросает EmbeddingBindingNotSupportedException. Других модулей с провайдерами в эталоне нет.
Стандартный интерфейс выводит встройки через старый PlacementTable::getHandlersList(), который берёт только строки с активным приложением. Свои встройки вы покажете сами через EmbeddingProvider::getListByPlacement($placement, $userId): он вернёт EmbeddingViewDto с локализованным заголовком, иконкой, URL и payload от провайдера. Администратор видит все общие и свои встройки, остальные только открытые их кодам доступа. При пустом списке кодов встройку увидят только администраторы.
REST 3.0 → MCP: пока заготовка
Атрибут #[McpTool] помечает метод контроллера REST 3.0 для экспорта в MCP-инструмент, набор инструментов собирает наследник RestToolSetProvider. Он наследует класс из модуля aiassistant, которого в БУС нет, а #[McpTool] в эталоне не стоит ни на одном контроллере, так что в БУС мост не заработает.
Скоупы вебхуков сравниваются без учёта регистра
PermissionTable::cleanPermissionList() вычищает скоупы, которые вебхуку выдавать нельзя: rating, entity, placement, landing_cloud и глобальный. Раньше сравнение было регистрозависимым, и Placement сохранялся в правах вебхука. В списке прав он оставался, но методы placement.* не открывал, потому что проверка доступа тоже регистрозависима. Теперь скоупы сравниваются в нижнем регистре и без пробелов по краям, а дубли вида crm/CRM у вебхуков и приложений из «Интеграций» схлопываются.
AppTable::delete() удаляет привязки приложения
AppTable::delete() теперь сам удаляет обработчики событий, офлайн-обработчики, встройки и коды доступа приложения. Раньше привязки чистились только у локальных приложений и только при зарегистрированном OAuth. Сбой одного шага пишется в лог и не мешает остальным.
Что сломается
CleanApp с CLIENT_ID бросает исключение
PlacementTable::deleteByApp() и PlacementLangTable::deleteByApp() при ID ≤ 0 бросают Main\ArgumentException. Раньше строка приводилась к 0, и удалялись встройки с APP_ID = 0, обычно ни одной. CRestUtil::CleanApp($appId, $bClean) передаёт ID дальше как есть, поэтому вызов с CLIENT_ID или кодом приложения теперь падает, причём обработчики OnRestAppDelete к этому моменту уже отработали. Передавайте числовой ID, например (new ApplicationProvider())->findByClientId($clientId)?->getId(), и проверьте результат на null.
AppTable::uninstall() сам переводит CLIENT_ID в ID, поэтому через него падения нет, и OnRestAppDelete при таком вызове получает числовой APP_ID.
onAfterPlacementDelete приходит на каждую встройку
Старый deleteByApp() выбирал встройки с 'limit' => 1, и событие onAfterPlacementDelete::<PLACEMENT> уходило только для первой. Теперь оно приходит на каждую удалённую встройку. Обработчик, который ждал одного вызова на удаление приложения, получит несколько. Сделайте его идемпотентным.
PlacementTable проверяет владельца при записи
PlacementTable::add() и update() валидируют тройку владельца. Обычный add() с положительным APP_ID работает как раньше, тройка заполнится значениями rest/app/<APP_ID>. Ошибку вы получите в трёх случаях:
- передали только часть тройки:
MODULE_ID, ENTITY_TYPE and ENTITY_ID must be changed together; - значение длиннее 50 символов;
- явно передали
APP_ID≤ 0 без тройки илиAPP_ID, который не совпадает сENTITY_IDвладельцаrest/app:Invalid embedding owner.
Сломается код, который создавал встройки без приложения через APP_ID = 0.
Deprecated
Новых @deprecated нет, но сам модуль начал уходить от двух старых методов. В десяти файлах вместо AppTable::getByClientId() он вызывает ApplicationProvider::findByClientId() и findById(). ApplicationStatusCalculator::calculate() заменил AppTable::getAppStatusInfo() в рассылке событий Event\Callback и Event\Sender, а события REST 3.0 и компоненты Маркета по-прежнему зовут getAppStatusInfo(). Калькулятор отдаёт статус, флаги и число дней: подстановок для текста уведомлений (MESSAGE_REPLACE с #DETAIL_URL#, #DAYS#, #CODE#) в нём нет, а 'Y'/'N' стали bool.
БД
В b_rest_placement добавлены MODULE_ID varchar(50) (по умолчанию rest), ENTITY_TYPE varchar(50) (по умолчанию app) и ENTITY_ID varchar(50), плюс уникальный индекс по владельцу, плейсменту и обработчику. У старых строк ENTITY_ID пустой, владельца модуль вычисляет по APP_ID. Встройки приложения ищите по APP_ID, а не по ENTITY_ID. У встроек других владельцев APP_ID равен NULL.
Новая таблица b_rest_embedding_access (EMBEDDING_ID, ACCESS_CODE varchar(100)) хранит коды доступа встроек. Для встроек приложения коды берутся из его поля ACCESS. У обычного приложения пустой ACCESS даёт AU. У личного к кодам из ACCESS добавляется U<владелец>, а AU при пустом ACCESS не ставится. Существующие встройки заполняет степпер BackfillEmbeddingAccess, его регистрации в эталоне не видно.
Мелочи и находки
ApplicationData::toArray()и__debugInfo()включаютClientSecret,SharedKeyиApplicationToken.var_dump()или DTO в логе целиком раскроет секреты приложения, логируйте отдельные поля.- Конструкторы
InstallPersonalAppCommandHandlerиCreateIncomingWebhookCommandHandlerпотерялиTariffAccessService: тарифная проверка дублировалась и осталась в пресете. Заденет только тех, кто собирает эти хендлеры руками. УAppTable::clearClientCache()иPlacementTable::checkUniq()поменялись protected-сигнатуры, проверьте наследников, если они их переопределяют.