Загрузка
Все статьи

WordPress: REST API и кастомные эндпоинты без плагинов

Для интеграции WordPress с мобильным приложением понадобилось создать кастомные REST-эндпоинты. Плагины типа ACF to REST API не подходили — нужна была специфическая логика: фильтрация по пользовательским полям, пагинация, кэширование. Сделали всё через register_rest_route и шорткоды — без единого плагина.

К нам пришла задача: есть сайт на WordPress с каталогом курсов, и нужно отдавать данные в мобильное приложение. Стандартный REST API WordPress отдаёт посты, но у нас были ACF-поля, кастомная таксономия, связанная таблица с расписанием и куча условной логики. Плагин ACF to REST API тупо отдавал все поля, включая служебные, а фильтровать на клиенте 500 записей — плохая идея.

Решили написать свои эндпоинты в functions.php дочерней темы. Без плагинов, без лишних зависимостей.

Базовый кастомный эндпоинт

Регистрация эндпоинта через register_rest_route — это стандартный механизм WordPress, не требующий плагинов:

add_action('rest_api_init', function () {
    register_rest_route('myapp/v1', '/courses', [
        'methods'             => 'GET',
        'callback'            => 'myapp_get_courses',
        'permission_callback' => '__return_true',
        'args'                => [
            'category' => [
                'default'           => '',
                'sanitize_callback' => 'sanitize_text_field',
            ],
            'page' => [
                'default'           => 1,
                'sanitize_callback' => 'absint',
            ],
            'per_page' => [
                'default'           => 20,
                'sanitize_callback' => 'absint',
            ],
        ],
    ]);
});

Обратите внимание на sanitize_callback — WordPress автоматически очистит входящие параметры. Не доверяйте данным от клиента, даже если это ваш мобильный приложени.

Callback с фильтрацией и пагинацией

Сам коллбэк — это где живёт вся логика. Мы фильтруем по ACF-полю «сложность», по кастомной таксономии и по дате начала курса:

function myapp_get_courses(WP_REST_Request $request) {
    $category = $request->get_param('category');
    $page     = $request->get_param('page');
    $per_page = min($request->get_param('per_page'), 50);

    $cache_key = 'myapp_courses_' . md5(serialize([
        'category' => $category,
        'page'     => $page,
        'per_page' => $per_page,
    ]));

    $cached = get_transient($cache_key);
    if ($cached !== false) {
        return new WP_REST_Response($cached, 200);
    }

    $args = [
        'post_type'      => 'course',
        'posts_per_page' => $per_page,
        'paged'          => $page,
        'post_status'    => 'publish',
        'orderby'        => 'meta_value',
        'meta_key'       => 'start_date',
        'order'          => 'ASC',
    ];

    if (!empty($category)) {
        $args['tax_query'] = [
            [
                'taxonomy' => 'course_category',
                'field'    => 'slug',
                'terms'    => $category,
            ],
        ];
    }

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

    foreach ($query->posts as $post) {
        $courses[] = [
            'id'          => $post->ID,
            'title'       => $post->post_title,
            'slug'        => $post->post_name,
            'excerpt'     => wp_strip_all_tags($post->post_excerpt),
            'difficulty'  => get_field('difficulty', $post->ID),
            'duration'    => get_field('duration', $post->ID),
            'start_date'  => get_field('start_date', $post->ID),
            'price'       => (float) get_field('price', $post->ID),
            'image'       => get_the_post_thumbnail_url($post->ID, 'medium'),
            'instructor'  => [
                'name' => get_field('instructor_name', $post->ID),
                'bio'  => get_field('instructor_bio', $post->ID),
            ],
        ];
    }

    $response = [
        'data'  => $courses,
        'total' => (int) $query->found_posts,
        'pages' => (int) $query->max_num_pages,
    ];

    set_transient($cache_key, $response, 5 * MINUTE_IN_SECONDS);

    return new WP_REST_Response($response, 200);
}

Кэширование через Transients API

Каждый запрос к БД с WP_Query — это несколько SQL-запросов. Для каталога из 200 курсов, запрашиваемого каждые 5 секунд мобильными пользователями, это стало проблемой. Transients API WordPress — простой способ кэшировать результаты в таблице wp_options.

Ключ кэша генерируется из хэша параметров, поэтому разные фильтры получают разные кэши. При обновлении курса инвалидируем кэш:

add_action('save_post_course', function ($post_id) {
    global $wpdb;
    $wpdb->query(
        "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_myapp_courses_%'"
    );
    $wpdb->query(
        "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_myapp_courses_%'"
    );
});

Простой, но эффективный подход. Для высоконагруженных проектов лучше использовать Redis через объектный кэш WordPress, но transients справляются для moderate-нагрузки.

Безопасность и пермишены

Все публичные эндпоинты используют permission_callback равный __return_true. Но если нужны защищённые данные, делаем проверку nonce или JWT-токен:

'permission_callback' => function () {
    $header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
    if (empty($header) || !str_starts_with($header, 'Bearer ')) {
        return new WP_Error(
            'unauthorized',
            'Отсутствует токен авторизации',
            ['status' => 401]
        );
    }
    $token = substr($header, 7);
    return myapp_validate_token($token);
},

Итог: кастомные REST-эндпоинты в WordPress без плагинов — это реальность, и это работает отлично. Главное — не прятать бизнес-логику в шаблоны, правильно кэшировать и не забывать про санитизацию входных данных.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

WordPress REST API: где пропадает кастомное поле в сообщении о заказе WooCommerce?

Добавили кастомное поле в заказ WooCommerce через REST API — поле в админке видно, а в письме покупателя его нет. Знакомая ситуация? Разбираю, почему WooCommerce email-шаблоны игнорируют произвольные поля заказа, и показываю три способа заставить их отображаться без копипаста шаблонов.

6 мая 2026 5 мин

WordPress Gutenberg: custom-блоки без JavaScript — как создать блок через PHP

Все гайды по Gutenberg-блокам заставляют писать React и собирать через wp-scripts. Но что если вы backend-разработчик и не хотите тащить node_modules в проект? В WordPress 5.9+ появилась возможность регистрировать серверные блоки полностью на PHP. Разбираю этот подход.

6 мая 2026 7 мин

Сложности с разделами каталога в Битрикс24 через REST API: игра с деревом

Обновляете товар через API, передаете SECTION_ID, но он не появляется в нужной папке. В этой статье я объясняю разницу между простым SECTION_ID и сложным деревом инфоблоков, как правильно выстроить иерархию и почему метод crm.product.update не умеет создавать папки.

5 июля 2026 1 мин