WordPress Logo WordPress REST API の使い方

このページでは、WordPress REST API の基本から応用までを詳しく解説しています。REST API のルートやエンドポイントの構造、データ取得や投稿の作成・更新・削除の方法、認証の仕組み、カスタムフィールドの活用、独自のエンドポイントの作成方法まで、幅広くカバーしています。

記事の内容

関連ページ:WordPress REST API カスタムエンドポイントの作成

更新日:2025年04月16日

作成日:2025年04月04日

WordPress REST API とは

WordPress REST API は、WordPress のデータ(投稿、ユーザー、コメントなど)を他のアプリやシステムとやり取りできる仕組みです。これを使うと、プラグインや外部のウェブサイトから WordPress の記事を取得したり、新しい投稿を作成したりできます。

例えば、WordPress のブロックエディター(Gutenberg)は、REST API を使って投稿データを取得・保存し、リアルタイムで編集内容を反映しています。

API(Application Programming Interface)は、アプリケーション同士がデータをやり取りするための仕組みです。WordPress には多くの API があり、REST API はその一つです。

REST(REpresentational State Transfer)は、Web API の設計原則の一つで、リソース(データ)を統一されたルールでやり取りする仕組みです。

WordPress REST API は、投稿、ページ、タクソノミー、カスタム投稿タイプなどのデータを扱うエンドポイントを提供します。これらのエンドポイントを通じて JSON データを送受信し、WordPress のデータを取得(GET)、更新(PUT)、作成(POST)、削除(DELETE)することができます。

代表的な REST API コマンド:

  • GET → データを取得(例:投稿一覧を取得)
  • POST → 新しいデータを作成(例:新規投稿を追加)
  • PUT → 既存のデータを更新(例:投稿のタイトルを変更)
  • DELETE → データを削除(例:投稿を削除)

例えば、投稿(Post)に関する API は /wp/v2/posts エンドポイントを使用し、GET /wp/v2/posts で投稿一覧を取得し、POST /wp/v2/posts で新規投稿を作成できます。

HTTP メソッド

これらの GET、POST、PUT/PATCH、DELETE などのコマンドは、HTTP メソッドとも呼ばれます。

REST API では、クライアント(例: JavaScript の fetch など)がサーバーにリクエストを送信するときに、「どのような操作をしたいのか」 を指定する必要があり、この操作を指定するためのものが HTTP メソッド(HTTP method)であり、「リクエストの種類」を表します。

メソッド 役割 例(WordPress REST API)
GET データの取得 GET /wp/v2/posts (投稿一覧を取得)
POST データの作成 POST /wp/v2/posts (新規投稿を作成)
PUT データの更新 PUT /wp/v2/posts/123 (投稿 ID 123 の内容を更新)
DELETE データの削除 DELETE /wp/v2/posts/123 (投稿 ID 123 を削除)

WordPress ドキュメント: REST API Handbook

ルートとエンドポイント

WordPress REST API では、URI 構造を「ルート」と「エンドポイント」という概念で整理しています。

ルート(Route)

ルートは、API の基本的なパス(URI)を表し、どの種類のデータを扱うのかを決めるものです。

例えば、以下のルートは、それぞれ異なる種類のデータを扱います。

  • 投稿データ(posts): /wp-json/wp/v2/posts
  • カテゴリーデータ(categories): /wp-json/wp/v2/categories

ルートそのものはデータの種類を示すだけで、どのような操作を行うかは指定されていません。

エンドポイント(Endpoint)

エンドポイントは、「ルート + HTTPメソッド」の組み合わせによって定義され、特定の操作(GET、POST、PUT、DELETE など)を実行するための具体的な URI です。

例えば、投稿データ(/wp-json/wp/v2/posts)のルートには、以下のようなエンドポイントがあります。

HTTP メソッド エンドポイント 説明
GET /wp-json/wp/v2/posts 投稿一覧を取得
POST /wp-json/wp/v2/posts 新規投稿を作成
GET /wp-json/wp/v2/posts/1 ID=1 の投稿を取得
PUT /wp-json/wp/v2/posts/1 ID=1 の投稿を更新
DELETE /wp-json/wp/v2/posts/1 ID=1 の投稿を削除

ルート自体も URI ですが、エンドポイントはルートと HTTP メソッド(動作)によって定義されます。

つまり、

  • ルート:「どの種類のデータを扱うのか?」(例:/wp-json/wp/v2/posts)
  • エンドポイント:「そのデータにどんな操作をするのか?」(例:GET で取得、POST で作成)

という関係になります。

リソースとエンドポイントの例
リソース ベースルート
Posts (投稿) /wp-json/wp/v2/posts
カスタム投稿タイプ /wp-json/wp/v2/{custom_post_type}
Pages (固定ページ) /wp-json/wp/v2/pages
Categories (カテゴリー) /wp-json/wp/v2/categories
Tags (タグ) /wp-json/wp/v2/tags
Taxonomies (タクソノミー) /wp-json/wp/v2/taxonomies
Media (メディア) /wp-json/wp/v2/media
Users (ユーザー) /wp-json/wp/v2/users
Post Types (投稿タイプ) /wp-json/wp/v2/types
Settings (設定) /wp-json/wp/v2/settings

エンドポイントと HTTP メソッド

WordPress REST API では、1つのルートに対して複数の HTTP メソッドを紐づけることができます。

これにより、同じルートでも異なるエンドポイントを定義できます。

例えば、

  • GET   /wp-json/wp/v2/posts は投稿一覧を取得
  • POST /wp-json/wp/v2/posts は新規投稿を作成

というように、HTTP メソッドごとに異なる動作を持たせることができます。

REST API をテストする際の注意点

WordPress の REST API をテストする際は、パーマリンク設定を「基本」以外に設定します。

パーマリンク設定を「基本」で使用する場合、/wp-json/ の代わりに /?rest_route=/ を使用します。

参考:Routes & Endpoints

ルートとエンドポイントの例

ブラウザで、WordPress サイトの /wp-json/ にアクセスすると、その URI に GET リクエストが送信され、JSON レスポンスが返されます。

例: https://example.com/wp-json/ (example.com の部分は環境に合わせて変更します)

以下は、ローカル環境の http://localhost/wp-sample/wp-json/ にアクセスする例です。返されるデータは、使用可能なルートと各ルート内で使用可能なエンドポイントを示す JSON レスポンスです。

一部のブラウザでは、JSON レスポンスを読みやすい形式で表示されますが、Chrome などでは読みやすく表示するには拡張機能(例 JSON Formatter)が必要です。Firefox ではデフォルトで、ビューを切り替えたり、リクエストヘッダーを調べたりすることができます。

上記の例は、/wp-json/ はルートであり、そのルートが GET リクエストを受信すると、データを表示するエンドポイントによって処理されます。

以下は /wp-json/wp/v2/posts で投稿(posts)のリソースにアクセスした場合の例です。

/wp-json/wp/v2/posts ルートは、投稿のリストを返す GET エンドポイントだけでなく、POST エンドポイントも提供します。

通常、同じルート (この場合は /wp-json/wp/v2/posts) には、データを取得するための GET、データを作成するための POST、データを削除するための DELETE など、さまざまな HTTP メソッドに対して異なるエンドポイントがあります。

REST API Handbook:

グローバルパラメータ

WordPress REST API には、リクエストやレスポンスの処理方法を制御する 「グローバルパラメータ(Global Parameters)」 があります。

これらは、特定のリソース(Posts, Pages, Comments, Users, Terms など)に限定されず、すべてのエンドポイントで共通して使用できる設定です。

グローバルパラメータの使い方

グローバルパラメータは、クエリ文字列(URL の末尾に追加する ?key=value 形式のデータ)として指定します。複数のパラメータを指定する場合は、& で区切ります。

例: /wp-json/wp/v2/posts?_fields=author,id,excerpt,title,link

通常、/wp-json/wp/v2/posts に GET リクエストを送ると、すべての投稿フィールドがレスポンスとして返されますが、_fields パラメータを追加すると、レスポンスで必要なフィールドだけを取得できます。

以下は GET /wp-json/wp/v2/posts ですべてのフィールドを取得したレスポンスの例です。

[
  {
    "id": 19,
    "date": "2025-02-20T17:07:16",
    "date_gmt": "2025-02-20T08:07:16",
    "guid": {
      "rendered": "http://localhost/wp-sample/?p=19"
    },
    "modified": "2025-02-26T19:30:37",
    "modified_gmt": "2025-02-26T10:30:37",
    "slug": "my-custom-posts-block",
    "status": "publish",
    "type": "post",
    "link": "http://localhost/wp-sample/my-custom-posts-block/",
    "title": {
      "rendered": "My Custom Posts Block"
    },
    "content": {
      "rendered": "...",
      "protected": false
    },
    "excerpt": {
      "rendered": "...",
      "protected": false
    },
    "author": 1,
    "featured_media": 0,
    "comment_status": "open",
    "ping_status": "open",
    "sticky": false,
    "template": "",
    "format": "standard",
    "meta": {
      ...
    },
    "categories": [
      9
    ],
    "tags": [],
    "class_list": [
      ...
    ],
    "acf": [],
    "_links": {
      ...
    }
  },
  ...
]

以下は GET /wp-json/wp/v2/posts?_fields=author,id,excerpt,title,link で _fields を使用して必要なデータだけ取得したレスポンスの例です。

このように、グローバルパラメータを活用すれば、必要なデータだけを取得(レスポンスのサイズを軽量化)し、API のパフォーマンスを最適化できます。

[
  {
    "id": 19,
    "link": "http://localhost/wp-sample/my-custom-posts-block/",
    "title": {
      "rendered": "My Custom Posts Block"
    },
    "excerpt": {
      "rendered": "...",
      "protected": false
    },
    "author": 1
  },
  ...
]

WordPress REST API には _fields 以外にも以下のようないくつかのグローバルパラメータがあります。

これらはすべてのエンドポイントで使用可能で、リクエストの動作を制御したり、レスポンスのデータを最適化したりするのに使用できます。

パラメータ 目的 使用例
_fields 必要なフィールドだけを取得 ?_fields=id,title
_embed 関連データ(例: author の詳細)を含める ?_embed=author
_envelope レスポンスをラップしてステータスなどを追加 ?_envelope=true
_method HTTP メソッドをオーバーライド ?_method=DELETE

REST API Handbook: Global Parameters

ページネーションと並び順の制御

WordPress REST API では、ページネーション(複数ページに分ける処理) やデータの並び順を制御できます。

ページネーション(ページごとのデータ取得)

ページネーションは、次の 3 つのパラメータで調整できます。

  • per_page:1ページあたりの取得件数
  • page:取得するページ番号
  • offset:スキップする件数(per_page と併用不可)

例:1ページに5件の投稿を表示する(per_page=5 を追加)

GET /wp-json/wp/v2/posts?_fields=author,id,excerpt,title,link&per_page=5

これにより、最初の5件の投稿のみが取得されます。

例:2ページ目 を取得する(page=2 を追加)

GET /wp-json/wp/v2/posts?_fields=author,id,excerpt,title,link&per_page=5&page=2

6件目以降の投稿が取得されます(5件ずつ表示の2ページ目)。

合計数と合計ページ数

利用可能なデータの合計数やページ数を判断するために、API は2つのヘッダーフィールドを返します。

  • X-WP-Total : コレクション内のレコードの合計数
  • X-WP-TotalPages : 利用可能なすべてのレコードを含むページの合計数

WordPress REST API で取得したデータには、投稿数やページ数は含まれていないので、それらが必要な場合は、上記 HTTP ヘッダーから取得することができます。

以下は fetch() を使って投稿データを取得する際に、投稿レコードの合計数とページの合計数を取得してコンソールに出力する例です。per_page の値を変えることで、ページの合計数は変わります。

fetch('http://example.com/wp-json/wp/v2/posts?per_page=1')
.then(response =>{
  // 投稿レコードの合計数
  const totalPosts = parseInt(response.headers.get("X-WP-Total"));
  // 投稿レコードを含むページの合計数(この場合、per_page=1 を指定しているので、投稿レコードの合計数と同じ)
  const totalPages = parseInt(response.headers.get("X-WP-TotalPages"));
  console.log("Total Posts:", totalPosts, "Total Pages:", totalPages);
  // レスポンスを JSON として解析して then() に渡す
  return response.json()})
.then(data => {
  // 投稿のデータをコンソールに出力
  console.log(data);
});

データの並び順を変更する

投稿の順序を制御するには、次の2つのパラメータを使用します。

  • orderby:並び替えの基準(例:title, date, id など)
  • order:並び順(昇順 asc / 降順 desc)

例:例:投稿タイトルの昇順(A → Z)に並び替える(orderby=title&order=asc)

GET /wp-json/wp/v2/posts?_fields=author,id,excerpt,title,link&per_page=5&orderby=title&order=asc

投稿タイトルの アルファベット順(昇順) に並べ替えられます。

例:例:投稿日時の降順(新しい順)に並び替える(orderby=date&order=desc)

GET /wp-json/wp/v2/posts?_fields=author,id,excerpt,title,link&per_page=5&orderby=date&order=desc

最新の投稿から順に表示されます(デフォルト設定)。

REST API Handbook: Pagination

Schema(スキーマ)

スキーマ(Schema) は、REST API でやり取りするデータの 「設計図」や「ルール」 のことです。具体的には、API のエンドポイント(URL)で取得・送信できるデータの 型 や 構造、制約 を決めます。

REST API を操作するときは、以下の WP REST API の公式リファレンスが参考になります。

エンドポイントリファレンス: REST API Developer Endpoint Reference

エンドポイントリファレンスには、WordPress コアに同梱されているすべてのエンドポイントがリストされており、各エンドポイントのページでは 利用可能なデータのフィールド・型・制約 などがスキーマとして定義されています。

個々のエンドポイント (例:Posts) をクリックすると、そのエンドポイントの スキーマ(データ構造) を確認できます。スキーマは、特定のタイプのデータを取得または作成するときに、リソースに含まれるすべてのフィールドを定義します。

カスタム投稿タイプを作成した場合、そのエンドポイント(例:/wp/v2/books)は、デフォルトの posts エンドポイントに類似したスキーマを持ちます。これは、投稿とカスタム投稿タイプが WordPress の posts テーブルを共有しているためです。

エンドポイントのフィールドの多くは、対応する WordPress のデータベーステーブルのフィールドと一致していますが、一部異なる場合があります。例えば、posts エンドポイントの title フィールドは、データベースの post_title フィールドに対応します。この違いを理解し、API を操作するときは正しいフィールド名を使用する必要があります。

Context

context はエンドポイントでデータの取得モードを指定するプロパティです。

例えば、GET /wp/v2/posts のリクエスト時に context を指定すると、返されるデータの詳細レベル(取得できるデータ)が変わります。

context の値 説明 使用例
view デフォルト。公開情報のみ取得(認証不要) 一般的な投稿・ページの取得
embed 軽量データ(抜粋) API レスポンスを軽くする
edit 非公開データ含む全ての情報を取得(認証必要) 下書き・カスタムフィールドの取得
  • GET /wp-json/wp/v2/posts?context=view ( 一般公開情報。デフォルト)
  • GET /wp-json/wp/v2/posts?context=embed (軽量版データ)
  • GET /wp-json/wp/v2/posts?context=edit (非公開データも取得)

context は REST API のレスポンスデータを制御するオプションで、デフォルトは view(公開情報のみ)です。context を指定しない場合は、デフォルトの view が適用されます。

管理者として詳細なデータを取得したい場合は edit(認証必須)を、API レスポンスを軽量化したい(取得するデータを少なくする)場合は embed を指定することができます。

また、グローバルパラメータの _fields を使えば、必要なフィールドのみを取得できるのでレスポンスの軽量化により パフォーマンスが向上します。例えば、_fields=title,link を指定すればタイトルとリンクのフィールドだけを取得することができ、この場合は、context=embed を指定する必要はありません。

例えば、投稿タイプ(Types)のエンドポイントで capabilities のデータを取得するには、context に edit を指定する必要があり、認証が必要になります。

WP REST API を使ってみる

WP REST API(WordPress REST API)を使用して WordPress サイトからデータを取得する例です。

この例では、book というカスタム投稿タイプを登録するプラグイン booklist を作成し、WP REST API を使用して book カスタム投稿タイプのデータを操作します。

プラグインディレクトリ(wp-content/plugins/)に booklist というフォルダを作成し、booklist.php という名前のプラグインファイルを作成します。

plugins/
└─ booklist/
    └─ booklist.php

カスタム投稿タイプを登録

booklist.php に以下を記述して、book というカスタム投稿タイプを登録します。

カスタム投稿タイプを登録する関数 register_post_type() では、show_in_rest に true を設定してこのカスタム投稿タイプが REST API に公開されるようにします(32行目)。

これにより、wp-json/wp/v2/book ルートを参照すると、レスポンスにカスタム投稿タイプ book のデータが表示されます。

また、book ルートからは、実際には複数の book のデータを取得するので、rest_base を books(複数形)に設定して、wp-json/wp/v2/books ルートを参照するようにしています(33行目)。

その他にも rest_namespace を設定すると、REST API ルートの namespace URL(デフォルトは wp/v2)を変更することもできます。例えば、33行目のコメントを外すと、wp-json/wp/v2/books の代わりに wp-json/booklist/books でアクセスできます。

<?php

/**
* Plugin Name: Booklist
* Description: A plugin to manage books
* Version: 0.0.1
*
*/

if (! defined('ABSPATH')) {
  exit; // Exit if accessed directly.
}

// カスタム投稿タイプ book の登録
add_action('init', 'booklist_register_book_post_type');
function booklist_register_book_post_type() {
  $args = array(
    'labels'       => array(
      'name'          => 'Books',
      'singular_name' => 'Book',
      'menu_name'     => 'Books',
      'add_new'       => 'Add New Book',
      'add_new_item'  => 'Add New Book',
      'new_item'      => 'New Book',
      'edit_item'     => 'Edit Book',
      'view_item'     => 'View Book',
      'all_items'     => 'All Books',
    ),
    'public'       => true,
    'has_archive'  => true,
    'menu_icon' => 'dashicons-book',
    'show_in_rest' => true,  // REST API に公開する
    'rest_base'    => 'books',  // REST API route の base URL を book から books に変更
    //'rest_namespace' => 'booklist',
    'supports'     => array('title', 'editor', 'author', 'thumbnail', 'excerpt'),
  );

  register_post_type('book', $args);
}

プラグインを有効化して、book カスタム投稿タイプの投稿をいくつか作成しておきます。

管理用サブメニューページを作成

カスタム投稿タイプ book のデータを取得して、タイトルのリストを表示するページをダッシュボード(管理画面)に追加します。

以下を booklist.php に追加します。

add_submenu_page() を使って、管理画面のサイドバーの book カスタム投稿タイプのメニューにサブメニューを追加し、サブメニューページ(Book List 管理ページ)を作成します。

add_submenu_page() は admin_menu にフックし、サブメニューページのコンテンツ(HTML)は第6引数に指定したコールバック関数で描画(出力)し、コンテンツは div.wrap でラップします。

// サブメニューページ(Book List 管理ページ)を追加
add_action('admin_menu', 'booklist_add_submenu', 11);

function booklist_add_submenu() {
  add_submenu_page(
    'edit.php?post_type=book', // 親メニューのスラッグ
    'Book List', // サブメニューページのタイトル
    'Book List', // サブメニューページのメニューのタイトル
    'edit_posts', // サブメニューページにアクセスできるユーザーの権限
    'book-list', // サブメニューページを参照するスラッグ
    'booklist_render_book_list' // サブメニューページを描画するコールバック関数
  );
}

// サブメニューページの HTML を描画するコールバック関数
function booklist_render_book_list() {
?>
  <div class="wrap" id="booklist-admin">
    <h1>Actions</h1>
    <button id="booklist-load-books">Load Books</button>
    <h2>Books</h2>
    <textarea id="booklist-listarea" cols="80" rows="15"></textarea>
  </div>
<?php
}

add_submenu_page() の第1引数には親メニューのスラッグを指定します。この例の場合、カスタム投稿タイプ book のサブメニューなので edit.php?post_type=book のように指定します。

[参考]他の組み込みメニューの親スラッグには以下のようなものがあります。※ 設定のサブメニューページを作成する場合は、add_options_page() を使うこともできます。

組み込みメニュー スラッグ
ダッシュボード index.php
投稿 edit.php
メディア upload.php
固定ページ edit.php?post_type=page
コメント edit-comments.php
テーマ themes.php
プラグイン plugins.php
ユーザ users.php
ツール tools.php
設定 options-general.php

カスタム投稿タイプ book のメニュー「Books」の下にサブメニュー「Book List」が追加され、クリックすると以下のようなサブメニューページ(Book List 管理ページ)が表示されます。

管理画面(サブメニューページ)に JavaScript を読み込む

REST API を使ってデータを取得するための JavaScript ファイル booklist-admin.js を作成します。

plugins/
└─ booklist/
    ├─ booklist-admin.js  // 追加
    └─ booklist.php

booklist.php に以下を追加して、作成した JavaScript ファイル booklist-admin.js を読み込みます。

管理画面で CSS や JavaScript をエンキューする(管理ダッシュボードでのみ読み込まれるようにする)には、admin_enqueue_scripts アクションフックを使用します。

以下では現在表示されている管理ページの識別子(スラッグ)を $hook に受取り、スラッグの値を調べて、このサブメニューページでのみ JavaScript を読み込むようにしています。

// admin_enqueue_scripts アクションフックを使用
add_action('admin_enqueue_scripts', 'booklist_admin_enqueue_scripts');
// 現在表示されている管理ページのスラッグを $hook に受取る
function booklist_admin_enqueue_scripts($hook) {
  // サブメニューページのスラッグ
  $allowed_pages = ['book_page_book-list'];
  // $hook が対象のページ(サブメニューページ)であれば JavaScript を読み込む
  if (in_array($hook, $allowed_pages)) {
    wp_enqueue_script(
      'booklist-admin',
      plugin_dir_url(__FILE__) . '/booklist-admin.js',
      array(),
      '1.0.0',
      true
    );
  }
}

[参考]管理画面(この場合はサブメニューページ)のスラッグは、以下を記述して管理画面にアクセスすると、右上にスラッグが表示されます(確認したら忘れずにに削除します)。

add_action('admin_enqueue_scripts', function($hook) {
  echo '<h3 style="float:right; margin-right:30px;">スラッグ: '. $hook .'</h3>';
});

booklist-admin.js に alert( 'Hello from the Book list admin' ); と記述して、Book List 管理ページでのみ、アラートが表示されるのが確認できたら削除します。

関連ページ:CSS や JavaScript の読み込み

WP REST API へリクエストを送信

WP REST API のエンドポイントへ HTTP リクエストを送信するには、以下のような方法があります。

  • Fetch API を使用
  • Backbone.js client を使用
  • @wordpress/fetch-api を使用
  • @wordpress/core-data を使用
Fetch API

JavaScript の fetch() メソッドを使って HTTP リクエストを送信する例です。

他の3つの方法とは異なり、WordPress に依存しないので、WordPress がインストールされていない環境でも使用することもできます。

以下は投稿(posts)リソースのエンドポイント /wp-json/wp/v2/posts に GET リクエストを送信して、取得したデータをコンソールに出力する例です。

メソッドのデフォルトは GET なので、{method: 'GET'} は省略可能です。

また、エンドポイントの URL の example.com の部分は環境に合わせて適宜変更します。

fetch('http://example.com/wp-json/wp/v2/posts', {method: 'GET'})
.then(response => response.json()) // レスポンスを JSON として解析
.then(data => {
  console.log(data);  // 投稿のデータをコンソールに出力
});

上記を JavaScript ファイル booklist-admin.js に記述して、Book List 管理ページでコンソールを確認すると、以下のように投稿のデータが出力されます。

投稿のデータ(data)には、各投稿のデータが配列で格納されています。

以下は最初(インデックスが 0)の投稿のデータを展開して表示しています。コンテンツは content.rendered、リンクは link 、タイトルは title.rendered でアクセスできます。

上記の JavaScript を削除して、以下を記述します。

以下はサブメニューページで、ボタンをクリックすると、カスタム投稿タイプ book のエンドポイントからデータを取得して、データのタイトルとリンクをテキストエリアに出力するコードです。

カスタム投稿タイプのエンドポイントは /wp-json/wp/v2/{custom_post_type} になります。

この例の場合、カスタム投稿タイプのスラッグは book ですが、カスタム投稿タイプの登録で、rest_base を books(複数形)に設定しているので、エンドポイントは /wp-json/wp/v2/books になります。

// ボタン
const loadButton = document.getElementById("booklist-load-books");
// テキストエリア
const textarea = document.getElementById("booklist-listarea");

if (loadButton && textarea) {
  // ボタンにクリックイベントのリスナーを設定
  loadButton.addEventListener("click", () => {
    //  fetch() メソッドを使って HTTP リクエストを送信 ( example.com の部分は適宜変更します)
    fetch("http://example.com/wp-json/wp/v2/books")
      .then((response) => response.json())
      .then((books) => {
        textarea.value = ""; // 既存の内容をクリア
        // 配列のデータ books を forEach で処理
        books.forEach((book) => {
          // テキストエリアに各投稿のタイトルとパーマリンクを出力
          textarea.value += `${book.title.rendered}, ${book.link}\n`;
        });
      });
  });
}

Book List 管理ページで、「Load Books」ボタンをクリックすると、テキストエリアにカスタム投稿タイプ book の各投稿のタイトルとパーマリンクのリストが表示されます。

Backbone.js client

WordPress には、Backbone.js を使って REST API に直接リクエストを送信できる仕組み(REST API JavaScript Client)が組み込まれています。これを利用すると、特別な設定をしなくても WordPress のデータ(投稿やユーザーなど)を取得・更新・削除できます。

但し、現在では次項の @wordpress/fetch-api パッケージを利用することが多いです。

モデルとコレクション

Backbone.js では、データの構造を「モデル」、データの集まりを「コレクション」という形で扱います。

  • モデル(Model):記事(Post)やユーザー(User)など、個々のデータを表す
  • コレクション(Collection):記事一覧やユーザー一覧など、複数のデータの集合を表す

WordPress の REST API によって提供されるデータは、このモデルとコレクションを使って管理します。

wp-api を依存関係に追加

Backbone.js を利用するには、wp-api を依存関係に追加する必要があります。

wp-api を依存関係に追加すると、wp-api.js(WordPress の REST API クライアント)が 自動で読み込まれ、以下のようなオブジェクトが JavaScript で使えるようになります。

  • wp.api.models:投稿 (Posts)、ページ (Pages)、ユーザー (Users) などの個別データを扱う
  • wp.api.collections:投稿一覧 (Posts)、ページ一覧 (Pages) などのリストデータを扱う
  • wp.api.loadPromise:wp-api の読み込みが完了したことを確認するための Promise

プラグインファイルでの booklist-admin.js の読み込みで依存関係に 'wp-api' を追加します。

add_action('admin_enqueue_scripts', 'booklist_admin_enqueue_scripts');
function booklist_admin_enqueue_scripts($hook) {
  $allowed_pages = ['book_page_book-list'];
  if (in_array($hook, $allowed_pages)) {
    wp_enqueue_script(
      'booklist-admin',
      plugin_dir_url(__FILE__) . '/booklist-admin.js',
      array('wp-api'),  // 依存関係を追加
      '1.0.0',
      true
    );
  }
}

以下は投稿のデータを取得してコンソールに出力する例です。

wp.api.collections は WordPress の REST API の コレクション(データのリスト) を管理するオブジェクトが格納されています。new wp.api.collections.Posts() で投稿一覧を管理するための Backbone.Collection インスタンスを作成します(この時点ではデータはまだ取得されていません)。

そして、作成した posts インスタンスに対して .fetch() を実行すると、/wp-json/wp/v2/posts へリクエストを送り、データが取得されると、コレクション (posts) に自動で追加され、WordPress の REST API から投稿データを取得できます。

const posts = new wp.api.collections.Posts();

posts.fetch().then(function (collection) {
  console.log(collection); // 投稿のデータをコンソールに出力
});

以下は、Book List 管理ページで、「Load Books」ボタンをクリックすると、テキストエリアにカスタム投稿タイプ book の各投稿のタイトルとパーマリンクのリストを表示するコードを Backbone.js を使って書き換えたものです。

const loadButton = document.getElementById("booklist-load-books");
const textarea = document.getElementById("booklist-listarea");

if (loadButton && textarea) {
  loadButton.addEventListener("click", () => {
    // book 一覧を管理するための Backbone.Collection インスタンスを作成
    const allBooks = new wp.api.collections.Books();
    allBooks.fetch().then((books) => {
      textarea.value = "";
      books.forEach((book) => {
        textarea.value += `${book.title.rendered}, ${book.link}\n`;
      });
    });
  });
}
@wordpress/fetch-api

WordPress 5.0 でブロックエディターが導入されて以来、REST API にリクエストを送るための @wordpress/fetch-api パッケージが利用できるようになりました。

WordPress では、従来 Backbone.js ベースの wp-api を使って REST API にアクセスしていましたが、現在では @wordpress/fetch-api を使うほうが推奨されるケースが増えています。

@wordpress/fetch-api のメリット

  1. シンプルな API で扱いやすい
    • 標準的な fetch に近い
    • Backbone のモデル・コレクションを理解しなくてもよい
  2. 標準 fetch() に近く、ネイティブの挙動に沿っている
    • 標準の fetch() をラップしただけ なので、JavaScript に詳しい開発者には直感的
    • fetch() と同じように Promise ベースで動作するため、非同期処理を扱いやすい
  3. カスタム REST API にも柔軟に対応
    • Backbone.js (wp-api) は WordPress の標準エンドポイント向けに設計 されている
    • カスタムエンドポイントを作る場合、@wordpress/fetch-api のほうが使いやすい
  4. WordPress のセキュリティ機能と統合しやすい
    • @wordpress/fetch-api は WordPress の認証システム(nonce など)と統合が簡単
    • Backbone.js (wp-api) では、手動で nonce を管理する必要がある
  5. 不要な依存関係を減らせる
    • Backbone.js を使うと、Backbone.js と Underscore.js に依存する
    • @wordpress/fetch-api は 依存関係が少なく、軽量

wp-api-fetch を依存関係に追加

@wordpress/fetch-api を使用するには、JavaScript 依存関係に wp-api-fetch を追加する必要があります。プラグインファイルの booklist-admin.js の読み込みで依存関係に 'wp-api-fetch' を追加します。

add_action('admin_enqueue_scripts', 'booklist_admin_enqueue_scripts');
function booklist_admin_enqueue_scripts($hook) {
  $allowed_pages = ['book_page_book-list'];
  if (in_array($hook, $allowed_pages)) {
    wp_enqueue_script(
      'booklist-admin',
      plugin_dir_url(__FILE__) . '/booklist-admin.js',
      array('wp-api-fetch'),  // 依存関係に wp-api-fetch を追加
      '1.0.0',
      true
    );
  }
}
wp.apiFetch()

wp.apiFetch() は WordPress の @wordpress/fetch-api パッケージの関数で、WordPress の REST API を簡単に操作できるように設計されています。この関数は fetch API のラッパーであり、WordPress の非同期リクエストを統一的に処理します。

wp.apiFetch() の書式

以下は wp.apiFetch() の基本的な書式です。

wp.apiFetch( options ).then( successCallback ).catch( errorCallback );
options オブジェクト形式でリクエストの設定を指定。以下のオプションが利用できます。また、method や headers などの fetch() のすべてのリソースオプションをサポートしています。
successCallback リクエスト成功時のコールバック関数
errorCallback エラー時のコールバック関数
wp.apiFetch() オプション
オプション 説明
path string REST API のエンドポイント(例: /wp/v2/posts)。path に指定したエンドポイントには自動的に /wp-json/ が付与されます。
url string path の代わりに完全な URL を指定することで、WordPress 以外のエンドポイントにもアクセスできます。
data object リクエスト時に送信するデータのオブジェクト(POST や PUT の場合)
parse boolean true(デフォルト)なら JSON をパース(解析)して返す
method string HTTP メソッド(GET, POST, PUT, DELETE など)デフォルトは GET
headers object カスタム HTTP ヘッダー(例: { 'X-Custom-Header': 'value' })

apiFetch の Promise 戻り値

fetch() とは異なり、apiFetch の Promise 戻り値は解析された JSON 結果に解決されます。

つまり、Promise 戻り値を json() を使って解析された JSON 結果に変換する必要がありません。

※ ヘッダー情報を取得する場合(総ページ数や投稿数などを取得する場合)など、レスポンスオブジェクトを直接取得する必要がある場合は、parse オプションを false として渡すことで、この動作を無効にして処理する必要があります。

data オプション

apiFetch の data はデフォルトで application/json を扱うようになっており、データの処理が簡単になっています。wp.apiFetch() では data にそのままオブジェクトを渡すだけで、内部で JSON 変換と Content-Type の設定を自動的に行ってくれます(投稿を作成 POST)。

以下は投稿のデータを取得してコンソールに出力する例です。

wp.apiFetch({ path: '/wp/v2/posts' }).then((posts) => {
  console.log(posts); // 投稿のデータ(JSON)をコンソールに出力
}).catch((error) => {
  console.error('Error:', error);
});

以下はオブジェクト形式のパラメータを URLSearchParams を使ってクエリ文字列に変換して、path に結合してリクエストを送る例です。※ addQueryArgs() を使ってパラメータを追加することもできます。

// クエリパラメータを作成
const params = {
  per_page: 5, // 5件表示(デフォルト10)
  context: "embed", // content の内容などを含めない(デフォルト 'view')
  order: "asc", // 古い記事から表示(デフォルト 'desc')
};

// URLSearchParams でクエリ文字列を作成
const queryString = new URLSearchParams(params).toString();

// path にクエリ文字列を直接追加
wp.apiFetch({ path: `/wp/v2/posts?${queryString}` })
  .then((posts) => console.log(posts))
  .catch((error) => console.error("エラー:", error));

以下は、Book List 管理ページで、「Load Books」ボタンをクリックすると、テキストエリアにカスタム投稿タイプ book の各投稿のタイトルとパーマリンクのリストを表示するコードを @wordpress/fetch-api を使って書き換えたものです。

const loadButton = document.getElementById("booklist-load-books");
const textarea = document.getElementById("booklist-listarea");

if (loadButton && textarea) {
  loadButton.addEventListener("click", () => {
    wp.apiFetch({ path: "/wp/v2/books" })
      .then((books) => {
        textarea.value = "";
        books.map((book) => {
          textarea.value += `${book.title.rendered}, ${book.link}\n`;
        });
      })
      .catch((error) => console.error("Failed to fetch books:", error));
  });
}
@wordpress/core-data

ブロックを開発している場合は、REST API からデータにアクセスするための core-data パッケージも利用できます。

core-data は、コア WordPress エンティティへのアクセスと操作を簡素化することを目的としています。独自のストアを登録し、WordPress REST API からのデータを自動的に解決するセレクターをいくつか提供し、データを操作するためのアクション クリエーターをディスパッチします。

core-data は、React のさまざまな機能を利用するため、通常、ブロックのコンテキストで使用します。

以下は、ブロックで core-data モジュールを使用して、REST API から book カスタム投稿タイプのデータを取得する例です。単にエディター側で動作を確認するだけのものです。

以下を行うには Node.js(npm)がインストールされている必要があります。

まず、create-block ツールを使用して、booklist-block という新しいブロックを作成します。ターミナルでプラグインディレクトリ(wp-content/plugins)に移動して以下を実行します。

% npx @wordpress/create-block@latest --variant=dynamic booklist-block 

プラグインディレクトリにブロックの雛形(booklist-block)が作成されます。

作成されたディレクトリに移動して、npm start を実行して開発を開始します。

% cd booklist-block
% npm start

src ディレクトリの edit.js を以下のように書き換えます。

@wordpress/data パッケージから useSelect フックをインポートします。

useSelect は、登録されたセレクタからデータを取得できるフックです。

useSelect は最初の引数としてコールバック関数を受け入れ、core ストアの getEntityRecords セレクタを使用して REST API から book の投稿データを取得します。

取得した投稿データは books 変数に格納されます。

そして、books オブジェクトをループして book のタイトルとリンクを出力しています。

import { __ } from "@wordpress/i18n";
import { useBlockProps } from "@wordpress/block-editor";
import "./editor.scss";
// @wordpress/data から useSelect をインポート
import { useSelect } from "@wordpress/data";

export default function Edit() {
  // core ストアから postType が book の投稿データを取得
  const books = useSelect(
    // select 関数で core ストアにアクセスして getEntityRecords セレクターで book の投稿データを取得
    (select) => select("core").getEntityRecords("postType", "book"),
    [],
  );

  // 取得したデータ(books)を map() でループして各投稿のタイトルにリンクを設定してレンダリング
  return (
    <div {...useBlockProps()}>
      {books &&
        books.map((book) => (
          <p>
            <a href={book.link}>{book.title.rendered}</a>
          </p>
        ))}
    </div>
  );
}

プラグインを有効化して、任意の投稿に作成したブロック(Booklist Block)を挿入すると、例えば以下のように book の投稿のリストが表示されます。

[プラグインファイルでのエラー]@wordpress/create-block@4.63.0 で生成されたブロックの雛形のプラグインファイルでは、register_block_type によるブロックの登録が変更になっていてエラーになってしまいました。More efficient block type registration in 6.8

そのため、取り敢えず以下のように以前の登録方法に変更しています。

<?php
/**
* Plugin Name:       Booklist Block
* Description:       Example block scaffolded with Create Block tool.
* Version:           0.1.0
* Requires at least: 6.7
* Requires PHP:      7.4
* Author:            The WordPress Contributors
* License:           GPL-2.0-or-later
* License URI:       https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain:       booklist-block
*
* @package CreateBlock
*/

if ( ! defined( 'ABSPATH' ) ) {
  exit; // Exit if accessed directly.
}

// 以下に変更
function create_block_booklist_block_block_init() {
  register_block_type( __DIR__ . '/build/booklist-block' );
}
add_action( 'init', 'create_block_booklist_block_block_init' );

@wordpress/core-data の使い方やダイナミックブロックの作成方法については、以下を御覧ください。

Authentication(認証)

WordPress REST API の認証方法

デフォルトでは、WordPress REST API は、WordPress ダッシュボードへのログイン時に使用されるのと同じCookie ベースの認証を使用します。

認証が必要な REST API エンドポイント(非公開データの取得やデータの変更を伴うリクエストなど)には、この認証 Cookie が必要です。

たとえば、ブロックエディター(Gutenberg)は、この仕組みを利用して API にアクセスします。

その他の認証方法

REST API には、Cookie ベースの認証以外にも、いくつかの認証方法が用意されています。主なものとして、以下の方法があります。

  • アプリケーションパスワード(Application Passwords)
  • JSON Web Token(JWT) プラグイン
  • OAuth 1.0a プラグイン

アプリケーションパスワードの設定方法

アプリケーションパスワードは、各ユーザーごとに設定可能で、WordPress ダッシュボードのパスワードを共有せずに API 認証を行うことができる WordPress の組み込み機能です。

手順:

  1. ダッシュボードの 「ユーザー」 メニューをクリックし、対象のユーザーを選択して、プロフィール編集画面を開く。
  2. 画面の下部にある 「アプリケーションパスワード」 セクションへ移動。
  3. 新しいアプリケーションパスワードの名前(任意の名前)を入力し、「新しいアプリケーションパスワードの追加」ボタンをクリック。

  4. パスワードが自動生成されるので、安全な場所にコピーして保管します。(一度閉じると再表示できない)

  5. 必要に応じて、漏洩時にはここからパスワードを無効化できます。また、パスワードを忘れた場合は、取り消して再作成できます。

アプリケーションパスワードは、REST API のテストツールでリクエストをテストする際にも便利です。

ローカル環境の場合

アプリケーションパスワードの生成は原則として HTTPS が有効になっている必要があります。HTTPS が無効なサイトでは以下のような説明が表示されます。

開発時のローカル環境でアプリケーションパスワードを使用する場合は、設定ファイル wp-config.php で環境タイプ WP_ENVIRONMENT_TYPE を local に設定すれば利用できます。

define( 'WP_ENVIRONMENT_TYPE', 'local' ); // 環境タイプ WP_ENVIRONMENT_TYPE を local に設定

より高度な認証が必要な場合

モバイルアプリや外部サービスとの連携など、より高度な認証が求められる場合は、JSON Web Token(JWT) 認証や OAuth 1.0a などの利用を検討します。

OAuth は、複数のユーザーが API を安全に利用できるようにするための標準的な認証プロトコルです。

REST API テストツール

REST API リクエストをテストするために利用できるツールは多数あります。

例えば、PhpStorm を使用する場合は HTTP クライアントが組み込まれており、VS Code を使用する場合は Postcode などの拡張機能があります。

また、Hoppscotch や Postman などのスタンドアロンツールもあります。ターミナルで curl コマンドを使用して REST API エンドポイントをテストすることもできます。

Postman

Postman は GUI で API をテストするツールの定番の1つです。

Postman を使用すると、HTTP リクエストを作成し、API エンドポイントに送信することができるので、REST API の動作をテストしたり、リクエストとレスポンスのデータを確認したりできます。

ダウンロードしてインストールして使用するか、Weアプリを使用することができ、制限はありますが無料で利用することができます。

ダウンロードして使う場合、インストーラを実行後、アカウント作成画面が表示されますが、登録しなくとも API リクエストの実行など基本的な機能は使用できます。試しに Postman を使用してみたい場合はスキップして、「Continue without an account」をクリックして使用できます。

「Continue without an account」をクリックすると、以下のような画面が表示されます。アカウントを登録しない場合は、Workspaces や Collections などの機能は使えませんが、リクエストを送信してテストすることはできます。

投稿一覧を取得(GET)

例えば、カスタム投稿タイプ book のエンドポイント「http://xxxxxx/wp-json/wp/v2/books」を URL 入力欄に入力し、「Send」をクリックするとリクエストが送信されてレスポンスエリアに JSON レスポンスが表示されます。

新しいリクエストを作成するには、タブの「+」をクリックします。または、同じタブでメソッドやエンドポイントの URL を変更してリクエストすることもできます。

新規投稿を作成(POST)

今度はメソッドを POST に変更し、「Send」をクリックして同じエンドポイントにリクエストを送信してみます。今回は認証されていないため、ステータスが 401 でエラーメッセージが表示されます。

リクエストを認証するには、Auth タブをクリックし、ドロップダウンから Basic Auth を選択します。そしてユーザー名と作成したアプリケーションパスワードを入力します。

再度リクエストを送信すると、今度はコンテンツを指定していないので、ステータスが 400 で「本文、タイトル、抜粋が空欄です。」というメッセージが表示されます。

Body タブをクリックし、raw ラジオボタンを選択して作成します。次に、ドロップダウンから JSON を選択し、例えば以下のような JSON を入力します。

{
  "title": "My Postman Book",
  "content": "This is my Postman book",
  "status": "publish"
}

「Send」をクリックします。

新規に book の投稿が作成され、作成した book の JSON レスポンスが返されます。

ダッシュボードで book カスタム投稿タイプを確認すると、作成した投稿が表示されるはずです。

投稿を更新(PUT)

book を更新するには、book を追加するときと同じリクエスト構成を使用しますが、エンドポイント URL を変更して book の ID を含め、メソッドを PUT に変更します。Body タブで必要に応じてタイトルやコンテンツを変更します。

投稿を削除(DELETE)

book を削除するには、book を更新する場合と同じエンドポイント URL を使用しますが、リクエストメソッドを DELETE に変更し、リクエスト本文でデータを送信しません。

また、投稿を削除すると、実際には投稿がゴミ箱に移動され、完全に削除されるわけではありません。これは、WordPress ダッシュボードの動作と一致します。

wp.apiFetch() を使ってリクエストを送信

「WP REST API を使ってみる」で作成したプラグイン booklist に、wp.apiFetch() を使って投稿を作成、更新、削除する機能を追加します。

以下は現時点でのプラグインファイル(booklist.php)です。

プラグインファイルでは、カスタム投稿タイプ book の登録、サブメニューページ(Book List 管理ページ)の追加とそのマークアップの出力、及び JavaScript ファイルの読み込みが記述されています。

<?php

/**
 * Plugin Name: Booklist
 * Description: A plugin to manage books
 * Version: 0.0.1
 *
 */

if (! defined('ABSPATH')) {
  exit; // Exit if accessed directly.
}

// カスタム投稿タイプ book の登録
add_action('init', 'booklist_register_book_post_type');
function booklist_register_book_post_type() {
  $args = array(
    'labels'       => array(
      'name'          => 'Books',
      'singular_name' => 'Book',
      'menu_name'     => 'Books',
      'add_new'       => 'Add New Book',
      'add_new_item'  => 'Add New Book',
      'new_item'      => 'New Book',
      'edit_item'     => 'Edit Book',
      'view_item'     => 'View Book',
      'all_items'     => 'All Books',
    ),
    'public'       => true,
    'has_archive'  => true,
    'menu_icon' => 'dashicons-book',
    'show_in_rest' => true,  // REST API に公開する
    'rest_base'    => 'books',  // REST API route の base URL を book から books に変更
    'supports'     => array('title', 'editor', 'author', 'thumbnail', 'excerpt'),
  );

  register_post_type('book', $args);
}

// サブメニューページ(Book List 管理ページ)を追加
add_action('admin_menu', 'booklist_add_submenu', 11);

function booklist_add_submenu() {
  add_submenu_page(
    'edit.php?post_type=book',
    'Book List',
    'Book List',
    'edit_posts', // 管理者のみに制限する場合は 'manage_options'
    'book-list',
    'booklist_render_book_list'
  );
}

// サブメニューページのHTMLを描画するコールバック関数
function booklist_render_book_list() {
?>
  <div class="wrap" id="booklist-admin">
    <h1>Actions</h1>
    <button id="booklist-load-books">Load Books</button>
    <h2>Books</h2>
    <textarea id="booklist-listarea" cols="70" rows="10"></textarea>
  </div>
<?php
}

// サブメニューページに JavaScript ファイル(booklist-admin.js)を読み込む
add_action('admin_enqueue_scripts', 'booklist_admin_enqueue_scripts');
function booklist_admin_enqueue_scripts($hook) {
  $allowed_pages = ['book_page_book-list'];
  if (in_array($hook, $allowed_pages)) {
    wp_enqueue_script(
      'booklist-admin',
      plugin_dir_url(__FILE__) . '/booklist-admin.js',
      array('wp-api-fetch'),  // 依存関係に wp-api-fetch を追加
      '1.0.0',
      true
    );
  }
}

投稿を取得(GET)

以下は現時点でのBook List 管理ページで読み込んでいる JavaScript(booklist-admin.js)です。

「Load Books」ボタンをクリックすると、wp.apiFetch() を使ってカスタム投稿タイプ book の各投稿のタイトルとパーマリンクを取得してそのリストを表示します。

wp.apiFetch() オプションのメソッドは省略していますが、デフォルトの GET が適用されます。

// 「Load Books」ボタン
const loadButton = document.getElementById("booklist-load-books");
// 出力先のテキストエリア
const textarea = document.getElementById("booklist-listarea");

if (loadButton && textarea) {
  loadButton.addEventListener("click", () => {
    wp.apiFetch({ path: "/wp/v2/books" })
      .then((books) => {
        // 出力先のテキストエリアの値をクリア
        textarea.value = "";
        books.map((book) => {
          textarea.value += `${book.title.rendered}, ${book.link}\n`;
        });
      })
      .catch((error) => console.error("Failed to fetch books:", error));
  });
}

booklist-admin.js を以下のように書き換え、投稿 ID も表示するようにします。

エンドポイント wp/v2/books のレスポンスには content や excerpt などの不要なデータが含まれるため、以下では _fields グローバルパラメータを指定して、必要な id, title, link のみ取得することでデータ量を減らすようにしています。

また、textarea.value += ... をループするのではなく、map() で配列を作成し、join("\n") で改行を挿入しつつ結合することで、文字列結合の処理を最適化 しています。

以下の場合、map() は ["タイトル(ID)リンク", "タイトル(ID)リンク",...] のような新しい配列を作るので、.join("\n") を使って配列を 1つの文字列に結合しています。

const loadButton = document.getElementById("booklist-load-books");
const textarea = document.getElementById("booklist-listarea");

if (loadButton && textarea) {
  loadButton.addEventListener("click", () => {
    // 必要なフィールドのみを指定してデータを取得(不要なデータの取得を除外)
    wp.apiFetch({ path: "/wp/v2/books?_fields=id,title,link" })
      .then((books) => {
        textarea.value = books
          .map((book) => `${book.title.rendered} (${book.id}) ${book.link}`) // 投稿 ID も表示
          .join("\n");  // `map()` + `join()` で文字列を直接生成
      })
      .catch((error) => console.error("Failed to fetch books:", error));
  });
}
Load More ボタンを追加

ページネーションのパラメータ per_page(1ページあたりの取得件数)のデフォルトは10件です。そのため現時点では、投稿が10件以上ある場合は、最新の投稿10件だけを取得して表示します。

per_page に指定した件数以上の投稿がある場合は追加で投稿を取得できるように、追加で読み込むための「Load More」ボタンを配置します。

HTML マークアップの変更

HTMLを描画するコールバック関数に Load More ボタンを追加します。Load More ボタンは、初期状態では style="display:none;" を指定して非表示にします。

// サブメニューページのHTMLを描画するコールバック関数
function booklist_render_book_list() {
?>
  <div class="wrap" id="booklist-admin">
    <h1>Actions</h1>
    <div>
      <button id="booklist-load-books">Load Books</button>
    </div>
    <h2>Books</h2>
    <div>
      <textarea id="booklist-listarea" cols="70" rows="20"></textarea>
    </div>
    <div>
      <button id="booklist-load-more" style="display:none;">Load More</button>
    </div>
  </div>
<?php
}

JavaScript の変更

booklist-admin.js を以下のように書き換えます。

投稿を取得する処理を関数 loadBooks() として定義します。

この関数では現在のページ番号(currentPage)を引数として受け取るようにし、最初に投稿を読み込む際(Load Books ボタンがクリックされた時)はページ番号として 1 を渡し、追加で投稿を読み込む Load More ボタンがクリックされた際は、クリックされる度にページ番号を1ずつ増加して渡します。

そして、エンドポイントに page パラメータと per_page パラメータを設定し、それぞれ現在のページ番号と1ページあたりの取得件数を指定します。

総ページ数は、取得したデータには含まれていないので、レスポンスヘッダー X-WP-TotalPages から取得し、変数 totalPages に格納します。

Load More ボタンは、現在のページ番号が総ページ数より小さい場合は表示するようにします。

let currentPage = 1;  // 現在のページ(最初は1)

const loadButton = document.getElementById("booklist-load-books");
const loadMoreButton = document.getElementById("booklist-load-more");
const textarea = document.getElementById("booklist-listarea");
// 1ページあたりの取得件数
const perPage = 10;

function loadBooks(page = 1) {
  wp.apiFetch({
    // page と per_page のパラメータを追加
    path: `/wp/v2/books?_fields=id,title,link&page=${page}&per_page=${perPage}`,
    parse: false  // JSON を解析しない(レスポンスヘッダーを取得するために必要)
  })
  .then((response) => {
    // 総ページ数
    const totalPages = parseInt(response.headers.get("X-WP-TotalPages"), 10);

    return response.json().then((books) => {
      // 取得した投稿のデータがあれば
      if (books.length > 0) {
        const newLine = page === 1 ? "" : "\n"; // 改行文字
        textarea.value +=  newLine + books
          .map((book) => `${book.title.rendered} (${book.id}) ${book.link}`)
          .join("\n");
        // 「Load More」ボタンの表示判定(現在のページが最後のページ未満なら表示)
        loadMoreButton.style.display = currentPage < totalPages ? 'inline-block' : 'none';
      } else {
        loadMoreButton.style.display = 'none';
      }
    });
  })
  .catch((error) => console.error("Failed to fetch books:", error));
}

// 最初に投稿を読み込む(Load Books ボタンがクリックされた時の動作)
loadButton.addEventListener("click", () => {
  textarea.value = '';  // 既存の投稿を消去
  currentPage = 1;  // ページをリセット
  loadBooks(currentPage);
});

// Load More ボタンがクリックされた時の動作
loadMoreButton.addEventListener("click", () => {
  currentPage++;  // 次のページ
  loadBooks(currentPage);
});

総ページ数

総ページ数や取得したレコードの合計数は、レスポンスのヘッダーから取得する必要があります。

wp.apiFetch() は通常 JSON を自動的に解析しますが、ヘッダー情報を取得するには、オプションの parse: false を設定してレスポンスオブジェクトを直接取得する必要があります。これにより、ヘッダー情報を response.headers.get() で取得することができます(上記17行目)。

そのため、上記では response.json() でレスポンスを JSON として解析しています(19行目)。

以下は1ページあたりの取得件数を10とした場合の投稿の総ページ数と投稿の総数を出力する例です。

wp.apiFetch({
  path: "/wp/v2/posts?&per_page=10", // 投稿のエンドポイント
  parse: false
}).then((response) => {
  const totalPosts = response.headers.get("X-WP-Total");
  const totalPages = response.headers.get("X-WP-TotalPages");
  console.log("Total Posts:", totalPosts, "Total Pages:", totalPages);
});
並び順を制御

orderby(並び替えの基準)のプルダウンと order(並び順)のラジオボタンを追加し、それに基づいて投稿を取得するようにします。

HTML の更新

HTMLを描画するコールバック関数に、プルダウンとラジオボタンを追加します。

// サブメニューページのHTMLを描画するコールバック関数
function booklist_render_book_list() {
?>
  <div class="wrap" id="booklist-admin">
    <h1>Actions</h1>
    <div>
      <label for="booklist-orderby">Order by:</label>
      <select id="booklist-orderby">
        <option value="date">Date</option>
        <option value="title">Title</option>
        <option value="modified">Last Modified</option>
        <option value="id">ID</option>
      </select>
      <label>
        <input type="radio" name="booklist-order" value="asc"> Ascending
      </label>
      <label>
        <input type="radio" name="booklist-order" value="desc" checked> Descending
      </label>
    </div>
    <div>
      <button id="booklist-load-books">Load Books</button>
    </div>
    <h2>Books</h2>
    <textarea id="booklist-listarea" cols="70" rows="20"></textarea>
    <div>
      <button id="booklist-load-more" style="display:none;">Load More</button>
    </div>
  </div>
<?php
}

JavaScript の更新

booklist-admin.js を以下のように書き換えます。

選択された orderby と order の値に基づいて投稿を取得するように、エンドポイントのパラメータに orderby と order を追加します。

let currentPage = 1;
const loadButton = document.getElementById("booklist-load-books");
const loadMoreButton = document.getElementById("booklist-load-more");
const textarea = document.getElementById("booklist-listarea");
const perPage = 10;
// orderby のセレクト要素(プルダウン)
const orderbySelect = document.getElementById("booklist-orderby");

function loadBooks(page = 1) {
  // orderby のセレクト要素で選択された値
  const orderby = orderbySelect.value;
  // order のラジオボタンで選択された値
  const order = document.querySelector('[name="booklist-order"]:checked').value;
  wp.apiFetch({
    // エンドポイントに orderby と order を追加
    path: `/wp/v2/books?_fields=id,title,link&page=${page}&per_page=${perPage}&orderby=${orderby}&order=${order}`,
    parse: false
  })
  .then((response) => {
    const totalPages = parseInt(response.headers.get("X-WP-TotalPages"), 10);
    return response.json().then((books) => {
      if (books.length > 0) {
        const newLine = page === 1 ? "" : "\n";
        textarea.value +=  newLine + books
          .map((book) => `${book.title.rendered} (${book.id}) ${book.link}`)
          .join("\n");
        loadMoreButton.style.display = currentPage < totalPages ? 'inline-block' : 'none';
      } else {
        loadMoreButton.style.display = 'none';
      }
    });
  })
  .catch((error) => console.error("Failed to fetch books:", error));
}

loadButton.addEventListener("click", () => {
  textarea.value = '';
  currentPage = 1;
  loadBooks(currentPage);
});

loadMoreButton.addEventListener("click", () => {
  currentPage++;
  loadBooks(currentPage);
});

orderby

投稿やカスタム投稿タイプの orderby には、以下のような値を指定できます。

orderby の値 説明
author 投稿の 作成者 ID を基準に昇順または降順で並び替える。
date 投稿の 公開日時(post_date) を基準に昇順または降順で並び替える。
id 投稿の ID(post_id) を基準に昇順または降順で並び替える。
include include パラメータで指定された ID の順番通りに並び替える(手動で並び順を決められる)。
modified 投稿の 最終更新日時(post_modified) を基準に昇順または降順で並び替える。
parent 親投稿(post_parent)を基準に並び替える。カスタム投稿タイプや階層構造のある投稿(例: 固定ページ)で使用。
relevance 検索クエリとの関連度 を基準に並び替える。検索時に search パラメータと組み合わせて使用。
slug 投稿の スラッグ(post_name) を基準に昇順または降順で並び替える。
include_slugs include にスラッグを指定した場合、その順番通りに並び替える。
title 投稿の タイトル(post_title) を基準に昇順または降順で並び替える。(ただし、title はアルファベット順ソートのため、数値が含まれると意図しない並び順になる可能性あり)。
addQueryArgs

前述のコードでは wp.apiFetch() に指定している path が長くなってきたので、addQueryArgs() を使ってクエリパラメータをより読みやすくし、URL 文字列の組み立てミスを防ぐようにします。

addQueryArgs() は WordPress の @wordpress/url パッケージに含まれる関数で、URL に対して明示的にクエリパラメータを追加できます。

addQueryArgs() を使用するには、@wordpress/url からインポートするか、wp_enqueue_script() の依存関係に wp-url を指定する必要があります。

但し、wp-url は wp-api-fetch の依存関係に含まれているため、この例では wp-api-fetch を依存関係に指定しているので、追加の依存関係の指定は不要です。

以下は addQueryArgs() の基本的な書式です。

import { addQueryArgs } from '@wordpress/url';
const newUrl = addQueryArgs(url, params);

または、依存関係として wp-api-fetch(または wp-url)を指定した場合 は wp.url.addQueryArgs で使用できます。この例では以下の書式を使用します。

const newUrl = wp.url.addQueryArgs(url, params);
引数 説明
url string ベースとなる URL
params object 追加・更新するクエリパラメータのオブジェクト

使用例

クエリパラメータの追加

const url = 'https://example.com/books';
const newUrl = wp.url.addQueryArgs(url, { orderby: 'title', order: 'asc', per_page: 10 });

console.log(newUrl);
// 出力: "https://example.com/books?orderby=title&order=asc&per_page=10"

addQueryArgs() は、元の URL にすでにクエリパラメータがある場合でも適切に追加できます。

const url = 'https://example.com/books?category=fiction';
const newUrl = wp.url.addQueryArgs(url, { orderby: 'title', order: 'asc' });

console.log(newUrl);
// 出力: "https://example.com/books?category=fiction&orderby=title&order=asc"

配列を指定すると、addQueryArgs() は URLSearchParams の仕様に沿って、配列を「キー+インデックス」の形式でエンコードします(デコード後の形は tags[0]=1&tags[1]=2&tags[2]=3 となります)。

const url = 'https://example.com/books';
const newUrl = wp.url.addQueryArgs(url, { tags: [1, 2, 3] });

console.log(newUrl);
// 出力: "https://example.com/books?tags%5B0%5D=1&tags%5B1%5D=2&tags%5B2%5D=3"

例えば、以下は addQueryArgs() を使うと、

 wp.apiFetch({
  path: `/wp/v2/books?_fields=id,title,link&page=${page}&per_page=${perPage}&orderby=${orderby}&order=${order}`,
  parse: false
})

以下のように書き換えることができます。

 wp.apiFetch({
  // addQueryArgs() で明示的にクエリパラメータを追加
  path: wp.url.addQueryArgs("/wp/v2/books", {
    _fields: "id,title,link",
    page: page,
    per_page: perPage,
    orderby: orderby,
    order: order
  }),
  parse: false
})

booklist-admin.js の全体のコードは以下のようになります。

let currentPage = 1;
const loadButton = document.getElementById("booklist-load-books");
const loadMoreButton = document.getElementById("booklist-load-more");
const textarea = document.getElementById("booklist-listarea");
const perPage = 10;
const orderbySelect = document.getElementById("booklist-orderby");

function loadBooks(page = 1) {
  const orderby = orderbySelect.value;
  const order = document.querySelector('[name="booklist-order"]:checked').value;
  wp.apiFetch({
    // addQueryArgs() で明示的にクエリパラメータを追加
    path: wp.url.addQueryArgs("/wp/v2/books", {
      _fields: "id,title,link",
      page: page,
      per_page: perPage,
      orderby: orderby,
      order: order
    }),
    parse: false
  })
  .then((response) => {
    const totalPages = parseInt(response.headers.get("X-WP-TotalPages"), 10);
    return response.json().then((books) => {
      if (books.length > 0) {
        const newLine = page === 1 ? "" : "\n";
        textarea.value +=  newLine + books
          .map((book) => `${book.title.rendered} (${book.id}) ${book.link}`)
          .join("\n");
        loadMoreButton.style.display = currentPage < totalPages ? 'inline-block' : 'none';
      } else {
        loadMoreButton.style.display = 'none';
      }
    });
  })
  .catch((error) => console.error("Failed to fetch books:", error));
}

loadButton.addEventListener("click", () => {
  textarea.value = '';
  currentPage = 1;
  loadBooks(currentPage);
});

loadMoreButton.addEventListener("click", () => {
  currentPage++;
  loadBooks(currentPage);
});

URLSearchParams を使う

addQueryArgs() を使わずに、オブジェクト形式のパラメータを URLSearchParams を使ってクエリ文字列に変換して、path に結合してリクエストを送ることもできます。

let currentPage = 1;
const loadButton = document.getElementById("booklist-load-books");
const loadMoreButton = document.getElementById("booklist-load-more");
const textarea = document.getElementById("booklist-listarea");
const perPage = 5;
const orderbySelect = document.getElementById("booklist-orderby");

function loadBooks(page = 1) {
  const orderby = orderbySelect.value;
  const order = document.querySelector('[name="booklist-order"]:checked').value;

  // クエリパラメータを作成
  const params = {
    _fields: "id,title,link",
    page: page,
    per_page: perPage,
    orderby: orderby,
    order: order
  };

  // URLSearchParams でクエリ文字列を作成
  const queryString = new URLSearchParams(params).toString();

  wp.apiFetch({
    // path にクエリ文字列を直接追加
    path: `/wp/v2/books?${queryString}`,
    parse: false
  })
  .then((response) => {
    const totalPages = parseInt(response.headers.get("X-WP-TotalPages"), 10);
    return response.json().then((books) => {
      if (books.length > 0) {
        const newLine = page === 1 ? "" : "\n";
        textarea.value +=  newLine + books
          .map((book) => `${book.title.rendered} (${book.id}) ${book.link}`)
          .join("\n");
        loadMoreButton.style.display = currentPage < totalPages ? 'inline-block' : 'none';
      } else {
        loadMoreButton.style.display = 'none';
      }
    });
  })
  .catch((error) => console.error("Failed to fetch books:", error));
}

loadButton.addEventListener("click", () => {
  textarea.value = '';
  currentPage = 1;
  loadBooks(currentPage);
});

loadMoreButton.addEventListener("click", () => {
  currentPage++;
  loadBooks(currentPage);
});

Book List 管理ページを開いて、Load Books をクリックすると、カスタム投稿タイプ book の投稿を10件取得して、そのタイトルと ID、及びリンクを表示します。

投稿が10件以上ある場合は、Load More ボタンが表示され、追加で投稿を読み込むことができます。また、プルダウンから並び順の基準を選択したり、ラジオボタンで昇順・降順を切り替えることができます。

投稿を作成(POST)

WP REST API と api-fetch を使用して、カスタム投稿タイプ book の投稿を作成します。

そのためには、タイトルとコンテンツのフィールドを POST リクエストとして books エンドポイントに渡す必要があります。

プラグインファイル(booklist.php)の HTML を描画するコールバック関数に、投稿のタイトルを入力する input 要素、コンテンツを入力する textarea 要素、リクエストを送信する button 要素のマークアップを追加します。

// サブメニューページのHTMLを描画するコールバック関数
function booklist_render_book_list() {
?>
  <div class="wrap" id="booklist-admin">
    //・・・中略・・・
    <div style="width:50%;">
      <h2>Add Book</h2>
        <div>
          <label for="booklist-book-title">Book Title</label><br>
          <input type="text" id="booklist-book-title" placeholder="Title">
        </div>
        <div>
          <label for="booklist-book-content">Book Content</label><br>
          <textarea id="booklist-book-content" cols="70" rows="10"></textarea>
        </div>
        <div>
          <button id="booklist-submit-book">Add</button>
        </div>
    </div>
  </div>
<?php
}

そして、wp.apiFetch() を使用して books エンドポイントへのリクエストを作成します。

以下は wp.apiFetch() の options パラメータの設定部分です。

path に books エンドポイントへのパスを設定し、method にリクエストメソッド POST に設定します。

別途タイトルとコンテンツを変数に取得しておき、data にデータオブジェクトとして渡します。その際、データの status には "publish" を指定します(指定しない場合は「下書き」として作成されます)。

wp.apiFetch({
  path: "/wp/v2/books/",  // エンドポイントへのパス
  method: "POST",  // リクエストメソッド
  data: {   // データオブジェクト
    title: title,
    content: content,
    status: "publish",  // 公開済みに
  },
})

wp.apiFetch() の data

wp.apiFetch() の data は fetch() の body に相当しますが、wp.apiFetch() はデフォルトで application/json を扱うようになっており、データの処理が簡単になっています。

具体的には、wp.apiFetch() では data にそのままオブジェクトを渡すだけで、内部で JSON 変換と Content-Type の設定を自動的に行ってくれます。そのため、JSON.stringify() でデータを JSON 文字列に変換する必要も、headers に Content-Type: application/json を設定する必要もありません。

但し、POST(または PUT)リクエストで JSON データを送信する場合、ネストされたオブジェクトや配列は正しく送れない可能性もあるようです。その場合は、data ではなく、body を使用し、JSON.stringify() で JSON 形式の文字列にに変換し、headers で Content-Type: application/json を明示的に指定します。

booklist-admin.js に以下を追加します。

// POST リクエスト送信用ボタン
const submitBookButton = document.getElementById("booklist-submit-book");
if (submitBookButton) {
  // 上記ボタンにクリックイベントのリスナーを設定
  submitBookButton.addEventListener("click", () => {
    // タイトル入力欄の値
    const title = document.getElementById("booklist-book-title").value;
    // コンテンツ入力欄の値
    const content = document.getElementById("booklist-book-content").value;
    // タイトルとコンテンツが入力されていない場合はアラートを表示して終了
    if (!title || !content) {
      alert("Both title and content are required.");
      return;
    }
    // POST リクエストを送信
    wp.apiFetch({
      path: "/wp/v2/books/",
      method: "POST",
      data: {
        title: title,
        content: content,
        status: "publish",
      },
    })
      .then((result) => {
        // 成功した場合
        alert("Book saved!");
        console.log("Book created:", result);
      })
      .catch((error) => {
        // 失敗(エラー)の場合
        console.error("Failed to create book:", error);
        alert("Failed to save book.");
      });
  });
}

data の代わりに body を使う場合は、上記の16ー24行目を以下のように書き換えます。

wp.apiFetch({
  path: "/wp/v2/books/",
  method: "POST",
  // data の代わりに body を使う場合
  body: JSON.stringify({
    title: title,
    content: content,
    status: "publish",
  }),
  // Content-Type に application/json を指定
  headers: {
    "Content-Type": "application/json",
  },
})

Book List 管理ページを開き、タイトルとコンテンツを入力して、Add ボタンをクリックすると、「Book saved!」というアラートが表示されます。

アラートを消すと、コンソールに「Book created」という文字列に続いてレスポンスが出力され、新規に投稿が作成されます。LoadLoad Books ボタンをクリックすると、追加された投稿もリストに表示されます。

投稿を更新(PUT)

以下は PUT メソッドを使ってカスタム投稿タイプ book の投稿を更新する例です。

booklist.php の HTML を描画するコールバック関数に以下の投稿のID とタイトルを入力する input 要素、コンテンツを入力する textarea 要素、リクエストを送信する button 要素のマークアップを追加します。

// サブメニューページのHTMLを描画するコールバック関数
function booklist_render_book_list() {
?>
    //・・・中略・・・
    <div style="width:50%;">
      <h2>Update Book</h2>
      <div>
        <label for="booklist-update-book-id">Book ID</label><br>
        <input type="text" id="booklist-update-book-id" placeholder="ID">
      </div>
      <div>
        <label for="booklist-update-book-title">Book Title</label><br>
        <input type="text" id="booklist-update-book-title" placeholder="Title">
      </div>
      <div>
        <label for="booklist-update-book-content">Book Content</label><br>
        <textarea id="booklist-update-book-content" cols="70" rows="10"></textarea>
      </div>
      <div>
        <button id="booklist-update-book">Update</button>
      </div>
    </div>
  </div>
<?php
}

booklist-admin.js に以下を追加します。

id とタイトル、コンテンツを取得し、その際、trim() で 余計なスペースを削除します。そして、id が入力され数値であることと、タイトルまたはコンテンツのどちらかが入力されていることを確認します。

続いて、エンドポイントに id を指定して GET メソッドで現在のデータを取得し(変更がない項目は上書きしないように)、そのデータを元に PUT メソッドを使って投稿を更新します。

また、return wp.apiFetch({...}) として return することで、Promise を次の .then() に渡して、成功時にコンソールに更新後のデータ(updatedBook)を出力するようにしています。

// PUT リクエスト送信用ボタン
const updateBookButton = document.getElementById("booklist-update-book");
if (updateBookButton) {
  updateBookButton.addEventListener("click", function () {
    // id の値
    const id = document.getElementById("booklist-update-book-id").value.trim();
    const title = document.getElementById("booklist-update-book-title").value.trim();
    const content = document.getElementById("booklist-update-book-content").value.trim();

    // id が数値であることを確認
    if (!id || isNaN(id) || !(title || content)) {
      alert("ID は数値であり、タイトルまたはコンテンツのどちらかが必要です。");
      return;
    }

    // まず現在のデータを取得(エンドポイントに id を指定)
    wp.apiFetch({ path: `/wp/v2/books/${id}`, method: "GET" })
      .then((currentBook) => {
        // 取得したデータを元に更新データを作成(非同期処理を return して次の .then() に渡す)
        return wp.apiFetch({
          // エンドポイントに id を追加
          path: `/wp/v2/books/${id}`,
          method: "PUT",
          data: {
            title: title || currentBook.title.rendered,  // タイトルが空なら元の値を保持
            content: content || currentBook.content.rendered,  // コンテンツが空なら元の値を保持
            status: currentBook.status,  // 投稿の状態を維持
          },
        });
      })
      .then((updatedBook) => {
        // 成功時の処理
        alert("Book updated successfully!");
        // updatedBook は return された更新データ
        console.log("Book updated:", updatedBook);
      })
      .catch((error) => {
        // エラーハンドリング
        console.error("Failed to update book:", error);
        alert("Failed to update book: " + (error.message || "Unknown error"));
      });
  });
}

data の代わりに body を使う場合は、24-28行目を以下のように書き換えます。

body: JSON.stringify({
  title: title || currentBook.title.rendered, // タイトルが空なら元の値を保持
  content: content || currentBook.content.rendered, // コンテンツが空なら元の値を保持
  status: currentBook.status, // 投稿の状態を維持
}),
headers: {
  "Content-Type": "application/json",
},

以下は Promise チェーンの代わりに async / await を使って書き換えた例です。

// PUT リクエスト送信用ボタン
const updateBookButton = document.getElementById("booklist-update-book");
if (updateBookButton) {
  // async / await を使う場合
  updateBookButton.addEventListener("click", async () => {
    const id = document.getElementById("booklist-update-book-id").value.trim();
    const title = document.getElementById("booklist-update-book-title").value.trim();
    const content = document.getElementById("booklist-update-book-content").value.trim();

    // id が数値であることを確認
    if (!id || isNaN(id) || !(title || content)) {
      alert("ID は数値であり、タイトルまたはコンテンツのどちらかが必要です。");
      return;
    }

    try {
      // まず現在のデータを取得
      const currentBook = await wp.apiFetch({ path: `/wp/v2/books/${id}`, method: "GET" });
      // 変更する値だけ上書き
      const updatedBook = await wp.apiFetch({
        path: `/wp/v2/books/${id}`,
        method: "PUT",
        data: {
          title: title || currentBook.title.rendered,  // タイトルが空なら元の値を保持
          content: content || currentBook.content.rendered,  // コンテンツが空なら元の値を保持
          status: currentBook.status,  // 投稿の状態を維持
        },
      });

      alert("Book updated successfully!");
      console.log("Book updated:", updatedBook);
    } catch (error) {
      console.error("Failed to update book:", error);
      alert("Failed to update book: " + (error.message || "Unknown error"));
    }
  });
}

Book List 管理ページを開き、Update Book で ID とタイトル、コンテンツを入力して、Update ボタンをクリックすると、「Book updated successfully!」というアラートが表示されます。

アラートを消すと、コンソールに「Book updated:」という文字列に続いてレスポンスが出力され、指定した投稿が更新されます。以下は ID とコンテンツを入力して更新した例です。

投稿を削除(DELETE)

以下は DELETE メソッドを使ってカスタム投稿タイプ book の投稿を削除する例です。

booklist.php の HTML を描画するコールバック関数に以下の投稿のID とタイトルを入力する input 要素、投稿を完全に削除するためのチェックボックス(Force Delete)、リクエストを送信する button 要素のマークアップを追加します。

// サブメニューページのHTMLを描画するコールバック関数
function booklist_render_book_list() {
?>
  //・・・中略・・・
  <div style="width:50%;">
    <h2>Delete Book</h2>
    <div>
      <label for="booklist-delete-book-id">Book ID</label><br>
      <input type="text" id="booklist-delete-book-id" placeholder="ID">
    </div>
    <div>
    <input type="checkbox" id="booklist-delete-force">
      <label for="booklist-delete-force">Force Delete</label>
    </div>
    <div>
      <button id="booklist-delete-book">Delete</button>
    </div>
  </div>
<?php
}

booklist-admin.js に以下を追加します。

投稿を完全に削除するためのチェックボックスのチェック状態を取得します。チェックが入っている場合は、投稿をゴミ箱に移動するのではなく、完全に削除します。

wp.apiFetch() で DELETE リクエストを送信する際に、チェックが入っている場合は、data に force: true を指定します。

DELETE メソッドのデフォルトの動作では、投稿は完全に削除されず、ゴミ箱(Trash)に移動するだけなので、完全に削除 する場合は force: true を指定する必要があります。

// DELETE リクエスト送信用ボタン
const deleteBookButton = document.getElementById("booklist-delete-book");
if (deleteBookButton) {
  deleteBookButton.addEventListener("click", function () {
    const id = document.getElementById("booklist-delete-book-id").value.trim();
    // チェック状態を取得
    const forceDelete = document.getElementById("booklist-delete-force").checked;

    // id が数値であることを確認
    if (!id || isNaN(id)) {
      alert("ID は数値ででなければなりません。");
      return;
    }
    // DELETE リクエストを送信
    wp.apiFetch({
      path: `/wp/v2/books/${id}`,
      method: "DELETE",
      // チェックされている場合のみ `force: true` を設定
      data: forceDelete ? { force: true } : {},
    })
      .then((result) => {
        // 成功した場合
        alert("Book deleted!");
        console.log("Book deleted:", result);
      })
      .catch((error) => {
        // 失敗(エラー)の場合
        console.error("Failed to delete book:", error);
        alert("Failed to delete book: " + (error.message || "Unknown error"));
      });
  });
}

Book List 管理ページを開き、Delete Book で ID を入力して、Delete ボタンをクリックすると、「Book deleted!」というアラートが表示され、ID で指定された投稿はゴミ箱に移動します。

「Force Delete」にチェックを入れた場合は、ゴミ箱に移動ではなく、完全に削除されます。

アラートを消すと、コンソールに「Book deleted:」という文字列に続いてレスポンスが出力されます。以下は ID を入力して投稿をゴミ箱へ移動した例です。

最終的なコード

以下はプラグインファイルと Book List 管理ページで読み込んでいる JavaScript の最終的なコードです。

<?php

/**
 * Plugin Name: Booklist
 * Description: A plugin to manage books
 * Version: 0.0.1
 *
 */

if (! defined('ABSPATH')) {
  exit; // Exit if accessed directly.
}

// カスタム投稿タイプ book の登録
add_action('init', 'booklist_register_book_post_type');
function booklist_register_book_post_type() {
  $args = array(
    'labels'       => array(
      'name'          => 'Books',
      'singular_name' => 'Book',
      'menu_name'     => 'Books',
      'add_new'       => 'Add New Book',
      'add_new_item'  => 'Add New Book',
      'new_item'      => 'New Book',
      'edit_item'     => 'Edit Book',
      'view_item'     => 'View Book',
      'all_items'     => 'All Books',
    ),
    'public'       => true,
    'has_archive'  => true,
    'menu_icon' => 'dashicons-book',
    'show_in_rest' => true,  // REST API に公開する
    'rest_base'    => 'books',  // REST API route の base URL を book から books に変更
    'supports'     => array('title', 'editor', 'author', 'thumbnail', 'excerpt'),
  );

  register_post_type('book', $args);
}

// サブメニューページを追加
add_action('admin_menu', 'booklist_add_submenu', 11);

function booklist_add_submenu() {
  add_submenu_page(
    'edit.php?post_type=book', // 親メニューのスラッグ
    'Book List', // サブメニューページのタイトル
    'Book List', // サブメニューページのメニューのタイトル
    'edit_posts', // サブメニューページにアクセスできるユーザーの権限
    'book-list', // サブメニューページを参照するスラッグ
    'booklist_render_book_list' // サブメニューページを描画するコールバック関数
  );
}

// サブメニューページのHTMLを描画するコールバック関数
function booklist_render_book_list() {
?>
  <div class="wrap" id="booklist-admin">
    <h1>Actions</h1>
    <div style="margin-top:20px">
      <label for="booklist-orderby">Order by:</label>
      <select id="booklist-orderby">
        <option value="date">Date</option>
        <option value="title">Title</option>
        <option value="modified">Last Modified</option>
        <option value="id">ID</option>
      </select>
      <label>
        <input type="radio" name="booklist-order" value="asc"> Ascending
      </label>
      <label>
        <input type="radio" name="booklist-order" value="desc" checked> Descending
      </label>
    </div>
    <div style="margin-top:20px">
      <button id="booklist-load-books">Load Books</button>
    </div>
    <h2>Books</h2>
    <textarea id="booklist-listarea" cols="70" rows="20"></textarea>
    <div>
      <button id="booklist-load-more" style="display:none;">Load More</button>
    </div>
    <div style="width:50%; margin-top:300px">
      <h2>Add Book</h2>
      <div>
        <label for="booklist-book-title">Book Title</label><br>
        <input type="text" id="booklist-book-title" placeholder="Title">
      </div>
      <div>
        <label for="booklist-book-content">Book Content</label><br>
        <textarea id="booklist-book-content" cols="70" rows="10"></textarea>
      </div>
      <div>
        <button id="booklist-submit-book">Add</button>
      </div>
    </div>
    <div style="width:50%;">
      <h2>Update Book</h2>
      <div>
        <label for="booklist-update-book-id">Book ID</label><br>
        <input type="text" id="booklist-update-book-id" placeholder="ID">
      </div>
      <div>
        <label for="booklist-update-book-title">Book Title</label><br>
        <input type="text" id="booklist-update-book-title" placeholder="Title">
      </div>
      <div>
        <label for="booklist-update-book-content">Book Content</label><br>
        <textarea id="booklist-update-book-content" cols="70" rows="10"></textarea>
      </div>
      <div>
        <button id="booklist-update-book">Update</button>
      </div>
    </div>
    <div style="width:50%;">
      <h2>Delete Book</h2>
      <div>
        <label for="booklist-delete-book-id">Book ID</label><br>
        <input type="text" id="booklist-delete-book-id" placeholder="ID">
      </div>
      <div>
        <input type="checkbox" id="booklist-delete-force">
        <label for="booklist-delete-force">Force Delete</label>
      </div>
      <div>
        <button id="booklist-delete-book">Delete</button>
      </div>
    </div>
  </div>
<?php
}

// サブメニューページに JavaScript ファイル(booklist-admin.js)を読み込む
add_action('admin_enqueue_scripts', 'booklist_admin_enqueue_scripts');
function booklist_admin_enqueue_scripts($hook) {
  $allowed_pages = ['book_page_book-list'];
  if (in_array($hook, $allowed_pages)) {
    wp_enqueue_script(
      'booklist-admin',
      plugin_dir_url(__FILE__) . '/booklist-admin.js',
      array('wp-api-fetch'),
      '1.0.0',
      true
    );
  }
}
// 投稿を取得してリスト表示する処理
let currentPage = 1;
const loadButton = document.getElementById("booklist-load-books");
const loadMoreButton = document.getElementById("booklist-load-more");
const textarea = document.getElementById("booklist-listarea");
const perPage = 10;
const orderbySelect = document.getElementById("booklist-orderby");

// 投稿を取得する関数
function loadBooks(page = 1) {
  const orderby = orderbySelect.value;
  const order = document.querySelector('[name="booklist-order"]:checked').value;
  wp.apiFetch({
    path: wp.url.addQueryArgs("/wp/v2/books", {
      _fields: "id,title,link",
      page: page,
      per_page: perPage,
      orderby: orderby,
      order: order
    }),
    parse: false
  })
  .then((response) => {
    const totalPages = parseInt(response.headers.get("X-WP-TotalPages"), 10);
    return response.json().then((books) => {
      if (books.length > 0) {
        const newLine = page === 1 ? "" : "\n";
        textarea.value +=  newLine + books
          .map((book) => `${book.title.rendered} (${book.id}) ${book.link}`)
          .join("\n");
        loadMoreButton.style.display = currentPage < totalPages ? 'inline-block' : 'none';
      } else {
        loadMoreButton.style.display = 'none';
      }
    });
  })
  .catch((error) => console.error("Failed to fetch books:", error));
}

// Load Books ボタンのイベントリスナー
loadButton.addEventListener("click", () => {
  textarea.value = '';
  currentPage = 1;
  loadBooks(currentPage);
});
// Load More ボタンのイベントリスナー
loadMoreButton.addEventListener("click", () => {
  currentPage++;
  loadBooks(currentPage);
});

// POST リクエスト送信用ボタン
const submitBookButton = document.getElementById("booklist-submit-book");
if (submitBookButton) {
  // 上記ボタンにクリックイベントのリスナーを設定
  submitBookButton.addEventListener("click", () => {
    // タイトル入力欄の値
    const title = document.getElementById("booklist-book-title").value;
    // コンテンツ入力欄の値
    const content = document.getElementById("booklist-book-content").value;
    // タイトルとコンテンツが入力されていない場合はアラートを表示して終了
    if (!title || !content) {
      alert("Both title and content are required.");
      return;
    }
    // POST リクエストを送信
    wp.apiFetch({
      path: "/wp/v2/books/",
      method: "POST",
      data: {
        title: title,
        content: content,
        status: "publish",
      },
    })
      .then((result) => {
        // 成功した場合
        alert("Book saved!");
        console.log("Book created:", result);
      })
      .catch((error) => {
        // 失敗(エラー)の場合
        console.error("Failed to create book:", error);
        alert("Failed to save book.");
      });
  });
}

// PUT リクエスト送信用ボタン
const updateBookButton = document.getElementById("booklist-update-book");
if (updateBookButton) {
  updateBookButton.addEventListener("click", function () {
    const id = document.getElementById("booklist-update-book-id").value.trim();
    const title = document.getElementById("booklist-update-book-title").value.trim();
    const content = document.getElementById("booklist-update-book-content").value.trim();

    // id が数値であることを確認
    if (!id || isNaN(id) || !(title || content)) {
      alert("ID は数値であり、タイトルまたはコンテンツのどちらかが必要です。");
      return;
    }

    // まず現在のデータを取得
    wp.apiFetch({ path: `/wp/v2/books/${id}`, method: "GET" })
      .then((currentBook) => {
        // 取得したデータを元に更新データを作成(非同期処理を return して次の .then() に渡す)
        return wp.apiFetch({
          path: `/wp/v2/books/${id}`,
          method: "PUT",
          data: {
            title: title || currentBook.title.rendered,  // タイトルが空なら元の値を保持
            content: content || currentBook.content.rendered,  // コンテンツが空なら元の値を保持
            status: currentBook.status,  // 投稿の状態を維持
          },
        });
      })
      .then((updatedBook) => {
        // 成功時の処理
        alert("Book updated successfully!");
        // updatedBook は return された更新データ
        console.log("Book updated:", updatedBook);
      })
      .catch((error) => {
        // エラーハンドリング
        console.error("Failed to update book:", error);
        alert("Failed to update book: " + (error.message || "Unknown error"));
      });
  });
}

// DELETE リクエスト送信用ボタン
const deleteBookButton = document.getElementById("booklist-delete-book");
if (deleteBookButton) {
  deleteBookButton.addEventListener("click", function () {
    const id = document.getElementById("booklist-delete-book-id").value.trim();
    // チェック状態を取得
    const forceDelete = document.getElementById("booklist-delete-force").checked;

    // id が数値であることを確認
    if (!id || isNaN(id)) {
      alert("ID は数値ででなければなりません。");
      return;
    }
    // DELETE リクエストを送信
    wp.apiFetch({
      path: `/wp/v2/books/${id}`,
      method: "DELETE",
      // チェックされている場合のみ `force: true` を設定
      data: forceDelete ? { force: true } : {},
    })
      .then((result) => {
        // 成功した場合
        alert("Book deleted!");
        console.log("Book deleted:", result);
      })
      .catch((error) => {
        // 失敗(エラー)の場合
        console.error("Failed to delete book:", error);
        alert("Failed to delete book: " + (error.message || "Unknown error"));
      });
  });
}

固定ページで投稿を取得して表示

以下は wp.apiFetch() を使って、固定ページ(フロントエンド)で、取得した投稿のタイトルにリンクを付けて表示する例です。

固定ページを作成

新規に固定ページを作成し、スラッグを fetch-posts にします(任意のスラッグを付けられますが、異なる名前をつけた場合は JavaScript の読み込みでもその値を使用します)。

そして、カスタム HTML ブロックを挿入し、以下の HTML を記述します。

Load Posts ボタンをクリックすると、セレクトボックスのプルダウンで選択した並び替えの基準で、ラジオボタンで選択した並び順で投稿を取得して、ul#fetch-posts-list にリストを表示します。

<div>
  <label for="fetch-posts-orderby">Order by:</label>
  <select id="fetch-posts-orderby">
    <option value="date">Date</option>
    <option value="title">Title</option>
    <option value="modified">Last Modified</option>
    <option value="id">ID</option>
  </select>
  <label>
    <input type="radio" name="fetch-posts-order" value="asc"> Ascending
  </label>
  <label>
    <input type="radio" name="fetch-posts-order" value="desc" checked> Descending
  </label>
</div>
<div style="margin-top:20px">
  <button id="fetch-posts-load-posts">Load Posts</button>
</div>
<ul id="fetch-posts-list"></ul>
<div style="margin-top:20px">
  <button id="fetch-posts-load-more" style="display:none;">Load More</button>
</div>

フロントエンド側は以下のように表示されます。

JavaScript を作成

テーマに JavaScript を追加します。

この例ではデフォルトのテーマ Twenty-Twenty-Five の assets フォルダの中に js フォルダを作成し、その中に fetch-posts.js というファイルを作成します。

fetch-posts.js には、取り敢えず以下を記述しておきます。

console.log('hello from fetch-post.js');

functions.php

functions.php で上記で作成した fetch-posts.js を、スラッグが fetch-posts の固定ページで読み込むように以下を追加します。

function fetch_posts_enqueue_scripts() {
  if (is_page('fetch-posts')) { // 固定ページ「fetch-posts」のみで読み込む
    wp_enqueue_script(
      'fetch-posts-script',
      get_template_directory_uri() . '/assets/js/fetch-posts.js',
      array('wp-api-fetch'), // wp-api-fetch を依存関係として登録
      '1.0.0',
      true // フッターで読み込む
    );
  }
}
add_action('wp_enqueue_scripts', 'fetch_posts_enqueue_scripts');

これでスラッグが fetch-posts の固定ページを表示して、開発ツールでコンソールを確認すると、JavaScript により「hello from fetch-post.js」と出力されます。

JavaScript を更新

fetch-posts.js から console.log('hello from fetch-post.js'); を削除して、以下のように書き換えます。

内容的には、投稿を取得(GET)とほぼ同じですが、リンク文字列(post.link)とタイトル文字列(post.title.rendered)を念の為サニタイズしています。

タイトル文字列は HTML のマークアップが含まれている可能性があるので、sanitizeHtml という関数を定義して、HTML を除去するようにしています。

リンク文字列は WordPress REST API が正しく動作していれば悪意のあるスクリプトが含まれることはありませんが、念の為、URL が http: または https: で始まる場合のみ許可するようにしています。

// URL サニタイズ用の関数
const sanitizeUrl = (url) => {
  try {
    const sanitizedUrl = new URL(url);
    if (sanitizedUrl.protocol === 'http:' || sanitizedUrl.protocol === 'https:') {
      // protocol が 'http:' または 'https:' の場合のみ許可
      return sanitizedUrl.href;
    }
  } catch (e) {
    console.warn("Invalid URL:", url);
  }
  return ''; // 無効な URL は空文字を返す
};

// HTML からテキストを取得する関数
const sanitizeHtml = (htmlRendered) => {
  if (!htmlRendered) return "";
  const elem = document.createElement("div");
  elem.innerHTML = htmlRendered;
  return elem.textContent || elem.innerText || "";
};

// 投稿を取得してリスト表示する処理
let currentPage = 1;
const loadButton = document.getElementById("fetch-posts-load-posts");
const loadMoreButton = document.getElementById("fetch-posts-load-more");
const postList = document.getElementById("fetch-posts-list");
const perPage = 5;
const orderbySelect = document.getElementById("fetch-posts-orderby");

// 投稿を取得する関数
function loadPosts(page = 1) {
  const orderby = orderbySelect.value;
  const order = document.querySelector('[name="fetch-posts-order"]:checked').value;
  wp.apiFetch({
    path: wp.url.addQueryArgs("/wp/v2/posts", {
      _fields: "id,title,link",
      page: page,
      per_page: perPage,
      orderby: orderby,
      order: order
    }),
    parse: false
  })
  .then((response) => {
    const totalPages = parseInt(response.headers.get("X-WP-TotalPages"), 10);
    return response.json().then((posts) => {
      if (posts.length > 0) {
        const html = posts.map((post) => {
          const sanitizedLink = sanitizeUrl(post.link); // URL をサニタイズ
          const sanitizedTitle = sanitizeHtml(post.title.rendered); // タイトルをサニタイズ
          return `<li><a href="${sanitizedLink}">${sanitizedTitle} (ID: ${post.id})</a></li>`;
        }).join("");
        postList.insertAdjacentHTML('beforeend', html);
        loadMoreButton.style.display = currentPage < totalPages ? 'inline-block' : 'none';
      } else {
        loadMoreButton.style.display = 'none';
      }
    });
  })
  .catch((error) => console.error("Failed to fetch posts:", error));
}

// Load Posts ボタンのイベントリスナー
loadButton.addEventListener("click", () => {
  postList.innerHTML = '';
  currentPage = 1;
  loadPosts(currentPage);
});

// Load More ボタンのイベントリスナー
loadMoreButton.addEventListener("click", () => {
  currentPage++;
  loadPosts(currentPage);
});

フロントエンドで Load Posts ボタンをクリックすると、投稿が5件、セレクトボックスとラジオボタンで指定した条件で取得されます。

投稿が5件以上ある場合は、Load More ボタンが表示され、追加で投稿を取得することができます。

カテゴリーを選択

WordPress REST API のカテゴリーのエンドポイントを使い、カテゴリー一覧を取得し、セレクトボックスでユーザーがカテゴリーを選択できるようにします。

HTML を更新

固定ページに記述している HTML を以下のように変更して、カテゴリーを選択するセレクトボックスの select 要素とラベルの label 要素を追加します(2-5行目)。

<div class="fetch-post-wrapper">
  <div>
    <label for="fetch-posts-category">Category:</label>
    <select id="fetch-posts-category"></select>
  </div>
  <div style="margin-top:20px">
    <label for="fetch-posts-orderby">Order by:</label>
    <select id="fetch-posts-orderby">
      <option value="date">Date</option>
      <option value="title">Title</option>
      <option value="modified">Last Modified</option>
      <option value="id">ID</option>
    </select>
    <label>
      <input type="radio" name="fetch-posts-order" value="asc"> Ascending
    </label>
    <label>
      <input type="radio" name="fetch-posts-order" value="desc" checked> Descending
    </label>
  </div>
  <div style="margin-top:20px">
    <button id="fetch-posts-load-posts">Load Posts</button>
  </div>
  <ul id="fetch-posts-list"></ul>
  <div style="margin-top:20px">
    <button id="fetch-posts-load-more" style="display:none;">Load More</button>
  </div>
</div>

JavaScript を更新

カテゴリー一覧は、カテゴリーのエンドポイント(/wp/v2/categories)を使って取得できます。

カテゴリーを取得してセレクトボックスにオプションを追加する関数 loadCategories() を定義します。

カテゴリーのエンドポイントからは、id と name を取得できれば良いので、_fields パラメータに id と name を指定します。option 要素の生成では、デフォルトの All Categories を作成し、value を空にし、このオプションが選択された場合は、投稿を取得する際にパラメータにカテゴリーを含めません。

// カテゴリーを取得してセレクトボックスに追加する関数
function loadCategories() {
  wp.apiFetch({ path: "/wp/v2/categories?_fields=id,name" })
    .then((categories) => {
      categorySelect.innerHTML = '<option value="">All Categories</option>' +
        categories.map(cat => `<option value="${cat.id}">${sanitizeHtml(cat.name)}</option>`).join('');
    })
    .catch((error) => console.error("Failed to fetch categories:", error));
}

投稿を取得する関数 loadPosts() では、クエリパラメータを別途定義し、カテゴリーが選択されていれば、クエリパラメータに追加します。

そして、 wp.apiFetch() の path オプションに addQueryArgs() を使ってパラメータを追加します。

// 投稿を取得する関数
function loadPosts(page = 1) {
  const orderby = orderbySelect.value;
  const order = document.querySelector('[name="fetch-posts-order"]:checked').value;
  // セレクトボックスで選択されたカテゴリーの値
  const category = categorySelect.value;

  // クエリパラメータを作成
  const query = {
    _fields: "id,title,link",
    page: page,
    per_page: perPage,
    orderby: orderby,
    order: order
  };
  // カテゴリーが選択されていれば、クエリパラメータに追加
  if (category) query.categories = category;

  // addQueryArgs() で path にクエリパラメータを追加
  wp.apiFetch({ path: wp.url.addQueryArgs("/wp/v2/posts", query), parse: false })
    .then((response) => {
      const totalPages = parseInt(response.headers.get("X-WP-TotalPages"), 10);
      return response.json().then((posts) => {
        if (posts.length > 0) {
          const html = posts.map((post) => {
            const sanitizedLink = sanitizeUrl(post.link);
            const sanitizedTitle = sanitizeHtml(post.title.rendered);
            return `<li><a href="${sanitizedLink}">${sanitizedTitle} (ID: ${post.id})</a></li>`;
          }).join("");
          postList.insertAdjacentHTML('beforeend', html);
          loadMoreButton.style.display = currentPage < totalPages ? 'inline-block' : 'none';
        } else {
          loadMoreButton.style.display = 'none';
        }
      });
    })
    .catch((error) => console.error("Failed to fetch posts:", error));
}

fetch-posts.js のコード全体は以下のようになります。

const sanitizeUrl = (url) => {
  try {
    const sanitizedUrl = new URL(url);
    if (sanitizedUrl.protocol === 'http:' || sanitizedUrl.protocol === 'https:') {
      return sanitizedUrl.href;
    }
  } catch (e) {
    console.warn("Invalid URL:", url);
  }
  return '';
};

const sanitizeHtml = (htmlRendered) => {
  if (!htmlRendered) return "";
  const elem = document.createElement("div");
  elem.innerHTML = htmlRendered;
  return elem.textContent || elem.innerText || "";
};

let currentPage = 1;
const loadButton = document.getElementById("fetch-posts-load-posts");
const loadMoreButton = document.getElementById("fetch-posts-load-more");
const postList = document.getElementById("fetch-posts-list");
const perPage = 5;
const orderbySelect = document.getElementById("fetch-posts-orderby");
// カテゴリーを選択するセレクトボックス
const categorySelect = document.getElementById("fetch-posts-category");

// カテゴリーを取得してセレクトボックスに追加する関数
function loadCategories() {
  wp.apiFetch({ path: "/wp/v2/categories?_fields=id,name" })
    .then((categories) => {
      categorySelect.innerHTML = '<option value="">All Categories</option>' +
        categories.map(cat => `<option value="${cat.id}">${sanitizeHtml(cat.name)}</option>`).join('');
    })
    .catch((error) => console.error("Failed to fetch categories:", error));
}

function loadPosts(page = 1) {
  const orderby = orderbySelect.value;
  const order = document.querySelector('[name="fetch-posts-order"]:checked').value;
  // セレクトボックスで選択されたカテゴリーの値
  const category = categorySelect.value;

  // クエリパラメータを作成
  const query = {
    _fields: "id,title,link",
    page: page,
    per_page: perPage,
    orderby: orderby,
    order: order
  };
  // カテゴリーが選択されていれば、クエリパラメータに追加
  if (category) query.categories = category;

  // addQueryArgs() で path にクエリパラメータを追加
  wp.apiFetch({ path: wp.url.addQueryArgs("/wp/v2/posts", query), parse: false })
    .then((response) => {
      const totalPages = parseInt(response.headers.get("X-WP-TotalPages"), 10);
      return response.json().then((posts) => {
        if (posts.length > 0) {
          const html = posts.map((post) => {
            const sanitizedLink = sanitizeUrl(post.link);
            const sanitizedTitle = sanitizeHtml(post.title.rendered);
            return `<li><a href="${sanitizedLink}">${sanitizedTitle} (ID: ${post.id})</a></li>`;
          }).join("");
          postList.insertAdjacentHTML('beforeend', html);
          loadMoreButton.style.display = currentPage < totalPages ? 'inline-block' : 'none';
        } else {
          loadMoreButton.style.display = 'none';
        }
      });
    })
    .catch((error) => console.error("Failed to fetch posts:", error));
}

// 初期カテゴリーの取得(カテゴリーを取得してセレクトボックスに追加)
loadCategories();

loadButton.addEventListener("click", () => {
  postList.innerHTML = '';
  currentPage = 1;
  loadPosts(currentPage);
});

loadMoreButton.addEventListener("click", () => {
  currentPage++;
  loadPosts(currentPage);
});

デフォルトの All Categories を選択した状態では、全ての投稿が取得され、カテゴリーを選択すると、そのカテゴリーに属する投稿だけが取得されます。

投稿タイプを選択

投稿タイプ(Post Types)のエンドポイントから投稿タイプ一覧を取得し、セレクトボックスでユーザーが投稿タイプを選択できるようにします。

HTML を更新

固定ページに記述している HTML を以下のように変更して、カテゴリーの代わりに、投稿タイプを選択するセレクトボックスの select 要素とラベルの label 要素に置き換えます(2-5行目)。

<div class="fetch-post-wrapper">
  <div>
    <label for="fetch-posts-post-types">Post Type:</label>
    <select id="fetch-posts-post-types"></select>
  </div>
  <div style="margin-top:20px">
    <label for="fetch-posts-orderby">Order by:</label>
    <select id="fetch-posts-orderby">
      <option value="date">Date</option>
      <option value="title">Title</option>
      <option value="modified">Last Modified</option>
      <option value="id">ID</option>
    </select>
    <label>
      <input type="radio" name="fetch-posts-order" value="asc"> Ascending
    </label>
    <label>
      <input type="radio" name="fetch-posts-order" value="desc" checked> Descending
    </label>
  </div>

  <div style="margin-top:20px">
    <button id="fetch-posts-load-posts">Load Posts</button>
  </div>
  <ul id="fetch-posts-list"></ul>
  <div style="margin-top:20px">
    <button id="fetch-posts-load-more" style="display:none;">Load More</button>
  </div>
</div>

JavaScript を更新

投稿タイプ一覧は、投稿タイプのエンドポイント(/wp/v2/types)を使って取得できます。

投稿タイプを取得してセレクトボックスにオプションを追加する関数 loadPostTypes() を定義します。

function loadPostTypes() {
  wp.apiFetch({ path: "/wp/v2/types" })
    .then((types) => {
      postTypeSelect.innerHTML = ""; // 初期化
      // types はオブジェクト
      Object.keys(types).forEach((type) => {
        // typeData は各投稿タイプのオブジェクト(詳細データ)
        const typeData = types[type];
        const option = document.createElement("option");
        option.value = typeData.rest_base; // REST API route の base URL
        option.textContent = typeData.name;
        postTypeSelect.appendChild(option);
      });
    })
    .catch((error) => console.error("Failed to fetch post types:", error));
}

wp.apiFetch({ path: "/wp/v2/types" }) が返す types は、配列ではなくオブジェクトなので、map() や forEach() ではループできないため、Object.keys() を使って処理しています。

Object.keys(types) で ["post", "page", "attachment", "wp_navigation", ...] のようなキー(投稿タイプのスラッグ)の配列を取得して、forEach() でキー(投稿タイプ)をループ処し、typeData = types[type] で、投稿タイプの詳細データを取得しています。

/wp/v2/types のレスポンスデータは オブジェクトのプロパティとして各投稿タイプが格納されていて、types は、オブジェクトのキーに投稿タイプ(post, page, attachment)が入っています。

{
  "post": {
    "name": "Posts",
    "slug": "post",
    "rest_base": "posts",
    ・・・
  },
  "page": {
    "name": "Pages",
    "slug": "page",
    "rest_base": "pages",
    ・・・
  },
  "attachment": {
    "name": "Media",
    "slug": "attachment",
    "rest_base": "media",
    ・・・
  },
  "wp_navigation": {
    "name": "Navigation Menus",
    "slug": "wp_navigation",
    "rest_base": "navigation",
    ・・・
  }
}

更新した fetch-posts.js のコード全体は以下のようになります。

投稿タイプを選択するセレクトボックスを取得し(32行目)、loadPostTypes() で取得した投稿タイプをセレクトボックスのオプションに追加します(85行目)。

投稿一覧の取得では、投稿タイプを選択するセレクトボックスで選択された値をエンドポイントに指定します(56行目)。

const sanitizeUrl = (url) => {
  try {
    const sanitizedUrl = new URL(url);
    if (sanitizedUrl.protocol === 'http:' || sanitizedUrl.protocol === 'https:') {
      return sanitizedUrl.href;
    }
  } catch (e) {
    console.warn("Invalid URL:", url);
  }
  return '';
};

const sanitizeHtml = (htmlRendered) => {
  if (!htmlRendered) return "";
  const elem = document.createElement("div");
  elem.innerHTML = htmlRendered;
  return elem.textContent || elem.innerText || "";
};

let currentPage = 1;
const loadButton = document.getElementById("fetch-posts-load-posts");
const loadMoreButton = document.getElementById("fetch-posts-load-more");
const postList = document.getElementById("fetch-posts-list");
const perPage = 5;
const orderbySelect = document.getElementById("fetch-posts-orderby");
// 投稿タイプを選択するセレクトボックス
const postTypeSelect = document.getElementById("fetch-posts-post-types");

function loadPostTypes() {
  wp.apiFetch({ path: "/wp/v2/types" })
    .then((types) => {
      postTypeSelect.innerHTML = ""; // 初期化
      // types はオブジェクト
      Object.keys(types).forEach((type) => {
        // typeData は各投稿タイプのオブジェクト
        const typeData = types[type];
        const option = document.createElement("option");
        option.value = typeData.rest_base; // REST API route の base URL
        option.textContent = typeData.name;
        postTypeSelect.appendChild(option);
      });
    })
    .catch((error) => console.error("Failed to fetch post types:", error));
}

function loadPosts(page = 1) {
  const orderby = orderbySelect.value;
  const order = document.querySelector('[name="fetch-posts-order"]:checked').value;

  wp.apiFetch({
    path: wp.url.addQueryArgs(`/wp/v2/${postTypeSelect.value}`, {
      _fields: "id,title,link",
      page: page,
      per_page: perPage,
      orderby: orderby,
      order: order,
    }),
    parse: false,
  })
    .then((response) => {
      const totalPages = parseInt(response.headers.get("X-WP-TotalPages"), 10);
      return response.json().then((posts) => {
        if (posts.length > 0) {
          const html = posts.map((post) => {
            const sanitizedLink = sanitizeUrl(post.link);
            const sanitizedTitle = sanitizeHtml(post.title.rendered);
            return `<li><a href="${sanitizedLink}">${sanitizedTitle} (ID: ${post.id})</a></li>`;
          }).join("");
            postList.insertAdjacentHTML('beforeend', html);
          loadMoreButton.style.display = currentPage < totalPages ? 'inline-block' : 'none';
        } else {
          loadMoreButton.style.display = 'none';
        }
      });
    })
    .catch((error) => console.error("Failed to fetch posts:", error));
}

// ページロード時に投稿タイプを取得
document.addEventListener("DOMContentLoaded", loadPostTypes);

loadButton.addEventListener("click", () => {
  postList.innerHTML = '';
  currentPage = 1;
  loadPosts(currentPage);
});

loadMoreButton.addEventListener("click", () => {
  currentPage++;
  loadPosts(currentPage);
});

但し、上記のコードの場合、投稿タイプを選択するセレクトボックスには、全ての投稿タイプが表示されてしまいます。

不要な投稿タイプを除外(1)

適切な投稿タイプのみをフィルタリングする方法としては、viewable や capabilities プロパティを使う方法があります。関連ページ:ダイナミックブロックの作成(投稿タイプの選択)

但し、viewable や capabilities プロパティは Context が edit に設定されているため、以下の方法は管理者など編集権限があるユーザーとしてログインしていないと利用できません。

投稿タイプを取得してセレクトボックスに追加する関数 loadPostTypes() を以下のように書き換えます。

エンドポイントのパラメータに context=edit を追加し、viewable や capabilities にアクセスできるようにします(context のデフォルトは view)。

そして viewable が true(サイトのフロントエンドで閲覧可能な投稿タイプ)であり、capabilities.edit_posts が true(編集できる投稿タイプ)である場合に option を作成します。

もし、メディアを除外する場合は、条件に && typeData.slug !== "attachment" を追加します。

function loadPostTypes() {
  // context に edit を指定
  wp.apiFetch({ path: "/wp/v2/types?context=edit" })
    .then((types) => {
      postTypeSelect.innerHTML = ""; // 初期化

      Object.keys(types).forEach((type) => {
        const typeData = types[type];
        // フィルター条件:
        // 1. viewable が true であること
        // 2. capabilities.edit_posts が true であること(省略可能)
        if (typeData.viewable && typeData.capabilities?.edit_posts) {
          const option = document.createElement("option");
          option.value = typeData.rest_base; // REST API route の base URL
          option.textContent = typeData.name;
          postTypeSelect.appendChild(option);
        }
      });
    })
    .catch((error) => console.error("Failed to fetch post types:", error));
}

更新した fetch-posts.js のコード全体は以下のようになります(変更は loadPostTypes() の部分のみ) 。

const sanitizeUrl = (url) => {
  try {
    const sanitizedUrl = new URL(url);
    if (sanitizedUrl.protocol === 'http:' || sanitizedUrl.protocol === 'https:') {
      return sanitizedUrl.href;
    }
  } catch (e) {
    console.warn("Invalid URL:", url);
  }
  return '';
};

const sanitizeHtml = (htmlRendered) => {
  if (!htmlRendered) return "";
  const elem = document.createElement("div");
  elem.innerHTML = htmlRendered;
  return elem.textContent || elem.innerText || "";
};

let currentPage = 1;
const loadButton = document.getElementById("fetch-posts-load-posts");
const loadMoreButton = document.getElementById("fetch-posts-load-more");
const postList = document.getElementById("fetch-posts-list");
const perPage = 5;
const orderbySelect = document.getElementById("fetch-posts-orderby");
// 投稿タイプを選択するセレクトボックス
const postTypeSelect = document.getElementById("fetch-posts-post-types");

function loadPostTypes() {
  // context に edit を指定
  wp.apiFetch({ path: "/wp/v2/types?context=edit" })
    .then((types) => {
      postTypeSelect.innerHTML = ""; // 初期化

      Object.keys(types).forEach((type) => {
        const typeData = types[type];
        // フィルター条件:
        // 1. viewable が true であること
        // 2. capabilities.edit_posts が true であること
        if (typeData.viewable && typeData.capabilities?.edit_posts) {
          const option = document.createElement("option");
          option.value = typeData.rest_base; // REST API route の base URL
          option.textContent = typeData.name;
          postTypeSelect.appendChild(option);
        }
      });
    })
    .catch((error) => console.error("Failed to fetch post types:", error));
}

function loadPosts(page = 1) {
  const orderby = orderbySelect.value;
  const order = document.querySelector('[name="fetch-posts-order"]:checked').value;

  wp.apiFetch({
    path: wp.url.addQueryArgs(`/wp/v2/${postTypeSelect.value}`, {
      _fields: "id,title,link",
      page: page,
      per_page: perPage,
      orderby: orderby,
      order: order,
    }),
    parse: false,
  })
    .then((response) => {
      const totalPages = parseInt(response.headers.get("X-WP-TotalPages"), 10);
      return response.json().then((posts) => {
        if (posts.length > 0) {
          const html = posts.map((post) => {
            const sanitizedLink = sanitizeUrl(post.link); // URL をサニタイズ
            const sanitizedTitle = sanitizeHtml(post.title.rendered); // タイトルをサニタイズ
            return `<li><a href="${sanitizedLink}">${sanitizedTitle} (ID: ${post.id})</a></li>`;
          }).join("");
            postList.insertAdjacentHTML('beforeend', html);
          loadMoreButton.style.display = currentPage < totalPages ? 'inline-block' : 'none';
        } else {
          loadMoreButton.style.display = 'none';
        }
      });
    })
    .catch((error) => console.error("Failed to fetch posts:", error));
}

// ページロード時に投稿タイプを取得
document.addEventListener("DOMContentLoaded", loadPostTypes);

loadButton.addEventListener("click", () => {
  postList.innerHTML = '';
  currentPage = 1;
  loadPosts(currentPage);
});

loadMoreButton.addEventListener("click", () => {
  currentPage++;
  loadPosts(currentPage);
});

これで、投稿タイプを選択するセレクトボックスには、投稿、固定ページ、メディア、カスタム投稿タイプ(Books)のみが表示されます。※ 但し、管理者としてログインしている必要があります。

ログインしていない状態で、ページを表示すると、セレクトボックスには投稿タイプが表示されず、コンソールを確認すると「この投稿タイプの投稿を編集する権限がありません。」というエラーが表示されます。

不要な投稿タイプを除外(2)

以下は、除外する投稿タイプのリストを作成し、除外リストにない投稿タイプのみ表示する例です。

この場合、管理者としてログインしていなくても機能します。但し、メディアは管理者としてログインしていないと取得できないようなので、除外しています。

投稿タイプを取得してセレクトボックスに追加する関数 loadPostTypes() を以下のように書き換えます。

viewable や capabilities にアクセスする必要はないので、エンドポイントのパラメータには context は指定しません。

除外する投稿タイプのキー名のリスト(配列)を作成し、 Array インスタンスのメソッド includes() と論理否定 (!) 演算子を使って除外します。

function loadPostTypes() {
  wp.apiFetch({ path: "/wp/v2/types" })
    .then((types) => {
      postTypeSelect.innerHTML = ""; // 初期化
      Object.keys(types).forEach((type) => {
        // 除外する投稿タイプのリスト
        const excludedTypes = [
          "attachment",  // メディア
          "nav_menu_item", // ナビゲーションメニューの項目
          "wp_block", // パターン
          "wp_font_face", // フォントフェイス
          "wp_font_family", // フォントファミリー
          "wp_global_styles", // グローバルスタイル
          "wp_navigation", // ナビゲーションメニュー
          "wp_template", // テンプレート
          "wp_template_part",  // テンプレートパーツ
        ];

        // 除外リストにない投稿タイプのみ表示(option に設定)
        if (!excludedTypes.includes(type)) {
          const option = document.createElement("option");
          const typeData = types[type];
          option.value = typeData.rest_base;
          option.textContent = typeData.name;
          postTypeSelect.appendChild(option);
        }
      });
    })
    .catch((error) => console.error("Failed to fetch post types:", error));
}

更新した fetch-posts.js のコード全体は以下のようになります(変更は loadPostTypes() の部分のみ) 。

const sanitizeUrl = (url) => {
  try {
    const sanitizedUrl = new URL(url);
    if (sanitizedUrl.protocol === 'http:' || sanitizedUrl.protocol === 'https:') {
      return sanitizedUrl.href;
    }
  } catch (e) {
    console.warn("Invalid URL:", url);
  }
  return '';
};

const sanitizeHtml = (htmlRendered) => {
  if (!htmlRendered) return "";
  const elem = document.createElement("div");
  elem.innerHTML = htmlRendered;
  return elem.textContent || elem.innerText || "";
};

let currentPage = 1;
const loadButton = document.getElementById("fetch-posts-load-posts");
const loadMoreButton = document.getElementById("fetch-posts-load-more");
const postList = document.getElementById("fetch-posts-list");
const perPage = 5;
const orderbySelect = document.getElementById("fetch-posts-orderby");
const postTypeSelect = document.getElementById("fetch-posts-post-types");

function loadPostTypes() {
  wp.apiFetch({ path: "/wp/v2/types" })
    .then((types) => {
      postTypeSelect.innerHTML = ""; // 初期化
      Object.keys(types).forEach((type) => {
        // 除外する投稿タイプのリスト
        const excludedTypes = [
          "attachment",  // メディア
          "nav_menu_item", // ナビゲーションメニューの項目
          "wp_block", // パターン
          "wp_font_face", // フォントフェイス
          "wp_font_family", // フォントファミリー
          "wp_global_styles", // グローバルスタイル
          "wp_navigation", // ナビゲーションメニュー
          "wp_template", // テンプレート
          "wp_template_part",  // テンプレートパーツ
        ];

        // 除外リストにない投稿タイプのみ表示
        if (!excludedTypes.includes(type)) {
          const option = document.createElement("option");
          const typeData = types[type];
          option.value = typeData.rest_base;
          option.textContent = typeData.name;
          postTypeSelect.appendChild(option);
        }
      });
    })
    .catch((error) => console.error("Failed to fetch post types:", error));
}

function loadPosts(page = 1) {
  const orderby = orderbySelect.value;
  const order = document.querySelector('[name="fetch-posts-order"]:checked').value;

  wp.apiFetch({
    path: wp.url.addQueryArgs(`/wp/v2/${postTypeSelect.value}`, {
      _fields: "id,title,link",
      page: page,
      per_page: perPage,
      orderby: orderby,
      order: order,
    }),
    parse: false,
  })
    .then((response) => {
      const totalPages = parseInt(response.headers.get("X-WP-TotalPages"), 10);
      return response.json().then((posts) => {
        if (posts.length > 0) {
          const html = posts.map((post) => {
            const sanitizedLink = sanitizeUrl(post.link);
            const sanitizedTitle = sanitizeHtml(post.title.rendered);
            return `<li><a href="${sanitizedLink}">${sanitizedTitle} (ID: ${post.id})</a></li>`;
          }).join("");
            postList.insertAdjacentHTML('beforeend', html);
          loadMoreButton.style.display = currentPage < totalPages ? 'inline-block' : 'none';
        } else {
          loadMoreButton.style.display = 'none';
        }
      });
    })
    .catch((error) => console.error("Failed to fetch posts:", error));
}

// ページロード時に投稿タイプを取得
document.addEventListener("DOMContentLoaded", loadPostTypes);

loadButton.addEventListener("click", () => {
  postList.innerHTML = '';
  currentPage = 1;
  loadPosts(currentPage);
});

loadMoreButton.addEventListener("click", () => {
  currentPage++;
  loadPosts(currentPage);
});

即時実行関数を使う

これまでの例では、currentPage, loadButton などの変数は全てグローバル変数となっていますが、即時実行関数 (IIFE) を使うとグローバル変数を隠蔽する(グローバル名前空間の汚染を避ける)ことができます。

IIFE は Immediately Invoked Function Expression の省略形です。

即時実行関数 (IIFE) にするには、スクリプト全体を (() => { スクリプト })(); で囲むだけです。

(() => {
  // ここにスクリプトを記述
})();

前述の fetch-posts.js は以下のように記述することができます。

このように記述することで、すべての変数 (sanitizeUrl, currentPage, loadButton など) が IIFE 内部のローカルスコープに閉じ込められるため、他のスクリプトと変数名が衝突するリスクを防げます。

(() => {

  const sanitizeUrl = (url) => {
    try {
      const sanitizedUrl = new URL(url);
      if (sanitizedUrl.protocol === 'http:' || sanitizedUrl.protocol === 'https:') {
        return sanitizedUrl.href;
      }
    } catch (e) {
      console.warn("Invalid URL:", url);
    }
    return '';
  };

  const sanitizeHtml = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  let currentPage = 1;
  const loadButton = document.getElementById("fetch-posts-load-posts");
  const loadMoreButton = document.getElementById("fetch-posts-load-more");
  const postList = document.getElementById("fetch-posts-list");
  const perPage = 5;
  const orderbySelect = document.getElementById("fetch-posts-orderby");
  const postTypeSelect = document.getElementById("fetch-posts-post-types");

  function loadPostTypes() {
    wp.apiFetch({ path: "/wp/v2/types" })
      .then((types) => {
        postTypeSelect.innerHTML = ""; // 初期化
        Object.keys(types).forEach((type) => {
          // 除外する投稿タイプのリスト
          const excludedTypes = [
            "attachment",  // メディア
            "nav_menu_item", // ナビゲーションメニューの項目
            "wp_block", // パターン
            "wp_font_face", // フォントフェイス
            "wp_font_family", // フォントファミリー
            "wp_global_styles", // グローバルスタイル
            "wp_navigation", // ナビゲーションメニュー
            "wp_template", // テンプレート
            "wp_template_part",  // テンプレートパーツ
          ];

          // 除外リストにない投稿タイプのみ表示
          if (!excludedTypes.includes(type)) {
            const option = document.createElement("option");
            const typeData = types[type];
            option.value = typeData.rest_base;
            option.textContent = typeData.name;
            postTypeSelect.appendChild(option);
          }
        });
      })
      .catch((error) => console.error("Failed to fetch post types:", error));
  }

  function loadPosts(page = 1) {
    const orderby = orderbySelect.value;
    const order = document.querySelector('[name="fetch-posts-order"]:checked').value;

    wp.apiFetch({
      path: wp.url.addQueryArgs(`/wp/v2/${postTypeSelect.value}`, {
        _fields: "id,title,link",
        page: page,
        per_page: perPage,
        orderby: orderby,
        order: order,
      }),
      parse: false,
    })
      .then((response) => {
        const totalPages = parseInt(response.headers.get("X-WP-TotalPages"), 10);
        return response.json().then((posts) => {
          if (posts.length > 0) {
            const html = posts.map((post) => {
              const sanitizedLink = sanitizeUrl(post.link);
              const sanitizedTitle = sanitizeHtml(post.title.rendered);
              return `<li><a href="${sanitizedLink}">${sanitizedTitle} (ID: ${post.id})</a></li>`;
            }).join("");
              postList.insertAdjacentHTML('beforeend', html);
            loadMoreButton.style.display = currentPage < totalPages ? 'inline-block' : 'none';
          } else {
            loadMoreButton.style.display = 'none';
          }
        });
      })
      .catch((error) => console.error("Failed to fetch posts:", error));
  }

  // ページロード時に投稿タイプを取得
  document.addEventListener("DOMContentLoaded", loadPostTypes);

  loadButton.addEventListener("click", () => {
    postList.innerHTML = '';
    currentPage = 1;
    loadPosts(currentPage);
  });

  loadMoreButton.addEventListener("click", () => {
    currentPage++;
    loadPosts(currentPage);
  });

})();

REST API でカスタムフィールドを使う

WordPress の REST API は広範なデータ型のスキーマをサポートしていますが、カスタムフィールドのようにコアスキーマに含まれないデータを保存・取得する必要がある場合もあります。

REST API でカスタムフィールドを扱うには、以下の方法があります。

  1. register_meta() や register_post_meta() を使用する:
    特定のカスタムフィールドを REST API に公開し、適切なスキーマや権限を設定できます。
  2. register_rest_field() を使用する:
    既存の REST API エンドポイントに独自のフィールドを追加し、データの取得や更新のロジックを定義できます。

これにより、REST API リクエストでカスタムフィールドのデータを適切に扱うことができます。

カスタムフィールド (メタデータとも呼ばれます) は、投稿タイプに固有の追加データを保存するための仕組みで、内部的には、カスタムフィールドは postmeta テーブルに、投稿 ID に紐づくキーと値のペアとして保存されます。

WP REST API を使用すると、カスタムフィールドを含む投稿データを取得・更新できます。但し、デフォルトではカスタムフィールドのデータは REST API で公開されないため、適切に登録する必要があります。

特定のカスタムフィールドを REST API に公開

REST API を通じてカスタムフィールドを扱うには、対象の投稿タイプのメタプロパティにキーと値のペアのオブジェクトを渡します。

そのためには、事前に register_meta() や register_post_meta() 関数を使用してカスタムフィールドを REST API に登録する必要があります。この際、適切なスキーマや権限を設定することで、REST API 経由での取得・更新を制御できます。

register_meta()

register_meta() は投稿、ユーザー、タクソノミーなど、様々なオブジェクトのメタデータを登録できる汎用的な関数です。

register_meta( string $object_type, string $meta_key, array $args );
引数 説明
$object_type string メタデータを関連付けるオブジェクトの種類(例: 'post', 'comment', 'user', 'term')
$meta_key string 登録するメタキー(カスタムフィールド名)
$args array メタデータのオプション
$args のオプション(register_meta() / register_post_meta() 共通)
オプション 説明
single bool true にすると 1 つの値のみを保存、false だと配列として複数の値を保存可能
type string メタデータの型('string', 'boolean', 'integer', 'number', 'array', 'object')
default mixed 値が設定されていない場合のデフォルト値
show_in_rest bool/array REST API で公開する場合は true、または詳細なスキーマを含む配列を指定
auth_callback callable メタデータの読み書き権限を制御するコールバック関数
object_subtype string $object_type で指定したオブジェクトを特定のカスタム投稿タイプやタクソノミーに対してメタデータを登録する場合に使用。register_post_meta() では内部的に $post_type を object_subtype に指定しているので不要。
register_post_meta()

register_post_meta() は、投稿(カスタム投稿タイプを含む)のメタデータを登録するための専用関数です。register_post_meta() は、register_meta() を投稿専用にラップした関数で、内部的に $post_type を register_meta() の object_subtype に指定しています。

register_post_meta( string $post_type, string $meta_key, array $args );
引数 説明
$post_type string メタデータを関連付ける投稿タイプ(例: 'post', 'page', 'custom_post_type')
$meta_key string 登録するメタキー(カスタムフィールド名)
$args array メタデータのオプション(register_meta() と共通)
  • 投稿(カスタム投稿含む)のメタデータ登録には register_post_meta() を使うのがベストプラクティス
  • タクソノミーやコメントメタデータの登録には register_meta() を使う

register_meta() や register_post_meta() は、プラグインファイルや functions.php に記述します。

使用例

例えば、投稿(post)に location というカスタムフィールドを登録したい場合は、プラグインファイルや functions.php で、次のように register_post_meta() 関数を使用します。

この処理は、init アクションフック内で実行することが推奨されます。また、REST API でカスタムフィールドを扱うには、show_in_rest プロパティを true に設定する必要があります。

add_action( 'init', 'my_post_register_meta');
function my_post_register_meta() {
  register_post_meta(
    'post', // 投稿タイプ
    'location',
    array(
      'single'       => true,
      'type'         => 'string',
      'default'      => '',
      'show_in_rest' => true, // REST API でカスタムフィールドを扱う
    )
  );
}

さらに、スキーマを指定することで、API レスポンスのデータ構造を明確に定義できます。

show_in_rest にスキーマを指定することで、API のレスポンスに適切な情報を含めることができます。

add_action('init', 'my_post_register_meta');
function my_post_register_meta() {
  register_post_meta(
    'post', // 投稿タイプ
    'location',
    array(
      'single'       => true,
      'type'         => 'string',
      'default'      => '',
      'show_in_rest' => array(
        'schema' => array(
          'type' => 'string',
          'description' => '投稿の場所情報',
          'context' => array('view', 'edit', 'embed'),
        ),
      ),
    )
  );
}

これにより、カスタムフィールドを投稿の REST API スキーマに追加できるようになり、REST API を使用してカスタムフィールドにデータを投稿することも可能になります。

REST API を使用してカスタムフィールドにデータを投稿する場合、リクエストボディ(body)のメタオブジェクト(meta)でカスタムフィールドをキーと値のペアとして渡します。

{
  "title": "New Post Title",
  "content": "New Post Content",
  "status": "publish",
  "meta": {
    "location": "Tokyo"
  }
}

これをテストするには、例えば、REST API テストツールの Postman で新しい POST リクエストを作成し、そのリクエストを posts ルートに送信します。

リクエストが成功すると、レスポンスの meta フィールドの中に指定したカスタムフィールド location が確認できます。

また、作成した投稿を確認するとカスタムフィールドが設定されています。

GET クエリで検索可能に

GET でパラメータを指定して特定のメタデータ(例: meta[location]=Tokyo)を持つ投稿を取得する場合、以下では期待通りには取得できず、全ての投稿が取得されてしまいます。

GET /wp-json/wp/v2/posts?meta_key=location&meta_value=Tokyo

filter フックを使って meta_query を拡張

方法としては、rest_{post_type}_query フックを利用して、meta_query をカスタマイズします。

以下のコードをプラグインファイルまたは functions.php に追加します。

REST API で投稿を取得するときのクエリ($args)をカスタマイズする関数を定義し、rest_post_query フックを使い、REST API の投稿検索クエリをカスタマイズしています。関数の第2引数 $request は API リクエストのデータ(GET で渡されたパラメータ)です。

function my_rest_filter_by_meta_location($args, $request) {
  if (isset($request['meta']['location'])) {
    $args['meta_query'][] = array(
      'key'     => 'location',
      'value'   => sanitize_text_field($request['meta']['location']),
      'compare' => 'LIKE', // 部分一致(完全一致なら '=' に変更)
    );
  }
  return $args;
}
add_filter('rest_post_query', 'my_rest_filter_by_meta_location', 10, 2);

クエリの meta パラメータに "location" が指定されているかチェックし(2行目)、$request['meta']['location'] に値が設定されている場合のみ、WP_Query の meta_query に検索条件を追加しています。

  • key : 検索対象のカスタムフィールド名(この場合 "location")。
  • value : 検索する値(例: "Tokyo")。 sanitize_text_field() を使って安全なデータに変換。
  • compare : 検索条件 LIKE は部分一致。完全一致にしたい場合は = に変更。

そして、修正した $args(検索条件付き)を返しています。

投稿を検索

例えば、location=Tokyo の投稿を検索するには以下を指定します。

GET /wp-json/wp/v2/posts?meta[location]=Tokyo

これで、location=Tokyo の投稿のみを取得できます。

エンドポイントに独自のフィールドを追加

register_rest_field() を使って、既存の REST API エンドポイントに独自データのフィールド(カスタムフィールド)を追加し、データの取得や更新のロジックを定義することができます。

register_rest_field()

register_rest_field() は WP REST API に独自のフィールドを追加するための関数です。

特定のエンドポイント(投稿、カスタム投稿タイプ、ユーザー、コメントなど)のトップレベルのフィールドにカスタムフィールドなど独自のフィールドを追加することができます。

register_rest_field( $object_type, $attribute, $args = array() );
引数 説明
$object_type string または array フィールドを追加するオブジェクトのタイプ(例: 'post', 'page', 'custom_post_type', 'user' など)
$attribute string REST API に追加するカスタムフィールドの名前
$args array 追加するフィールドの詳細設定(取得・更新用のコールバック、スキーマ)

$args には、以下のキーを含む配列を指定できます。

オプション 説明
get_callback callable REST API のレスポンスにフィールドを追加するときに実行される(フィールドの値を取得する)関数。以下のパラメータを受け取ります。
  • $object (array) REST API でリクエストされたオブジェクト(REST API のレスポンスデータの配列)
  • $field_name (string) 取得するカスタムフィールドの名前
  • $request (WP_REST_Request) REST API のリクエスト情報
update_callback callable REST API を通じてフィールドを更新するときに実行される関数。以下のパラメータを受け取ります。
  • $value (mixed)リクエストで送信された値
  • $object (WP_Post などのオブジェクト)更新対象のオブジェクト
  • $field_name (string)更新するカスタムフィールドの名前
schema array フィールドのデータのスキーマを定義。以下は指定できる主なキーです。
  • description(string)フィールドの説明
  • type(string)フィールドのデータ型(string, integer, boolean, array, object, null など)
  • default(mixed)デフォルト値
  • context(array)フィールドが使用できるコンテキスト(view, edit, embed)
  • format(string)形式を指定(date-time, email, uri, uuid など)
  • enum(array)許可する値のリスト
  • required(boolean)true にすると必須フィールドになる
  • readonly(boolean)true にすると更新不可
  • minimum / maximum(number)数値の最小値 / 最大値
  • minLength / maxLength(integer)文字列の最小長 / 最大長

register_rest_field() は基本的に rest_api_init にフックする必要があります(REST API が正しく初期化された後に実行するため)。

以下は、register_rest_field() を使って WordPress の REST API のエンドポイントのトップレベルのフィールドにスタムフィールドを追加する例です。

具体的には、投稿(post)に location というカスタムフィールドを追加し、REST API 経由で取得・更新できるようにします。前項の register_post_meta() を使う場合と同じカスタムフィールド location を使用するので、前項で定義した my_post_register_meta() は削除しておきます。

add_action('rest_api_init', function () {
  register_rest_field(
    'post', // 対象の投稿タイプ(ここでは通常の投稿)
    'location', // 追加するフィールド名
    array(
      'get_callback'    => 'my_get_location',  // 値を取得する関数
      'update_callback' => 'my_update_location', // 値を更新する関数
      'schema'          => array( // フィールドのデータ仕様(スキーマ)
        'description' => '投稿のロケーション情報', // フィールドの説明
        'type'        => 'string', // 文字列データ
        'context'     => array('view', 'edit'), // view(閲覧時)と edit(編集時)に利用可能
      ),
    )
  );
});

// カスタムフィールドの値を取得する関数
function my_get_location($object, $field_name) {
  return get_post_meta($object['id'], $field_name, true);
}

// カスタムフィールドの値を更新する関数
function my_update_location($value, $object, $field_name) {
  return update_post_meta($object->ID, $field_name, sanitize_text_field($value));
}

get_callback

カスタムフィールドの値を取得する関数 my_get_location() は、get_post_meta() を使って該当する投稿の location の値を取得し、REST API のレスポンスに含めます。

第1引数 $object は配列なので、連想配列のキー id(小文字)を指定して投稿の ID を取得し、第2引数 $field_nam は register_rest_field() の第2引数の location が渡されます。get_post_meta() の第3引数は true を指定して単一の値を取得しています。

update_callback

カスタムフィールドの値を更新する関数 my_update_location() は、update_post_meta() を使って投稿のカスタムフィールド location の値を更新します。

第1引数 $object は更新対象のオブジェクト(WP_Post や WP_User などのオブジェクト)なので $object->ID で ID プロパティにアクセスしています。リクエストで送信された値($value)は sanitize_text_field() でサニタイズして、update_post_meta() の第3引数に渡します。

リクエスト

register_rest_field() を使用した場合、REST API へのリクエストのボディでは、カスタムフィールド location はトップレベルに指定します。

{
  "title": "New Post Title 2",
  "content": "New Post Content 2",
  "status": "publish",
  "location": "Kyoto"
}

例えば、REST API テストツールの Postman で新しい POST リクエストを作成し、そのリクエストを posts ルートに送信します。

リクエストが成功すると、レスポンスのトップレベルのフィールドの中に指定したカスタムフィールド location が確認できます。

また、作成した投稿を確認するとカスタムフィールドが設定されています。

GET クエリで検索可能に

register_rest_field() を使ってエンドポイントに独自データのフィールドを追加した場合も、register_meta() や register_post_meta()を使用してカスタムフィールドを登録した場合同様、REST API のクエリパラメータ(?location=Tokyo)でのフィルタリングは 自動的には動作しません。

WordPress の REST API では、デフォルトでカスタムフィールドをフィルタリングできないため、カスタムフィールドを元に検索するためのカスタムクエリを追加する必要があります。

以下のコードを追加します。コードの内容は GET クエリで検索可能にとほぼ同じです。

rest_post_query フィルターを使用して、?location=Tokyo のようなリクエストで location カスタムフィールドを検索できるように、REST API の投稿検索時のクエリ ($args) をカスタマイズします。

$request['location'] に値が設定されている場合のみ、フィルタリングを適用します。

そして WordPress の WP_Query に meta_query(メタデータを使った検索条件)を追加しています。

  • key : 検索対象のカスタムフィールドのキー(location)
  • value : ユーザーが検索した値(例: "Tokyo")
  • compare : 検索方法(LIKE は部分一致、= は完全一致)
add_filter('rest_post_query', function ($args, $request) {
  if (isset($request['location'])) {
    $args['meta_query'][] = array(
      'key'     => 'location',
      'value'   => sanitize_text_field($request['location']),
      'compare' => 'LIKE', // 部分一致(完全一致なら '=' に変更)
    );
  }
  return $args;
}, 10, 2);

これで、location=Tokyo の投稿は GET /wp-json/wp/v2/posts?location=Tokyo で取得できます。

どちらを使うべきか?

REST API でカスタムフィールドを使う場合、register_post_meta() と register_rest_field() のどちらを使うかは、API の設計次第で、開発の目的によります。

方法 追加される場所 リクエスト時の指定方法 使いどころ
register_post_meta() meta の中 "meta": { "location": "Tokyo" } カスタムフィールドを(meta の中で)整理して管理したいとき
register_rest_field() トップレベル "location": "Tokyo" API のレスポンスをシンプルにしたいとき

また、それぞれの関数には以下のような役割の違いがあります。

関数名 役割 REST API との関連
register_post_meta() WordPress のメタデータを登録し、REST API で扱えるようにする 'show_in_rest' => true に設定すると、自動的に REST API に追加される
register_rest_field() 既存の REST API エンドポイントにカスタムフィールドを追加 get_callback や update_callback を指定して独自のデータ処理を定義できる

register_post_meta() を使用すると、以下のように show_in_rest => true を指定するだけで、REST API にメタデータが自動的に追加されます。

register_post_meta(
  'post',
  'location',
  array(
    'single'       => true,
    'type'         => 'string',
    'show_in_rest' => true, // これだけで自動的に REST API で利用可能
  )
);

以下のように REST API でカスタムフィールドのデータを加工して返したい(get_callback を定義したい)場合やデータの保存時に追加のバリデーションや加工を行いたい(update_callback を定義したい)場合は、register_post_meta() の show_in_rest では対応できません。

register_rest_field(
  'post',
  'location',
  array(
    'get_callback'    => function($object, $field_name) {
      // 例: すべて大文字で返す
      return strtoupper(get_post_meta($object['id'], $field_name, true));
    },
    'update_callback' => function($value, $object, $field_name) {
      return update_post_meta($object->ID, $field_name, sanitize_text_field($value));
    },
    'schema' => array(
      'description' => 'Location metadata for the post',
      'type'        => 'string',
      'context'     => array('view', 'edit'),
    ),
  )
);

register_meta() / register_post_meta() を使う方法の場合、実行する必要のある追加のコード(コールバック関数)を追加しないため、よりパフォーマンスの高いオプションでもあります。

一方、register_rest_field() を使う利点は、データが返される前、または保存される前に、データに対して追加の処理を実行できることです。

例えば、データベースに保存される前に、データに対して何らかの検証を実行できます。また、get_callback 関数と update_callback 関数にフックを追加して、データに対して追加の処理を実行したり、他の開発者がカスタムフィールドを拡張できるようにしたりすることもできます。欠点は、実行する必要があるコードが増えるため、API リクエストに若干のオーバーヘッドが追加されることです。

カスタム投稿タイプでの実装例

以下はこれまで使用してきた book カスタム投稿タイプのエンドポイントに isbn というカスタムフィールドを追加する例です。

まず、カスタム投稿タイプの登録でカスタムフィールドが使用できるように supports に custom-fields を追加します。

// カスタム投稿タイプ book の登録
add_action('init', 'booklist_register_book_post_type');
function booklist_register_book_post_type() {
  $args = array(
    'labels'       => array(
      'name'          => 'Books',
      'singular_name' => 'Book',
      'menu_name'     => 'Books',
      'add_new'       => 'Add New Book',
      'add_new_item'  => 'Add New Book',
      'new_item'      => 'New Book',
      'edit_item'     => 'Edit Book',
      'view_item'     => 'View Book',
      'all_items'     => 'All Books',
    ),
    'public'       => true,
    'has_archive'  => true,
    'menu_icon' => 'dashicons-book',
    // REST API に公開する
    'show_in_rest' => true,
    // REST API route の base URL を book から books に変更
    'rest_base'    => 'books',
    // カスタムフィールドをサポート
    'supports'     => array('title', 'editor', 'author', 'thumbnail', 'excerpt', 'custom-fields'),
  );

  register_post_type('book', $args);
}

続いて、register_rest_field() を使って book エンドポイントのトップレベルのフィールドにスタムフィールド isbn を追加します。

// book エンドポイントのトップレベルのフィールドにスタムフィールド isbn を追加
add_action('rest_api_init', 'booklist_add_rest_fields');
function booklist_add_rest_fields() {
  register_rest_field(
    'book',
    'isbn',
    array(
      'get_callback'    => 'booklist_rest_get_isbn',
      'update_callback' => 'booklist_rest_update_isbn',
      'schema'          => array(
        'description' => __('The ISBN of the book'), // フィールドの説明
        'type'        => 'string', // 文字列データ
        'context'     => array('view', 'edit'), // view(閲覧時)と edit(編集時)に利用可能
      ),
    )
  );
}
// get_callback(カスタムフィールドの値を取得する関数)
function booklist_rest_get_isbn($object, $field_name) {
  return get_post_meta($object['id'], $field_name, true);
}
// update_callback(カスタムフィールドの値を更新する関数)
function booklist_rest_update_isbn($value, $object, $field_name) {
  return update_post_meta($object->ID, $field_name, sanitize_text_field($value));
}

そして、GET でパラメータを指定して ?isbn=12345 のようなリクエストで isbn カスタムフィールドを持つ book カスタム投稿タイプの投稿を取得できるように、rest_{post_type}_query フック(rest_book_query フック)を使ってカスタムクエリを追加します。

// GET クエリで検索可能に
add_filter('rest_book_query', function ($args, $request) {
  if (isset($request['isbn'])) {
    $args['meta_query'][] = array(
      'key'     => 'isbn',
      'value'   => sanitize_text_field($request['isbn']),
      'compare' => '=', // 完全一致
    );
  }
  return $args;
}, 10, 2);

この例の場合、register_rest_field() を使ってエンドポイントのトップレベルのフィールドにカスタムフィールド isbn を追加しているので、REST API へのリクエストのボディでは、カスタムフィールド isbn はトップレベルに指定します。

{
  "title": "New Book with ISBN",
  "content": "New Book with ISBN Content",
  "status": "publish",
  "isbn": "123456789-0"
}

以下は、REST API テストツールの Postman で新しい POST リクエストを作成し、そのリクエストを books ルートに送信しています。

以下は、GET /wp-json/wp/v2/books?isbn=123456789-0 でカスタムフィールド isbn の値が 123456789-0 の投稿を取得しています。

カスタムフィールドの値を検証

register_rest_field() のカスタムフィールドの値を更新する update_callback で、指定されたカスタムフィールド isbn の値を検証する例です。

前述のコードのカスタムフィールドの値を更新する関数では isbn の値を sanitize_text_field() で処理していますが、値を検証して10桁または13桁の数字でなければ、エラーにします。

isbn の値を検証する関数 booklist_validate_isbn() を定義します。この関数は、指定された isbn の値からハイフンを削除し、preg_match() で正規表現を使って10桁または13桁の数字であれば true(1)を返し、そうでなければ false(0)を返します。

そして register_rest_field() のカスタムフィールドの値を更新するコールバックで定義した関数を使って値を検証し、10桁または13桁の数字でなければ、WP_Error クラスを使ってエラーにします。

// isbn の値を検証する関数
function booklist_validate_isbn($isbn) {
  // ハイフンを削除
  $isbn = str_replace('-', '', $isbn);
  // 10桁または13桁の数字であれば true を返す
  return preg_match('/^\d{10}(\d{3})?$/', $isbn);
}

// update_callback
function booklist_rest_update_isbn($value, $object, $field_name) {
  if (!booklist_validate_isbn($value)) {
    // 値が正しくなければ、エラーにする
    return new WP_Error('invalid_isbn', __('Invalid ISBN format.', 'booklist'), array('status' => 400));
  }
  return update_post_meta($object->ID, $field_name, sanitize_text_field($value));
}

WP_Error

WP_Error は WordPress でエラーハンドリングを行うためのクラスです。REST API やプラグイン開発時に、適切なエラーメッセージや HTTP ステータスコードを返すために使用されます。

WP_Error::__construct( $code, $message, $data = array() )
WP_Error::__construct パラメータ
パラメータ 説明
$code string | int エラーコード(文字列または数値)
$message string エラーメッセージ。
$data mixed 追加情報(オプション、配列やステータスコードなど)。

以下は基本的な使い方です。

return new WP_Error(
  'invalid_isbn',  // エラーコード
  __('Invalid ISBN format.', 'booklist'),  // ユーザー向けのメッセージ(この例では翻訳関数を適用)
  array('status' => 400)  // HTTP ステータスコード
);

新しい POST リクエストを作成して、book の投稿を作成する際に、正しくない ISBN の値を指定すると以下のようにエラーになり、投稿は作成されません。

プラグイン booklist を更新

管理画面(Book List 管理ページ)で book カスタム投稿タイプの投稿を取得、作成、更新するプラグイン booklist に ISBN の入力欄を追加して、投稿を取得、作成、更新する際にカスタムフィールド isbn を指定できるようにする例です。

プラグインファイル booklist.php の投稿を取得して表示する部分の HTML に以下を追加します。

<div><!-- 追加 -->
  <label for="booklist-isbn">ISBN</label><br>
  <input type="text" id="booklist-isbn" placeholder="ISBN">
</div>

Book List 管理ページで読み込んでいる JavaScript(booklist-admin.js)の投稿を取得する関数を以下のように書き換えると、 ISBN 入力欄に値が入力されていれば、その値をクエリパラメータの isbn に追加して、指定された ISBN の投稿を取得して表示します。

wp/v2/books のレスポンスに isbn も含めるように _fields に isbn を追加します。

クエリパラメータは別途定義しておき、ISBN 入力欄に isbn が入力されていれば、クエリパラメータに isbn を追加して指定された ISBN の値を持つ投稿のみを取得し、isbn が入力されていなければ、クエリパラメータに isbn を追加しません(isbn の値に関係なく取得します)。

出力する際は、ISBN(book.isbn)が投稿に含まれていれば (ISBN: xxxx) のような形式で出力します。

// 投稿を取得する関数
function loadBooks(page = 1) {
  const orderby = orderbySelect.value;
  const order = document.querySelector('[name="booklist-order"]:checked').value;
  // ISBN 入力欄の値
  const isbn = document.getElementById("booklist-isbn").value.trim();

  // クエリパラメータを別途定義
  const query = {
    _fields: "id,title,link,isbn",  // isbn を追加
    page: page,
    per_page: perPage,
    orderby: orderby,
    order: order,
  };
  // isbn が入力されていれば、クエリパラメータに追加
  if (isbn) query.isbn = isbn;

  // addQueryArgs() で path にクエリパラメータを追加
  wp.apiFetch({
    path: wp.url.addQueryArgs("/wp/v2/books", query),
    parse: false,
  })
    .then((response) => {
      const totalPages = parseInt(response.headers.get("X-WP-TotalPages"), 10);
      return response.json().then((books) => {
        if (books.length > 0) {
          const newLine = page === 1 ? "" : "\n";
          textarea.value +=  newLine + books
            .map((book) => {
              // isbn が取得できれば (ISBN: 123456789-0) のような形式で出力
              const isbn = book.isbn ? ` (ISBN: ${book.isbn}) `: "";
              return `${book.title.rendered} (${book.id}) ${book.link} ${isbn}`}
            ).join("\n");
          loadMoreButton.style.display = currentPage < totalPages ? 'inline-block' : 'none';
        } else {
          loadMoreButton.style.display = 'none';
        }
      });
    })
    .catch((error) => console.error("Failed to fetch books:", error));
}

以下は、投稿を作成・更新する際もカスタムフィールド isbn を入力できるようにしたコードの全体です。

<?php

/**
 * Plugin Name: Booklist
 * Description: A plugin to manage books
 * Version: 0.0.1
 *
 */

if (! defined('ABSPATH')) {
  exit; // Exit if accessed directly.
}

// カスタム投稿タイプ book の登録
add_action('init', 'booklist_register_book_post_type');
function booklist_register_book_post_type() {
  $args = array(
    'labels'       => array(
      'name'          => 'Books',
      'singular_name' => 'Book',
      'menu_name'     => 'Books',
      'add_new'       => 'Add New Book',
      'add_new_item'  => 'Add New Book',
      'new_item'      => 'New Book',
      'edit_item'     => 'Edit Book',
      'view_item'     => 'View Book',
      'all_items'     => 'All Books',
    ),
    'public'       => true,
    'has_archive'  => true,
    'menu_icon' => 'dashicons-book',
    // REST API に公開する
    'show_in_rest' => true,
    // REST API route の base URL を book から books に変更
    'rest_base'    => 'books',
    // カスタムフィールドをサポート
    'supports'     => array('title', 'editor', 'author', 'thumbnail', 'excerpt', 'custom-fields'),
  );

  register_post_type('book', $args);
}

// book エンドポイントのトップレベルのフィールドにスタムフィールド isbn を追加
add_action('rest_api_init', 'booklist_add_rest_fields');
function booklist_add_rest_fields() {
  register_rest_field(
    'book',
    'isbn',
    array(
      'get_callback'    => 'booklist_rest_get_isbn',
      'update_callback' => 'booklist_rest_update_isbn',
      'schema'          => array(
        'description' => __('The ISBN of the book'), // フィールドの説明
        'type'        => 'string', // 文字列データ
        'context'     => array('view', 'edit'), // view(閲覧時)と edit(編集時)に利用可能
      ),
    )
  );
}
// get_callback
function booklist_rest_get_isbn($object, $field_name) {
  return get_post_meta($object['id'], $field_name, true);
}
// isbn の値を検証する関数
function booklist_validate_isbn($isbn) {
  // ハイフンを削除
  $isbn = str_replace('-', '', $isbn);
  // 10桁または13桁の数字であれば true を返す
  return preg_match('/^\d{10}(\d{3})?$/', $isbn);
}
// update_callback
function booklist_rest_update_isbn($value, $object, $field_name) {
  if (!booklist_validate_isbn($value)) {
    // 値が正しくなければ、エラーにする
    return new WP_Error(
      'invalid_isbn',
      __('Invalid ISBN format.', 'booklist'),
      array('status' => 400)
    );
  }
  return update_post_meta($object->ID, $field_name, sanitize_text_field($value));
}

// GET クエリで検索可能に
add_filter('rest_book_query', function ($args, $request) {
  if (isset($request['isbn'])) {
    $args['meta_query'][] = array(
      'key'     => 'isbn',
      'value'   => sanitize_text_field($request['isbn']),
      'compare' => '=', // 完全一致
    );
  }
  return $args;
}, 10, 2);

// サブメニューページを追加
add_action('admin_menu', 'booklist_add_submenu', 11);
function booklist_add_submenu() {
  add_submenu_page(
    'edit.php?post_type=book', // 親メニューのスラッグ
    'Book List', // サブメニューページのタイトル
    'Book List', // サブメニューページのメニューのタイトル
    'edit_posts', // サブメニューページにアクセスできるユーザーの権限
    'book-list', // サブメニューページを参照するスラッグ
    'booklist_render_book_list' // サブメニューページを描画するコールバック関数
  );
}

// サブメニューページのHTMLを描画するコールバック関数
function booklist_render_book_list() {
?>
  <div class="wrap" id="booklist-admin">
    <h1>Actions</h1>
    <div style="margin-top:20px">
      <label for="booklist-orderby">Order by:</label>
      <select id="booklist-orderby">
        <option value="date">Date</option>
        <option value="title">Title</option>
        <option value="modified">Last Modified</option>
        <option value="id">ID</option>
      </select>
      <label>
        <input type="radio" name="booklist-order" value="asc"> Ascending
      </label>
      <label>
        <input type="radio" name="booklist-order" value="desc" checked> Descending
      </label>
    </div>
    <div style="margin-top:20px"><!-- 追加 -->
      <label for="booklist-isbn">ISBN</label><br>
      <input type="text" id="booklist-isbn" placeholder="ISBN">
    </div>
    <div style="margin-top:20px">
      <button id="booklist-load-books">Load Books</button>
    </div>
    <h2>Books</h2>
    <textarea id="booklist-listarea" cols="90" rows="20"></textarea>
    <div>
      <button id="booklist-load-more" style="display:none;">Load More</button>
    </div>
    <div style="width:50%; margin-top:300px">
      <h2>Add Book</h2>
      <div>
        <label for="booklist-book-title">Book Title</label><br>
        <input type="text" id="booklist-book-title" placeholder="Title">
      </div>
      <div>
        <label for="booklist-book-content">Book Content</label><br>
        <textarea id="booklist-book-content" cols="70" rows="10"></textarea>
      </div>
      <div><!-- 追加 -->
        <label for="booklist-book-isbn">ISBN</label><br>
        <input type="text" id="booklist-book-isbn" placeholder="ISBN">
      </div>
      <div style="margin-top:20px">
        <button id="booklist-submit-book">Add</button>
      </div>
    </div>
    <div style="width:50%;">
      <h2>Update Book</h2>
      <div>
        <label for="booklist-update-book-id">Book ID</label><br>
        <input type="text" id="booklist-update-book-id" placeholder="ID">
      </div>
      <div>
        <label for="booklist-update-book-title">Book Title</label><br>
        <input type="text" id="booklist-update-book-title" placeholder="Title">
      </div>
      <div>
        <label for="booklist-update-book-content">Book Content</label><br>
        <textarea id="booklist-update-book-content" cols="70" rows="10"></textarea>
      </div>
      <div><!-- 追加 -->
        <label for="booklist-update-book-isbn">ISBN</label><br>
        <input type="text" id="booklist-update-book-isbn" placeholder="ISBN">
      </div>
      <div style="margin-top:20px">
        <button id="booklist-update-book">Update</button>
      </div>
    </div>
    <div style="width:50%;">
      <h2>Delete Book</h2>
      <div>
        <label for="booklist-delete-book-id">Book ID</label><br>
        <input type="text" id="booklist-delete-book-id" placeholder="ID">
      </div>
      <div>
        <input type="checkbox" id="booklist-delete-force">
        <label for="booklist-delete-force">Force Delete</label>
      </div>
      <div>
        <button id="booklist-delete-book">Delete</button>
      </div>
    </div>
  </div>
<?php
}

// サブメニューページに JavaScript ファイル(booklist-admin.js)を読み込む
add_action('admin_enqueue_scripts', 'booklist_admin_enqueue_scripts');
function booklist_admin_enqueue_scripts($hook) {
  $allowed_pages = ['book_page_book-list'];
  if (in_array($hook, $allowed_pages)) {
    wp_enqueue_script(
      'booklist-admin',
      plugin_dir_url(__FILE__) . '/booklist-admin.js',
      array('wp-api-fetch'),
      '1.0.0',
      true
    );
  }
}
// 投稿を取得してリスト表示する処理
let currentPage = 1;
const loadButton = document.getElementById("booklist-load-books");
const loadMoreButton = document.getElementById("booklist-load-more");
const textarea = document.getElementById("booklist-listarea");
const perPage = 10;
const orderbySelect = document.getElementById("booklist-orderby");

// 投稿を取得する関数
function loadBooks(page = 1) {
  const orderby = orderbySelect.value;
  const order = document.querySelector('[name="booklist-order"]:checked').value;
  // ISBN 入力欄の値
  const isbn = document.getElementById("booklist-isbn").value.trim();

  // クエリパラメータを別途定義
  const query = {
    _fields: "id,title,link,isbn",  // isbn を追加
    page: page,
    per_page: perPage,
    orderby: orderby,
    order: order,
  };
  // isbn が入力されていれば、クエリパラメータに追加
  if (isbn) query.isbn = isbn;

  // addQueryArgs() で path にクエリパラメータを追加
  wp.apiFetch({
    path: wp.url.addQueryArgs("/wp/v2/books", query),
    parse: false,
  })
    .then((response) => {
      const totalPages = parseInt(response.headers.get("X-WP-TotalPages"), 10);
      return response.json().then((books) => {
        if (books.length > 0) {
          const newLine = page === 1 ? "" : "\n";
          textarea.value +=  newLine + books
            .map((book) => {
              // isbn が取得できれば (ISBN: 123456789-0) のような形式で出力
              const isbn = book.isbn ? ` (ISBN: ${book.isbn}) `: "";
              return `${book.title.rendered} (${book.id}) ${book.link} ${isbn}`}
            ).join("\n");
          loadMoreButton.style.display = currentPage < totalPages ? 'inline-block' : 'none';
        } else {
          loadMoreButton.style.display = 'none';
        }
      });
    })
    .catch((error) => console.error("Failed to fetch books:", error));
}
loadButton.addEventListener("click", () => {
  textarea.value = "";
  currentPage = 1;
  loadBooks(currentPage);
});
loadMoreButton.addEventListener("click", () => {
  currentPage++;
  loadBooks(currentPage);
});

// POST リクエスト送信用ボタン
const submitBookButton = document.getElementById("booklist-submit-book");
if (submitBookButton) {
  submitBookButton.addEventListener("click", () => {
    const title = document.getElementById("booklist-book-title").value;
    const content = document.getElementById("booklist-book-content").value;
    if (!title || !content) {
      alert("Both title and content are required.");
      return;
    }
    // POST リクエストの ISBN 入力欄の値
    const isbnPost = document.getElementById("booklist-book-isbn").value.trim();
    // data を別途定義
    const data = {
      title: title,
      content: content,
      status: "publish",
    }
    // ISBN が入力されていれば data に追加
    if (isbnPost) data.isbn = isbnPost;

    wp.apiFetch({
      path: "/wp/v2/books/",
      method: "POST",
      data: data, // 別途定義した data を指定
    })
      .then((result) => {
        alert("Book saved!");
        console.log("Book created:", result);
      })
      .catch((error) => {
        console.error("Failed to create book:", error);
        alert("Failed to save book.");
      });
  });
}

// PUT リクエスト送信用ボタン
const updateBookButton = document.getElementById("booklist-update-book");
if (updateBookButton) {
  updateBookButton.addEventListener("click", function () {
    const id = document.getElementById("booklist-update-book-id").value.trim();
    const title = document.getElementById("booklist-update-book-title").value.trim();
    const content = document.getElementById("booklist-update-book-content").value.trim();
    // PUT リクエストの ISBN 入力欄の値
    const isbnPut = document.getElementById("booklist-update-book-isbn").value.trim();

    // 判定条件に ISBN も追加
    if (!id || isNaN(id) || !(title || content || isbnPut)) {
      alert("ID は数値であり、タイトルまたはコンテンツ、ISBN のいずれかが必要です。");
      return;
    }

    wp.apiFetch({ path: `/wp/v2/books/${id}`, method: "GET" })
      .then((currentBook) => {
        return wp.apiFetch({
          path: `/wp/v2/books/${id}`,
          method: "PUT",
          data: {
            title: title || currentBook.title.rendered,
            content: content || currentBook.content.rendered,
            isbn: isbnPut || currentBook.isbn,  // ISBN が空なら元の値を保持
            status: currentBook.status,
          },
        });
      })
      .then((updatedBook) => {
        alert("Book updated successfully!");
        console.log("Book updated:", updatedBook);
      })
      .catch((error) => {
        console.error("Failed to update book:", error);
        alert("Failed to update book: " + (error.message || "Unknown error"));
      });
  });
}

// DELETE リクエスト送信用ボタン
const deleteBookButton = document.getElementById("booklist-delete-book");
if (deleteBookButton) {
  deleteBookButton.addEventListener("click", function () {
    const id = document.getElementById("booklist-delete-book-id").value.trim();
    const forceDelete = document.getElementById(
      "booklist-delete-force"
    ).checked;

    if (!id || isNaN(id)) {
      alert("ID は数値ででなければなりません。");
      return;
    }
    wp.apiFetch({
      path: `/wp/v2/books/${id}`,
      method: "DELETE",
      data: forceDelete ? { force: true } : {},
    })
      .then((result) => {
        alert("Book deleted!");
        console.log("Book deleted:", result);
      })
      .catch((error) => {
        console.error("Failed to delete book:", error);
        alert("Failed to delete book: " + (error.message || "Unknown error"));
      });
  });
}

外部から投稿を取得して表示

WordPress 外部から JavaScript で WordPress REST API を使って投稿のデータを取得して、タイトルにリンクを付けて一覧表示する例です。

この例では、id が fetch-posts の div 要素があれば、JavaScript でその要素内に各投稿のリンクを ul と li 要素でマークアップして一覧として出力します。

<div id="fetch-posts"></div>

以下の JavaScript を作成します。HTML が記述されているページの script 要素に記述することもできますし、ファイルを作成して記述し、HTML が記述されているページで読み込むこともできます。

リンク文字列を検証する関数 sanitizeUrl と HTML からテキストを取得する関数 sanitizeHtml を定義して、リンクと HTML が含まれる可能性のある文字列を念の為サニタイズします。

そしてエンドポイントの URL と出力先の要素を受取って、投稿のタイトルとリンクを取得してその一覧を出力する関数 renderPostLinks を定義し、DOMContentLoaded イベントで呼び出します。

エンドポイントへのリクエストの送信は、JavaScript(Fetch API)の fetch() メソッドを使用します。

エンドポイントに追加するパラメータは見やすいようにオブジェクト形式で定義し、URLSearchParams とその toString() メソッドを使ってクエリ文字列に変換します。

パラメータには、_fields=title,link を指定することで必要なデータのみ取得し、API のレスポンスサイズを削減してパフォーマンスを最適化しています(41行目)。1ページあたりの取得件数(per_page)や並び順(order)は環境に合わせて適宜変更します。

(() => {
  // URL サニタイズ用の関数
  const sanitizeUrl = (url) => {
    try {
      const sanitizedUrl = new URL(url);
      if ( sanitizedUrl.protocol === "http:" || sanitizedUrl.protocol === "https:") {
        // protocol が 'http:' または 'https:' の場合のみ許可
        return sanitizedUrl.href;
      }
    } catch (e) {
      console.warn("Invalid URL:", url);
    }
    return ""; // 無効な URL は空文字を返す
  };

  // HTML からテキストを取得する関数
  const sanitizeHtml = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  // 指定されたエンドポイントの URL から投稿のタイトルとリンクを取得して出力する関数
  const renderPostLinks = (url, target) => {
    const sanitizedUrl = sanitizeUrl(url);
    // url が無効な場合は終了
    if (!sanitizedUrl) {
      console.error("無効な API URL");
      return;
    }

    // 投稿リストを出力する ul 要素を作成し、出力先に追加
    const list = document.createElement("ul");
    list.setAttribute("id", "post-list");
    list.style.setProperty("list-style-type", "none");
    target.appendChild(list);

    // URLSearchParams でオブジェクト形式のパラメータをクエリ文字列に変換
    const params = new URLSearchParams({
      _fields: "title,link", // 必要なデータ(タイトルとリンク)のみ取得してレスポンスサイズを削減
      per_page: 20, // 20件表示(デフォルト 10)
      order: "asc", // 古い記事から表示(デフォルト 'desc')
    }).toString();

    // fetch() メソッドで GET リクエストを送信
    fetch(`${sanitizedUrl}?${params}`)
      .then((response) => {
        if (!response.ok)
          throw new Error(
            `リクエスト失敗: ${response.status} ${response.statusText}`
          );
        return response.json();
      })
      .then((posts) => {
        list.innerHTML = posts
          .map((post) => {
            const sanitizedLink = sanitizeUrl(post.link);
            const sanitizedTitle = sanitizeHtml(post.title?.rendered);
            // sanitizedLink が 有効な URL の場合のみ、<li> 要素を作成
            return sanitizedLink
              ? `<li><a href="${sanitizedLink}">${sanitizedTitle}</a></li>`
              : "";
          })
          .join(""); // map()で作成された配列をjoin('')で1つの文字列に結合(空文字列を指定することで、カンマ,が入らないように)
      })
      .catch((error) => {
        console.warn("投稿の取得エラー:", error);
        if (list)
          list.innerHTML = `<li>投稿の取得に失敗しました:${error.message}</li>`;
      });
  };

  document.addEventListener("DOMContentLoaded", () => {
    // エンドポイントの URL (http://example.com の部分は適宜変更)
    const url = "http://example.com/wp-json/wp/v2/posts";
    // 出力先の要素
    const target = document.getElementById("fetch-posts");
    // 出力先の要素が存在すれば
    if (target) {
      // エンドポイントの URL と出力先の要素を指定して、投稿を取得して表示
      renderPostLinks(url, target);
    }
  });
})();

REST API から取得した投稿のデータを HTML のリストに変換する処理(55-66行目)では、map() と join() を使って HTML の <li> 要素を作成し、それをまとめて list.innerHTML にセットしています。

その際、sanitizeUrl() で URL をサニタイズし、sanitizedLink が有効な URL の場合のみ、<li> 要素を作成します(sanitizedLink が空文字の場合はその投稿はリストに追加されません)。

.map() が生成する新しい配列は、["<li>...</li>", "<li>...</li>", ...] のようになるので、.join() を使って配列を1つの文字列に結合します。.join() には空の文字列を指定することでカンマ(,)が入らないようにします。

また、上記の処理(55-66行目)は、HTML を文字列として一気に作成しているので効率的(高速)ですが、.innerHTML を直接代入する方法は XSS のリスクもあります(この例の場合はリンクもタイトル文字列もサニタイズしているので問題ありません)。

XSS のリスクを避ける方法として、以下のように DocumentFragment を使用することもできます。空の DocumentFragment は document.createDocumentFragment() で作成することができます。

.then((posts) => {
  // documentFragment を使用して仮想のコンテナ(フラグメント)を作成
  const fragment = document.createDocumentFragment();
  posts.forEach((post) => {
    const sanitizedLink = sanitizeUrl(post.link);
    const sanitizedTitle = sanitizeHtml(post.title?.rendered);
    if (sanitizedLink) {
      const li = document.createElement("li"); // li 要素を生成
      const a = document.createElement("a"); // a 要素を生成
      a.href = sanitizedLink;
      a.textContent = sanitizedTitle;
      li.appendChild(a);  // li 要素にリンクを追加
      fragment.appendChild(li); // フラグメントに li 要素を追加(この時点では、まだ DOM には追加されない)
    }
  });
  // 最後にまとめてフラグメントを DOM に追加(1回の DOM 更新で済むため効率的)
  list.appendChild(fragment);
})

上記のコードは文字列として処理するよりは少し処理速度は落ちますが、documentFragment を使用することで、以下のように DocumentFragment を使わない処理より効率的です。

以下の場合、list.appendChild(li) を直接実行しているので、ループのたびに DOM の再計算・再描画 が発生してしまい、効率的ではありません。

// 効率的でない方法
.then((posts) => {
  posts.forEach((post) => {
    const sanitizedLink = sanitizeUrl(post.link);
    const sanitizedTitle = sanitizeHtml(post.title?.rendered);
    if (sanitizedLink) {
      const li = document.createElement("li");
      const a = document.createElement("a");
      a.href = sanitizedLink;
      a.textContent = sanitizedTitle;
      li.appendChild(a);
      list.appendChild(li); // 毎回 DOM を更新してしまう
    }
  });
})

投稿タイプを指定

HTML 側で以下のようにカスタムデータ属性(data-post-type 属性)を使って投稿タイプの rest_base を指定して、その投稿タイプの投稿を取得して表示する例です。

data-post-type 属性が指定されていない場合やその値が空の場合は、投稿(posts)を取得して表示します。data-post-type 属性に間違った値(例えば page や post)を指定するとエラーになります。

<div id="fetch-posts" data-post-type="pages"></div>

data-post-type には以下を指定できます。

  • 投稿:posts
  • 固定ページ: pages
  • カスタム投稿タイプ:カスタム投稿タイプのスラッグまたは登録時に rest_base に指定した値

JavaScript を以下のように書き換えます。

DOMContentLoaded の中で出力先の要素を取得して、dataset.postType で data-post-type 属性の値を取得し、その値を使って関数 renderPostLinks に指定して実行します。

(() => {
  const sanitizeUrl = (url) => {
    try {
      const sanitizedUrl = new URL(url);
      if ( sanitizedUrl.protocol === "http:" || sanitizedUrl.protocol === "https:") {
        return sanitizedUrl.href;
      }
    } catch (e) {
      console.warn("Invalid URL:", url);
    }
    return "";
  };

  const sanitizeHtml = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  const renderPostLinks = (url, target) => {
    const sanitizedUrl = sanitizeUrl(url);
    if (!sanitizedUrl) {
      console.error("無効な API URL");
      return;
    }

    const list = document.createElement("ul");
    list.setAttribute("id", "post-list");
    list.style.setProperty("list-style-type", "none");
    target.appendChild(list);

    const params = new URLSearchParams({
      _fields: "title,link",
      per_page: 20,
      order: "asc",
    }).toString();

    fetch(`${sanitizedUrl}?${params}`)
      .then((response) => {
        if (!response.ok)
          throw new Error(
            `リクエスト失敗: ${response.status} ${response.statusText}`
          );
        return response.json();
      })
      .then((posts) => {
        list.innerHTML = posts
          .map((post) => {
            const sanitizedLink = sanitizeUrl(post.link);
            const sanitizedTitle = sanitizeHtml(post.title?.rendered);
            return sanitizedLink
              ? `<li><a href="${sanitizedLink}">${sanitizedTitle}</a></li>`
              : "";
          })
          .join("");
      })
      .catch((error) => {
        console.warn("投稿の取得エラー:", error);
        if (list)
          list.innerHTML = `<li>投稿の取得に失敗しました:${error.message}</li>`;
      });
  };

  document.addEventListener("DOMContentLoaded", () => {
    // URL のプロトコルとホスト名部分(適宜変更)
    const host = "http://example.com";
    // 出力先の要素
    const target = document.getElementById("fetch-posts");
    // 出力先の要素が存在すれば
    if(target){
      // data-post-type 属性を取得(指定されていない場合は posts を適用)
      const postTypeBase = target.dataset.postType || "posts";
      renderPostLinks(`${host}/wp-json/wp/v2/${postTypeBase}`, target);
    }
  });
})();

複数表示に対応

これまでの例は、id 属性が指定された1つの要素を対象にしていましたが、class 属性が指定された複数(1つ以上)の要素を対象にできるようにします。

以下は fetch-posts クラスを指定した要素に data-post-type 属性が指定されていれば、指定された投稿タイプの投稿を出力します。 data-post-type 属性を省略した場合は、投稿(posts)を取得して表示します。

必要に応じて見出し(h タグ)を記述しておくこともできます。

<div class="fetch-posts" data-post-type="pages">
  <h3>Page</h3>
</div>
<div class="fetch-posts">
  <h3>Post</h3>
</div>
<div class="fetch-posts" data-post-type="books">
  <h3>Book</h3>
</div>

JavaScript を以下のように書き換えます。

関数 renderPostLinks() では追加で出力先の要素(target)を受け取るように変更します。また、投稿を出力する ul 要素(list)には id 属性ではなく class 属性を設定します。

そして DOMContentLoaded で出力先の要素を getElementsByClassName() で取得します。

getElementsByClassName() は HTMLCollection を返すため、Array.from() で配列に変換してから forEach() でループします。forEach は targets が空でもエラーにならず、単に forEach の中身が実行されないので、targets.length > 0 のチェックなしでも安全です。

(() => {
  const sanitizeUrl = (url) => {
    try {
      const sanitizedUrl = new URL(url);
      if ( sanitizedUrl.protocol === "http:" || sanitizedUrl.protocol === "https:") {
        return sanitizedUrl.href;
      }
    } catch (e) {
      console.warn("Invalid URL:", url);
    }
    return "";
  };

  const sanitizeHtml = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  // 引数に追加で出力先の要素を受け取る
  const renderPostLinks = (url, target) => {
    const sanitizedUrl = sanitizeUrl(url);
    if (!sanitizedUrl) {
      console.error("無効な API URL");
      return;
    }

    const list = document.createElement("ul");
    // id ではなく class を設定
    list.classList.add("post-list");
    list.style.setProperty("list-style-type", "none");
    target.appendChild(list);

    const params = new URLSearchParams({
      _fields: "title,link",
      per_page: 5,
      order: "desc",
    }).toString();

    fetch(`${sanitizedUrl}?${params}`)
      .then((response) => {
        if (!response.ok)
          throw new Error(
            `リクエスト失敗: ${response.status} ${response.statusText}`
          );
        return response.json();
      })
      .then((posts) => {
        list.innerHTML = posts
          .map((post) => {
            const sanitizedLink = sanitizeUrl(post.link);
            const sanitizedTitle = sanitizeHtml(post.title?.rendered);
            return sanitizedLink
              ? `<li><a href="${sanitizedLink}">${sanitizedTitle}</a></li>`
              : "";
          })
          .join("");
      })
      .catch((error) => {
        console.warn("投稿の取得エラー:", error);
        if (list)
          list.innerHTML = `<li>投稿の取得に失敗しました:${error.message}</li>`;
      });
  };

  document.addEventListener("DOMContentLoaded", () => {
    // URL(適宜変更)
    const host = "http://example.com";
    // 出力先の要素(fetch-posts クラスを持つ全ての要素)
    const targets = Array.from(document.getElementsByClassName("fetch-posts"));
    // 出力先の要素が1つ以上存在すればそこに投稿を表示
    targets.forEach((target) => {
      // data-post-type 属性が指定されていない場合は posts を適用
      const postType = target.dataset.postType || "posts";
      renderPostLinks(`${host}/wp-json/wp/v2/${postType}`, target);
    });
  });
})();

追加読み込みボタン

per_page(1ページあたりの取得件数)に指定した件数以上の投稿がある場合は追加で投稿を取得できるように、追加読み込み用の Load More ボタンを配置する例です。

総ページ数は、レスポンスヘッダー X-WP-TotalPages から取得し、現在のページ番号と比較して Load More ボタンを表示します(Load More ボタンを追加)。

JavaScript を以下のように書き換えます。

関数 renderPostLinks() では引数に追加で現在のページ番号(page)を受け取るようにします。

renderPostLinks() はページ読み込み時と Load More ボタンをクリックする度に呼び出されるので、投稿を出力する ul 要素と Load More ボタンの button 要素はページ読み込み時にのみ生成するようにします。

また、Load More ボタンへのクリックイベントのリスナーの登録も重複登録されないように、ボタン要素のカスタムデータ属性を使って登録済みかどうかを判定して登録します。

現在のページ(currentPage)は、カスタムデータ属を使って各ターゲット(renderPostLinks の第2引数の target)ごとに保存します。

(() => {
  const sanitizeUrl = (url) => {
    try {
      const sanitizedUrl = new URL(url);
      if ( sanitizedUrl.protocol === "http:" || sanitizedUrl.protocol === "https:" ) {
        return sanitizedUrl.href;
      }
    } catch (e) {
      console.warn("Invalid URL:", url);
    }
    return "";
  };

  const sanitizeHtml = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  // 現在のページ番号を追加で受け取る
  const renderPostLinks = (url, target, page = 1) => {
    const sanitizedUrl = sanitizeUrl(url);
    if (!sanitizedUrl) {
      console.error("無効な API URL");
      return;
    }

    // ul 要素を作成して追加 (すでに存在すればその要素を使用)
    const list = target.querySelector("ul") || document.createElement("ul");
    list.classList.add("post-list");
    list.style.setProperty("list-style-type", "none");
    if (!target.contains(list)) target.appendChild(list);

    // Load More ボタンを作成して追加 (すでに存在すればその要素を使用)
    const loadMoreButton = target.querySelector(".fetch-load-more") || document.createElement("button");
    if (!target.contains(loadMoreButton)) {
      loadMoreButton.classList.add("fetch-load-more");
      loadMoreButton.textContent = "Load More";
      loadMoreButton.style.display = "none"; // 初期状態では非表示に
      target.appendChild(loadMoreButton);
    }

    // クエリパラメータに page(ページ番号)を追加
    const params = new URLSearchParams({
      _fields: "title,link",
      page: page, // ページ番号
      per_page: 5,
      order: "desc",
    }).toString();

    fetch(`${sanitizedUrl}?${params}`)
      .then((response) => {
        if (!response.ok) throw new Error( `リクエスト失敗: ${response.status} ${response.statusText}`);
        //  totalPages(総ページ数)をレスポンスヘッダーから取得
        const totalPagesHeader = response.headers.get("X-WP-TotalPages");
        const totalPages = totalPagesHeader ? parseInt(totalPagesHeader, 10) : 1;
        target.dataset.totalPages = totalPages; // 各ターゲットごとに総ページ数を保存
        return response.json();
      })
      .then((posts) => {
        // 初回はリストをクリア
        if (page === 1) list.innerHTML = "";
        const html = posts.map((post) => {
          const sanitizedLink = sanitizeUrl(post.link);
          const sanitizedTitle = sanitizeHtml(post.title.rendered);
          return `<li><a href="${sanitizedLink}">${sanitizedTitle}</a></li>`;
        }).join("");
        list.insertAdjacentHTML("beforeend", html);
        // 現在のページが最後のページ(総ページ数)未満なら表示
        if (page >= target.dataset.totalPages) {
          loadMoreButton.style.display = "none";
        } else {
          loadMoreButton.style.display = "inline-block";
        }
      })
      .catch((error) => {
        console.warn("投稿の取得エラー:", error);
      });

    // 既にイベントが登録済みかをチェック(イベントリスナーの重複登録を防止)
    if (!loadMoreButton.dataset.listenerAdded) {
      loadMoreButton.addEventListener("click", () => {
        let currentPage = parseInt(target.dataset.currentPage || "1", 10);
        currentPage++;
        // ページ番号(現在のページ)を更新し、各ターゲットごとに保存
        target.dataset.currentPage = currentPage;
        renderPostLinks(url, target, currentPage);
      });
      // 登録後に dataset.listenerAdded = "true" を設定
      loadMoreButton.dataset.listenerAdded = "true"; // これで二重登録を防ぐ
    }
  };

  document.addEventListener("DOMContentLoaded", () => {
    // URL(適宜変更)
    const host = "http://example.com";
    const targets = document.getElementsByClassName("fetch-posts");
    Array.from(targets).forEach((target) => {
      const postType = target.dataset.postType || "posts";
      target.dataset.currentPage = "1"; // 各ターゲットの初期ページ設定
      renderPostLinks(`${host}/wp-json/wp/v2/${postType}`, target, 1);
    });
  });
})();

例えば、この例の場合、以下のように表示する投稿が5件より多い場合は Load More ボタンが表示され、クリックすると追加で次の5件が表示されます(別途 CSS を適用しています)。

カテゴリーを指定

投稿タイプではなく、カテゴリーを指定する例です。

HTML 側で以下のようにカスタムデータ属性(data-category-ids)を使って、カテゴリーの ID(複数の id 指定可能)を指定すると、そのカテゴリーの投稿を取得して表示します。

data-category-ids 属性を指定しない場合やその値が空または無効な場合は、カテゴリーを指定しないのと同様、全てのカテゴリーを対象にします。見出し(h タグ)はオプションです。

<div class="fetch-posts">
  <h3>All Categories</h3>
</div>
<div class="fetch-posts" data-category-ids="11,9">
  <h3>Category: Foo & Bar</h3>
</div>
<div class="fetch-posts" data-category-ids="1">
  <h3>Category:未分類</h3>
</div>

JavaScript を以下のように書き換えます。

data-category-ids 属性に指定されたカテゴリーIDを取得してその配列を返す関数 getCategoryIds を定義します(22-32行目)。この関数では、dataset.categoryIds で取得した値が数値であることを検証(数値のみをフィルタ)し、値が空であったり、無効な場合は undefined を返します。

投稿を取得して出力する関数 renderPostLinks() では、追加でカテゴリーID を受取り、カテゴリーID が有効な値であれば、クエリパラメータに追加します(62-63行目)。

renderPostLinks() の呼び出しでは、第3引数に getCategoryIds() を指定して getCategoryIds 関数で処理した値を渡します(101,115行目)。

(() => {
  const sanitizeUrl = (url) => {
    try {
      const sanitizedUrl = new URL(url);
      if (sanitizedUrl.protocol === "http:" || sanitizedUrl.protocol === "https:") {
        return sanitizedUrl.href;
      }
    } catch (e) {
      console.warn("Invalid URL:", url);
    }
    return "";
  };

  const sanitizeHtml = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  // カテゴリーIDを取得してその配列を返す関数
  const getCategoryIds = (target) => {
    // data-category-ids 属性の値を取得
    const categoryData = target.dataset.categoryIds;
    if (!categoryData) return undefined;
    const ids = categoryData
      .split(",")                            // カンマで分割
      .map(id => id.trim())                  // 空白を削除
      .filter(id => /^\d+$/.test(id))        // 数値のみをフィルタ
      .map(id => parseInt(id, 10));          // 数値に変換
    return ids.length > 0 ? ids : undefined; // 有効な ID があるなら返す、なければ undefined
  };

  // 追加で categoryIds を引数に受け取る
  const renderPostLinks = (url, target, page = 1, categoryIds = undefined) => {
    const sanitizedUrl = sanitizeUrl(url);
    if (!sanitizedUrl) {
      console.error("無効な API URL");
      return;
    }

    const list = target.querySelector("ul") || document.createElement("ul");
    list.classList.add("post-list");
    list.style.setProperty("list-style-type", "none");
    if (!target.contains(list)) target.appendChild(list);

    const loadMoreButton = target.querySelector("button") || document.createElement("button");
    if (!target.contains(loadMoreButton)) {
      loadMoreButton.textContent = "Load More";
      loadMoreButton.style.display = "none";
      target.appendChild(loadMoreButton);
    }

    // カテゴリー以外のクエリパラメータ
    const query = {
      _fields: "title,link",
      page: page,
      per_page: 5,
      order: "desc",
    };
    // categoryIds が undefined でなければカテゴリーをクエリパラメータに追加
    if (categoryIds !== undefined) query.categories = categoryIds;
    const params = new URLSearchParams(query).toString();

    fetch(`${sanitizedUrl}?${params}`)
      .then((response) => {
        if (!response.ok) throw new Error(`リクエスト失敗: ${response.status} ${response.statusText}`);

        const totalPagesHeader = response.headers.get("X-WP-TotalPages");
        const totalPages = totalPagesHeader ? parseInt(totalPagesHeader, 10) : 1;
        target.dataset.totalPages = totalPages;
        return response.json();
      })
      .then((posts) => {
        if (page === 1) list.innerHTML = "";
        const html = posts.map((post) => {
          const sanitizedLink = sanitizeUrl(post.link);
          const sanitizedTitle = sanitizeHtml(post.title.rendered);
          return `<li><a href="${sanitizedLink}">${sanitizedTitle}</a></li>`;
        }).join("");

        list.insertAdjacentHTML("beforeend", html);

        if (page >= target.dataset.totalPages) {
          loadMoreButton.style.display = "none";
        } else {
          loadMoreButton.style.display = "inline-block";
        }
      })
      .catch((error) => {
        console.warn("投稿の取得エラー:", error);
      });

    // イベントリスナーの登録(重複登録を防止)
    if (!loadMoreButton.dataset.listenerAdded) {
      loadMoreButton.addEventListener("click", () => {
        let currentPage = parseInt(target.dataset.currentPage || "1", 10);
        currentPage++;
        target.dataset.currentPage = currentPage;
        // 第3引数に別途定義した getCategoryIds() 関数でカテゴリーを指定
        renderPostLinks(url, target, currentPage, getCategoryIds(target));
      });
      loadMoreButton.dataset.listenerAdded = "true";
    }
  };

  document.addEventListener("DOMContentLoaded", () => {
    // エンドポイントの URL (http://example.com の部分は適宜変更)
    const url = "http://example.com/wp-json/wp/v2/posts";
    const targets = document.getElementsByClassName("fetch-posts");

    Array.from(targets).forEach((target) => {
      target.dataset.currentPage = "1"; // 初期ページ設定
      // 第3引数に別途定義した getCategoryIds() 関数でカテゴリーを指定
      renderPostLinks(url, target, 1, getCategoryIds(target));
    });
  });
})();

カテゴリーを選択(フィルタリング)

カテゴリーのエンドポイント(Categories)を使ってカテゴリー一覧を取得し、セレクトボックスでユーザーがカテゴリーを選択できるようにする例です。初期状態ではカテゴリー指定なしの一覧を表示します。

class が fetch-posts の div 要素があれば、そこへカテゴリーを選択するプルダウンと投稿一覧を表示し、カテゴリーを選択することができます(1ページに複数配置可能)。

<div class="fetch-posts"></div>

以下が JavaScript です。

カテゴリーのエンドポイントからカテゴリー一覧を取得して select 要素に option 要素を追加する関数 loadCategories を定義します(22-45行目)。

この関数では引数にカテゴリーのエンドポイントの URL とカテゴリー項目を表示する select 要素を受け取ります。この関数の fetch() に指定する URL には、_fields=id,name を指定してカテゴリーの id と name のみを取得し、不要なデータを取得しないようにします。そして取得したカテゴリーのデータ(id と name)を使って option 要素を作成し、select 要素の HTML に設定します。最初の option 要素の value は空文字列を設定します。

関数 renderPostLinks では、初回セレクトボックスのラッパーの div 要素と select 要素を生成する際に、loadCategories を呼び出して select 要素に option 要素を追加します(78行目)。

そして、セレクトボックスで選択されたカテゴリーの ID の値を取得してクエリパラメータに追加して投稿を取得します。初期状態または最初のオプションの All Categories が選択された場合は、セレクトボックスで選択された値は空文字なので、クエリパラメータにカテゴリーは追加されません(97-107行目)。

また、カテゴリーを変更して新しいカテゴリーで「Load More」をクリックした場合に、前のカテゴリーの続きとしてデータを取得しないように、select 要素の change イベントでカテゴリー変更時に currentPage をリセットします(150行目)。

(() => {
  const sanitizeUrl = (url) => {
    try {
      const sanitizedUrl = new URL(url);
      if ( sanitizedUrl.protocol === "http:" || sanitizedUrl.protocol === "https:" ) {
        return sanitizedUrl.href;
      }
    } catch (e) {
      console.warn("Invalid URL:", url);
    }
    return "";
  };

  const sanitizeHtml = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  // カテゴリーを取得して select 要素に option 要素を追加する関数
  const loadCategories = (catUrl, categorySelect) => {
    const sanitizedUrl = sanitizeUrl(catUrl);
    if (!sanitizedUrl) {
      console.error("無効な API URL");
      return;
    }
    // _fields パラメータに id と name を指定(必要なデータだけ取得)
    fetch(`${sanitizedUrl}?_fields=id,name`)
      .then((response) => {
        if (!response.ok)
          throw new Error(
            `リクエスト失敗: ${response.status} ${response.statusText}`
          );
        return response.json();
      })
      .then((categories) => {
        // 取得したカテゴリーのタームの id と name プロパティを使って select 要素に option 要素を追加
        categorySelect.innerHTML = '<option value="">All Categories</option>' +
        categories.map(cat => `<option value="${cat.id}">${sanitizeHtml(cat.name)}</option>`).join('');
      })
      .catch((error) => {
        console.error("Failed to fetch categories:", error);
      });
  }

  // 現在のページ番号を追加で受け取る
  const renderPostLinks = (url, target, page = 1) => {
    const sanitizedUrl = sanitizeUrl(url);
    if (!sanitizedUrl) {
      console.error("無効な API URL");
      return;
    }

    // セレクトボックスのラッパーの div 要素
    const categorySelectWrapper = target.querySelector(".category-select-wrapper");
    // セレクトボックスの select 要素
    let categorySelect = target.querySelector(".select-category");
    // セレクトボックスのラッパーがまだ存在しなければ div 要素と select 要素、label 要素を生成
    if (!categorySelectWrapper) {
      const wrapper = document.createElement("div");
      wrapper.classList.add('category-select-wrapper');
      // select 要素を生成
      categorySelect = document.createElement("select");
      categorySelect.classList.add("select-category");
      categorySelect.name = "select-category";
      // label 要素を生成
      const categorySelectLabel = document.createElement("label");
      categorySelectLabel.htmlFor = "select-category";  // for 属性
      categorySelectLabel.textContent = "Category: ";
      // ラッパーの div 要素にセレクトボックスとラベルを追加
      wrapper.appendChild(categorySelectLabel);
      wrapper.appendChild(categorySelect);
      // ターゲットにラッパーを追加
      target.appendChild(wrapper);

      // セレクトボックスを作成したタイミングで loadCategories を呼び出す(http://example.com の部分は適宜変更)
      loadCategories("http://example.com/wp-json/wp/v2/categories", categorySelect);
    }

    // ul 要素を作成して追加 (すでに存在すればその要素を使用)
    const list = target.querySelector("ul") || document.createElement("ul");
    list.classList.add("post-list");
    list.style.setProperty("list-style-type", "none");
    if (!target.contains(list)) target.appendChild(list);

    // Load More ボタンを作成して追加 (すでに存在すればその要素を使用)
    const loadMoreButton = target.querySelector(".fetch-load-more") || document.createElement("button");
    if (!target.contains(loadMoreButton)) {
      loadMoreButton.classList.add("fetch-load-more");
      loadMoreButton.textContent = "Load More";
      loadMoreButton.style.display = "none";
      target.appendChild(loadMoreButton);
    }

    // セレクトボックスで選択されたカテゴリーの値
    const category = categorySelect.value;
    // カテゴリー以外のクエリパラメータ
    const query = {
      _fields: "title,link",
      page: page,
      per_page: 5,
      order: "desc",
    };
    // カテゴリーが選択されていればクエリパラメータに追加
    if (category) query.categories = category;
    const params = new URLSearchParams(query).toString();

    fetch(`${sanitizedUrl}?${params}`)
      .then((response) => {
        if (!response.ok) throw new Error( `リクエスト失敗: ${response.status} ${response.statusText}`);
        const totalPagesHeader = response.headers.get("X-WP-TotalPages");
        const totalPages = totalPagesHeader ? parseInt(totalPagesHeader, 10) : 1;
        target.dataset.totalPages = totalPages;
        return response.json();
      })
      .then((posts) => {
        // 初回はリストをクリア
        if (page === 1) list.innerHTML = "";
        const html = posts.map((post) => {
          const sanitizedLink = sanitizeUrl(post.link);
          const sanitizedTitle = sanitizeHtml(post.title.rendered);
          return `<li><a href="${sanitizedLink}">${sanitizedTitle}</a></li>`;
        }).join("");
        list.insertAdjacentHTML("beforeend", html);
        // 現在のページが最後のページ(総ページ数)未満なら表示
        if (page >= target.dataset.totalPages) {
          loadMoreButton.style.display = "none";
        } else {
          loadMoreButton.style.display = "inline-block";
        }
      })
      .catch((error) => {
        console.warn("投稿の取得エラー:", error);
      });

    // 既に click イベントが登録済みかをチェック(イベントリスナーの重複登録を防止)
    if (!loadMoreButton.dataset.listenerAdded) {
      loadMoreButton.addEventListener("click", () => {
        let currentPage = parseInt(target.dataset.currentPage || "1", 10);
        currentPage++;
        target.dataset.currentPage = currentPage;
        renderPostLinks(url, target, currentPage);
      });
      loadMoreButton.dataset.listenerAdded = "true";
    }
    // 既に change イベントが登録済みかをチェック(イベントリスナーの重複登録を防止)
    if (!categorySelect.dataset.listenerAdded) {
      categorySelect.addEventListener("change", () => {
        target.dataset.currentPage = "1"; // カテゴリー変更時にページ番号をリセット
        renderPostLinks(url, target, 1);
      });
      categorySelect.dataset.listenerAdded = "true";
    }
  };

  document.addEventListener("DOMContentLoaded", () => {
    // URL(http://example.com の部分は適宜変更)
    const url = "http://example.com/wp-json/wp/v2/posts";
    const targets = document.getElementsByClassName("fetch-posts");
    Array.from(targets).forEach((target) => {
      target.dataset.currentPage = "1";
      renderPostLinks(url, target, 1);
    });
  });
})();

例えば、以下のようなカテゴリー選択のセレクトボックスが表示されます(別途 CSS を適用しています)。

独自のエンドポイントを作成

WordPress の REST API に独自のエンドポイントを作成するには、register_rest_route() 関数を使用します。この関数を使うことで、独自の API エンドポイントを追加し、GET や POST などのリクエストに対応できます。Adding Custom Endpoints

register_rest_route() の書式と引数

register_rest_route( string $namespace, string $route, array $args )
引数
引数 説明
$namespace エンドポイントの名前空間(例: myplugin/v1)
$route ルートの URL パス(例: /data → /wp-json/myplugin/v1/data)
$args エンドポイントの詳細設定(HTTP メソッド、コールバック関数など。以下参照)
$args の主なプロパティ
プロパティ 説明
methods 許可する HTTP メソッド('GET', 'POST', 'PUT', 'DELETE' など)
callback リクエストを処理するコールバック関数
permission_callback リクエストの権限をチェックするコールバック
args エンドポイントで受け取るパラメータの設定

実装例

以下のコードは myplugin/v1/data というエンドポイントを作成し、JSON データを返します。

// register_rest_route() を使って API ルートを作成
function myplugin_register_rest_routes() {
  register_rest_route('myplugin/v1', '/data', [
    'methods'  => 'GET',
    'callback' => 'myplugin_get_data',
    'permission_callback' => '__return_true', // 認証なしでアクセス可能にする
  ]);
}
// rest_api_init アクションフックを使用してエンドポイントを登録
add_action('rest_api_init', 'myplugin_register_rest_routes');

// リクエストを処理するコールバック関数
function myplugin_get_data() {
  $data = array('message' => 'Hello, REST API!');
  return rest_ensure_response($data);
  // または return new WP_REST_Response($data, 200); // 200 は HTTP ステータスコード(省略可能)
}

上記のコードを functions.php に追加して、https://xxxxx/wp-json/myplugin/v1/data に GET リクエストを送ると、{ "message": "Hello, REST API!" } が返されます。

コールバック関数の戻り値

コールバックが呼び出されると、戻り値は JSON に変換され、クライアントに返されます。

WordPress の REST API では、カスタムエンドポイントのコールバック関数が配列やオブジェクトを返す場合、自動的に WP_REST_Response にラップして処理してくれます。

前述の実装例の場合、単に return $data でも機能しますが、rest_ensure_response() を使えば、関数の戻り値を適切な WP_REST_Response オブジェクトに変換してくれるので、$data は 自動的に WP_REST_Response に変換され、REST API のレスポンスとして適切に処理されます。

但し、レスポンスのステータスコードを手動で指定したい場合やカスタムヘッダー(キャッシュ制御など)を追加したい場合は new WP_REST_Response() を使います。

$response = new WP_REST_Response($posts, 201); // 201 Created
$response->header('Cache-Control', 'no-cache');
return $response;

permission_callback

register_rest_route() を使って REST API エンドポイントを登録する際、permission_callback を指定しない(省略した)場合、デフォルトで「公開 API(認証不要)」として扱われます。

但し、デバッグモードで、permission_callback を指定しない場合、投稿の管理画面ソースコードに「Notice:関数 register_rest_route が誤って呼び出されました。(中略)public REST API ルートに対してはパーミッションコールバックとして __return_true を使用してください。」という注意が出力されます。

register_rest_route() Changelog Version 5.5.0 には「必須の permission_callback 引数が設定されていない場合に _doing_it_wrong() 通知を追加」とあります。

そのため、認証なしでアクセス可能にする(公開 API とする)場合は、明示的に permission_callback に __return_true を指定したほうが良さそうです。

また、permission_callback を省略すると、未ログイン含む全ユーザーにデータを公開することになるので、認証が必要な API の場合は permission_callback でリクエストの権限をチェックする必要があります。例えば、認証が必要なエンドポイントなら以下のように指定することができます。

'permission_callback' => function() {
  return current_user_can('edit_posts'); // 投稿編集権限があるユーザーのみ
}

クエリパラメータを受け取る

以下は投稿の取得件数(per_page)をサポートするカスタム REST API エンドポイントを作成する例です。先の例と同じエンドポイント(custom/v1/posts)を使用するので、functions.php(またはプラグインファイル)に記述した先のコードは削除して以下に書き換えます。

REST API のコールバック関数 custom_get_posts_with_pagination() では、引数として $request を受け取ります。WP_REST_Request は WordPress REST API のリクエストデータを扱うクラスです。

$request オブジェクトを使うことで、$request->get_param('key') のようにクエリパラメータの値を取得したり、HTTP ヘッダーを取得したりすることができます。

以下では、$request->get_param('per_page') でリクエストから per_page パラメータを取得し、isset() で存在チェックし、intval() で整数に変換し、指定がない場合は10をデフォルトにしています。

そしてカスタム REST エンドポイントの登録の args(エンドポイントで受け取るパラメータの設定)の validate_callback で正の整数のみ許可するようにしています。

また、以下ではアイキャッチ画像の各サイズの URL と抜粋を取得して公開しています。

// カスタム REST API のコールバック関数:指定された件数の投稿を取得し、JSON 形式で返す
function custom_get_posts_with_pagination(WP_REST_Request $request) {
  // リクエストから per_page パラメータを取得(指定がない場合は10をデフォルトに)
  $per_page = $request->get_param('per_page');
  $per_page = isset($per_page) ? intval($per_page) : 10;

  // 投稿取得のためのクエリ引数を設定
  $args = array(
    'post_type'      => 'post',        // 通常の投稿タイプ
    'posts_per_page' => $per_page,     // 取得件数(リクエストに応じて変動)
    'no_found_rows'  => true,          // ページネーション情報を無視して高速化
    'ignore_sticky_posts'  => true,    // スティッキーポストを無視(必要に応じて)
  );

  $query = new WP_Query($args); // 投稿クエリを実行
  $posts_data = array();        // レスポンス用の配列

  // 投稿が存在する場合はループでデータを整形
  if ($query->have_posts()) {
    while ($query->have_posts()) {
      $query->the_post();
      $posts_data[] = array(
        // タイトルから HTML タグを除去し、エスケープ
        'title' => esc_html(wp_strip_all_tags(get_the_title())),
        // 抜粋(excerpt)を取得し、HTML タグを除去してエスケープ
        'excerpt' => esc_html(wp_strip_all_tags(get_the_excerpt())),
        // 投稿のパーマリンクを取得・エスケープ
        'link'  => esc_url(get_permalink()),
        // アイキャッチ画像の各サイズの URL を取得・エスケープ
        'featured_image_full'      => esc_url(get_the_post_thumbnail_url(get_the_ID(), 'full')),
        'featured_image_large'     => esc_url(get_the_post_thumbnail_url(get_the_ID(), 'large')),
        'featured_image_medium'    => esc_url(get_the_post_thumbnail_url(get_the_ID(), 'medium')),
        'featured_image_thumbnail' => esc_url(get_the_post_thumbnail_url(get_the_ID(), 'thumbnail')),
      );
    }
    wp_reset_postdata(); // グローバルな投稿データをリセット
  }

  // REST API レスポンスとして返却(自動で JSON 形式に変換される)
  return rest_ensure_response($posts_data);
}

// カスタム REST エンドポイントの登録
function register_custom_posts_endpoint() {
  register_rest_route('custom/v1', '/posts/', array(
    'methods'  => WP_REST_Server::READABLE, // GET / HEAD リクエストを許可
    'callback' => 'custom_get_posts_with_pagination', // コールバック関数を指定
    'permission_callback' => '__return_true', // 認証なしでアクセス可能にする
    'args'     => array(
      // per_page パラメータのバリデーションと説明
      'per_page' => array(
        'description' => '取得する投稿数(最大20件)',
        'type'        => 'integer',
        'default'     => 10,
        'minimum'     => 1,
        'maximum'     => 20,
        // バリデーション:正の整数のみ許可
        'validate_callback' => function ($param) {
          return is_numeric($param) && intval($param) > 0;
        }
      ),
    ),
  ));
}

// REST API 初期化時にエンドポイントを登録
add_action('rest_api_init', 'register_custom_posts_endpoint');

上記の register_rest_route() の methods には GET ではなく WP_REST_Server::READABLE を指定しています。GET も WP_REST_Server::READABLE もどちらも GET リクエストを許可 しますが、WP_REST_Server::READABLE を使うと GET および HEAD を許可(サポート)します。この例の場合、GET を指定しても同じです。

JavaScript

JavaScript を以下のように書き換えます。

以下ではアイキャッチ画像は large サイズを取得して表示するように _fields に featured_image_large を指定しています。この例では抜粋は使用しないので指定していません。

取得件数を指定できるので、per_page: 5 を指定して投稿のデータを5件取得するようにしています。

(() => {
  // HTML からテキストを取得する関数
  const sanitizeHtml = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  // 指定されたエンドポイントの URL から投稿のアイキャッチ画像とタイトル、リンクを取得して出力する関数
  const renderPostLinks = (url, target) => {
    if (!url) {
      console.error("無効な API URL");
      return;
    }

    const list = document.createElement("ul");
    list.setAttribute("id", "post-list");
    list.style.setProperty("list-style-type", "none");
    target.appendChild(list);

    const params = new URLSearchParams({
      // 画像 URL は featured_image_large を指定。
      _fields: "title,link,featured_image_large",
      // 取得件数を指定
      per_page: 5,
    }).toString();

    fetch(`${url}?${params}`)
      .then((response) => {
        if (!response.ok)
          throw new Error(
            `リクエスト失敗: ${response.status} ${response.statusText}`
          );
        return response.json();
      })
      .then((posts) => {
        list.innerHTML = posts
          .map((post) => {
            const sanitizedLink = post.link;
            const sanitizedTitle = sanitizeHtml(post.title);
            // アイキャッチ画像の URL を post.featured_image_large で取得
            const imageUrl = post.featured_image_large || undefined;
            return sanitizedLink
              ? `<li style="margin-bottom:20px">
                  <a href="${sanitizedLink}">
                  ${imageUrl ? `<div><img src="${imageUrl}" alt="${sanitizedTitle}" style="max-width:200px;"></div>`: ""}
                  <h3>${sanitizedTitle}</h3>
                  </a>
                </li>`
              : "";
          })
          .join("");
      })
      .catch((error) => {
        console.warn("投稿の取得エラー:", error);
        if (list)
          list.innerHTML = `<li>投稿の取得に失敗しました:${error.message}</li>`;
      });
  };

  document.addEventListener("DOMContentLoaded", () => {
    // 独自のエンドポイントを指定 (http://example.com の部分は適宜変更)
    const url = "http://example.com/wp-json/custom/v1/posts";
    const target = document.getElementById("fetch-posts");
    if (target) {
      renderPostLinks(url, target);
    }
  });
})();
パラメータに画像サイズを指定

前述の例のエンドポイントでは、アイキャッチ画像の各サイズの URL を取得して返していますが、リクエストパラメータ image_size を追加し、指定されたサイズのアイキャッチ画像のみを返すようにする例です。

対応するサイズは full, large, medium, thumbnail として、それ以外が指定された場合は medium をデフォルトにします。これにより、余計なデータは返さないためレスポンスがコンパクトになります。

functions.php(またはプラグインファイル)に記述したコードを以下に書き換えます。

$request->get_param('image_size') で image_size パラメータを取得し、full, large, medium, thumbnail のみ許可するように image_size のバリデーションを追加します。

また、register_rest_route() の validate_callback でも同様にバリデーションを行います。

基本的には register_rest_route() の validate_callback でバリデーションを行えば良いのですが、custom_get_posts_with_pagination でも isset() や in_array() で デフォルト値をセットし、安全なデータを使用する補助的なバリデーションを行うことで、より安全で予期しないエラーを防ぐことができます。

// カスタム REST API のコールバック関数:指定された件数の投稿を取得し、JSON 形式で返す
function custom_get_posts_with_pagination(WP_REST_Request $request) {
  // per_page パラメータを取得(指定がない場合は10)
  $per_page = $request->get_param('per_page');
  $per_page = isset($per_page) ? intval($per_page) : 10;

  // image_size パラメータを取得(指定がない場合は 'medium')
  $image_size = $request->get_param('image_size');
  $allowed_sizes = array('full', 'large', 'medium', 'thumbnail');
  if (!in_array($image_size, $allowed_sizes, true)) {
    $image_size = 'medium'; // デフォルトサイズ
  }

  // 投稿取得のためのクエリ引数を設定
  $args = array(
    'post_type'      => 'post',        // 通常の投稿タイプ
    'posts_per_page' => $per_page,     // 取得件数(リクエストに応じて変動)
    'post_status'    => 'publish',     // 公開済み投稿のみ
    'no_found_rows'  => true,          // ページネーション情報を無視して高速化
    'ignore_sticky_posts'  => true,    // スティッキーポストを無視(必要に応じて)
  );

  $query = new WP_Query($args); // 投稿クエリを実行
  $posts_data = array();        // レスポンス用の配列

  // 投稿が存在する場合はループでデータを整形
  if ($query->have_posts()) {
    while ($query->have_posts()) {
      $query->the_post();
      $posts_data[] = array(
        // タイトルから HTML タグを除去し、エスケープ
        'title' => esc_html(wp_strip_all_tags(get_the_title())),
        // 抜粋(excerpt)を取得し、HTML タグを除去してエスケープ
        'excerpt' => esc_html(wp_strip_all_tags(get_the_excerpt())),
        // 投稿のパーマリンクを取得・エスケープ
        'link'  => esc_url(get_permalink()),
        // 指定されたサイズのアイキャッチ画像 URL を取得・エスケープ
        'featured_image' => esc_url(get_the_post_thumbnail_url(get_the_ID(), $image_size)),
      );
    }
    wp_reset_postdata(); // グローバルな投稿データをリセット
  }

  // REST API レスポンスとして返却(自動で JSON 形式に変換される)
  return rest_ensure_response($posts_data);
}

// カスタム REST エンドポイントの登録
function register_custom_posts_endpoint() {
  register_rest_route('custom/v1', '/posts/', array(
    'methods'  => WP_REST_Server::READABLE, // GET / HEAD リクエストを許可
    'callback' => 'custom_get_posts_with_pagination', // コールバック関数を指定
    'permission_callback' => '__return_true', // 認証なしでアクセス可能にする
    'args'     => array(
      // per_page パラメータのバリデーションと説明
      'per_page' => array(
        'description' => '取得する投稿数(最大20件)',
        'type'        => 'integer',
        'default'     => 10,
        'minimum'     => 1,
        'maximum'     => 20,
        // バリデーション:正の整数のみ許可
        'validate_callback' => function ($param) {
          return is_numeric($param) && intval($param) > 0;
        }
      ),
      // image_size パラメータのバリデーションと説明
      'image_size' => array(
        'description' => '取得するアイキャッチ画像のサイズ(full, large, medium, thumbnail)',
        'type'        => 'string',
        'default'     => 'medium',
        'validate_callback' => function ($param) {
          return in_array($param, array('full', 'large', 'medium', 'thumbnail'), true);
        }
      ),
    ),
  ));
}

// REST API 初期化時にエンドポイントを登録
add_action('rest_api_init', 'register_custom_posts_endpoint');

JavaScript

JavaScript を以下のように書き換えます。

以下ではクエリパラメータ image_size に large を指定して large サイズの画像 URL を取得しています。クエリパラメータ image_size を省略するとデフォルトの medium サイズの画像 URL が適用されます。

また、この例では抜粋(excerpt)も取得して表示しています。カスタムエンドポイントが提供する全てのフィールドを取得するので、この場合、_fields は指定しなくても(省略しても)同じです。

(() => {
  // HTML からテキストを取得する関数
  const sanitizeHtml = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  // 指定されたエンドポイントの URL から投稿のアイキャッチ画像とタイトル、リンクを取得して出力する関数
  const renderPostLinks = (url, target) => {
    if (!url) {
      console.error("無効な API URL");
      return;
    }

    const list = document.createElement("ul");
    list.setAttribute("id", "post-list");
    list.style.setProperty("list-style-type", "none");
    target.appendChild(list);

    const params = new URLSearchParams({
      // 画像 URL は featured_image を指定。excerpt を追加(全てのフィールドを取得するので以下は省略可能)
      _fields: "title,link,featured_image,excerpt",
      // 取得件数を指定
      per_page: 5,
      // 取得する画像サイズを指定(省略するとデフォルトの medium)
      image_size: 'large',
    }).toString();

    fetch(`${url}?${params}`)
      .then((response) => {
        if (!response.ok)
          throw new Error(
            `リクエスト失敗: ${response.status} ${response.statusText}`
          );
        return response.json();
      })
      .then((posts) => {
        list.innerHTML = posts
          .map((post) => {
            const sanitizedLink = post.link;
            const sanitizedTitle = sanitizeHtml(post.title);
            const sanitizedExcerpt = sanitizeHtml(post.excerpt);
            // アイキャッチ画像の URL を post.featured_image で取得
            const imageUrl = post.featured_image || undefined;
            return sanitizedLink
              ? `<li style="margin-bottom:20px">
                  <a href="${sanitizedLink}">
                  ${imageUrl ? `<div><img src="${imageUrl}" alt="${sanitizedTitle}" style="max-width:200px;"></div>`: ""}
                  <h3>${sanitizedTitle}</h3>
                  </a>
                  <div>${sanitizedExcerpt}</div>
                </li>`
              : "";
          })
          .join("");
      })
      .catch((error) => {
        console.warn("投稿の取得エラー:", error);
        if (list)
          list.innerHTML = `<li>投稿の取得に失敗しました:${error.message}</li>`;
      });
  };

  document.addEventListener("DOMContentLoaded", () => {
    // 独自のエンドポイントを指定 (http://example.com の部分は適宜変更)
    const url = "http://example.com/wp-json/custom/v1/posts";
    const target = document.getElementById("fetch-posts");
    if (target) {
      renderPostLinks(url, target);
    }
  });
})();

ブラウザでカスタムエンドポイントを確認

ブラウザで設定したカスタムエンドポイントの名前空間(custom/v1)にアクセスして内容を確認することができます。