Контроллер изнутри - маршрут, действие, фильтры, ответ
Разбираем путь асинхронного запроса через контроллер: как из адреса получается класс действия и что успевает произойти до первой строки нашего кода. Дальше смотрим, как собирается ответ и куда в нём попадают ошибки.
Механика
Штатных входов у контроллера два. Первый - общий адрес обмена
/bitrix/services/main/ajax.php с параметром action, второй - объявленный
маршрут из файла /local/routes/web.php (роутер работает с версии main
21.400.0). Оба входа приводят к одному и тому же вызову метода класса.
Идентификатор действия читается по частям: [вендор]:[модуль].[Класс].[действие].
Без вендора платформа подставляет bitrix, поэтому чужой модуль без префикса
просто не находится. Собирать этот идентификатор руками в разметке не нужно -
ссылку строит Engine\UrlManager.
Класс ищется в базовом пространстве имён модуля. Его объявляет секция
controllers файла .settings.php самого модуля, и без неё ядро смотрит в
Bitrix\Main\Engine\DefaultController, где описания действия нет. Ответ в этом
случае содержит код 22002 и текст о ненайденном описании.
Действие отличается от обычного метода тремя вещами сразу: оно публично, названо
с суффиксом Action, а его аргументы платформа заполняет из запроса по имени и
типу. Отсутствие обязательного аргумента даёт ошибку Could not find value for parameter ещё до входа в тело метода. Типовые объекты подставляет автоваринг
(Engine\CurrentUser, UI\PageNavigation, Engine\JsonPayload), свои классы
подключают правилом из getAutoWiredParameters(), а проверяемые структуры
приходят через ValidationParameter.
Вокруг действия работают фильтры. Префильтр выполняется до вызова и способен отменить действие целиком, постфильтр получает уже готовый результат и может его изменить. По умолчанию на каждом действии стоят три префильтра: проверка подписи, проверка метода запроса и требование авторизации.
Управляют этим набором декларативно. Атрибуты из
Engine\ActionFilter\Attribute\Rule\* вешают прямо на метод, и это
предпочтительный путь; getDefaultPreFilters() переопределяют только вместе с
вызовом родителя. Пересобранная с нуля защита - самая частая причина открытого
наружу действия.
Подпись сеанса проверяет фильтр Csrf: при несовпадении ключа действие не
выполняется, а клиент получает ответ с ошибкой о подписи. Штатный вызов
BX.ajax.runAction умеет забрать свежий ключ из этого ответа и повторить запрос
сам, а обращение по прямому адресу такой попытки не делает. Соседний фильтр
Authentication при неудаче отдаёт гостю ответ со статусом 401, потому что
редирект на страницу входа включается только явным параметром $enableRedirect.
Ответ собирает ядро. Скаляр или массив из действия оборачиваются в
Response\AjaxJson с полями status, data и errors; ошибки, добавленные
через addError(), попадают в третье поле. Для разметки и заголовков возвращают
HttpResponse, для чистого JSON без конверта - Response\Json.
Клиентский код видит не то же самое, что вернул метод. BX.ajax.runAction
отдаёт промис: возвращённый массив лежит в поле data ответа, а список ошибок
приходит отдельно в catch. Отсюда постоянная путаница с undefined при чтении
результата сразу после вызова.
Контроллер компонента живёт по другим правилам входа. У него действия описаны в
class.php, вызов идёт через BX.ajax.runComponentAction с режимом и
подписанными параметрами, а пространство имён модуля здесь ни при чём. Устройство
таких действий разобрано в
отдельной странице про компонент.
Шаги
- Разобрать идентификатор действия на вендора, модуль, класс и имя метода без суффикса.
- Проверить базовое пространство имён контроллеров в файле настроек своего модуля.
- Сверить сигнатуру действия с составом запроса: имена аргументов, типы и обязательность.
- Посмотреть набор префильтров действия: подпись сеанса, метод запроса и требование авторизации.
- Проследить путь ответа от возврата метода до поля
dataв клиентском коде.
Код
Объявляем базовое пространство имён контроллеров:
return [ 'controllers' => [ 'value' => ['defaultNamespace' => '\\Vendor\\Module\\Controller'], 'readonly' => true, ],];// без этой секции ядро ищет действие в DefaultController и отвечает кодом 22002Именно эта секция превращает часть vendor:module в каталог классов. AJAX- и
HTTP-контроллеры разводят по разным пространствам имён, и в настройках модуля
указывают то из них, которое обслуживает асинхронные вызовы.
Ведём к тому же действию свой маршрут:
use Bitrix\Main\Routing\RoutingConfigurator;use Vendor\Module\Controller\Item;
return static function (RoutingConfigurator $routes) { $routes->post('api/v1/item/{id}/', [Item::class, 'view']) ->where('id', '[0-9]+') // параметр без шаблона матчится как [^/]+ ->name('Vendor.Item.View'); // имя уникально среди всех файлов маршрутов};// имя действия пишут без суффикса Action, как и в идентификаторе вызова// сам файл подключают в секции routing файла настроекМаршрут даёт человекочитаемый адрес и явный метод запроса, но приводит к тому же методу класса. Фильтры действия при этом остаются на месте: путь входа на них никак не влияет.
Описываем действие с фильтрами-атрибутами:
namespace Vendor\Module\Controller;
use Bitrix\Main\Engine\Controller;use Bitrix\Main\Engine\ActionFilter\Attribute\Rule\{Authentication, HttpMethod};use Bitrix\Main\Engine\CurrentUser;use Bitrix\Main\Error;
final class Item extends Controller{ #[Authentication] #[HttpMethod([HttpMethod::METHOD_POST])] public function viewAction(int $id, CurrentUser $user): ?array { if ($id <= 0) { $this->addError(new Error('Неверный идентификатор', 'INVALID_ID')); return null; // ответ придёт со статусом ошибки } return ['id' => $id, 'userId' => $user->getId()]; }}Суффикс Action обязателен, а в идентификаторе его не пишут: метод выше
вызывается как vendor:module.Item.view. Аргумент $id платформа берёт из
запроса и приводит к числу, а объект пользователя подставляет автоваринг.
Конверт ответа при отказе выглядит так:
{ "status": "error", "data": null, "errors": [ { "message": "Неверный идентификатор", "code": "INVALID_ID", "customData": null } ]}Поле status различает успех и отказ, данные и ошибки лежат в разных полях, а
код ошибки задаём мы сами. Отказ фильтра приходит в этом же конверте, поэтому
клиентский код разбирает оба случая одинаково.
Добавляем свой префильтр, не ломая штатные:
protected function getDefaultPreFilters(): array{ return array_merge(parent::getDefaultPreFilters(), [ new \Bitrix\Main\Engine\ActionFilter\CloseSession(), // снимает блокировку сессии ]);}// parent даёт три штатных фильтра: подпись, метод запроса и авторизацию// возврат пустого массива снимает их разом, включая проверку подписиСписок префильтров действия задают и через configureActions(), но атрибуты на
методе читаются рядом с кодом и потому предпочтительнее.
Подставляем в действие свой объект вместо идентификатора:
public function getAutoWiredParameters(): array{ return [ new \Bitrix\Main\Engine\AutoWire\ExactParameter( \Vendor\Module\Item::class, 'code', static fn(string $className, string $code) => \Vendor\Module\Item::load($code) ), ];}Правило связывает параметр запроса code с готовой сущностью. Без такого правила
нештатный тип в сигнатуре даёт ошибку о ненайденном значении параметра, а не
пустой объект.
Строим ссылку на действие из PHP:
$url = \Bitrix\Main\Engine\UrlManager::getInstance() ->create('vendor:module.Item.view', ['id' => 7]);// идентификатор сайта и кодирование параметров подставляются сами// собранный строкой адрес ломается на многосайтовости и кириллице в параметрах// третий аргумент делает адрес абсолютным: он нужен письмам и внешним клиентамТот же адрес пригождается при разборе сбоя: открытый в браузере, он показывает сырой конверт ответа целиком, без промежуточного разбора клиентским кодом.
Приводим ключи ответа к виду, привычному фронтенду:
use Bitrix\Main\Engine\Response\Converter;
$converter = new Converter(Converter::OUTPUT_JSON_FORMAT);$data = $converter->process(['item_id' => 7, 'created_at' => $date->toString()]);// на выходе ключи itemId и createdAt: преобразование идёт рекурсивно// объект даты сам в строку не превратится: приводим его явно на границе ответа// то же касается сущностей ORM, денег и любых объектов со своим форматомЧитаем ответ на стороне браузера:
BX.ajax.runAction('vendor:module.Item.view', { data: { id: 7 } }) .then((response) => console.log(response.data)) // возврат действия лежит здесь .catch((response) => console.log(response.errors)); // код и текст отказа
// та же пара полей у варианта с ожиданием ответаconst { data, errors } = await BX.ajax.runAction('vendor:module.Item.view', { method: 'POST', data: { id: 7 },});Поле data содержит ровно то, что вернул метод, а поле errors - коллекцию
ошибок контроллера. Ошибка фильтра приходит тем же путём, поэтому отказ по
подписи и отказ по правам различают по коду, а не по поведению страницы.
Ограничения
Контроллеры для страниц и для асинхронных вызовов разделяют по разным
пространствам имён. Вызов BX.ajax.runAction для действия, оставшегося только в
HTTP-маршруте, вернёт ошибку 22002, и это ожидаемое поведение, а не сбой.
Маршрут может не сработать по причинам вне самого контроллера. Тот же адрес
перекрывают старые правила urlrewrite.php, которые проверяются раньше, либо
объявленный выше маршрут {any}: сопоставление идёт до первого совпадения.
Снятие префильтров - решение не про удобство, а про безопасность. Пустой список убирает сразу и подпись сеанса, и требование авторизации, поэтому изменяющее данные действие после такой правки открыто любому знающему его адрес.
Автоваринг понимает только штатные типы и объявленные правила. Всё остальное приходит скалярами из запроса, а нештатный тип в сигнатуре без правила останавливает вызов ошибкой о ненайденном значении параметра.
Ответ уходит через сериализацию, и объекты в нём не превращаются в строки сами. Даты, деньги и сущности ORM приводят к простым типам на границе действия, иначе клиент получит структуру, которой не ждал.
Типичные проблемы
Ответ: «Could not find description of Item.view in Bitrix\Main\Engine\DefaultController».
В файле настроек модуля не объявлено базовое пространство имён контроллеров. Ядро ищет действие в контроллере по умолчанию и описания там не находит.
Действие не находится, хотя класс лежит на месте и автозагрузка работает.
У метода забыт суффикс Action в имени класса контроллера. В идентификаторе суффикс не пишут, а в самом методе он обязателен.
Запрос отклоняется по подписи после долгой жизни вкладки или входа на другой вкладке.
Ключ сеанса сменился, а страница отправляет прежний. Штатный вызов забирает свежий ключ из ответа и повторяет запрос, обращение по прямому адресу - нет.
Даты в ответе приходят строкой не того формата, что показывает сайт.
Объект даты уезжает в ответ через сериализацию, а не через приведение к строке. На границе действия его приводят явно вызовом toString.
Печать результата сразу после вызова показывает undefined.
Вызов действия возвращает промис, а не данные. Ответ читают в then или через ожидание, и лежит он в поле data, а не в корне.
Гость получает 401 вместо страницы входа.
Фильтр аутентификации по умолчанию не делает редиректа. Он включается отдельным параметром и ведёт только на страницу авторизации с обратным адресом.
Частые вопросы
Почему не работает контроллер?
Чаще всего из-за адреса, а не из-за кода: не объявлено пространство имён модуля или в имени метода забыт суффикс. Точную причину называет код ошибки в ответе.
Как узнать, какие параметры надо прописывать в запросе?
По сигнатуре действия: имена аргументов совпадают с именами параметров запроса, а типы приводятся платформой. Обязательный аргумент без значения останавливает вызов.
Как вытащить возвращаемое значение из ajax-запроса?
Через промис вызова: возврат действия лежит в поле data ответа. Читать его сразу после вызова бесполезно, ответ к этому моменту ещё не пришёл.
Чем действие контроллера отличается от обычного метода?
Именем с суффиксом, аргументами из запроса и фильтрами вокруг вызова. Обычный метод класса контроллера снаружи недоступен и в идентификаторе не адресуется.
Можно ли обращаться к общему адресу обмена напрямую?
Технически да, и для отладки это удобно. В рабочем коде используют штатный вызов: он передаёт подпись, повторяет запрос при смене ключа и разбирает конверт ответа.
Смежное
- AJAX-запросы - оглавление подтемы
- JS-ядро и интерфейсы - что доступно на стороне браузера
- AJAX-запрос: контроллер, свой файл и ответ в JSON - как написать свой обработчик
- Не работает AJAX-запрос: разбор причин - когда запрос не доезжает или не разбирается
- Проверка входных данных атрибутами: правила, результат, контроллер - что успевает проверить фильтр до действия
- Отправка формы через AJAX: проверка полей, защита, ответ с ошибками - подпись сеанса на живой форме
- AJAX-действия своего компонента: действия, параметры, ошибки - второй вид действий и подписанные параметры
- Ядро D7 - основы - контроллеры, маршруты, результат и ошибки
- Своё API для приложения: контроллер, токен, версии - те же контроллеры для внешних клиентов
- Свой модуль: структура, установка, автозагрузка классов - где лежит файл настроек модуля
- Данные от посетителя: экранирование, проверка, сеанс - зачем нужна подпись запроса
- Свои действия по REST: включение, ограничение, контекст - тот же контроллер, вызванный снаружи