Bitrix D7 Engine\Controller: AJAX-контроллеры, runAction и типичные ошибки

Для AJAX в модулях Битрикс до сих пор плодят /local/ajax/*.php, ручной echo json_encode и проверку sessid «на глаз». Штатный контракт ядра — Bitrix\Main\Engine\Controller: action-методы, prefilters, единый JSON-ответ и вызов из JS через BX.ajax.runAction.

Рядом по теме: AJAX в компонентах, REST API на D7, Application / Context / Request, Web\Json, Result / Error, HttpClient. Ниже — чем Controller отличается от component ajax, как настроить configureActions, вызвать action из JS и разобрать типичные 400/«пустой» ответ.

Controller vs component ajax vs старый ajax.php

Три слоя, которые часто путают:

  • Отдельный ajax.php — произвольный скрипт под document root. Нет prefilters, легко забыть CSRF и права, сложно тестировать. Для нового кода — антипаттерн.
  • AJAX компонента — AJAX_MODE, ветка isAjaxRequest() или BX.ajax.runComponentAction к контроллеру компонента. Удобно, когда логика живёт рядом с UI-блоком (см. гайд по AJAX в компонентах).
  • Engine\Controller модуля — action’ы модуля, вызов BX.ajax.runAction('vendor:module.controller.action') или HTTP-маршрут. Это правильное место для переиспользуемой бизнес-логики, админки, виджетов и API «внутри сайта».

Правило: UI-перерисовка списка — компонент; сохранение сущности, права, валидация, общий API — Controller модуля. REST «наружу» с префиксом api/v1 — отдельный слой маршрутов поверх тех же контроллеров (REST API D7).

Минимальный контроллер модуля

<?php
namespace Vendor\Demo\Controller;

use Bitrix\Main\Engine\Controller;
use Bitrix\Main\Engine\ActionFilter;
use Bitrix\Main\Engine\CurrentUser;
use Bitrix\Main\Error;
use Bitrix\Main\Loader;

class Item extends Controller
{
    public function configureActions(): array
    {
        return [
            'get' => [
                'prefilters' => [
                    new ActionFilter\Authentication(),
                    new ActionFilter\HttpMethod([ActionFilter\HttpMethod::METHOD_GET]),
                ],
            ],
            'save' => [
                'prefilters' => [
                    new ActionFilter\Authentication(),
                    new ActionFilter\HttpMethod([ActionFilter\HttpMethod::METHOD_POST]),
                    new ActionFilter\Csrf(),
                ],
            ],
            // публичный read-only без авторизации — явно снимаем дефолтные фильтры
            'publicList' => [
                'prefilters' => [
                    new ActionFilter\HttpMethod([ActionFilter\HttpMethod::METHOD_GET]),
                ],
            ],
        ];
    }

    public function getAction(int $id): ?array
    {
        if (!Loader::includeModule('vendor.demo')) {
            $this->addError(new Error('Module vendor.demo is not installed'));
            return null;
        }

        $userId = (int)CurrentUser::get()->getId();
        // проверка прав — в action или через кастомный prefilter
        if ($userId <= 0) {
            $this->addError(new Error('Access denied', 'ACCESS_DENIED'));
            return null;
        }

        return [
            'id' => $id,
            'name' => 'Demo item',
            'userId' => $userId,
        ];
    }

    public function saveAction(string $name, int $id = 0): ?array
    {
        $name = trim($name);
        if ($name === '') {
            $this->addError(new Error('Name is required', 'VALIDATION'));
            return null;
        }

        // ... сохранение ...
        return ['id' => $id > 0 ? $id : 42, 'name' => $name];
    }
}

Имя action в конфиге — без суффикса Action: ключ get соответствует методу getAction. Класс кладут в namespace контроллеров модуля (см. раздел про маршрутизацию ниже).

configureActions, prefilters и дефолты

Метод configureActions() возвращает карту «имя action → опции». Главное поле — prefilters: цепочка проверок до вызова метода. Базовые фильтры ядра:

  • ActionFilter\Authentication — пользователь должен быть авторизован;
  • ActionFilter\HttpMethod — только указанные HTTP-методы (GET/POST/…);
  • ActionFilter\Csrf — проверка sessid / заголовка CSRF;
  • ActionFilter\ContentType — при необходимости ограничить Content-Type.

Если для action не задать prefilters явно, у Controller сработают дефолтные prefilters родителя (в типичной конфигурации ядра это включает аутентификацию и CSRF). Поэтому «открытый» публичный action нужно описывать осознанно: перечислить только нужные фильтры (часто — один HttpMethod), а не надеяться, что пустой массив «ничего не проверяет» во всех версиях одинаково. При сомнении смотрите getDefaultPreFilters() в вашей версии main.

Postfilters (postfilters) применяют уже к результату — реже нужны в прикладном коде. Для кастомной проверки прав удобнее свой класс, наследник ActionFilter\Base, или явная проверка CurrentUser / $USER->CanDoOperation в начале action.

Что возвращает action: массив, Result, ошибки

Успешный action обычно возвращает array — Engine сам упакует его в JSON-поле data. Можно вернуть объект Bitrix\Main\Result / наследников — контракт успеха и ошибок разобран в статье про Result и Error.

Ошибки добавляйте через $this->addError(new Error(...)) и возвращайте null (или Result с ошибками). Не бросайте «голый» Exception с HTML-текстом — фронт ждёт структуру errors[]. Для контролируемых отказов с кодом статуса иногда используют исключения Engine (в т.ч. связанные с JSON-ответом) — главное не смешивать это с ручным echo.

<?php
use Bitrix\Main\Error;

public function deleteAction(int $id): ?array
{
    if ($id <= 0) {
        $this->addError(new Error('Invalid id', 'VALIDATION'));
        return null;
    }

    if (!$this->canDelete($id)) {
        $this->addError(new Error('Access denied', 'ACCESS_DENIED'));
        return null;
    }

    // delete...
    return ['deleted' => true, 'id' => $id];
}

Нельзя внутри action писать echo Json::encode(...); die; — Engine уже сериализует return. Двойная кодировка и «битый» ответ — классика отладки в 2 часа ночи. Ручной Web\Json оставляйте вне Engine (свой webhook, отдача файла) — см. Web\Json.

Вызов из JS: BX.ajax.runAction

Штатный клиент — BX.ajax.runAction. Имя действия: vendor:module.controllerName.actionName (модуль с точкой в id, контроллер в camelCase/как зарегистрирован, action без суффикса Action):

BX.ajax.runAction('vendor:demo.item.get', {
    data: { id: 15 },
    // method: 'GET' // при необходимости; по умолчанию POST
}).then(function (response) {
    // response.data — то, что вернул getAction
    console.log(response.data);
}).catch(function (response) {
    console.error(response.errors);
});

// Изменяющее действие — POST + CSRF закроет prefilter
BX.ajax.runAction('vendor:demo.item.save', {
    data: {
        id: 15,
        name: 'Новое имя',
    },
}).then(function (response) {
    BX.UI.Notification.Center.notify({
        content: 'Сохранено: #' + response.data.id,
    });
}).catch(function (response) {
    var msg = (response.errors || []).map(function (e) {
        return e.message;
    }).join('; ') || 'Ошибка сохранения';
    alert(msg);
});

CSRF для браузерных запросов Bitrix подставляет сам (sessid / заголовок). Если вызываете action «голым» fetch — передайте sessid / X-Bitrix-Csrf-Token, как в примерах из AJAX в компонентах. Для server-to-server лучше отдельный токен и свой prefilter, а не отключение CSRF «для всех».

Маршрутизация: .settings.php, Controllers, routes

Чтобы Engine нашёл класс, модуль должен объявить namespace контроллеров. В типовой схеме в .settings.php модуля (или эквивалентной конфигурации) задают секцию controllers / defaultNamespace — например Vendor\Demo\Controller. Тогда короткое имя item в runAction резолвится в Vendor\Demo\Controller\Item.

HTTP-маршруты (красивые URL вида /api/...) подключают через routes.php / RoutingConfigurator — это уже слой REST, не обязательный для runAction. Краткий каркас маршрутов и связка с Controller — в статье про REST API D7. Для внутреннего AJAX админки и публички достаточно корректного namespace + Loader::includeModule.

<?php
// фрагмент идеи .settings.php модуля (уточняйте ключи под вашу версию ядра)
return [
    'controllers' => [
        'value' => [
            'defaultNamespace' => '\\Vendor\\Demo\\Controller',
        ],
        'readonly' => true,
    ],
];

CurrentUser и проверка прав

Bitrix\Main\Engine\CurrentUser::get() — удобный доступ к текущему пользователю внутри action без глобального $USER. Для прав не ограничивайтесь «авторизован = можно»: проверяйте операции модуля, принадлежность сущности, роль в проекте.

<?php
use Bitrix\Main\Engine\CurrentUser;
use Bitrix\Main\Error;

public function updateAction(int $id, array $fields): ?array
{
    $user = CurrentUser::get();
    if (!$user->isAdmin() && !$this->isOwner($id, (int)$user->getId())) {
        $this->addError(new Error('Access denied', 'ACCESS_DENIED'));
        return null;
    }

    // Request/контекст при необходимости:
    // $request = \Bitrix\Main\Application::getInstance()->getContext()->getRequest();
    // см. статью Application / Context / Request

    return $this->doUpdate($id, $fields);
}

Контекст запроса (getRequest(), метод, заголовки) берите из Application/Context — подробности в гайде по HTTP-контексту. Исходящие вызовы к внешним API из action — через HttpClient с таймаутами, не через «голый» curl без обработки ошибок.

Типичные ошибки

  • CSRF / 400 — забыли prefilter Csrf на POST или наоборот вызвали без sessid из кастомного JS. Смотрите Network: статус и тело errors.
  • Wrong HTTP method — action разрешён только GET, а runAction шлёт POST (или наоборот). Явно задайте HttpMethod и метод на клиенте.
  • Module not installed / class not found — не вызвали Loader::includeModule, опечатка в id модуля, неверный defaultNamespace в настройках контроллеров.
  • Wrong action name — в JS указали getAction вместо get, или неверный сегмент контроллера (item vs ItemController).
  • Сессия / auth на публичном ajax — на кешируемой витрине или без куки сессии Authentication закономерно падает. Для гостя нужен отдельный action без этого фильтра и жёсткая проверка входных данных.
  • Двойной JSON — echo json_encode внутри action плюс сериализация Engine. Возвращайте массив/Result, не печатайте тело сами.
  • Ошибки «проглатываются» — на фронте читают только response.data и игнорируют catch/errors. Всегда обрабатывайте reject.

Чеклист перед выкладкой

  • У каждого изменяющего action есть Authentication (если нужно) + HttpMethod + Csrf.
  • Публичные action перечислены явно, без лишних прав и без утечки PII.
  • Имя в runAction совпадает с модулем/контроллером/action; модуль подключается.
  • В action нет echo/die с JSON; ошибки — через Error.
  • Права проверены относительно сущности, а не только «залогинен».
  • Фронт показывает errors[].message; в Network виден предсказуемый контракт.

Вывод

Bitrix\Main\Engine\Controller — базовый способ делать AJAX и внутренние API модулей: configureActions + prefilters, методы *Action, вызов BX.ajax.runAction. Для перерисовки UI компонента оставайтесь на component ajax; для REST наружу добавьте маршруты. Сериализацию в Engine не дублируйте, ошибки отдавайте через Error/Result, HTTP-контекст и исходящие запросы — через Application и HttpClient. Так проще сопровождать код и ловить 400 до продакшена, а не после.