GutenbergのエンティティとREST APIの関係

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は内部的にハードコーディングされています。

例を挙げると、投稿のエンティティ設定情報を取得したければkindpostTypenamepostになりますし、
カテゴリであればkindtaxonomynamecategoryになります。

以下はそれぞれの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} の項目が見つかります。
postTypepostからREST APIのパス(/wp/v2/posts)が得られるわけです。

次は投稿一覧を取得する例です。

wp.data.select('core').getEntityRecords('postType', 'post')

REST APIは非同期のため最初の実行は取得できません。
間をおいて2回目の実行します(リゾルバについて調べてください)。

成功すれば投稿一覧(最大10件)が取得されます。

getEntityRecords()は内部ではpostTypepostから/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_resttrueのものを返します。

$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']

同じようにアクションにもsavedeleteプレフィックスが付いたメソッドが追加されています。

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']

savesaveEntityRecord()deletedeleteEntityRecord()が内部でラップされてます。

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()を使いました。
rootmeeから/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みたいな感じでアクセスすることにします。

クエリ(objsub)を編集できるようにすれば他のも取得出来そう。

certificate Docker Gutenberg Hyper-V openssl PHP React ReduxToolkit REST ubuntu WordPress オレオレ認証局 フレームワーク