カスタムエンドポイントの追加

2020.03.25 2020.03.25

TOPICS

翻訳元記事はこちらです。

WordPress REST API は単なるデフォルトのルートのセットではありません。カスタムルートやエンドポイントを作成するためのツールでもあります。
WordPress のフロントエンドにはデフォルトの URL マッピングのセットが用意されていますが、それらを作成するためのツール (例: Rewrites API やクエリクラス: WP_Query, WP_User など) は、独自の URL マッピングやカスタムクエリを作成するためにも利用できます。

このドキュメントでは、独自のエンドポイントを持つ完全にカスタムなルートを作成する方法を詳述します。
最初に短い例を説明し、次に内部で使用されている完全なコントローラパターンに拡張します。

基本

APIにカスタムエンドポイントを追加したい場合の、簡単な例を見てみましょう。まずはこのようなシンプルな関数から始めましょう。

以下は、著者の最新の投稿タイトルを取得する例です。

<?php
/**
 * Grab latest post title by an author!
 *
 * @param array $data Options for the function.
 * @return string|null Post title for the latest,
 * or null if none.
 */
function my_awesome_func( $data ) {
  $posts = get_posts( array(
    'author' => $data['id'],
  ) );
 
  if ( empty( $posts ) ) {
    return null;
  }
 
  return $posts[0]->post_title;
}

これをAPI経由で利用できるようにするには、ルートを登録する必要があります。これにより、API が指定されたリクエストに関数で応答するようになります。これは register_rest_route と呼ばれる関数を使用して行います。
これは、APIがロードされていないときに余計な作業をしないように、rest_api_init のコールバックで呼ばれるべきものです。

register_rest_route には3つのものを渡す必要があります:。名前空間、必要なルート、オプションです。名前空間については後で説明しますが、今は myplugin/v1 を選択してみましょう。 ルートは /author/{id} にマッチするものを指定します。({id} は整数です)

<?php
add_action( 'rest_api_init', function () {
  register_rest_route( 'myplugin/v1', '/author/(?P<id>\d+)', array(
    'methods' => 'GET',
    'callback' => 'my_awesome_func',
  ) );
} );

今のところ、ルートのエンドポイントは1つしか登録していません。
「ルート」とはURLのことで、「エンドポイント」とはそこから呼び出されるメソッドやURLに対応する関数のことです。(詳しくは用語集を参照)

例えば、サイトドメインが example.comwp-json の API パスを保持している場合、フル URL は http://example.com/wp-json/myplugin/v1/author/(?P\d+) となります。

各ルートは、任意の数のエンドポイントを持つことができ、各エンドポイントに対して、許可される HTTP メソッド、リクエストに応答するためのコールバック関数、カスタムパーミッションを作成するためのパーミッションコールバックを定義することができます。
さらに、リクエストで許可されるフィールドを定義し、各フィールドに対してデフォルト値、サニタイズコールバック、バリデーションコールバック、およびフィールドが必須かどうかを指定することができます。

訳者注:Qiitaのこちらの記事にも実例が載っていますので、参考にしてみてください。
(要するにWordPressで独自のAPIの作成ができます)

名前空間

名前空間はエンドポイントの URL の最初の部分です。カスタムルート間の衝突を防ぐために、ベンダ/パッケージのプレフィックスとして使用します。名前空間を使用すると、2 つのプラグインの同じ名前のルートを異なる機能で追加することができます。

一般的に名前空間は vendor/v1 のパターンに従うべきで、 vendor は通常プラグインやテーマのスラッグ、v1 は API の最初のバージョンを表します。新しいエンドポイントとの互換性が必要になった場合は、これをv2に変更することができます。

上記のシナリオでは、2つの異なるプラグインからの同じ名前の2つのルートは、すべてのベンダーが一意の名前空間を使用する必要があります。
これを怠ることは、テーマやプラグインでベンダー関数のプレフィックス、クラスのプレフィックス、またはクラスの名前空間を使用しないことと類似しており、これは非常に悪いことです。

名前空間を使用することの利点は、クライアントがカスタム API のサポートを検出できることです。API インデックスは、サイトで利用可能な名前空間をリストアップします。

{
  "name": "WordPress Site",
  "description": "Just another WordPress site",
  "url": "http://example.com/",
  "namespaces": [
    "wp/v2",
    "vendor/v1",
    "myplugin/v1",
    "myplugin/v2",
  ]
}

クライアントがサイト上に API が存在することを確認したい場合は、このリストと照合して確認することができます。(詳細については、発見のガイドを参照してください)

引数

デフォルトでは、ルートはリクエストから渡されたすべての引数を受け取ります。これらの引数は一つのパラメーターセットにマージされ、Request オブジェクトに追加され、エンドポイントの最初のパラメーターとして渡されます。

<?php
function my_awesome_func( WP_REST_Request $request ) {
  // You can access parameters via direct array access on the object:
  $param = $request['some_param'];
 
  // Or via the helper method:
  $param = $request->get_param( 'some_param' );
 
  // You can get the combined, merged set of parameters:
  $parameters = $request->get_params();
 
  // The individual sets of parameters are also available, if needed:
  $parameters = $request->get_url_params();
  $parameters = $request->get_query_params();
  $parameters = $request->get_body_params();
  $parameters = $request->get_json_params();
  $parameters = $request->get_default_params();
 
  // Uploads aren't merged in, but can be accessed separately:
  $parameters = $request->get_file_params();
}

(パラメータがどのようにマージされるかを知るには、WP_REST_Request::get_parameter_order()のソースをチェックしてください。基本的な順序はボディ、クエリ、URLです。)

通常、受け取ったすべてのパラメータは変更されていません。しかし、ルートを登録する際に引数を登録することができるので、これらの引数に対してサニタイズとバリデーションを実行することができます。

リクエストに Content-type: application/json ヘッダが設定されていて、ボディに有効な JSON がある場合、 get_json_params() は解析した JSON ボディを連想配列として返します。

register_rest_route関数のargsは、各エンドポイントのキー引数のマップとして定義されます。 (callbackオプションの隣)
このマップは、キーの引数の名前を使用し、値はその引数のオプションのマップとなります。この配列には、defaultrequiredsanitize_callbackvalidate_callback のキーを含めることができます。

default

引数のデフォルト値として使用されます。

required

trueとして定義され、その引数に値が渡されなかった場合、エラーが返されます。デフォルト値が設定されていても、引数には常に値が設定されているため、影響はありません。

validate_callback

引数の値を渡す関数を渡すために使用します。この関数は、値が有効であれば真を返し、無効であれば偽を返します。(要するにバリデーション、値チェック)

sanitize_callback

メインのコールバックに渡す前に、引数の値をサニタイズするための関数を渡すために使用します。

sanitize_callbackvalidate_callback を使用することで、メインのコールバックはリクエストを処理するためだけに動作し、 WP_REST_Response クラスを使用して返されるデータを準備することができます。この2つのコールバックを使用することで、処理時に入力が有効であると想定することができるようになります。

次の例では、渡されたパラメータが常に数値であることを確認できます。

<?php
add_action( 'rest_api_init', function () {
  register_rest_route( 'myplugin/v1', '/author/(?P<id>\d+)', array(
    'methods' => 'GET',
    'callback' => 'my_awesome_func',
    'args' => array(
      'id' => array(
        'validate_callback' => function($param, $request, $key) {
          return is_numeric( $param );
        }
      ),
    ),
  ) );
} );

validate_callback に関数名を渡すこともできますが、is_numeric のような特定の関数を直接渡すと、余分なパラメータを渡した場合に警告が出るだけでなく、NULL を返して無効なデータでコールバック関数が呼び出されることになります。最終的には WordPress コアでこの問題を解決したいと考えています。

代わりに 'sanitize_callback' => 'absint' のようなものを使うこともできますが、バリデーションを行うとエラーが発生するので、 クライアントは何が間違ったことをしているのかを理解することができます。サニタイズは、エラー (無効な HTML など) をスローするよりも入力されたデータを変更したい場合に便利です。

戻り値

コールバックがコールされた後、戻り値は JSON に変換されてクライアントに返されます。これにより、基本的に任意の形式のデータを返すことができます。上の例では、文字列か null を返していますが、これらは API によって自動的に処理され、JSON に変換されます。

※訳者注:上の例というのは、「基本」のセクションにあるコードのことです。

他のWordPress関数と同様に、WP_Errorインスタンスを返すこともできます。このエラー情報は、500 ステータスコード(サーバー内部エラー)と共にクライアントに渡されます。
WP_Errorインスタンスデータのステータスオプションを、不正な入力データの場合は400などのコードに設定することで、さらにエラーをカスタマイズすることができます。

次の例では、エラーのインスタンスを返すことができます。
(著者の最新の投稿タイトルを取得するサンプルコード)

<?php
/**
 * Grab latest post title by an author!
 *
 * @param array $data Options for the function.
 * @return string|null Post title for the latest,
 * or null if none.
 */
function my_awesome_func( $data ) {
  $posts = get_posts( array(
    'author' => $data['id'],
  ) );
 
  if ( empty( $posts ) ) {
    return new WP_Error( 'no_author', 'Invalid author', array( 'status' => 404 ) );
  }
 
  return $posts[0]->post_title;
}

著者が自分に属する投稿を持っていない場合、クライアントに404 Not Foundエラーが返されます。

HTTP/1.1 404 Not Found
 
[{
   "code": "no_author",
   "message": "Invalid author",
   "data": { "status": 404 }
}]

より高度な使い方としては、WP_REST_Response オブジェクトを返すことができます。このオブジェクトは通常のボディデータを「ラップ」しますが、カスタムステータスコードやカスタムヘッダーを返すことができます。また、レスポンスにリンクを追加することもできます。これを使う最も簡単な方法はコンストラクタを使うことです。

<?php
$data = array( 'some', 'response', 'data' );
 
// Create the response object
$response = new WP_REST_Response( $data );
 
// Add a custom status code
$response->set_status( 201 );
 
// Add a custom header
$response->header( 'Location', 'http://example.com/' );

既存のコールバックをラップする際には、必ず戻り値に rest_ensure_response() を使用しなければなりません。これはエンドポイントから返された生のデータを受け取り、自動的に WP_REST_Response に変換してくれます。(WP_Error は、適切なエラー処理を可能にするために WP_REST_Response に変換されないことに注意してください)

パーミッションコールバック

エンドポイントのパーミッションコールバックを登録することもできます。これは、実際のコールバックが呼び出される前に、ユーザーがアクション(読み込み、更新など)を実行できるかどうかをチェックする機能です。
これにより、最初にリクエストを試みることなく、指定されたURLで実行できるアクションをAPIがクライアントに伝えることができます。

このコールバックは permission_callback として登録することができます。このコールバックは、ブール値か WP_Error インスタンスを返す必要があります。この関数が true を返す場合、レスポンスは処理されます。false を返した場合は、デフォルトのエラーメッセージが返され、リクエストは処理を続行しません。WP_Error を返した場合は、そのエラーがクライアントに返されます。

パーミッションコールバックはリモート認証後に実行され、現在のユーザーを設定します。つまり、認証されたユーザがアクションに適切な能力を持っているかどうかをチェックするために current_user_can を使用したり、現在のユーザ ID に基づいたその他のチェックを行うことができます。
可能であれば、常に current_user_can を使用すべきです。ユーザーがログインしているかどうかをチェックするのではなく、アクションを実行できるかどうかをチェックします。

permission_callback を登録したら、リクエストを認証する必要があるかもしれないし、 (例えば nonce パラメータを含むかなど) rest_forbiddenエラーを受け取るかもしれません。
詳細は 認証 を参照ください。

先ほどの例に引き続き、編集者以上の人だけがこの著者データを閲覧できるようにすることができます。
ここではいろいろな機能をチェックできますが、一番良いのは edit_others_posts で、これはエディタの核となる機能です。これを行うには、ここにコールバックが必要です。

<?php
add_action( 'rest_api_init', function () {
  register_rest_route( 'myplugin/v1', '/author/(?P<id>\d+)', array(
    'methods' => 'GET',
    'callback' => 'my_awesome_func',
    'args' => array(
      'id' => array(
        'validate_callback' => 'is_numeric'
      ),
    ),
    'permission_callback' => function () {
      return current_user_can( 'edit_others_posts' );
    }
  ) );
} );

permission_callbackはRequestオブジェクトを最初のパラメータとして受け取るので、必要に応じて、リクエストの引数に基づいたチェックを行うことができます。

コントローラーパターン

コントローラーパターンは、APIを使って複雑なエンドポイントを操作するためのベストプラクティスです。

このセクションを読む前に「内部クラスの拡張」を読むことをお勧めします。
そうすることで、デフォルトのルートで使われているパターンに慣れることができます。
リクエストの処理に使用するクラスが WP_REST_Controller クラスやそれを継承するクラスを継承している必要はありませんが、継承することでそれらのクラスで行われている作業を継承することができます。
また、使用しているコントローラのメソッドに基づいたベストプラクティスに従っていることも安心できます。

コアとなるコントローラは、便利なヘルパーと一緒に、RESTの規約に合わせた一般的な名前のメソッドのセット以上のものはありません。コントローラは register_routes メソッドでルートを登録し、get_items, get_item, create_item, update_item, delete_item でリクエストに応答します。このパターンに従うことで、エンドポイントのステップや機能を見逃すことがありません。

コントローラを使うには、まずベースとなるコントローラをサブクラス化する必要があります。これで基本となるメソッドのセットができ、独自の振る舞いを追加できるようになります。

コントローラをサブクラス化したら、動作させるためにクラスのインスタンスを作成する必要があります。これは、 rest_api_init にフックされたコールバックの中で行う必要があります。
通常のコントローラのパターンは、このコールバックの内部で $controller->register_routes() をコールします。

以下は「スターター」カスタムルートです。

<?php
 
class Slug_Custom_Route extends WP_REST_Controller {
 
  /**
   * Register the routes for the objects of the controller.
   */
  public function register_routes() {
    $version = '1';
    $namespace = 'vendor/v' . $version;
    $base = 'route';
    register_rest_route( $namespace, '/' . $base, array(
      array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => array( $this, 'get_items' ),
        'permission_callback' => array( $this, 'get_items_permissions_check' ),
        'args'                => array(
 
        ),
      ),
      array(
        'methods'             => WP_REST_Server::CREATABLE,
        'callback'            => array( $this, 'create_item' ),
        'permission_callback' => array( $this, 'create_item_permissions_check' ),
        'args'                => $this->get_endpoint_args_for_item_schema( true ),
      ),
    ) );
    register_rest_route( $namespace, '/' . $base . '/(?P<id>[\d]+)', array(
      array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => array( $this, 'get_item' ),
        'permission_callback' => array( $this, 'get_item_permissions_check' ),
        'args'                => array(
          'context' => array(
            'default' => 'view',
          ),
        ),
      ),
      array(
        'methods'             => WP_REST_Server::EDITABLE,
        'callback'            => array( $this, 'update_item' ),
        'permission_callback' => array( $this, 'update_item_permissions_check' ),
        'args'                => $this->get_endpoint_args_for_item_schema( false ),
      ),
      array(
        'methods'             => WP_REST_Server::DELETABLE,
        'callback'            => array( $this, 'delete_item' ),
        'permission_callback' => array( $this, 'delete_item_permissions_check' ),
        'args'                => array(
          'force' => array(
            'default' => false,
          ),
        ),
      ),
    ) );
    register_rest_route( $namespace, '/' . $base . '/schema', array(
      'methods'  => WP_REST_Server::READABLE,
      'callback' => array( $this, 'get_public_item_schema' ),
    ) );
  }
 
  /**
   * Get a collection of items
   *
   * @param WP_REST_Request $request Full data about the request.
   * @return WP_Error|WP_REST_Response
   */
  public function get_items( $request ) {
    $items = array(); //do a query, call another class, etc
    $data = array();
    foreach( $items as $item ) {
      $itemdata = $this->prepare_item_for_response( $item, $request );
      $data[] = $this->prepare_response_for_collection( $itemdata );
    }
 
    return new WP_REST_Response( $data, 200 );
  }
 
  /**
   * Get one item from the collection
   *
   * @param WP_REST_Request $request Full data about the request.
   * @return WP_Error|WP_REST_Response
   */
  public function get_item( $request ) {
    //get parameters from request
    $params = $request->get_params();
    $item = array();//do a query, call another class, etc
    $data = $this->prepare_item_for_response( $item, $request );
 
    //return a response or error based on some conditional
    if ( 1 == 1 ) {
      return new WP_REST_Response( $data, 200 );
    } else {
      return new WP_Error( 'code', __( 'message', 'text-domain' ) );
    }
  }
 
  /**
   * Create one item from the collection
   *
   * @param WP_REST_Request $request Full data about the request.
   * @return WP_Error|WP_REST_Response
   */
  public function create_item( $request ) {
    $item = $this->prepare_item_for_database( $request );
 
    if ( function_exists( 'slug_some_function_to_create_item' ) ) {
      $data = slug_some_function_to_create_item( $item );
      if ( is_array( $data ) ) {
        return new WP_REST_Response( $data, 200 );
      }
    }
 
    return new WP_Error( 'cant-create', __( 'message', 'text-domain' ), array( 'status' => 500 ) );
  }
 
  /**
   * Update one item from the collection
   *
   * @param WP_REST_Request $request Full data about the request.
   * @return WP_Error|WP_REST_Response
   */
  public function update_item( $request ) {
    $item = $this->prepare_item_for_database( $request );
 
    if ( function_exists( 'slug_some_function_to_update_item' ) ) {
      $data = slug_some_function_to_update_item( $item );
      if ( is_array( $data ) ) {
        return new WP_REST_Response( $data, 200 );
      }
    }
 
    return new WP_Error( 'cant-update', __( 'message', 'text-domain' ), array( 'status' => 500 ) );
  }
 
  /**
   * Delete one item from the collection
   *
   * @param WP_REST_Request $request Full data about the request.
   * @return WP_Error|WP_REST_Response
   */
  public function delete_item( $request ) {
    $item = $this->prepare_item_for_database( $request );
 
    if ( function_exists( 'slug_some_function_to_delete_item' ) ) {
      $deleted = slug_some_function_to_delete_item( $item );
      if ( $deleted ) {
        return new WP_REST_Response( true, 200 );
      }
    }
 
    return new WP_Error( 'cant-delete', __( 'message', 'text-domain' ), array( 'status' => 500 ) );
  }
 
  /**
   * Check if a given request has access to get items
   *
   * @param WP_REST_Request $request Full data about the request.
   * @return WP_Error|bool
   */
  public function get_items_permissions_check( $request ) {
    //return true; <--use to make readable by all
    return current_user_can( 'edit_something' );
  }
 
  /**
   * Check if a given request has access to get a specific item
   *
   * @param WP_REST_Request $request Full data about the request.
   * @return WP_Error|bool
   */
  public function get_item_permissions_check( $request ) {
    return $this->get_items_permissions_check( $request );
  }
 
  /**
   * Check if a given request has access to create items
   *
   * @param WP_REST_Request $request Full data about the request.
   * @return WP_Error|bool
   */
  public function create_item_permissions_check( $request ) {
    return current_user_can( 'edit_something' );
  }
 
  /**
   * Check if a given request has access to update a specific item
   *
   * @param WP_REST_Request $request Full data about the request.
   * @return WP_Error|bool
   */
  public function update_item_permissions_check( $request ) {
    return $this->create_item_permissions_check( $request );
  }
 
  /**
   * Check if a given request has access to delete a specific item
   *
   * @param WP_REST_Request $request Full data about the request.
   * @return WP_Error|bool
   */
  public function delete_item_permissions_check( $request ) {
    return $this->create_item_permissions_check( $request );
  }
 
  /**
   * Prepare the item for create or update operation
   *
   * @param WP_REST_Request $request Request object
   * @return WP_Error|object $prepared_item
   */
  protected function prepare_item_for_database( $request ) {
    return array();
  }
 
  /**
   * Prepare the item for the REST response
   *
   * @param mixed $item WordPress representation of the item.
   * @param WP_REST_Request $request Request object.
   * @return mixed
   */
  public function prepare_item_for_response( $item, $request ) {
    return array();
  }
 
  /**
   * Get the query params for collections
   *
   * @return array
   */
  public function get_collection_params() {
    return array(
      'page'     => array(
        'description'       => 'Current page of the collection.',
        'type'              => 'integer',
        'default'           => 1,
        'sanitize_callback' => 'absint',
      ),
      'per_page' => array(
        'description'       => 'Maximum number of items to be returned in result set.',
        'type'              => 'integer',
        'default'           => 10,
        'sanitize_callback' => 'absint',
      ),
      'search'   => array(
        'description'       => 'Limit results to those matching a string.',
        'type'              => 'string',
        'sanitize_callback' => 'sanitize_text_field',
      ),
    );
  }
}