Загрузка...

Middleware для обработки ошибок в WordPress REST API

Гайд для разработчиков, которые хотят превращать PHP-фаталы и исключения в нормальные JSON-ответы с HTTP-кодами вместо сырых `500 Internal Server Error`.
wordpress

Проблема

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-специфики.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *