WordPress REST API: создание своих эндпоинтов на PHP

WordPress REST API позволяет получать и отправлять данные сайта через HTTP-запросы в формате JSON. Встроенные эндпоинты покрывают стандартные сущности — записи, страницы, пользователей, таксономии. Но когда нужно отдать данные из кастомных таблиц, обработать внешний вебхук или построить SPA на React/Vue с WordPress-бэкендом — потребуются свои эндпоинты.

Регистрация простого эндпоинта

Функция register_rest_route() вызывается внутри хука rest_api_init. Она принимает неймспейс (обычно plugin-name/v1), маршрут и массив параметров.

<?php
add_action( 'rest_api_init', function() {
    register_rest_route( 'myapi/v1', '/posts-stats', [
        'methods'             => 'GET',
        'callback'            => 'myapi_get_posts_stats',
        'permission_callback' => '__return_true', // публичный доступ
    ]);
});

function myapi_get_posts_stats( WP_REST_Request $request ) {
    global $wpdb;

    $stats = $wpdb->get_results( "
        SELECT post_type, post_status, COUNT(*) as count
        FROM {$wpdb->posts}
        WHERE post_type IN ('post', 'page')
        GROUP BY post_type, post_status
        ORDER BY post_type, count DESC
    " );

    return new WP_REST_Response( [
        'stats'     => $stats,
        'generated' => current_time( 'mysql' ),
    ], 200 );
}

После регистрации эндпоинт доступен по адресу /wp-json/myapi/v1/posts-stats. Неймспейс должен содержать версию API — это позволяет выпускать обратно несовместимые изменения без поломки клиентов.

Параметры и валидация

Аргументы эндпоинта описываются в ключе args. WordPress автоматически валидирует и очищает входные данные до вызова callback-функции.

<?php
add_action( 'rest_api_init', function() {
    register_rest_route( 'myapi/v1', '/search', [
        'methods'             => 'GET',
        'callback'            => 'myapi_search_handler',
        'permission_callback' => '__return_true',
        'args'                => [
            'query' => [
                'required'          => true,
                'type'              => 'string',
                'description'       => 'Поисковый запрос',
                'minLength'         => 3,
                'maxLength'         => 100,
                'sanitize_callback' => 'sanitize_text_field',
            ],
            'category' => [
                'required'          => false,
                'type'              => 'integer',
                'default'           => 0,
                'validate_callback' => function( $value ) {
                    return is_numeric( $value ) && $value >= 0;
                },
            ],
            'per_page' => [
                'required' => false,
                'type'     => 'integer',
                'default'  => 10,
                'minimum'  => 1,
                'maximum'  => 50,
            ],
        ],
    ]);
});

function myapi_search_handler( WP_REST_Request $request ) {
    $args = [
        'post_type'      => 'post',
        'post_status'    => 'publish',
        's'              => $request->get_param( 'query' ),
        'posts_per_page' => $request->get_param( 'per_page' ),
    ];

    $cat = $request->get_param( 'category' );
    if ( $cat > 0 ) {
        $args['cat'] = $cat;
    }

    $query = new WP_Query( $args );
    $posts = [];

    foreach ( $query->posts as $post ) {
        $posts[] = [
            'id'      => $post->ID,
            'title'   => $post->post_title,
            'url'     => get_permalink( $post ),
            'excerpt' => wp_trim_words( $post->post_content, 30 ),
            'date'    => $post->post_date,
        ];
    }

    return new WP_REST_Response( [
        'results' => $posts,
        'total'   => $query->found_posts,
        'pages'   => $query->max_num_pages,
    ], 200 );
}

Встроенная валидация через JSON Schema поддерживает типы string, integer, number, boolean, array, object и атрибуты minimum, maximum, minLength, maxLength, enum, pattern. Для сложной логики используйте validate_callback.

Аутентификация и разрешения

Параметр permission_callback проверяет права доступа перед выполнением основного callback. Если функция вернёт false или WP_Error, WordPress ответит 403 Forbidden.

<?php
add_action( 'rest_api_init', function() {
    // Создание записи — только для авторов и выше
    register_rest_route( 'myapi/v1', '/articles', [
        'methods'             => 'POST',
        'callback'            => 'myapi_create_article',
        'permission_callback' => function( WP_REST_Request $request ) {
            return current_user_can( 'publish_posts' );
        },
        'args' => [
            'title'   => [ 'required' => true, 'type' => 'string' ],
            'content' => [ 'required' => true, 'type' => 'string' ],
            'status'  => [
                'type'    => 'string',
                'default' => 'draft',
                'enum'    => [ 'draft', 'publish', 'pending' ],
            ],
        ],
    ]);

    // Удаление — только для администраторов
    register_rest_route( 'myapi/v1', '/articles/(?P<id>\d+)', [
        'methods'             => 'DELETE',
        'callback'            => 'myapi_delete_article',
        'permission_callback' => function() {
            return current_user_can( 'manage_options' );
        },
        'args' => [
            'id' => [
                'required'          => true,
                'type'              => 'integer',
                'validate_callback' => function( $id ) {
                    return get_post( $id ) !== null;
                },
            ],
        ],
    ]);
});

function myapi_create_article( WP_REST_Request $request ) {
    $post_id = wp_insert_post([
        'post_title'   => sanitize_text_field( $request['title'] ),
        'post_content' => wp_kses_post( $request['content'] ),
        'post_status'  => $request['status'],
        'post_type'    => 'post',
        'post_author'  => get_current_user_id(),
    ]);

    if ( is_wp_error( $post_id ) ) {
        return new WP_REST_Response( [ 'error' => $post_id->get_error_message() ], 500 );
    }

    return new WP_REST_Response( [
        'id'  => $post_id,
        'url' => get_permalink( $post_id ),
    ], 201 );
}

function myapi_delete_article( WP_REST_Request $request ) {
    $result = wp_delete_post( $request['id'], true );

    if ( ! $result ) {
        return new WP_REST_Response( [ 'error' => 'Не удалось удалить запись' ], 500 );
    }

    return new WP_REST_Response( null, 204 );
}

Для аутентификации WordPress REST API поддерживает несколько методов:

  • Cookie-аутентификация — работает автоматически для залогиненных пользователей. При AJAX-запросах передавайте nonce в заголовке X-WP-Nonce
  • Application Passwords (WordPress 5.6+) — генерируются в профиле пользователя, передаются через HTTP Basic Auth
  • JWT-токены — через сторонние плагины, подходят для SPA и мобильных приложений

Контроллер как класс

Для сложных API рекомендуется наследоваться от WP_REST_Controller. Этот подход группирует связанные эндпоинты, упрощает тестирование и следует стандартам WordPress.

<?php
class My_Events_Controller extends WP_REST_Controller {
    protected $namespace = 'myapi/v1';
    protected $rest_base = 'events';

    public function register_routes() {
        register_rest_route( $this->namespace, '/' . $this->rest_base, [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_items' ],
                'permission_callback' => '__return_true',
                'args'                => $this->get_collection_params(),
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'create_item' ],
                'permission_callback' => [ $this, 'create_item_permissions_check' ],
            ],
        ]);

        register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>\d+)', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_item' ],
                'permission_callback' => '__return_true',
            ],
        ]);
    }

    public function get_items( $request ) {
        $query = new WP_Query([
            'post_type'      => 'event',
            'posts_per_page' => $request['per_page'] ?? 10,
            'paged'          => $request['page'] ?? 1,
            'post_status'    => 'publish',
        ]);

        $events = array_map( [ $this, 'prepare_event' ], $query->posts );

        $response = new WP_REST_Response( $events, 200 );
        $response->header( 'X-WP-Total', $query->found_posts );
        $response->header( 'X-WP-TotalPages', $query->max_num_pages );

        return $response;
    }

    public function get_item( $request ) {
        $post = get_post( $request['id'] );

        if ( ! $post || $post->post_type !== 'event' ) {
            return new WP_Error( 'not_found', 'Событие не найдено', [ 'status' => 404 ] );
        }

        return new WP_REST_Response( $this->prepare_event( $post ), 200 );
    }

    public function create_item_permissions_check( $request ) {
        return current_user_can( 'edit_posts' );
    }

    public function create_item( $request ) {
        $post_id = wp_insert_post([
            'post_type'   => 'event',
            'post_title'  => sanitize_text_field( $request['title'] ),
            'post_status' => 'publish',
        ]);

        if ( is_wp_error( $post_id ) ) {
            return $post_id;
        }

        update_post_meta( $post_id, 'event_date', sanitize_text_field( $request['event_date'] ) );
        update_post_meta( $post_id, 'event_location', sanitize_text_field( $request['location'] ) );

        return new WP_REST_Response( $this->prepare_event( get_post( $post_id ) ), 201 );
    }

    private function prepare_event( WP_Post $post ) {
        return [
            'id'       => $post->ID,
            'title'    => $post->post_title,
            'content'  => apply_filters( 'the_content', $post->post_content ),
            'date'     => $post->post_date,
            'event_date' => get_post_meta( $post->ID, 'event_date', true ),
            'location' => get_post_meta( $post->ID, 'event_location', true ),
            'url'      => get_permalink( $post ),
        ];
    }
}

// Инициализация контроллера
add_action( 'rest_api_init', function() {
    $controller = new My_Events_Controller();
    $controller->register_routes();
});

Работа с REST API из JavaScript

WordPress предоставляет глобальный объект wp.apiFetch для запросов к REST API из админки и фронтенда. Он автоматически обрабатывает nonce и базовый URL.

// Подключение wp-api-fetch в functions.php
// wp_enqueue_script( 'wp-api-fetch' );

// Запрос GET
wp.apiFetch({ path: '/myapi/v1/events' })
    .then(events => console.log(events))
    .catch(error => console.error(error));

// Запрос POST
wp.apiFetch({
    path: '/myapi/v1/articles',
    method: 'POST',
    data: {
        title: 'Новая статья',
        content: '<p>Содержимое статьи</p>',
        status: 'draft'
    }
}).then(result => {
    console.log('Создано:', result.id);
});

// Без wp.apiFetch — через fetch с nonce
fetch('/wp-json/myapi/v1/events', {
    headers: {
        'X-WP-Nonce': wpApiSettings.nonce,
        'Content-Type': 'application/json'
    }
}).then(r => r.json()).then(console.log);

Не забудьте локализовать nonce через wp_localize_script() или использовать wp_enqueue_script('wp-api-fetch') — он настроит всё автоматически.

Обработка ошибок и HTTP-коды

REST API должен возвращать правильные HTTP-коды. Используйте WP_Error для ошибок — WordPress автоматически преобразует их в JSON-ответ с нужным кодом:

  • 200 — успешный GET-запрос
  • 201 — ресурс создан (POST)
  • 204 — ресурс удалён (DELETE)
  • 400 — ошибка валидации параметров
  • 401 — не аутентифицирован
  • 403 — нет прав доступа
  • 404 — ресурс не найден
  • 500 — внутренняя ошибка сервера

Создание своих эндпоинтов REST API — мощный способ интеграции WordPress с внешними системами, SPA-фронтендами и мобильными приложениями. Главное — не забывайте про валидацию входных данных, проверку прав доступа и правильные HTTP-коды ответов.