wordpress

WordPress REST APIに独自エンドポイントを追加する|権限と引数検証の実装

register_rest_routeで公開GETと権限付きPOSTを作る1ファイルのプラグイン。permission_callback、引数の検証と正規化、Cookie+nonceを実装し、200・401・403・400の返り方を確認します。

この記事の目次
  1. 公開する情報と、権限を必要とする処理を分ける
  2. 1ファイルのプラグインで、APIと管理画面を作る
  3. 入力を拒否する検証と、受け取る形を整える処理を分ける
  4. Cookie+nonceとApplication Passwordsを使い分ける
  5. 200だけでなく、401・403・400の返り方を確かめる

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になります。

スポンサーリンク