В посте про AJAX action просто возвращал массив, а в посте про CSRF на ошибку invalid_csrf уже смотрели с фронта через response.errors. Но там это была ошибка от фильтра — платформа собрала её сама. Если ошибку в action нужно вернуть из своего кода (не прошла валидация, не найдена запись, нет прав на конкретное действие), возникает соблазн просто вернуть ['error' => 'Нет прав'] и разбирать текст на фронте. Работает, но не так, как задумано в D7 — для этого есть ErrorCollection.
Чем плох голый error в ответе
public function deleteAction(int $id): array
{
if (!$this->hasAccess($id)) {
return ['error' => 'Нет прав на удаление'];
}
// ...
return ['result' => true];
}Проблема не в том, что это не работает — проблема в статусе ответа. Action вернул обычный массив. Для платформы это успешный ответ, поэтому response.status остаётся success. BX.ajax.runComponentAction смотрит именно на этот статус: success уводит промис в resolve, всё остальное — в reject. В итоге код с ошибкой прилетает в ветку then, а не catch — клиентскому коду приходится самому заглядывать в данные и решать, не ошибка ли это, вместо того чтобы просто отловить reject. Плюс текст ошибки жёстко зашит в код — сравнивать строки, чтобы отличить «нет прав» от «не найдено», ненадёжно и ломается при любой правке текста.
Разделение статуса и содержимого ответа — та же идея, что в HTTP: успех и ошибка разъезжаются на уровне структуры ответа, а не прячутся внутри одинаковых по форме данных. Клиентскому коду не нужно разбирать содержимое каждого ответа вручную — достаточно одной проверки статуса, а обработку ошибок можно вынести в одно место вместо каждого .then() по отдельности.
ErrorCollection на компоненте
Чтобы ошибки участвовали в статусе ответа, компонент должен реализовать интерфейс \Bitrix\Main\Errorable (помимо уже знакомого Controllerable из поста про AJAX) и завести ErrorCollection в onPrepareComponentParams() — он выполняется и при обычном рендере, и при ajax-вызове, в отличие от executeComponent():
<?php
use Bitrix\Main\Error;
use Bitrix\Main\ErrorCollection;
use Bitrix\Main\Errorable;
use Bitrix\Main\Engine\Contract\Controllerable;
class ExampleComponent extends \CBitrixComponent implements Controllerable, Errorable
{
protected ErrorCollection $errorCollection;
public function onPrepareComponentParams($arParams)
{
$this->errorCollection = new ErrorCollection();
return $arParams;
}
public function configureActions(): array
{
return []; // фильтры вроде Csrf из прошлого поста опущены для краткости примера
}
public function getErrors(): array
{
return $this->errorCollection->toArray();
}
public function getErrorByCode($code)
{
return $this->errorCollection->getErrorByCode($code);
}
}getErrors() и getErrorByCode() — это как раз то, что требует Errorable; без них интерфейс не реализован, а без ErrorCollection, заведённой в onPrepareComponentParams(), обращение к $this->errorCollection в action упадёт с ошибкой на пустом свойстве.
addError в action
ErrorCollection реализует \ArrayAccess, поэтому добавить ошибку можно просто через [], без отдельного метода. Никакой магии в этом нет — [] здесь работает ровно потому, что класс реализует стандартный интерфейс PHP, а не потому, что ErrorCollection как-то по-особенному ведёт себя внутри D7:
public function deleteAction(int $id): ?array
{
if (!$this->hasAccess($id)) {
$this->errorCollection[] = new Error('Нет прав на удаление', 'access_denied', [
'entityId' => $id,
'permission' => 'delete',
]);
return null;
}
// ...
}Конструктор Error — __construct($message, $code = 0, $customData = null). Ошибку не нужно ещё и возвращать из action отдельно — за счёт интерфейса Errorable платформа сама вызывает getErrors() компонента и, если коллекция не пуста, переводит статус ответа в error и кладёт её содержимое в response.errors. Это никак не связано с тем, что именно возвращает action — return null в примере выше просто честно показывает, что при ошибке никаких данных для клиента нет, а не требование framework.
Так выглядит успешный ответ:
{
"status": "success",
"data": { "result": true },
"errors": []
}А так — ответ с ошибкой: по документации BX.ajax.runComponentAction в таком ответе ключа data нет, только status и errors с кодом и customData:
{
"status": "error",
"errors": [
{
"message": "Нет прав на удаление",
"code": "access_denied",
"customData": { "entityId": 42, "permission": "delete" }
}
]
}customData в этом примере — не поле формы, а данные о самой операции: с ними клиентский код может залогировать, к какой записи и какому праву относится отказ, без парсинга текста сообщения.
Несколько ошибок сразу — валидация формы
В отличие от ['error' => 'текст'], ErrorCollection — коллекция, а не одно значение, поэтому под неё естественно ложится валидация с несколькими полями сразу, а не только первая найденная ошибка:
public function sendAction(string $email = '', string $message = ''): ?array
{
if ($email === '' || !check_email($email)) {
$this->errorCollection[] = new Error('Некорректный email', 'invalid_email', ['field' => 'email']);
}
if ($message === '') {
$this->errorCollection[] = new Error('Сообщение не может быть пустым', 'empty_message', ['field' => 'message']);
}
if (!$this->errorCollection->isEmpty()) {
return null;
}
// ...
}Третий аргумент Error — customData — здесь несёт имя поля: без него на фронте пришлось бы искать нужное поле по тексту сообщения, с ним error.customData.field сразу говорит, у какого input подсвечивать рамку.
На фронте — код и customData вместо текста
BX.ajax.runComponentAction('vendor:example', 'send', {
mode: 'class',
data: { email, message },
}).then(function (response) {
console.log(response.data);
}, function (response) {
response.errors.forEach(function (error) {
if (error.code === 'invalid_email') {
showFieldError('email', error.message);
} else if (error.customData && error.customData.field) {
showFieldError(error.customData.field, error.message);
}
});
});Ветвление идёт по error.code, а не по error.message — текст можно свободно менять или переводить, код и структура customData при этом не трогаются, и фронт не ломается от правки текста ошибки.
ErrorCollection или просто throw
У ErrorCollection есть более короткая альтернатива — выбросить исключение (\Bitrix\Main\ArgumentException, \Bitrix\Main\SystemException) прямо из action. Controller, через который выполняются и Controllerable-компоненты, сам ловит исключение и превращает его в такой же error-ответ, без addError() в самом action. Разница по существу: ErrorCollection — это когда ошибок несколько и по каждой нужен свой код/customData (форма с несколькими полями из примера выше), throw — когда ошибка одна и фатальная, а детализация по полям не нужна.
Когда голого массива достаточно
Критерий — не в том, чтение это или изменение данных, а в том, есть ли у action бизнес-ошибка. Пустой список по фильтру, ноль в счётчике — нормальный результат, не ошибка; под него ErrorCollection не нужен, обычный return ['items' => []] читается ничуть не хуже. А вот «нет доступа», «запись не найдена», «некорректный ID» — это бизнес-ошибка независимо от того, GET это или POST по смыслу, и разбирать её текстом на клиенте так же неудобно, как в примере из начала поста. ErrorCollection окупается там, где клиенту нужно программно отличать один код ошибки от другого — а не просто показать текст в алерте.
