WordPress REST API Controllerを実装してみる。

API Fetch について
WordPressのブロック開発メモ その11 REST API へのアクセス

基本的な REST API
WordPressでREST APIを使う前編
WordPressでREST APIを使う後編 REST APIの登録と REST コントローラ

REST API コントローラの実装
WP_REST_Controller メソッド前編
WP_REST_Controller メソッド後編 追加フィールド
(σ・ω・)σ WordPress REST API Controllerを実装してみる。

WordPress REST API Controllerを実装してみる。

WordPress本体のREST API実装はコントローラで実装してあり、
その実体はWP_REST_Controllerの派生クラスです。

例えば投稿のREST API(/wp/v2/posts)はWP_REST_Posts_Controllerクラスで実装されてます。
独自のコントローラもこのWP_WP_REST_Controllerクラスを実装して行います。

ただし、結構分かりづらい・・・。

WP_REST_ControllerにはCRUDやその権限(パーミッション)のひな形となるメソッド一式があります。
例えばアイテム一覧を取得する場合は、アイテム一覧を取得するget_items()と、そのパーミッションを返すget_items_permissions_check()が定義されています。
アイテム取得一覧を実装する場合はこれら2つを実装します。
同じように追加したり削除したり変更したりするメソッドのペアが用意されています。

まずはコントローラを実装する前に、データを永続化する仕組みを作ります。
とにかく手抜きをするため、WordPressのオプション(get_option(), updateOption())を利用することにします。

この方法は膨大なデータには向いてません。
あくまで最低限の実装でコントローラを実装するためのものです。

class Osakanas
{
    public int $id;
    public string $label;
    public string $description;
    public int $min;
    public int $max;
}

class OsakanaService
{
    private const KURAGE_OPTION = 'KURAGE::Osakanas';

    private static function getOption()
    {
        return get_option(self::KURAGE_OPTION, []);
    }

    private static function saveOption($osakanas = [])
    {
        update_option(self::KURAGE_OPTION, $osakanas);
    }

    public static function clear()
    {
        self::saveOption();
    }

    public static function getOsakanas()
    {
        $items = self::getOption();

        return $items;
    }

    public static function getOsakana(int $id)
    {
        $items = self::getOption();
        foreach($items as $item)
        {
            if($item->id === $id)
            {
                return $item;
            }
        }

        return null;
    }

    public static function addOsakana(stdClass|Osakanas $obj)
    {
        $items = self::getOption();

        // 追加したアイテムのIDを決定
        $id = max( [...array_map(fn($v) => $v->id, $items), 0]) + 1;

        // インスタンス作成
        $osakana = new Osakanas();
        foreach((array)$obj as $key => $value) $osakana->$key = $value;
        $osakana->id = $id;

        // アイテムの追加
        $items = [...$items, $osakana];
        self::saveOption($items);

        return $id;
    }

    public static function editOsakana($data)
    {
        $data = $data instanceof Osakanas ? (array)$data : $data;
        $id = $data['id'] ?? null;

        if(!$id)
        {
            return;
        }

        $items = self::getOption();

        foreach($items as $item)
        {
            if($item->id === $id)
            {
                foreach(['label', 'description', 'min', 'max'] as $key)
                {
                    if(isset($data[$key]))
                    {
                        $item->$key = $data[$key];
                    }
                }               
            }
        }

        self::saveOption($items);
    }

    public static function deleteOsakana($id)
    {
        $items = self::getOption();
        $s = [];

        foreach($items as $item)
        {
            if($item->id !== $id)
            {
                $s[] = $item;
            }
        }

        self::saveOption($s);
    }

    public static function initialize()
    {
        OsakanaService::clear();

        $o = new Osakanas();
        $o->label = 'ika';
        $o->description = 'すみはくよ!';
        $o->min = 3;
        $o->max = 10;
        OsakanaService::addOsakana($o);

        $o = new Osakanas();
        $o->label = 'tako';
        $o->description = 'なぐるよ!';
        $o->min = 0;
        $o->max = 10;
        OsakanaService::addOsakana($o);

        $o = new Osakanas();
        $o->label = 'same';
        $o->description = 'かんじゃうぞ!';
        $o->min = 100;
        $o->max = 1000;
        OsakanaService::addOsakana($o);

        OsakanaService::editOsakana(['id' => 2, 'label' => 'ikasama', 'description' => '賭けないか!?']);

        $items = OsakanaService::getOsakanas();

        assert($items[1]->label === 'ikasama', '二番目のアイテムのラベル変更');
    }
}

見てわかる通り、オプションに対してCRUDする単純なサービスクラスを実装してます。
エンティティとなるクラスはOsakanasです。

class Osakanas
{
    public int $id;
    public string $label;
    public string $description;
    public int $min;
    public int $max;
}

識別用のid、後は適当に文字列型のlabel,descriptionと、
整数型のmin,maxがあるクラスです。

まずはinitialize()メソッドを最初の1回だけ実行します。
3つのアイテムを追加します。

OsakanaService::initialize();

これでデータの初期化は完了です。
初期化後の一覧を取得してみましょう。

$items = OsakanaService::getOsakanas();

以後Osakanas及びOsakanaServiceの両クラスは存在するものとして進めます。

アイテム一覧を取得する。

まずはアイテム一覧を取得します。
REST APIのルートは以下のようにします。

/wp/v2/osakanas

HTTPメソッドはGETです。

以下は最小限の実装です。

class OsakanasController extends WP_REST_Controller
{
    public function __construct()
    {
        $this->namespace = 'wp/v2';
        $this->rest_base = 'osakanas';
    }

    public function register_routes()
    {
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base,
            [
                'methods' => WP_REST_Server::READABLE,
                'callback' => [$this, 'get_items'],
                'permission_callback' => [$this, 'get_items_permissions_check'],
            ]
        );

    }

    public function get_items($request)
    {
        $items = OsakanaService::getOsakanas();
        $results = [];

        foreach( $items as $item )
        {
            $data = $this->prepare_item_for_response($item, $request);
            $results[] = $this->prepare_response_for_collection($data);
        }

        return rest_ensure_response($results);

    }

    public function get_items_permissions_check($request)
    {
        return true;
    }

    public function prepare_item_for_response($item, $request)
    {

        // オブジェクトで受け取ったアイテムを連想配列に変換。
        $data = (array)$item;

        /**
         * レスポンスの作成、必要であればレスポンスの操作
         */
        $response = rest_ensure_response($data);

        // レスポンスを返す
        return $response;
    }

}

// ルートの登録
add_action('rest_api_init', function(){
    $controller = new OsakanasController();
    $controller->register_routes();
});

ではREST API叩いてみます。

register_rest_route(
    $this->namespace,
    '/' . $this->rest_base,
    [
        'methods' => WP_REST_Server::READABLE,
        'callback' => [$this, 'get_items'],
        'permission_callback' => [$this, 'get_items_permissions_check'],
    ]
);

methodsにはGETを指定します。
callbackpermission_callbackにはそれぞれget_items()get_items_permissions_check()を指定しています。
これら二つのメソッドはWP_REST_Controllerですでに定義してあります。
エラーを返すだけの実装ですが、これをひな形としてオーバーライドする形になります。

誰でもアクセスできるようパーミッションはtrueを返してます。
またget_items()はアイテム一覧を取得して返すメソッドですが、
全てのアイテムをprepare_item_for_response()に渡して変換してます。
このメソッドはオブジェクトのアイテムを連想配列に変換します。

連想配列を作る過程でフィールドをフィルタリングして返すフィールドを限定したり、
又はフィールドを追加したりすることもできます。

次の例で紹介します。

スキーマ及びフィルタリングの実装

次にget_item_schema()メソッドをオーバーライドしてスキーマを取得出来るようにします。

class OsakanasController extends WP_REST_Controller
{
    public function __construct()
    {
        $this->namespace = 'wp/v2';
        $this->rest_base = 'osakanas';
    }

    public function register_routes()
    {
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base,
            [
                'methods' => WP_REST_Server::READABLE,
                'callback' => [$this, 'get_items'],
                'permission_callback' => [$this, 'get_items_permissions_check'],
            ]
        );

    }

    public function get_items($request)
    {
        $items = OsakanaService::getOsakanas();
        $results = [];

        foreach( $items as $item )
        {
            $data = $this->prepare_item_for_response($item, $request);
            $results[] = $this->prepare_response_for_collection($data);
        }

        return rest_ensure_response($results);

    }

    public function get_items_permissions_check($request)
    {
        return true;
    }

    public function prepare_item_for_response($item, $request)
    {

        // レスポンス用配列の準備。
        $data = [];

        /**
         * フィールドによるフィルタリング
         */

        $fields = $this->get_fields_for_response($request);

        if( rest_is_field_included('id', $fields) )
        {
            $data['id'] = $item->id;
        }

        if( rest_is_field_included('label', $fields) )
        {
            $data['label'] = $item->label;
        }

        if( rest_is_field_included('description', $fields) )
        {
            $data['description'] = $item->description;
        }

        if( rest_is_field_included('min', $fields) )
        {
            $data['min'] = $item->min;
        }

        if( rest_is_field_included('max', $fields) )
        {
            $data['max'] = $item->max;
        }

        /**
         * 追加フィールドの追加
         */
        $data = $this->add_additional_fields_to_object($data, $request);

        /**
         * コンテキストによるフィルタリング
         */
        $context = $request->get_param('context') ?? 'view';
        $data = $this->filter_response_by_context($data, $context);

        /**
         * レスポンスの作成、必要であればレスポンスの操作
         */
        $response = rest_ensure_response($data);

        // レスポンスを返す
        return $response;
    }

    public function get_item_schema()
    {
        $this->schema = [
            '$schema' => 'http://json-schema.org/draft-04/schema#',
            'title' => 'kurage',
            'type' => 'object',
            'properties' => [
                'id' =>
                [
                    'description' => 'ID',
                    'type' => 'integer',
                ],
                'label' =>
                [
                    'description' => 'あんたの表示名は?',
                    'type' => ['string', 'null'],
                ],
                'description' =>
                [
                    'description' => 'てきとーに自己紹介して!',
                    'type' => ['string', 'null'],
                ],
                'min' =>
                [
                    'description' => '1日のあくびの最小回数は?',
                    'type' => 'integer',
                ],
                'max' =>
                [
                    'description' => '1日のあくびの最大回数は?',
                    'type' => 'integer',
                ]
            ]
        ];

        return $this->add_additional_fields_schema($this->schema);
    }

}

// ルートの登録
add_action('rest_api_init', function(){

    register_rest_field(
        'kurage',
        'ikasan',
        [
            'get_callback' => fn() => '墨はくどー',
            'update_callback' => fn($value) => null,

            // ここにschemaを設定していると、add_additional_fields_schema()を呼び出した時にマージされる。
            'schema' =>
            [
                'description' => 'I have 10 legs.',
                'type' => 'string'
            ]
        ]
    );

    register_rest_field(
        'kurage',
        'takosan',
        [
            'get_callback' => fn() => 'タコ殴りだべー!',
            'update_callback' => fn($value) => null,

            // ここにschemaを設定していると、add_additional_fields_schema()を呼び出した時にマージされる。          
            'schema' =>
            [
                'description' => 'I have 8 legs.',
                'type' => 'string'
            ]
        ]
    );

    $controller = new OsakanasController();
    $controller->register_routes();
});

このようにget_item_schema()を追加してます。

/wp/v2/osakanas

こんな結果になりました。

追加フィールド

追加フィールド「ikasantakosan」が各アイテムに追加されて取得出来ていることがわかります。
以下の部分が追加フィールドを追加しているところです。

/**
 * 追加フィールドの追加
 */
$data = $this->add_additional_fields_to_object($data, $request);

フィールドフィルター

クエリパラメータに_fieldsを渡すとその項目だけ取得出来ていることが確認できます。

/wp/v2/osakanas?_fields=label,min,ikasan

この部分を実装しているのが以下のコードです。

$fields = $this->get_fields_for_response($request);

if( rest_is_field_included('id', $fields) )
{
    $data['id'] = $item->id;
}

if( rest_is_field_included('label', $fields) )
{
    $data['label'] = $item->label;
}

if( rest_is_field_included('description', $fields) )
{
    $data['description'] = $item->description;
}

if( rest_is_field_included('min', $fields) )
{
    $data['min'] = $item->min;
}

if( rest_is_field_included('max', $fields) )
{
    $data['max'] = $item->max;
}

コンテキスト

コンテキストの実装例です。

class OsakanasController extends WP_REST_Controller
{
    public function __construct()
    {
        $this->namespace = 'wp/v2';
        $this->rest_base = 'osakanas';
    }

    public function register_routes()
    {
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base,
            [
                'methods' => WP_REST_Server::READABLE,
                'callback' => [$this, 'get_items'],
                'permission_callback' => [$this, 'get_items_permissions_check'],
                'args' =>
                [
                    // コンテキストの追加
                    'context' => $this->get_context_param(['default' => 'view'])
                ]
            ]
        );

    }

    public function get_items($request)
    {
        $items = OsakanaService::getOsakanas();
        $results = [];

        foreach( $items as $item )
        {
            $data = $this->prepare_item_for_response($item, $request);
            $results[] = $this->prepare_response_for_collection($data);
        }

        return rest_ensure_response($results);

    }

    public function get_items_permissions_check($request)
    {
        return true;
    }

    public function prepare_item_for_response($item, $request)
    {

        // レスポンス用配列の準備。
        $data = [];

        /**
         * フィールドによるフィルタリング
         */

        $fields = $this->get_fields_for_response($request);

        if( rest_is_field_included('id', $fields) )
        {
            $data['id'] = $item->id;
        }

        if( rest_is_field_included('label', $fields) )
        {
            $data['label'] = $item->label;
        }

        if( rest_is_field_included('description', $fields) )
        {
            $data['description'] = $item->description;
        }

        if( rest_is_field_included('min', $fields) )
        {
            $data['min'] = $item->min;
        }

        if( rest_is_field_included('max', $fields) )
        {
            $data['max'] = $item->max;
        }

        /**
         * 追加フィールドの追加
         */
        $data = $this->add_additional_fields_to_object($data, $request);

        /**
         * コンテキストによるフィルタリング
         */
        $context = $request->get_param('context') ?? 'view';
        $data = $this->filter_response_by_context($data, $context);

        /**
         * レスポンスの作成、必要であればレスポンスの操作
         */
        $response = rest_ensure_response($data);

        // レスポンスを返す
        return $response;
    }

    public function get_item_schema()
    {
        $this->schema = [
            '$schema' => 'http://json-schema.org/draft-04/schema#',
            'title' => 'kurage',
            'type' => 'object',
            'properties' => [
                'id' =>
                [
                    'description' => 'ID',
                    'type' => 'integer',
                    'context' => ['view']
                ],
                'label' =>
                [
                    'description' => 'あんたの表示名は?',
                    'type' => ['string', 'null'],
                    'context' => ['view']
                ],
                'description' =>
                [
                    'description' => 'てきとーに自己紹介して!',
                    'type' => ['string', 'null'],
                    'context' => ['view', 'fire']
                ],
                'min' =>
                [
                    'description' => '1日のあくびの最小回数は?',
                    'type' => 'integer',
                    'context' => ['view']
                ],
                'max' =>
                [
                    'description' => '1日のあくびの最大回数は?',
                    'type' => 'integer',
                    'context' => ['view', 'fire']
                ]
            ]
        ];

        return $this->add_additional_fields_schema($this->schema);
    }

}

// ルートの登録
add_action('rest_api_init', function(){

    register_rest_field(
        'kurage',
        'ikasan',
        [
            'get_callback' => fn() => '墨はくどー',
            'update_callback' => fn($value) => null,

            // ここにschemaを設定していると、add_additional_fields_schema()を呼び出した時にマージされる。
            'schema' =>
            [
                'description' => 'I have 10 legs.',
                'type' => 'string',
                'context' => ['view', 'fire']
            ]
        ]
    );

    register_rest_field(
        'kurage',
        'takosan',
        [
            'get_callback' => fn() => 'タコ殴りだべー!',
            'update_callback' => fn($value) => null,

            // ここにschemaを設定していると、add_additional_fields_schema()を呼び出した時にマージされる。          
            'schema' =>
            [
                'description' => 'I have 8 legs.',
                'type' => 'string',
                'context' => ['view']
            ]
        ]
    );

    $controller = new OsakanasController();
    $controller->register_routes();
});

栗栄パラメータにcontext=fireを渡します。

/wp/v2/osakanas?context=fire

取得出来たフィールドは「description, ikasan, max」の3つです。

コンテキストを実装するには以下のようにします。

register_rest_route(
    $this->namespace,
    '/' . $this->rest_base,
    [
        'methods' => WP_REST_Server::READABLE,
        'callback' => [$this, 'get_items'],
        'permission_callback' => [$this, 'get_items_permissions_check'],
        'args' =>
        [
            // コンテキストの追加
            'context' => $this->get_context_param(['default' => 'view'])
        ]
    ]
);

ルートの設定でargscontextを追加します。
contextの値はviewfireのどちらかである必要がある設定です。

次にスキーマにコンテキストを指定します。

public function get_item_schema()
{
    $this->schema = [
        '$schema' => 'http://json-schema.org/draft-04/schema#',
        'title' => 'kurage',
        'type' => 'object',
        'properties' => [
            'id' =>
            [
                'description' => 'ID',
                'type' => 'integer',
                'context' => ['view']
            ],
            'label' =>
            [
                'description' => 'あんたの表示名は?',
                'type' => ['string', 'null'],
                'context' => ['view']
            ],
            'description' =>
            [
                'description' => 'てきとーに自己紹介して!',
                'type' => ['string', 'null'],
                'context' => ['view', 'fire']
            ],
            'min' =>
            [
                'description' => '1日のあくびの最小回数は?',
                'type' => 'integer',
                'context' => ['view']
            ],
            'max' =>
            [
                'description' => '1日のあくびの最大回数は?',
                'type' => 'integer',
                'context' => ['view', 'fire']
            ]
        ]
    ];

    return $this->add_additional_fields_schema($this->schema);
}

全てのフィールドにコンテキストを指定してます。
この例ではコンテキストは全てのフィールドがviewを持っており、
description及びmaxfireを持ってます。

ちなみにviewfireも適当な名前を付けています。

以下のように追加フィールドにもコンテキストを指定してます。

register_rest_field(
    'kurage',
    'ikasan',
    [
        'get_callback' => fn() => '墨はくどー',
        'update_callback' => fn($value) => null,

        // ここにschemaを設定していると、add_additional_fields_schema()を呼び出した時にマージされる。
        'schema' =>
        [
            'description' => 'I have 10 legs.',
            'type' => 'string',
            'context' => ['view', 'fire']
        ]
    ]
);

register_rest_field(
    'kurage',
    'takosan',
    [
        'get_callback' => fn() => 'タコ殴りだべー!',
        'update_callback' => fn($value) => null,

        // ここにschemaを設定していると、add_additional_fields_schema()を呼び出した時にマージされる。          
        'schema' =>
        [
            'description' => 'I have 8 legs.',
            'type' => 'string',
            'context' => ['view']
        ]
    ]
);

このようにikasanだけfireを指定してます。

/wp/v2/osakanas?context=fire

はコンテキストにfireを持ったフィールドだけ取得するものです。
ちなみにコンテキストの名前は自由に追加出来、コンテキストの名前の分だけargscontextに設定されます($this->get_context_param()の仕事)。

記事を作成する

REST APIのルートは、

/wp/v2/osakanas

HTTPメソッドはPOSTを使い、今度は記事を追加してみます。
create_item()create_item_permissions_check()をオーバーラードします。

今回はOsakanasControllerクラスのみ載せます。

class OsakanasController extends WP_REST_Controller
{
    public function __construct()
    {
        $this->namespace = 'wp/v2';
        $this->rest_base = 'osakanas';
    }

    public function register_routes()
    {
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base,
            [
                [
                    'methods' => WP_REST_Server::READABLE,
                    'callback' => [$this, 'get_items'],
                    'permission_callback' => [$this, 'get_items_permissions_check'],
                    'args' =>
                    [
                        'context' => $this->get_context_param(['default' => 'view'])
                    ]
                ],
                [
                    'methods' => WP_REST_Server::CREATABLE,
                    'callback' => [$this, 'create_item'],
                    'permission_callback' => [$this, 'create_item_permissions_check'],

                    // ここ注目。スキーマを使用するよ!
                    'args' => $this->get_endpoint_args_for_item_schema(WP_REST_Server::CREATABLE)
                ]
            ]
        );

    }

    public function get_items($request)
    {
        $items = OsakanaService::getOsakanas();
        $results = [];

        foreach( $items as $item )
        {
            $data = $this->prepare_item_for_response($item, $request);
            $results[] = $this->prepare_response_for_collection($data);
        }

        return rest_ensure_response($results);

    }

    public function get_items_permissions_check($request)
    {
        return true;
    }

    public function prepare_item_for_response($item, $request)
    {

        // レスポンス用配列の準備。
        $data = [];

        /**
         * フィールドによるフィルタリング
         */

        $fields = $this->get_fields_for_response($request);

        if( rest_is_field_included('id', $fields) )
        {
            $data['id'] = $item->id;
        }

        if( rest_is_field_included('label', $fields) )
        {
            $data['label'] = $item->label;
        }

        if( rest_is_field_included('description', $fields) )
        {
            $data['description'] = $item->description;
        }

        if( rest_is_field_included('min', $fields) )
        {
            $data['min'] = $item->min;
        }

        if( rest_is_field_included('max', $fields) )
        {
            $data['max'] = $item->max;
        }

        /**
         * 追加フィールドの追加
         */
        $data = $this->add_additional_fields_to_object($data, $request);

        /**
         * コンテキストによるフィルタリング
         */
        $context = $request->get_param('context') ?? 'view';
        $data = $this->filter_response_by_context($data, $context);

        /**
         * レスポンスの作成、必要であればレスポンスの操作
         */
        $response = rest_ensure_response($data);

        // レスポンスを返す
        return $response;
    }

    public function create_item_permissions_check($request)
    {
        return current_user_can('administrator');
    }

    public function create_item($request)
    {
        // リクエストからオブジェクトを作成
        $item = $this->prepare_item_for_database($request);
        if(is_wp_error($item))
        {
            return $item;
        }

        // 追加
        $id = OsakanaService::addOsakana($item);

        // 追加したものをもう一度取得(今回は意味がない)
        $osakana = OsakanaService::getOsakana($id);

        // 追加フィールドもアップデート
        $f = $this->update_additional_fields_for_object($osakana, $request);
        if(is_wp_error($f))
        {
            return $f;
        }

        // アイテム作成後返信用のアイテムの準備
        $data = $this->prepare_item_for_response($osakana, $request);

        // レスポンス
        $response = rest_ensure_response($data);
        $response->set_status(201);

        return $response;
    }

    protected function prepare_item_for_database($request)
    {
        $item = new stdClass;

        if($request->has_param('id'))
        {
            $item->id = $request->get_param('id');
        }

        if($request->has_param('label'))
        {
            $item->label = $request->get_param('label');
        }

        if($request->has_param('description'))
        {
            $item->description = $request->get_param('description');
        }

        if($request->has_param('min'))
        {
            $item->min = $request->get_param('min');
        }

        if($request->has_param('max'))
        {
            $item->max = $request->get_param('max');
        }

        return $item;
    }

    public function get_item_schema()
    {
        $this->schema = [
            '$schema' => 'http://json-schema.org/draft-04/schema#',
            'title' => 'kurage',
            'type' => 'object',
            'properties' => [
                'id' =>
                [
                    'description' => 'ID',
                    'type' => 'integer',
                    'context' => ['view']
                ],
                'label' =>
                [
                    'description' => 'あんたの表示名は?',
                    'type' => ['string', 'null'],
                    'context' => ['view']
                ],
                'description' =>
                [
                    'description' => 'てきとーに自己紹介して!',
                    'type' => ['string', 'null'],
                    'context' => ['view', 'fire']
                ],
                'min' =>
                [
                    'description' => '1日のあくびの最小回数は?',
                    'type' => 'integer',
                    'context' => ['view']
                ],
                'max' =>
                [
                    'description' => '1日のあくびの最大回数は?',
                    'type' => 'integer',
                    'context' => ['view', 'fire']
                ]
            ]
        ];

        return $this->add_additional_fields_schema($this->schema);
    }

}

追加と取得です。

追加するためのルートを指定します。
register_rest_route()は配列で複数のルートも指定できます。

register_rest_route(
    $this->namespace,
    '/' . $this->rest_base,
    [
        [
            'methods' => WP_REST_Server::READABLE,
            'callback' => [$this, 'get_items'],
            'permission_callback' => [$this, 'get_items_permissions_check'],
            'args' =>
            [
                'context' => $this->get_context_param(['default' => 'view'])
            ]
        ],
        [
            'methods' => WP_REST_Server::CREATABLE,
            'callback' => [$this, 'create_item'],
            'permission_callback' => [$this, 'create_item_permissions_check'],

            // ここ注目。スキーマを使用するよ!
            'args' => $this->get_endpoint_args_for_item_schema(WP_REST_Server::CREATABLE)
        ]
    ]
);

methodsPOSTに、callbackpermission_callbackはそれぞれcreate_item()及びcreate_item_permissions_check()を定義します。
どちらもWP_REST_Controllerクラスにすでに定義してあり、それをオーバーライドします。

リクエストの検証のため以下のメソッドの戻り値をargsに指定します。

'args' => $this->get_endpoint_args_for_item_schema(WP_REST_Server::CREATABLE)

このメソッドはget_item_schema()か返したスキーマをargsに指定出来る形にして返してくれます。

パーミッションは管理者のみ追加出来るようになってます。
権限についてはcurrent_user_can()を調べてください。

public function create_item_permissions_check($request)
{
    return current_user_can('administrator');
}

記事を追加する時などは記事の所有者であるかをチェックする必要がありますが、
今回は管理者しか使わないこと前提なのでこの設定です。

public function create_item($request)
{
    // リクエストからオブジェクトを作成
    $item = $this->prepare_item_for_database($request);
    if(is_wp_error($item))
    {
        return $item;
    }

    // 追加
    $id = OsakanaService::addOsakana($item);

    // 追加したものをもう一度取得(今回は意味がない)
    $osakana = OsakanaService::getOsakana($id);

    // 追加フィールドもアップデート
    $f = $this->update_additional_fields_for_object($osakana, $request);
    if(is_wp_error($f))
    {
        return $f;
    }

    // アイテム作成後返信用のアイテムの準備
    $data = $this->prepare_item_for_response($osakana, $request);

    // レスポンス
    $response = rest_ensure_response($data);
    $response->set_status(201);

    return $response;
}

追加時、レスポンスを返しますがその時もまたprepare_item_for_response()update_additional_fields_for_object()を通していることに注目してください。

まず以下のメソッドです。

$item = $this->prepare_item_for_database($request);

このメソッドもまたWP_REST_Controllerクラスで定義してありオーバーライドして使います。
リクエストからstdClassオブジェクトを作成して返します。

以下は追加フィールドに更新するためのものです。

$f = $this->update_additional_fields_for_object($osakana, $request);

追加フィールドregister_rest_field()update_callbackで指定したコールバックを実行します。
つまり、アイテムを保存するたびに追加フィールド一覧も保存するようにしているわけです。

最後に

これら追加フィールドやフィルタリング等の実装は必ずしも必要ないでしょう。
また今回はページネーション(page,per_page)を実装してません。
大量のアイテムを前提としていないためです。

必要な場合はルート登録の時「$this->get_collection_params()」を使って実装してください。

最後に「編集、特定のIDのアイテムの取得、削除」も実装したコードを載せます。
「一覧表示、追加」と違う点はIDを指定して単一のアイテム操作だという点です。
よってルートにはIDを指定します(以下の例はIDを12345とする場合)。

/wp/v2/osakanas/12345
class OsakanasController extends WP_REST_Controller
{
    public function __construct()
    {
        $this->namespace = 'wp/v2';
        $this->rest_base = 'osakanas';
    }

    public function register_routes()
    {
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base,
            [
                [
                    'methods' => WP_REST_Server::READABLE,
                    'callback' => [$this, 'get_items'],
                    'permission_callback' => [$this, 'get_items_permissions_check'],
                    'args' =>
                    [
                        'context' => $this->get_context_param(['default' => 'view'])
                    ]
                ],
                [
                    'methods' => WP_REST_Server::CREATABLE,
                    'callback' => [$this, 'create_item'],
                    'permission_callback' => [$this, 'create_item_permissions_check'],

                    // ここ注目。スキーマを使用するよ!
                    'args' => $this->get_endpoint_args_for_item_schema(WP_REST_Server::CREATABLE)
                ]
            ]
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id>[\d]+)',
            [
                'args' =>
                [
                    'id' =>
                    [
                        'description' => 'ID',
                        'type' => 'integer'
                    ]
                ],
                [
                    'methods' => WP_REST_Server::READABLE,
                    'callback' => [$this, 'get_item'],
                    'permission_callback' => [$this, 'get_item_permissions_check'],
                    'args' =>
                    [
                        'context' => $this->get_context_param(['default' => 'view'])
                    ]
                ],
                [
                    'methods' => WP_REST_Server::EDITABLE,
                    'callback' => [$this, 'update_item'],
                    'permission_callback' => [$this, 'update_item_permissions_check'],
                    'args' => $this->get_endpoint_args_for_item_schema(WP_REST_Server::EDITABLE)
                ],
                [
                    'methods' => WP_REST_Server::DELETABLE,
                    'callback' => [$this, 'delete_item'],
                    'permission_callback' => [$this, 'delete_item_permissions_check']
                ]
            ]
        );

    }

    public function get_items($request)
    {
        $items = OsakanaService::getOsakanas();
        $results = [];

        foreach( $items as $item )
        {
            $data = $this->prepare_item_for_response($item, $request);
            $results[] = $this->prepare_response_for_collection($data);
        }

        return rest_ensure_response($results);

    }

    public function get_items_permissions_check($request)
    {
        return true;
    }

    public function prepare_item_for_response($item, $request)
    {

        // レスポンス用配列の準備。
        $data = [];

        /**
         * フィールドによるフィルタリング
         */

        $fields = $this->get_fields_for_response($request);

        if( rest_is_field_included('id', $fields) )
        {
            $data['id'] = $item->id;
        }

        if( rest_is_field_included('label', $fields) )
        {
            $data['label'] = $item->label;
        }

        if( rest_is_field_included('description', $fields) )
        {
            $data['description'] = $item->description;
        }

        if( rest_is_field_included('min', $fields) )
        {
            $data['min'] = $item->min;
        }

        if( rest_is_field_included('max', $fields) )
        {
            $data['max'] = $item->max;
        }

        /**
         * 追加フィールドの追加
         */
        $data = $this->add_additional_fields_to_object($data, $request);

        /**
         * コンテキストによるフィルタリング
         */
        $context = $request->get_param('context');
        $data = $this->filter_response_by_context($data, $context);

        /**
         * レスポンスの作成、必要であればレスポンスの操作
         */
        $response = rest_ensure_response($data);

        // レスポンスを返す
        return $response;
    }

    public function create_item_permissions_check($request)
    {
        return current_user_can('administrator');
    }

    public function create_item($request)
    {
        // リクエストからオブジェクトを作成
        $item = $this->prepare_item_for_database($request);
        if(is_wp_error($item))
        {
            return $item;
        }

        // 追加
        $id = OsakanaService::addOsakana($item);

        // 追加したものをもう一度取得(今回は意味がない)
        $osakana = OsakanaService::getOsakana($id);

        // 追加フィールドもアップデート
        $f = $this->update_additional_fields_for_object($osakana, $request);
        if(is_wp_error($f))
        {
            return $f;
        }

        // アイテム作成後返信用のアイテムの準備
        $data = $this->prepare_item_for_response($osakana, $request);

        // レスポンス
        $response = rest_ensure_response($data);
        $response->set_status(201);

        return $response;
    }

    protected function prepare_item_for_database($request)
    {
        $item = new stdClass;

        if($request->has_param('id'))
        {
            $item->id = $request->get_param('id');
        }

        if($request->has_param('label'))
        {
            $item->label = $request->get_param('label');
        }

        if($request->has_param('description'))
        {
            $item->description = $request->get_param('description');
        }

        if($request->has_param('min'))
        {
            $item->min = $request->get_param('min');
        }

        if($request->has_param('max'))
        {
            $item->max = $request->get_param('max');
        }

        return $item;
    }

    public function get_item($request)
    {
        $id = $request->get_param('id') ?? 0;

        if($id)
        {
            $osakana = OsakanaService::getOsakana($id);
            if($osakana)
            {
                $data = $this->prepare_item_for_response($osakana, $request);
                return rest_ensure_response($data);
            }
        }

        return new WP_Error(
            'rest_get_osakana_invalid',
            'アイテムを取得できませんでした',
            ['args' => 404]
        );

    }

    public function get_item_permissions_check($request)
    {
        return true;
    }

    public function update_item($request)
    {

        // 対象のアイテム取得
        $id = $request->get_param('id') ?? 0;
        $osakana = OsakanaService::getOsakana($id);
        if(!$osakana)
        {
            return new WP_Error(
                'rest_get_osakana_invalid',
                '対象のアイテムを取得できませんでした',
                ['args' => 404]
            );          
        }

        // リクエストからオブジェクトを作成
        $item = $this->prepare_item_for_database($request);
        if(is_wp_error($item))
        {
            return $item;
        }

        // 編集する
        OsakanaService::editOsakana((array)$item);

        // 編集直後のアイテム
        $osakana = OsakanaService::getOsakana($id);

        // 追加フィールドの更新
        $r = $this->update_additional_fields_for_object($osakana, $request);
        if(is_wp_error($r))
        {
            return $r;
        }

        // 更新後のアイテムの準備
        $data = $this->prepare_item_for_response($osakana, $request);
        return rest_ensure_response($data);
    }

    public function update_item_permissions_check($request)
    {
        return current_user_can('administrator');
    }

    public function delete_item($request)
    {
        // 対象のアイテム取得
        $id = $request->get_param('id') ?? 0;
        $osakana = OsakanaService::getOsakana($id);
        if(!$osakana)
        {
            return new WP_Error(
                'rest_get_osakana_invalid',
                '対象のアイテムを取得できませんでした',
                ['args' => 404]
            );          
        }

        // 削除
        OsakanaService::deleteOsakana($id);

        return rest_ensure_response(true);
    }

    public function delete_item_permissions_check($request)
    {
        return current_user_can('administrator');
    }

    public function get_item_schema()
    {
        $this->schema = [
            '$schema' => 'http://json-schema.org/draft-04/schema#',
            'title' => 'kurage',
            'type' => 'object',
            'properties' => [
                'id' =>
                [
                    'description' => 'ID',
                    'type' => 'integer',
                ],
                'label' =>
                [
                    'description' => 'あんたの表示名は?',
                    'type' => ['string', 'null'],
                ],
                'description' =>
                [
                    'description' => 'てきとーに自己紹介して!',
                    'type' => ['string', 'null'],
                ],
                'min' =>
                [
                    'description' => '1日のあくびの最小回数は?',
                    'type' => 'integer',
                ],
                'max' =>
                [
                    'description' => '1日のあくびの最大回数は?',
                    'type' => 'integer',
                ]
            ]
        ];

        return $this->add_additional_fields_schema($this->schema);
    }

}

// ルートの登録
add_action('rest_api_init', function(){

    register_rest_field(
        'kurage',
        'ikasan',
        [
            'get_callback' => fn() => '墨はくどー',
            'update_callback' => fn($value) => null,

            // ここにschemaを設定していると、add_additional_fields_schema()を呼び出した時にマージされる。
            'schema' =>
            [
                'description' => 'I have 10 legs.',
                'type' => 'string'
            ]
        ]
    );

    register_rest_field(
        'kurage',
        'takosan',
        [
            'get_callback' => fn() => 'タコ殴りだべー!',
            'update_callback' => fn($value) => null,

            // ここにschemaを設定していると、add_additional_fields_schema()を呼び出した時にマージされる。          
            'schema' =>
            [
                'description' => 'I have 8 legs.',
                'type' => 'string'
            ]
        ]
    );

    $controller = new OsakanasController();
    $controller->register_routes();
});

ではブラウザから実行してみます。

編集する時

IDが2のアイテムを編集します。

await wp.apiFetch({
    path: '/wp/v2/osakanas/2',
    method: 'POST',
    data: {
        label: 'yariika',
        description: 'や、やりいか・・・'
    }
})

IDが13のアイテムを削除します。

await wp.apiFetch({ path: '/wp/v2/osakanas/1', method: 'DELETE' })
await wp.apiFetch({ path: '/wp/v2/osakanas/3', method: 'DELETE' })

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