WordPressの中で使う小さな処理を、フロントエンドからJSONで呼びたい。そんなときは、rest_api_initでregister_rest_route()を呼び、処理本体・権限判定・引数のルールを一緒に登録します。
処理が動くことだけでなく、「誰が呼べるか」「どの入力を受け付けるか」「失敗をどう返すか」までがエンドポイントの設計です。ログインしているだけで通すのではなく、必要な操作権限をpermission_callbackで確かめます。
以下では、公開GETで入力ルールを返し、編集権限がある人だけが使えるPOSTで見出しをプレーンテキストに整える、1ファイルのプラグインを作ります。管理画面から呼ぶフォームも含めて、WordPress 7.0・PHP 8.3.30で動作を確認しています。このPOSTはプレビューを返す処理で、投稿を保存しません。
公開する情報と、権限を必要とする処理を分ける
このデモでは、ds-preview/v1という名前空間を使います。名前空間はほかのプラグインとの衝突を避けるための接頭辞で、末尾のv1はAPIの版です。実際のプラグインでは、自分のプロジェクト固有の名前へ変えて使います。
| メソッドとパス | 返すもの | 利用できる人 |
|---|---|---|
| GET /ds-preview/v1/rules | 入力上限80文字とplain形式という固定ルール | 匿名を含む全員 |
| POST /ds-preview/v1/preview | 整えた見出しと文字数 | edit_posts権限を持つ認証済みユーザー |
GETだから自動的に公開してよい、という分け方ではありません。固定の入力ルールは誰に返してもよい内容なので公開し、編集作業用のプレビューには権限を付けています。取得処理でも、非公開データを返すならそのデータに必要な権限で制限します。
通常のURLなら、公開GETの受け口はhttps://example.com/wp-json/ds-preview/v1/rulesです。基本のパーマリンク設定では、https://example.com/?rest_route=/ds-preview/v1/rulesでも呼べます。サブディレクトリにWordPressを設置している場合を含め、アプリ内ではURLを手書きせず、rest_url()で組み立てます。
1ファイルのプラグインで、APIと管理画面を作る
検証用サイトのwp-content/plugins/ds-rest-preview/ds-rest-preview.phpへ、次のコードを保存します。PHP 8以降を前提にしています。管理画面の「プラグイン」で有効化すると、「ツール」にREST Preview Demoが追加されます。
<?php
/**
* Plugin Name: DS REST Preview Demo
* Description: Public rules and a title preview for users with edit_posts. No content is saved.
* Version: 1.0.0
*/
if (!defined('ABSPATH')) { exit; }
add_action('rest_api_init', function () {
register_rest_route('ds-preview/v1', '/rules', [
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => function () {
return new WP_REST_Response(['max_title_chars' => 80, 'format' => 'plain'], 200);
},
]);
register_rest_route('ds-preview/v1', '/preview', [
'methods' => 'POST',
'permission_callback' => function () {
if (current_user_can('edit_posts')) { return true; }
return new WP_Error('ds_preview_forbidden', '記事を編集する権限が必要です。',
['status' => rest_authorization_required_code()]);
},
'args' => [
'title' => [
'required' => true,
'type' => 'string',
'validate_callback' => function ($value, $request, $key) {
return is_string($value)
&& preg_match('/\A[\s\S]{1,80}\z/u', $value) === 1;
},
'sanitize_callback' => function ($value, $request, $key) {
$title = sanitize_text_field($value);
return $title !== '' ? $title
: new WP_Error('ds_preview_empty', '文字のある見出しを入力してください。', ['status' => 400]);
},
],
],
'callback' => function (WP_REST_Request $request) {
$title = $request->get_param('title');
return new WP_REST_Response([
'title' => $title,
'characters' => preg_match_all('/./us', $title),
], 200);
},
]);
});
// このデモの応答だけに付ける。キャッシュ層側でも除外を確認する。
add_filter('rest_post_dispatch', function ($response, $server, $request) {
if (str_starts_with($request->get_route(), '/ds-preview/v1/')) {
$response->header('Cache-Control', 'private, no-store');
}
return $response;
}, 10, 3);
add_action('admin_menu', function () {
add_management_page('REST Preview Demo', 'REST Preview Demo', 'edit_posts',
'ds-rest-preview', function () {
if (!current_user_can('edit_posts')) { return; }
?>
<div class="wrap">
<h1>REST Preview Demo</h1>
<p>見出しをプレーンテキストへ整えます。投稿は保存しません。</p>
<form id="ds-rest-preview"
data-url="<?php echo esc_url(rest_url('ds-preview/v1/preview')); ?>"
data-nonce="<?php echo esc_attr(wp_create_nonce('wp_rest')); ?>">
<label for="ds-title">見出し(入力時点で80文字まで)</label><br>
<input id="ds-title" name="title" type="text" class="regular-text" required>
<button type="submit" class="button button-primary">プレビュー</button>
</form>
<p id="ds-result" role="status" aria-live="polite"></p>
</div>
<script>
(() => {
const form = document.querySelector('#ds-rest-preview');
const result = document.querySelector('#ds-result');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const button = form.querySelector('button');
button.disabled = true;
result.textContent = '確認中…';
try {
const response = await fetch(form.dataset.url, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': form.dataset.nonce },
body: JSON.stringify({ title: form.elements.title.value }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || `HTTP ${response.status}`);
result.textContent = `${data.title}(${data.characters}文字)`;
} catch (error) {
result.textContent = error.message || '取得に失敗しました。';
} finally { button.disabled = false; }
});
})();
</script>
<?php
});
});
管理画面で<b>本文</b>を入力してプレビューすると、本文(2文字)と表示されます。HTMLを実行して表示するフォームではなく、見出しのテキストを整えるデモです。結果表示にもinnerHTMLではなくtextContentを使っています。
登録する主な項目の役割は、次のように分かれます。WordPress公式の独自エンドポイントの説明も、コールバック・権限・引数を分けて登録する形になっています。
| 項目 | 担当すること |
|---|---|
| methods | この処理で受け付けるHTTPメソッド |
| permission_callback | 現在のユーザーがこの操作をしてよいか |
| args | 必須項目、入力の検証、正規化 |
| callback | 検証後の値で処理し、データを返す |
callbackでは、WP_REST_Responseをreturnしています。JSONをechoしてexitしたり、wp_send_json()でレスポンスを送信して終えたりせず、REST API側へ返却処理を任せます。独自エラーはWP_Errorに識別コード・メッセージ・HTTPステータスを渡して返せます。
入力を拒否する検証と、受け取る形を整える処理を分ける
validate_callbackは、この入力を受け付けるかどうかの判定です。例では文字列であること、入力時点で1〜80文字であることを検査します。配列や数値を文字列へ変換して通したり、81文字を勝手に切り詰めたりしません。
sanitize_callbackは、受け付けた値を処理しやすい形へ整えます。sanitize_text_field()でタグや余分な空白などを整え、結果が空なら400を返します。たとえば<b></b>は入力文字列としては長さがありますが、見出しとして残る文字がないため受け付けません。
この2つを分けると、「送り方を直してほしい入力」と「このAPIが仕様として整える入力」が明確になります。タグの文字も入力上限に含むため、表示される文字が80以内でも、タグ付きの元の文字列が80を超えれば400になります。
このコードの文字数はUnicodeコードポイントで数えています。結合文字や複数のコードポイントからなる絵文字は、画面で一文字に見えても複数として数える場合があります。人が見る一文字単位で上限を設けたい場合は、入力検証と返却する文字数の両方を、その数え方にそろえてください。
また、argsにtitleだけを定義しても、未知のパラメーターすべてを自動で拒否するわけではありません。この例はtitleだけを読み、ほかの値では操作を変えません。送信されたオブジェクト全体を、そのままDB更新や別のAPI呼び出しへ渡さないようにします。
WordPress 7.0の通常の処理では、引数検証・正規化を通った後に権限コールバックへ進みます。したがって、引数の検証や正規化に、外部送信・DB更新・重い検索を入れないようにします。権限が必要な実処理はcallback側に置きます。
Cookie+nonceとApplication Passwordsを使い分ける
プラグイン内の管理画面から呼ぶ場合は、ログインCookieと、wp_rest用のnonceを使います。コードではPHPで作ったnonceをフォームの属性へ渡し、fetchでX-WP-Nonceヘッダーに設定しています。
nonceだけでログインできるわけではありません。Cookieによる認証に加えてnonceを送る形です。認証後もpermission_callbackの権限判定は必要です。公式のREST API認証の説明では、Cookie認証のリクエストにnonceがない場合、現在のユーザーを0、つまり未認証として扱うことも説明されています。
| 呼び出す場所 | 使うもの | このデモでの扱い |
|---|---|---|
| 公開ページや外部クライアントから公開GET | 認証なし | /rulesの固定情報を取得 |
| ログイン中のWordPress管理画面 | Cookie+wp_rest nonce | 同じサイトのフォームから/previewを呼ぶ |
| 外部のサーバースクリプト | HTTPS+Application Passwords | 必要な権限を持つ専用ユーザーで接続 |
公開GETは次のように確認できます。example.comは検証用サイトへ置き換えます。
curl --include 'https://example.com/wp-json/ds-preview/v1/rules'
外部から権限付きPOSTを確認する場合の例です。api-userはユーザー名の例です。次のコマンドはパスワードの入力を求めるので、そのユーザー用に発行したApplication Passwordを入力します。通常のログインパスワードを渡す例ではありません。
curl --include --user 'api-user' --header 'Content-Type: application/json' --data '{"title":"見出しのプレビュー"}' 'https://example.com/wp-json/ds-preview/v1/preview'
Application Passwordsを使っても、ユーザーがedit_postsを持たなければプレビューは通りません。この外部認証方式の実通信は今回のデモ検証には含めていません。ユーザー権限や導入済みプラグインによる制限を、導入先のHTTPS環境で確認してください。外部から投稿の下書きを登録する設計は、AI下書きのWordPress登録で扱っています。
200だけでなく、401・403・400の返り方を確かめる
管理者として成功するだけでは、匿名や権限の弱いユーザーにも処理が開いていないか分かりません。このデモでは、次の結果を確認しました。401・403の権限比較では、同じ正しいtitleを送っています。
| 条件 | 結果 | 確認方法 |
|---|---|---|
| 匿名でGET /rules | 200、固定の入力ルール | 実HTTP |
| 匿名でPOST /preview | 401 | 実HTTP |
| Cookieあり、nonceなし | 401 | 実HTTP |
| Cookieあり、不正なnonce | 403 | 実HTTP |
| 正しいCookie+nonce、edit_postsあり | 200、整えた見出し | 管理画面と実HTTP |
| 認証済みだがedit_postsなし | 403 | REST内部リクエストで権限を模擬 |
| title欠落・配列・数値・81文字・正規化後に空 | 400 | REST内部リクエスト。81文字は管理画面でも確認 |
| 日本語80文字 | 200 | REST内部リクエスト |
| GETで/previewを呼ぶ | 404、対応するルートなし | REST内部リクエスト |
エラーのHTTPステータスだけでなく、JSONのcodeとmessageも確認します。403には権限不足と不正なnonceの両方があるので、どちらなのかを分けます。入力が不正な場合は、権限判定まで進まず400になることもあります。
このデモの応答にはCache-Control: private, no-storeを付けています。ただし、CDNやキャッシュプラグインが別の設定で応答を保存していないかは、HTTPヘッダーとキャッシュ側の設定の両方で確認します。認証済みユーザー向けの結果が別の人へ返らないことを、導入先でも確かめてください。
外部APIやAI APIをcallbackから呼ぶように拡張すると、認証と引数検証だけでは処理回数を制限できません。呼び出し回数、処理時間、返すデータ量を制限し、失敗時に内部の認証情報をエラーメッセージへ混ぜないようにします。
既存の投稿を取得するだけなら、新しい受け口を増やす前にwp/v2/postsで足りるか確認できます。公開記事を集める例はWordPress記事をRAG用に取得する方法を参照してください。独自の処理が必要なときに、名前空間・権限・入力・返却を一組で設計すると、呼び出す側からも失敗を判別しやすいAPIになります。