Проблема
WordPress REST API по умолчанию не предоставляет удобного middleware для ловли исключений. Если в callback endpoint’а произойдёт TypeError, null->method() или любой другой Throwable, клиент получит либо HTML-страницу с fatal error, либо пустой ответ с 500.
В нашем плагине woo2iiko endpoint /wp-json/woo2iiko/v1/init_all_tables падал так:
Warning: foreach() argument must be of type array|object, null given
Fatal error: Uncaught Error: Call to a member function init_by_table() on null
Причина: фича «Оплата по столу» была выключена, table_sections был null, а $this->api не инициализировался. Клиент должен получать JSON с понятной ошибкой и HTTP-кодом, а не сырой PHP-trace.
Что не работает
rest_pre_dispatch — рекурсия
rest_pre_dispatch срабатывает внутри WP_REST_Server::dispatch(). Если в этом фильтре вызвать $server->dispatch($request) заново, получим бесконечную рекурсию и memory exhausted.
// ❌ Не делайте так
add_filter('rest_pre_dispatch', function ($result, $server, $request) {
if ($result !== null) return $result;
try {
return $server->dispatch($request); // ← рекурсия
} catch (Throwable $e) {
return new WP_REST_Response(['error' => $e->getMessage()], 500);
}
}, 0, 3);
rest_dispatch_request — не ловит исключения
rest_dispatch_request позволяет заменить callback, но сам callback вызывается WordPress напрямую. Если внутри замены бросить исключение, оно всплывёт наружу.
// ❌ Не поможет с исключениями
add_filter('rest_dispatch_request', function ($result, $request, $route, $handler) {
if ($result !== null) return $result;
return function () use ($handler) {
throw new RuntimeException('boom'); // ← не поймано
};
}, 10, 4);
Решение: оборачиваем callback
WordPress REST API callback — это обычный callable. Мы можем обернуть его в объект с __invoke, который ловит исключения и возвращает WP_REST_Response.
Это не middleware в классическом смысле, но по сути то же самое: единая точка обработки ошибок для всех нужных endpoint’ов.
Структура
// src/Features/WpRestApi/Services/RestException.php
final class RestException extends RuntimeException
{
public function __construct(
string $message,
private readonly int $status = 400,
private readonly ?string $errorCode = null,
) {
parent::__construct($message, $status);
}
public function getStatus(): int { return $this->status; }
public function getErrorCode(): ?string { return $this->errorCode; }
}
// src/Features/WpRestApi/Services/RestResponseFactory.php
final class RestResponseFactory
{
public static function success(mixed $data = null, int $status = 200): WP_REST_Response
{
return new WP_REST_Response([
'success' => true,
'data' => $data,
], $status);
}
public static function error(
string $message,
int $status = 400,
?string $code = null,
array $extra = [],
): WP_REST_Response {
$payload = array_merge([
'success' => false,
'message' => $message,
'code' => $code ?? 'error',
], $extra);
return new WP_REST_Response($payload, $status);
}
}
// src/Features/WpRestApi/Services/WrappedCallback.php
final class WrappedCallback
{
private function __construct(private readonly mixed $callback) {}
public static function wrap(callable $callback): self
{
return new self($callback);
}
public function getInnerCallback(): mixed
{
return $this->callback;
}
public function __invoke(WP_REST_Request $request): WP_REST_Response
{
try {
$result = ($this->callback)($request);
if ($result instanceof WP_REST_Response) {
return $result;
}
if ($result instanceof WP_Error) {
$status = (int) ($result->get_error_data('status') ?? 500);
return RestResponseFactory::error(
$result->get_error_message(),
$status,
$result->get_error_code(),
);
}
return RestResponseFactory::success($result);
} catch (RestException $exception) {
return RestResponseFactory::error(
$exception->getMessage(),
$exception->getStatus(),
$exception->getErrorCode(),
);
} catch (Throwable $throwable) {
// Здесь можно залогировать ошибку
return RestResponseFactory::error(
'Internal server error',
500,
'internal_error',
);
}
}
}
Применение в register_rest_route
Оберните только критичные callback’и — те, где есть внешние вызовы, nullable-зависимости или риск fatal:
use iikoFeaturesWpRestApiServicesWrappedCallback;
register_rest_route('woo2iiko/v1', '/init_all_tables', [
'methods' => ['POST', 'GET'],
'callback' => WrappedCallback::wrap([$system, 'init_all_tables']),
'permission_callback' => '__return_true',
]);
WrappedCallback реализует __invoke, поэтому WordPress воспринимает его как callable.
Domain без REST-зависимости
Важно не связывать ядро фичи с REST-исключениями. Вместо бросания RestException из domain-класса возвращайте result array:
// Domain класс
public function init_all_tables(): array
{
$settings = static::get_settings();
if (($settings['enable'] ?? 'no') !== 'yes') {
return [
'success' => false,
'message' => 'Модуль «Оплата по столу» выключен.',
'code' => 'pay_by_table_disabled',
];
}
if ($this->api === null) {
return [
'success' => false,
'message' => 'API iiko не инициализирован.',
'code' => 'api_not_initialized',
];
}
// ... основная логика
return ['success' => true, 'data' => $result];
}
А REST-контроллер форматирует ответ:
public function init_all_tables(WP_REST_Request $request): WP_REST_Response
{
$result = PayByTable::getInstance()->init_all_tables();
if ($result['success']) {
return RestResponseFactory::success($result['data'] ?? null);
}
return RestResponseFactory::error(
$result['message'] ?? 'Неизвестная ошибка',
400,
$result['code'] ?? 'unknown_error',
);
}
Так domain-класс остаётся независимым от REST API и может использоваться из cron, admin или CLI.
Тестирование
После оборачивания init_all_tables отдаёт структурированный JSON:
curl -X POST https://localhost/wp-json/woo2iiko/v1/init_all_tables
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=UTF-8
{
"success": false,
"message": "Модуль «Оплата по столу» выключен.",
"code": "pay_by_table_disabled"
}
Итог
В WordPress REST API нет встроенного middleware для исключений, но оборачивание callback в callable-объект решает задачу:
- Единый формат ответа
{success, data}/{success, message, code}. - Правильные HTTP-коды (
400,500и т.д.). - Защита от raw fatal в критичных endpoint’ах.
- Domain-классы не зависят от REST-специфики.