GutenbergのエンティティとREST APIの関係
Gutenbergのエンティティ
WordPressは投稿やカテゴリなど多くのREST APIを公開しています。
例えば「投稿」で考えてみます。
投稿は取得することが出来ます(複数取得することも)。
投稿は削除することもできます。
投稿は追加することもできます。
投稿は編集することもできます。
さらに編集は、ローカル上で編集(入力値を逐次にサーバーに転送していない)し、「公開」ボタンを押したときに編集が反映(この時REST APIが叩かれる)されます。
またREST APIはサーバーへのアクセスであり非同期処理なので「読み込み中」や「保存中」などの状態が存在しますし、エラーが発生したらそのメッセージを表示したいこともあります。
編集のやり直しが出来る「やり直す」や「元に戻す」のUndo/Redo
があります。
Gutenbergにはそれらを統一的な方法で使用する方法を提供してます。
それら投稿やカテゴリなどをエンティティで表しますが、
具体的にはコアデータのセレクタやアクションに実装されています。
エンティティとその種類
コアデータにはエンティティの設定情報が初期化されています。
エンティティの設定情報は種類(kind
)と名前(name
)で分類されています。
kindは「投稿のpostType
」「タクソノミーのtaxonomy
」「内部的にroot
」があります。
postType
は投稿タイプのことで、さらに名前(name
)が「投稿のpost
」「固定ページのpage
」「メディアのattachment
」などがあります。
他にもカスタム投稿タイプも含まれます。
taxonomy
はタクソノミーのことで、「カテゴリのcategory
」「タグのpost_tag
」などがあります。
カスタムタクソノミーも含まれます。
root
は内部的にハードコーディングされています。
例を挙げると、投稿のエンティティ設定情報を取得したければkind
がpostType
でname
がpost
になりますし、
カテゴリであればkind
がtaxonomy
でname
がcategory
になります。
以下はそれぞれのkind
に含まれるエンティティ情報一覧を取得する例です。
for(const kind of ['postType', 'taxonomy', 'root'])
{
const configs = wp.data.select('core').getEntitiesConfig(kind);
console.log(kind);
console.log(configs);
}
getEntitiesConfig()
は引数にkind
を渡し、そのエンティティ一覧を取得します。
↑表示はroot
は省略してます。
これらの設定情報はコアデータのストア読み込み時に初期化されます。
この中で投稿エンティティの設定はkindがpostType
でnameがpost
のレコードです。
{kind: 'postType', baseURL: '/wp/v2/posts', baseURLParams: {…}, name: 'post', label: '投稿', …}
この表示は全体の一部ですが、そこに{baseURL
: /wp/v2/posts
} の項目が見つかります。
postType
とpost
からREST APIのパス(/wp/v2/posts
)が得られるわけです。
次は投稿一覧を取得する例です。
wp.data.select('core').getEntityRecords('postType', 'post')
REST APIは非同期のため最初の実行は取得できません。
間をおいて2回目の実行します(リゾルバについて調べてください)。
成功すれば投稿一覧(最大10件)が取得されます。
getEntityRecords()
は内部ではpostType
とpost
から/wp/v2/posts
を取得しREST APIにアクセスします。
同じくカテゴリのレコード一覧は以下のようにします。
wp.data.select('core').getEntityRecords('taxonomy', 'category')
パス/wp/v2/categories
から取得されるカテゴリ一覧を取得します。
投稿タイプ(postType)の設定情報は何処から来た?
コアデータのストア初期化時にエンティティ設定一覧が初期化されているのですが、
ではpostType
のエンティティ一覧は何処から得られたのかというと/wp/v2/types?context=view
からです。
await wp.apiFetch({ path: '/wp/v2/types?context=view' })
ここからWordPress本体の話になりますが、
このREST APIコントローラのクラス名はWP_REST_Post_Types_Controller
です。
アイテム一覧はget_post_types()
から取得され、show_in_rest
がtrue
のものを返します。
$types = get_post_types( array( 'show_in_rest' => true ), 'objects' );
この関数は登録された「投稿タイプ」の一覧を取得します。
独自の投稿タイプを追加するにはregister_post_type()
をしようします。
タクソノミー(taxonomy)の設定情報は何処から来た?
/wp/v2/taxonomies?context=view
から取得します。
コントローラはWP_REST_Taxonomies_Controller
で、タクソノミー一覧はget_taxonomies()/get_object_taxonomies()
から取得します。
独自にカスタムタクソノミーを登録するにはregister_taxonomy()
を使用します。
rootって?
root
はGutenbergでハードコーディングされてます。
一覧を見てみましょ。
wp.data.select('core').getEntitiesConfig('root').map(_ => _.name)
['__unstableBase', 'site', 'postType', 'media', 'taxonomy', 'sidebar', 'widget', 'widgetType', 'user', 'comment', 'menu', 'menuItem', 'menuLocation', 'globalStyles', 'theme', 'plugin']
実はコアデータ初期化時、これらの情報は特定の命名規則でセレクタやアクションにマージされています。
例えばこの中のplugin
についてみてみましょう。
セレクタには以下が追加されています。
wp.data.select('core').getPlugins()
wp.data.select('core').getPlugin()
getPlugins()
のような複数形は内部でgetEntityRecord()
がラップされてます。
getPlugin()
のような単数形は内部でgetEntityRecords()
がラップされてます。
他のもみてみます。
設定にplural
が含まれているものは複数形の名前がこの値になります。
wp.data.select('core').getEntitiesConfig('root').map(_ => `${_.name} : ${_.plural || ''}`);
Object.keys(wp.data.select('core')).filter(k => k.indexOf('get') !== -1).filter((_, i) => i > 30);
['getUnstableBase', 'getUnstableBases', 'getSite', 'getSites', 'getPostType', 'getPostTypes', 'getMedia', 'getMediaItems', 'getTaxonomy', 'getTaxonomies', 'getSidebar', 'getSidebars', 'getWidget', 'getWidgets', 'getWidgetType', 'getWidgetTypes', 'getUser', 'getUsers', 'getComment', 'getComments', 'getMenu', 'getMenus', 'getMenuItem', 'getMenuItems', 'getMenuLocation', 'getMenuLocations', 'getGlobalStyles', 'getGlobalStylesVariations', 'getTheme', 'getThemes', 'getPlugin', 'getPlugins']
同じようにアクションにもsave
やdelete
プレフィックスが付いたメソッドが追加されています。
Object.keys(wp.data.dispatch('core')).filter(_ => _.indexOf('save') !== -1).filter((_, i) => i > 2)
Object.keys(wp.data.dispatch('core')).filter(_ => _.indexOf('delete') !== -1).filter((_, i) => i > 0)
['saveUnstableBase', 'saveSite', 'savePostType', 'saveMedia', 'saveTaxonomy', 'saveSidebar', 'saveWidget', 'saveWidgetType', 'saveUser', 'saveComment', 'saveMenu', 'saveMenuItem', 'saveMenuLocation', 'saveGlobalStyles', 'saveTheme', 'savePlugin']
['deleteUnstableBase', 'deleteSite', 'deletePostType', 'deleteMedia', 'deleteTaxonomy', 'deleteSidebar', 'deleteWidget', 'deleteWidgetType', 'deleteUser', 'deleteComment', 'deleteMenu', 'deleteMenuItem', 'deleteMenuLocation', 'deleteGlobalStyles', 'deleteTheme', 'deletePlugin']
save
はsaveEntityRecord()
、delete
はdeleteEntityRecord()
が内部でラップされてます。
rootに独自に追加するには?
ここからは実験的なものなので実戦で使う場合は注意してください。
WordPressで独自の機能を追加する時、
例えば不動産や商品などを扱う機能を作るときデータベースにテーブルを追加したくなります。
でもWordPressにはカスタム投稿タイプがすでにあります。
カスタム投稿タイプ(wp_posts)のテーブルにカラムが足りないときはメタ情報を使えます。
REST APIでフィールドが足りないときはカスタムフィールドやメタを後付けで追加できます。
WordPressにのっとって開発したほうが楽そうです。
ここから本題なのですが、投稿タイプでもなくタクソノミーでもないエンティティ設定情報はroot
にあります。
ここに独自に追加できないでしょうか?
試してみます。
まずは以下のREST APIのルートを追加します。
add_action('rest_api_init', function(){
// await wp.apiFetch({path: '/kurage-plugin/v1/info' });
register_rest_route(
'kurage-plugin/v1',
'/info',
[
'methods' => 'GET',
'callback' => function(WP_REST_Request $r)
{
$data = [
'name' => 'Kurage',
'description' => 'King of Sea'
];
return rest_ensure_response($data);
}
]
);
});
登録されたかブラウザ上から試してみます。
await wp.apiFetch({path: '/kurage-plugin/v1/info' });
以下がコンソールに出力されれば成功です。
{name: 'Kurage', description: 'King of Sea'}
kindにroot
、nameにmee
を指定してこの設定を追加することにしましょう。
await wp.data.dispatch('core').addEntities([{ kind: 'root', name: 'mee', label: 'MEE', baseURL: '/kurage-plugin/v1/info' }])
rootに追加されたエンティティ情報一覧をチェックします。
wp.data.select('core').getEntitiesConfig('root')
以下の項目があれば成功です。
{kind: 'root', name: 'mee', label: 'MEE', baseURL: '/kurage-plugin/v1/info'}
ではgetEntityRecord()
でアクセスしてみます。
wp.data.select('core').getEntityRecord('root', 'mee')
> undefined
wp.data.select('core').getEntityRecord('root', 'mee')
> {name: 'Kurage', description: 'King og Sea'}
1回目はundefined
が返ってきます。
間をおいてアクセスするとデータが返ってきます。
REST APIが返す値が単体なのでgetEntityRecord()
を使いました。
root
とmee
から/kurage-plugin/v1/info
が得られ、そこにアクセスしているのがわかります。
読み込み中は使えるのか?
まずREST APIで5秒間タイムラグを与えます。
add_action('rest_api_init', function(){
// await wp.apiFetch({path: '/kurage-plugin/v1/hello' });
register_rest_route(
'kurage-plugin/v1',
'/info',
[
'methods' => 'GET',
'callback' => function(WP_REST_Request $r)
{
// 5秒間まった!
sleep(5);
$data = [
'name' => 'Kurage',
'description' => 'King og Sea'
];
return rest_ensure_response($data);
}
]
);
});
import { BlockEditProps } from "@wordpress/blocks"
import React from "react"
import KurageExampleBlockProps from "./props"
import { useEffect, useState } from "@wordpress/element";
import { dispatch, useSelect } from "@wordpress/data"
import { useBlockProps } from "@wordpress/block-editor"
import { Spinner } from "@wordpress/components";
import { store as coreStore } from "@wordpress/core-data"
export default (props: BlockEditProps<KurageExampleBlockProps>) =>
{
const [isInit, setIsInit] = useState(false);
const p = useBlockProps();
useEffect(() => {
(async () => {
await dispatch(coreStore).addEntities([
{ kind: 'root', name: 'mee', label: 'MEE', baseURL: '/kurage-plugin/v1/info' }
]);
setIsInit(true);
})();
}, []);
const { data, resolved } = useSelect(select => {
const params = ['root', 'mee'];
return {
// @ts-ignore
data: select(coreStore).getEntityRecord(...params) as any,
// @ts-ignore
resolved: select(coreStore).hasFinishedResolution('getEntityRecord', params)
};
}, []);
if(!isInit)
{
return <p>... initialize</p>
}
return (
<div {...p}>
<h2>Kurage Info</h2>
<div>
{
( !resolved ) ? <Spinner style={{width: 100, height: 100}} /> :
( !data ) ? <div>???</div> :
(
<>
<p>name: {data.name ?? '?'}</p>
<p>description: {data.description ?? '?'}</p>
</>
)
}
</div>
</div>
);
}
結果、使えましたね。
今度は編集ですが、そのままでは失敗しました。
ID付きで返したら成功しました。
まずはREST APIを書き換えます(ID付き)。
IDはダミー的な意味を込めて0
にしてます。
add_action('rest_api_init', function(){
// await wp.apiFetch({path: '/kurage-plugin/v1/hello' });
register_rest_route(
'kurage-plugin/v1',
'/info',
[
'methods' => 'GET',
'callback' => function(WP_REST_Request $r)
{
sleep(5);
$name = get_option('KURAGE-TEST.NAME', 'Kurage');
$description = get_option('KURAGE-TEST.DESCRIPTION', 'King of Sea');
$data = [
'id' => 0,
'name' => $name,
'description' => $description
];
return rest_ensure_response($data);
}
]
);
register_rest_route(
'kurage-plugin/v1',
'/info',
[
'methods' => 'POST',
'callback' => function(WP_REST_Request $r)
{
sleep(5);
if($r->has_param('name'))
{
$name = (string)$r->get_param('name');
update_option('KURAGE-TEST.NAME', $name);
}
if($r->has_param('description'))
{
$description = (string)$r->get_param('description');
update_option('KURAGE-TEST.DESCRIPTION', $description);
}
$data = [
'id' => 0,
'name' => get_option('KURAGE-TEST.NAME'),
'description' => get_option('KURAGE-TEST.DESCRIPTION')
];
// idを付けないとgetEntityRecords()のキャッシュが更新されない?
return rest_ensure_response($data);
}
]
);
});
import { BlockEditProps } from "@wordpress/blocks"
import React from "react"
import KurageExampleBlockProps from "./props"
import { useEffect, useState } from "@wordpress/element";
import { dispatch, select, useDispatch, useSelect } from "@wordpress/data"
import { useBlockProps } from "@wordpress/block-editor"
import { Button, Spinner, TextControl } from "@wordpress/components";
import { store as coreStore } from "@wordpress/core-data"
export default (props: BlockEditProps<KurageExampleBlockProps>) =>
{
const [isInit, setIsInit] = useState(false);
const p = useBlockProps();
useEffect(() => {
(async () => {
await dispatch(coreStore).addEntities([
{ kind: 'root', name: 'mee', label: 'MEE', baseURL: '/kurage-plugin/v1/info' }
]);
setIsInit(true);
})();
}, []);
const { data, resolved } = useSelect(select => {
const params = ['root', 'mee', 0];
return {
// @ts-ignore
data: select(coreStore).getEntityRecord(...params) as any,
// @ts-ignore
resolved: select(coreStore).hasFinishedResolution('getEntityRecord', params),
};
}, []);
if(!isInit)
{
return <p>... initialize</p>
}
console.log(data);
return (
<div {...p}>
<h2>Kurage Info({data?.name ?? '-'})</h2>
<div>
{
( !resolved ) ? <Spinner style={{width: 100, height: 100}} /> :
( !data ) ? <div>???</div> :
(
<>
<div>
<EntityEdit />
</div>
<div>
<EntityView />
</div>
</>
)
}
</div>
</div>
);
}
const EntityView = () =>
{
// @ts-ignore
const {name, description} = select(coreStore).getEditedEntityRecord('root', 'mee', 0);
return (
<>
<p>name: {name ?? '?'}</p>
<p>description: {description ?? '?'}</p>
</>
)
}
const EntityEdit = () =>
{
const { editEntityRecord, saveEditedEntityRecord } = useDispatch(coreStore);
const { editedData, saving, editing, error } = useSelect(select => {
const s = select(coreStore);
const params = ['root', 'mee', 0];
return ({
// @ts-ignore
editedData: s.getEditedEntityRecord(...params),
// @ts-ignore
saving: s.isSavingEntityRecord(...params),
// @ts-ignore
editing: s.hasEditsForEntityRecord(...params),
// @ts-ignore
error: s.getLastEntitySaveError(...params)
})
}, []);
const edit = p => editEntityRecord('root', 'mee', 0, p);
const save = async () =>
{
if(await saveEditedEntityRecord('root', 'mee', 0, undefined))
{
// 保存し終えたら・・・
}
}
const { name, description } = editedData as any;
return (
<>
{ error && <h2>{error?.message}</h2> }
<div>
<TextControl label="name" value={name} onChange={name => edit({name})} />
</div>
<div>
<TextControl label="description" value={description} onChange={description => edit({description})} />
</div>
<Button variant="primary" onClick={save} disabled={saving || !editing}>
{ saving && <Spinner /> } ???
</Button>
</>
)
}
試しに何か入力するとUndo/Redoも動作することが確認できます。
今回は手を抜いてオプションに保存しました。
しかも複数のレコードではなく単一のレコードを返してます。
もっとちゃんとコントローラを実装したほうが無難そうです。
ちょっと工夫して、登録したメタ一覧を取得するのを作ってみる。
add_action('rest_api_init', function(){
register_post_meta(
'shops',
'stars',
[
'type' => 'number',
'description' => '店の評価',
'single' => true,
'show_in_rest' => true,
]
);
register_post_meta(
'shops',
'comment',
[
'type' => 'string',
'description' => '店のコメント',
'single' => true,
'show_in_rest' => true,
]
);
register_rest_route(
'wp/v2',
'/metas',
[
'methods' => 'GET',
'callback' => function(WP_REST_Request $r)
{
$obj = $r->get_param('obj');
$sub = $r->get_param('sub');
$metas = get_registered_meta_keys($obj, $sub);
// 戻り値を加工せずそのまま返すのはどうかとは思うが、手抜きです。
$metas = array_map(
fn($key, $meta) => [...$meta, 'id' => $key],
array_keys($metas),
array_values($metas)
);
return rest_ensure_response($metas);
}
]
);
});
カスタム投稿に「shops」を追加した場合です。
エンドポイントは適当に/wp/v2/metas
を追加しましたが、将来WordPress本体で実装されるかもしれないのでな名前空間は変更してた方がよさそうです。
ここで得られるものはregister_meta()
で追加したものだけです。
あくまでget_registered_meta_keys()
から引っ張ってきているだけです。
投稿毎のメタ情報一覧を得るわけではありません。
メタのオブジェクトタイプはpost
, comment
, user
, term
があります。
import { BlockEditProps } from "@wordpress/blocks"
import React from "react"
import KurageExampleBlockProps from "./props"
import Edit from './map-app/map-app'
import { dispatch, useSelect } from "@wordpress/data"
import { store as coreStore } from "@wordpress/core-data"
dispatch(coreStore).addEntities([
{
kind: 'root',
name: 'metas',
label: 'Metas',
baseURL: '/wp/v2/metas',
}
])
export default (props: BlockEditProps<KurageExampleBlockProps>) =>
{
const query = { obj: 'post', sub: 'shops' };
// @ts-ignore
const metas = useSelect(s => s(coreStore).getEntityRecords('root', 'metas', query)) as any[];
const length = metas?.length ?? 0;
return (
<>
<h2>メタ情報、{length}個</h2>
{ metas?.map(meta => <MetaView key={meta.id} meta={meta} />) }
</>
)
}
const MetaView = ({meta}) =>
{
const {id, description, type} = meta;
return (
<div style={{margin: 3, border: "1px solid red"}}>
<h2>{id}</h2>
<p>{description}</p>
<p>{type}</p>
</div>
)
}
今回はカスタム投稿タイプなのでpost
です。
またカスタム投稿タイプの名前はshops
を設定してます。
よってクエリではobj=post&sub=shops
みたいな感じでアクセスすることにします。
クエリ(obj
とsub
)を編集できるようにすれば他のも取得出来そう。