REST API в ядрі з WP 4.7, і власний маршрут реєструється однією функцією — register_rest_route($namespace, $route, $args). Ключове обмеження: викликати її можна лише на хуку rest_api_init, бо саме там ядро створює WP_REST_Server і збирає таблицю маршрутів. Виклик у файлі плагіна або на init мовчки нічого не дасть — маршрут просто не зʼявиться у /wp-json/. Namespace має вигляд vendor/v1: версія живе в namespace, а не в шляху, щоб згодом можна було випустити vendor/v2 поруч зі старим. Динамічні сегменти описуються іменованою групою регулярного виразу, /leads/(?P<id>\d+), і потрапляють у $request['id']. Якщо на сайті plain-перміалінки, /wp-json/ не працює й потрібен запасний вигляд ?rest_route=/crm/v1/leads — про це варто памʼятати, коли ендпоінт «не існує» лише на одному стенді.
Права перевіряє permission_callback, і з WP 5.5 це обовʼязковий аргумент: без нього ядро пише _doing_it_wrong, але маршрут усе одно реєструється і лишається публічним. Тому забутий permission_callback — це не помилка розробки, а відкритий ендпоінт у продакшені; для навмисно публічного маршруту пишуть явне 'permission_callback' => '__return_true', щоб намір було видно з коду. Усередині перевіряють здатність через current_user_can з конкретною capability, а для дії над конкретним записом — мета-здатність з id: current_user_can('edit_post', (int) $request['id']), бо вона проходить через map_meta_cap і враховує авторство та статус. is_user_logged_in() як перевірка прав означає, що будь-який передплатник дістає доступ до адмінської дії. Повертати з колбеку можна true, false або WP_Error; зручний хелпер rest_authorization_required_code() дає 401 для гостя й 403 для залогіненого.
Валідація описується в масиві args. Тут є пастка, на якій валяться на співбесіді: у рукописному args ключі type, enum, format, minimum самі по собі нічого не перевіряють — вони лише потрапляють у схему, яку віддає запит OPTIONS. Реальну перевірку робить validate_callback, тому в кожен параметр підставляють 'validate_callback' => 'rest_validate_request_arg', і лише тоді enum чи minimum починають відхиляти запит з кодом rest_invalid_param і статусом 400. Контролери, успадковані від WP_REST_Controller, отримують це безкоштовно: rest_get_endpoint_args_for_schema() будує args з get_item_schema() і сам додає rest_validate_request_arg та rest_sanitize_request_arg. Порядок теж важливий: ядро спершу валідує всі параметри (has_valid_params), потім санітизує (sanitize_params), тож у валідатор приходить сире значення. Санітизація без валідації небезпечна тим, що absint('abc') тихо перетворить сміття на 0 і запит виконається не з тими даними.
Автентифікація — окремий шар від авторизації. Запит із браузера на тому ж домені йде під cookie, але cookie в REST довіряють лише разом із nonce дії wp_rest: його передають у заголовку X-WP-Nonce або параметром _wpnonce, інакше ядро повертає rest_cookie_invalid_nonce, і current_user_can у вашому колбеку бачить гостя. Класичний антипатерн — «полікувати» цей 401 заміною перевірки на __return_true. Для зовнішніх клієнтів cookie не підходять взагалі: з WP 5.6 у ядрі є Application Passwords, які працюють як Basic Auth поверх HTTPS і не дають доступу до адмінки.
Відповідь формують поверненням масиву, WP_REST_Response або WP_Error — жодних echo і wp_die(), які ламають JSON і віддають 200 замість потрібного коду. WP_Error з ['status' => 4xx] ядро саме серіалізує у {code, message, data}. Для колекцій варто повторити ядрову поведінку: заголовки X-WP-Total і X-WP-TotalPages, параметри page і per_page з minimum/maximum, інакше per_page=100000 стане найдешевшим способом покласти базу. І межа розумності: власний маршрут виправданий там, де ресурс не мапиться на наявний, — агрегації, дії, інтеграції. Якщо треба лише додати поле до поста, дешевше register_rest_field() або register_post_meta() з 'show_in_rest' => true, ніж дублювати половину WP_REST_Posts_Controller.
// Маршрути реєструються ЛИШЕ на rest_api_init: раніше WP_REST_Server ще не існує
add_action('rest_api_init', function (): void {
register_rest_route('crm/v1', '/leads', [
'methods' => WP_REST_Server::CREATABLE, // POST
'callback' => 'crm_create_lead',
// Обовʼязковий з WP 5.5; для публічного ендпоінта пишуть явне '__return_true'
'permission_callback' => static function (WP_REST_Request $request): bool|WP_Error {
if (! current_user_can('edit_others_posts')) {
return new WP_Error(
'crm_forbidden',
'Недостатньо прав для створення ліда',
['status' => rest_authorization_required_code()] // 401 гостю, 403 залогіненому
);
}
return true;
},
'args' => [
'email' => [
'required' => true,
'type' => 'string',
'format' => 'email',
// Без validate_callback ключі type і format лишаються лише документацією схеми
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'sanitize_email',
],
'source' => [
'type' => 'string',
'enum' => ['form', 'phone', 'import'],
'default' => 'form',
'validate_callback' => 'rest_validate_request_arg',
],
],
]);
});
function crm_create_lead(WP_REST_Request $request): WP_REST_Response|WP_Error
{
$postId = wp_insert_post([
'post_type' => 'crm_lead',
'post_status' => 'private',
'post_title' => $request['email'], // вже санітизоване
'meta_input' => ['source' => $request['source']],
], true); // true — повертати WP_Error замість 0
if (is_wp_error($postId)) {
$postId->add_data(['status' => 500]);
return $postId; // ядро саме перетворить WP_Error на JSON з потрібним кодом
}
return new WP_REST_Response(['id' => $postId], 201);
}
Скажіть, що permission_callback — це не «додаткова опція», а обовʼязковий аргумент з WP 5.5, і що ключ type в args — документація для схеми, а не валідація: перевірку вмикає rest_validate_request_arg. Додайте, що для складніших ресурсів варто успадкувати WP_REST_Controller, бо там args генеруються з get_item_schema через rest_get_endpoint_args_for_schema і валідатори підставляються автоматично.