# GraphQL API を実装する ## はじめに このドキュメントでは、GraphQLを実装し、リクエストする仕組みを読み解くために、Wordpress に GraphQL API を実装するプラグイン [GraphQL API for WordPress](https://www.wpgraphql.com/) の内部を調査しています。GraphQLの考え方については、[GraphQL 公式ドキュメント](https://graphql.org/)や周辺ライブラリなどのドキュメント・解説などを主に索引しています。 <br> ## GraphQL は API のためのクエリ言語 > [GraphQLの概要](https://graphql.org/learn/)より ``` GraphQL is a query language for your API ``` ### API とは? 情報の要求が1つのプログラム(アプリケーションなど)によって、APIエンドポイント(URL)に送信され、APIサーバーに存在するデジタルリソースを取得する仕様または実装のいずれかを指します。 ### 比較 > 従来の RESTful な API へのリクエスト ```javascript GET /post GET /pagenavi GET /news ``` > GraphQL へのリクエスト ```javascript POST /graphql { post, pagenavi, news } ``` 従来の RESTful な API では、取得させたい情報ごとにエンドポイントを定義し、ハンドラー(コールバック)を追加します。そのため、取得したい情報ごとに該当するエンドポイントへリクエストをする必要があります。 逆に GraphQL に最低限必要なエンドポイントは一つだけで、ペイロードとして取得させたい情報のクエリを含んだ POSTをします。そのため、リクエストを解決するためのスキーマ(階層構造)を定義し、リゾルバー(関連する処理を行う関数)を実装します。 ##### つまり・・・ ##### 従来のAPI 一つのAPIエンドポイントから単一の情報を取得する - 取得したい情報がごとにURLを定義する必要がある。 - URLの乱立を防ぐためにある程度一般化された、冗長な情報になりやすい。 ##### GraphQL 必要な情報をクエリによって取得できるように、スキーマ(型の階層構造)と、型ごとにリゾルバ(処理を実行する関数)を定義する必要がある。 <br> #### GraphQL のメリット - 1回のリクエストで、全ての必要な情報を得ることができる。 - つまり、余分なものが取得されません。 - スキーマの実装時、データ型を定義するので、クライアント(アプリケーション)とサーバー間の食い違いが減少します。 #### GraphQL のデメリット - キャッシュが REST よりも複雑になります。 - API 保守担当者には、保守可能な GraphQL スキーマを作成する作業が必要になります。 <br> #### まとめ API のエンドポイントに対して一つのコールバックを用意する従来のやり方に対して、API用にデータの階層構造を定義して、クエリによって必要なデータを取得できるようにしよう、という仕組みが GraphOLの実装の概要と言えると思います。 <br> <br> ## スキーマ ( Schema ) と リゾルバ ( Resolver ) ```graphql type Book { title: String author: Author } type Author { name: String books: [Book] } ``` スキーマは、フィールドを持つ型とそれらの型の間にある関係を定義します。 上記はスキーマ定義言語で記述されていますが、Bookという型は単一の著者(Author)を持ち、Authorという型は本(Book)の配列を持つことが定義されていることが判るかと思います。 ##### つまり.... ##### スキーマ (Schema) フィールドを持つ型の間の関係を定義したもの ##### リゾルバ (Resolver) 「データベースから、ある著者の本のリストを取得してくる」ような処理を定義したもの <br> <br> ## データのフォーマット・概念 GraphQLは、型の関係性を定義することでデータのリストを取得します。 データのリクエストに使用するクエリには、Node・Edge・Connection と呼ばれるデータの概念が使用されます。 ![](https://i.imgur.com/hFCT4cy.png) ### Node 「フィールドをもつ型」に一致する、単一なデータのことを指す。 ### Edge 「Nodeのデータと、他のNodeとの関連性を示すデータとのデータセット」のことを指す。 ### Connection 上記の図の通り、「PersonA と PersonC が Friend である」 といったような関係を Connection と呼びます。 グラフの頂点を Node、 また頂点を含む辺を Edge と捉えると概念上でのデータのスコープがわかりやすいかと思います。 <br> <br> <br> ![](https://i.imgur.com/xrds8ds.png) #### Server Side と Client Side Server Side( Wordpress )にある、Persons というデータテーブルに、Client Side( Next.js )は 「Friends である Person を 4人ずつ出力する」 クエリをリクエストし、データのリストを取得する。 #### pageInfo Connection には、前後の Connection との関係性を示す、 pageInfo という情報が含まれる。 Connection の位置情報である startCursor、endCursor や、 hasPreviousPage, hasNextPage といったデータの継続性を示すものが該当する。 #### Cursor データのリストとして Node が並んでいる時、Node の前後( before, after )には Cursor という位置情報が設定される。前後の Cursor と Node を含むものを Edge と呼ぶ。 pageInfo に含まれる、 startCursor は最前列の Node の before の Cursor であり、同様に endCursor は最後尾の after の Cursor を指す。 <br> <br> #### 実際のクエリとレスポンス 実際の WordPress 考えてみると、下記のようになる。 > Query ```graphql= query { # カスタム投稿「イベント紹介」を示す events にパラメータを指定して Connection を指定します events( first: 2, where: { dateQuery: {before: {year: 2022, month: 7}}, orderby: {field: DATE, order: ASC}, status: PUBLISH } ) { # Connection の内部では取得したいフィールドを指定します edges { node { uri title link date } cursor } pageInfo { hasNextPage hasPreviousPage } } } ``` ##### Connection カスタム投稿「イベント紹介」の"2020年5月以前のもの"の最初の2つ ##### Edge 各投稿の間にある位置を示す 「cursor」と、各投稿のデータである 「node」を含んだオブジェクト ##### Node 各投稿の詳細ページスラッグ、URL、タイトル、投稿日付を含んだオブジェクト > Responce ```json= { "data": { "events": { "edges": [ { "node": { "uri": "/event/kirapika/", "title": "きらピカおばけがでた!", "link": "http://localhost/event/kirapika/", "date": "2019-04-25T17:31:13" }, "cursor": "YXJyYXljb25uZWN0aW9uOjQ4OQ=="  # cursorはhash値で定義されています }, { "node": { "uri": "/event/konchu/", "title": "くうそうこんちゅうのせかい", "link": "http://localhost/event/konchu/", "date": "2019-04-25T17:36:19" }, "cursor": "YXJyYXljb25uZWN0aW9uOjQ5Ng==" } ], "pageInfo": { "hasNextPage": true, "hasPreviousPage": false # Connection で最初の二つ、と指定したので、続きがあるかどうかを取得しています } } }, # レスポンスにはエラーなどを含むので、リクエストした情報は data というキーでラップされています "extensions": { "debug": [] } } ``` <br> <br> ## Wordpress での実装 それでは実際に WordPress に API を設置し、データをリクエストする流れを確認します。 ### 1. プラグインを有効化して API を実装する [GraphQL API for WordPress](https://www.wpgraphql.com/) を利用した前提で検証しております。(もちろん使用せずに Composer ライブラリ 等を活用して必要なものだけ API 実装しても良いかと思います。[ivome/graphql-relay](https://github.com/ivome/graphql-relay-php), [webonyx/graphql-php](https://github.com/webonyx/graphql-php) など ) Wordpress.org にあるので、UIから プラグイン追加が可能です。wpackagist.org にもあるので、Composer 管理も可能です。 ```shell # WP CLIでインストール % wp plugin install wp-graphql --activate # Composerでインストール % composer require wpackagist-plugin/wp-graphql ``` ### 2. APIエンドポイントを設定する デフォルトでは `/graphql` に設定されています。 プラグインを有効化するとUIから設定が可能です。 ![](https://i.imgur.com/2RyGPyX.png) 下記の通りテーマファイル内で定義すると、 上記のようにグレーアウトされ、UIから変更できなくなります。 > functions.php など ```php /* エンドポイントを設定 */ add_filter( 'graphql_endpoint', function () : string { return 'my_endpoint'; }); /* デバッグを有効化 */ define( 'GRAPHQL_DEBUG', true ); ``` ### 3. 必要な型を追加する デフォルトでは下記のような型が定義されている。 > WPGraphQL.php ```php public static function show_in_graphql() { global $wp_post_types, $wp_taxonomies; // Adds GraphQL support for attachments. if ( isset( $wp_post_types['attachment'] ) ) { $wp_post_types['attachment']->show_in_graphql = true; $wp_post_types['attachment']->graphql_single_name = 'mediaItem'; $wp_post_types['attachment']->graphql_plural_name = 'mediaItems'; } // Adds GraphQL support for pages. if ( isset( $wp_post_types['page'] ) ) { $wp_post_types['page']->show_in_graphql = true; $wp_post_types['page']->graphql_single_name = 'page'; $wp_post_types['page']->graphql_plural_name = 'pages'; } // Adds GraphQL support for posts. if ( isset( $wp_post_types['post'] ) ) { $wp_post_types['post']->show_in_graphql = true; $wp_post_types['post']->graphql_single_name = 'post'; $wp_post_types['post']->graphql_plural_name = 'posts'; } // Adds GraphQL support for categories. if ( isset( $wp_taxonomies['category'] ) ) { $wp_taxonomies['category']->show_in_graphql = true; $wp_taxonomies['category']->graphql_single_name = 'category'; $wp_taxonomies['category']->graphql_plural_name = 'categories'; } // Adds GraphQL support for tags. if ( isset( $wp_taxonomies['post_tag'] ) ) { $wp_taxonomies['post_tag']->show_in_graphql = true; $wp_taxonomies['post_tag']->graphql_single_name = 'tag'; $wp_taxonomies['post_tag']->graphql_plural_name = 'tags'; } // Adds GraphQL support for post formats. if ( isset( $wp_taxonomies['post_format'] ) ) { $wp_taxonomies['post_format']->show_in_graphql = true; $wp_taxonomies['post_format']->graphql_single_name = 'postFormat'; $wp_taxonomies['post_format']->graphql_plural_name = 'postFormats'; } } ``` WordPressに定義されている post_type 、 taxonomies についても show_in_graphql の検証をし、それぞれの単数名 graphql_single_name と複数名 graphql_plural_name を使って型を定義していることがわかります。 カスタム投稿タイプ、カスタムタクソノミーをAPIに露出させる時は下記のような設定で追加できます。 > custom_post.php ```diff= register_post_type( 'event', array( 'labels' => array( 'name' => __( 'イベント紹介' ), 'singular_name' => __( 'イベント紹介' ), 'add_new_item' => __('イベント紹介を追加'), 'edit_item' => __('イベント紹介を編集'), 'new_item' => __('イベント紹介を追加') ), 'public' => true, 'supports' => array('title','editor','thumbnail'), 'menu_position' =>5, 'show_ui' => true, 'has_archive' => true, 'hierarchical' => false, + 'show_in_graphql' => true, + 'graphql_single_name' => 'event', + 'graphql_plural_name' => 'events' ) ); ``` ### 4. 必要なデータを取得する query を作成する #### GraphQL IDE プラグインを有効化すると、WordPress管理画面上で UI から Graph QL IDE という検証ツールが起動できるようになります。 追加した型の定義なども`Query Composer`というリストに追加されるので、さまざまな検証ができます。リクエストの結果が表示されます。 ![](https://i.imgur.com/2E7Ucoi.png) #### カスタム投稿タイプ 「イベント紹介」 のリストを取得する 型を設定するときに、複数形名( plural_name )を設定しました。 これを利用して、型のデータのリストを取得します。 > index.tsx : query ```graphql= { events { edges { node { uri title link date } } nodes { date link uri title } } } ``` レスポンスは下記の通り。 最上位には `error`, `extention : [ debug ]` などの情報も含まれるため、 リクエストしたデータリストは data に格納されています。 > Responce ( json ) ```json= { "data": { "events": { "edges": [ { "node": { "uri": "/event/acchi-cocchi-catch/", "title": "アッチコッチキャッチ", "link": "http://localhost/event/acchi-cocchi-catch/", "date": "2020-05-19T10:22:09" } }, { "node": { "uri": "/event/yasai-to-moji/", "title": "やさいともじであそぼう!", "link": "http://localhost/event/yasai-to-moji/", "date": "2020-05-17T12:05:24" } }, { "node": { "uri": "/event/konchu/", "title": "くうそうこんちゅうのせかい", "link": "http://localhost/event/konchu/", "date": "2019-04-25T17:36:19" } }, { "node": { "uri": "/event/kirapika/", "title": "きらピカおばけがでた!", "link": "http://localhost/event/kirapika/", "date": "2019-04-25T17:31:13" } } ], "nodes": [ { "date": "2020-05-19T10:22:09", "link": "http://localhost/event/acchi-cocchi-catch/", "uri": "/event/acchi-cocchi-catch/", "title": "アッチコッチキャッチ" }, { "date": "2020-05-17T12:05:24", "link": "http://localhost/event/yasai-to-moji/", "uri": "/event/yasai-to-moji/", "title": "やさいともじであそぼう!" }, { "date": "2019-04-25T17:36:19", "link": "http://localhost/event/konchu/", "uri": "/event/konchu/", "title": "くうそうこんちゅうのせかい" }, { "date": "2019-04-25T17:31:13", "link": "http://localhost/event/kirapika/", "uri": "/event/kirapika/", "title": "きらピカおばけがでた!" } ] } }, } ``` 上記の通り、この場合だと上段 `edges` と 下段 `nodes` が取得できる情報は同じように見えますが、 `edges` には`node`を含んだ`edge`のリストが格納されていて、 `nodes` には`node`のリストが格納されていることがわかります。 下記の例では「イベント投稿である」だけではなく、詳細に`connection` の条件を指定して`edge`のリストを取得しています。`connection` には`edge`のリストである`edges`以外にも`pageInfo`などが含まれます。 > index.ts : query ```diff= { events( + where: { + dateQuery: {before: {year: 2020, month: 5}}, + orderby: {field: DATE, order: ASC}, + status: PUBLISH + } ) { edges { node { uri title link date } cursor } pageInfo { hasNextPage hasPreviousPage } } } ``` GraphQlでは`where`句を利用して、より詳細な`Connection`を指定できます。 上記の例では用意された `dateQuery` というパラメータを利用して「2020年5月以前のもの」を取得しています。 レスポンスは下記の通りです。 > Responce ( json ) ```json= { "data": { "events": { "edges": [ { "node": { "uri": "/event/kirapika/", "title": "きらピカおばけがでた!", "link": "http://localhost/event/kirapika/", "date": "2019-04-25T17:31:13" }, "cursor": "YXJyYXljb25uZWN0aW9uOjQ4OQ==" }, { "node": { "uri": "/event/konchu/", "title": "くうそうこんちゅうのせかい", "link": "http://localhost/event/konchu/", "date": "2019-04-25T17:36:19" }, "cursor": "YXJyYXljb25uZWN0aW9uOjQ5Ng==" } ], "pageInfo": { "hasNextPage": false, "hasPreviousPage": false } } }, } ``` `dateQuery`のように、各`Connection`が受け入れることのできるパラメータに関しては、IDE の`Query Composer` というリストから確認できます。実装時はプラグインの`/src/Connection`内の各`Connection`での定義を参照します。 下記は `PostObjects` の`Connection` 設定ファイル内の関数です。 > PostObjects.php ```php public static function get_connection_args( $args = [], $post_type_object = null ) { $fields = [ /** * Search Parameter * * @see : https://codex.wordpress.org/Class_Reference/WP_Query#Search_Parameter * @since 0.0.5 */ 'search' => [ 'name' => 'search', 'type' => 'String', 'description' => __( 'Show Posts based on a keyword search', 'wp-graphql' ), ], /** * Post & Page Parameters * * @see : https://codex.wordpress.org/Class_Reference/WP_Query#Post_.26_Page_Parameters * @since 0.0.5 */ 'id' => [ 'type' => 'Int', 'description' => __( 'Specific ID of the object', 'wp-graphql' ), ], 'name' => [ 'type' => 'String', 'description' => __( 'Slug / post_name of the object', 'wp-graphql' ), ], 'title' => [ 'type' => 'String', 'description' => __( 'Title of the object', 'wp-graphql' ), ], 'parent' => [ 'type' => 'ID', 'description' => __( 'Use ID to return only children. Use 0 to return only top-level items', 'wp-graphql' ), ], 'parentIn' => [ 'type' => [ 'list_of' => 'ID', ], 'description' => __( 'Specify objects whose parent is in an array', 'wp-graphql' ), ], 'parentNotIn' => [ 'type' => [ 'list_of' => 'ID', ], 'description' => __( 'Specify posts whose parent is not in an array', 'wp-graphql' ), ], 'in' => [ 'type' => [ 'list_of' => 'ID', ], 'description' => __( 'Array of IDs for the objects to retrieve', 'wp-graphql' ), ], 'notIn' => [ 'type' => [ 'list_of' => 'ID', ], 'description' => __( 'Specify IDs NOT to retrieve. If this is used in the same query as "in", it will be ignored', 'wp-graphql' ), ], 'nameIn' => [ 'type' => [ 'list_of' => 'String', ], 'description' => __( 'Specify objects to retrieve. Use slugs', 'wp-graphql' ), ], /** * Password parameters * * @see : https://codex.wordpress.org/Class_Reference/WP_Query#Password_Parameters * @since 0.0.2 */ 'hasPassword' => [ 'type' => 'Boolean', 'description' => __( 'True for objects with passwords; False for objects without passwords; null for all objects with or without passwords', 'wp-graphql' ), ], 'password' => [ 'type' => 'String', 'description' => __( 'Show posts with a specific password.', 'wp-graphql' ), ], /** * NOTE: post_type is intentionally not supported on connections to Single post types as * the connection to the singular Post Type already sets this argument as the entry * point to the Graph * * @see : https://codex.wordpress.org/Class_Reference/WP_Query#Type_Parameters * @since 0.0.2 */ /** * Status parameters * * @see : https://developer.wordpress.org/reference/classes/wp_query/#status-parameters * @since 0.0.2 */ 'status' => [ 'type' => 'PostStatusEnum', 'description' => __( 'Show posts with a specific status.', 'wp-graphql' ), ], /** * List of post status parameters */ 'stati' => [ 'type' => [ 'list_of' => 'PostStatusEnum', ], 'description' => __( 'Retrieve posts where post status is in an array.', 'wp-graphql' ), ], /** * Order & Orderby parameters * * @see : https://codex.wordpress.org/Class_Reference/WP_Query#Order_.26_Orderby_Parameters * @since 0.0.2 */ 'orderby' => [ 'type' => [ 'list_of' => 'PostObjectsConnectionOrderbyInput', ], 'description' => __( 'What paramater to use to order the objects by.', 'wp-graphql' ), ], 'dateQuery' => [ 'type' => 'DateQueryInput', 'description' => __( 'Filter the connection based on dates', 'wp-graphql' ), ], 'mimeType' => [ 'type' => 'MimeTypeEnum', 'description' => __( 'Get objects with a specific mimeType property', 'wp-graphql' ), ], ]; /** * If the connection is to a single post type, add additional arguments. * * If the connection is to many post types, the `$post_type_object` will not be an instance * of \WP_Post_Type, and we should not add these additional arguments because it * confuses the connection args for connections of plural post types. * * For example, if you have one Post Type that supports author and another that doesn't * we don't want to expose the `author` filter for a plural connection of multiple post types * as it's misleading to be able to filter by author on a post type that doesn't have * authors. * * If folks want to enable these arguments, they can filter them back in per-connection, but * by default WPGraphQL is exposing the least common denominator (the fields that are shared * by _all_ post types in a multi-post-type connection) * * Here's a practical example: * * Lets's say you register a "House" post type and it doesn't support author. * * The "House" Post Type will show in the `contentNodes` connection, which is a connection * to many post types. * * We could (pseudo code) query like so: * * { * contentNodes( where: { contentTypes: [ HOUSE ] ) { * nodes { * id * title * ...on House { * ...someHouseFields * } * } * } * } * * But since houses don't have authors, it doesn't make sense to have WPGraphQL expose the * ability to query four houses filtered by author. * * ``` *{ * contentNodes( where: { author: "some author input" contentTypes: [ HOUSE ] ) { * nodes { * id * title * ...on House { * ...someHouseFields * } * } * } * } * ``` * * We want to output filters on connections based on what's actually possible, and filtering * houses by author isn't possible, so exposing it in the Schema is quite misleading to * consumers. */ if ( isset( $post_type_object ) && $post_type_object instanceof WP_Post_Type ) { /** * Add arguments to post types that support author */ if ( true === post_type_supports( $post_type_object->name, 'author' ) ) { /** * Author $args * * @see : https://codex.wordpress.org/Class_Reference/WP_Query#Author_Parameters * @since 0.0.5 */ $fields['author'] = [ 'type' => 'Int', 'description' => __( 'The user that\'s connected as the author of the object. Use the userId for the author object.', 'wp-graphql' ), ]; $fields['authorName'] = [ 'type' => 'String', 'description' => __( 'Find objects connected to the author by the author\'s nicename', 'wp-graphql' ), ]; $fields['authorIn'] = [ 'type' => [ 'list_of' => 'ID', ], 'description' => __( 'Find objects connected to author(s) in the array of author\'s userIds', 'wp-graphql' ), ]; $fields['authorNotIn'] = [ 'type' => [ 'list_of' => 'ID', ], 'description' => __( 'Find objects NOT connected to author(s) in the array of author\'s userIds', 'wp-graphql' ), ]; } $connected_taxonomies = get_object_taxonomies( $post_type_object->name ); if ( ! empty( $connected_taxonomies ) && in_array( 'category', $connected_taxonomies, true ) ) { /** * Category $args * * @see : https://codex.wordpress.org/Class_Reference/WP_Query#Category_Parameters * @since 0.0.5 */ $fields['categoryId'] = [ 'type' => 'Int', 'description' => __( 'Category ID', 'wp-graphql' ), ]; $fields['categoryName'] = [ 'type' => 'String', 'description' => __( 'Use Category Slug', 'wp-graphql' ), ]; $fields['categoryIn'] = [ 'type' => [ 'list_of' => 'ID', ], 'description' => __( 'Array of category IDs, used to display objects from one category OR another', 'wp-graphql' ), ]; $fields['categoryNotIn'] = [ 'type' => [ 'list_of' => 'ID', ], 'description' => __( 'Array of category IDs, used to display objects from one category OR another', 'wp-graphql' ), ]; } if ( ! empty( $connected_taxonomies ) && in_array( 'post_tag', $connected_taxonomies, true ) ) { /** * Tag $args * * @see : https://codex.wordpress.org/Class_Reference/WP_Query#Tag_Parameters * @since 0.0.5 */ $fields['tag'] = [ 'type' => 'String', 'description' => __( 'Tag Slug', 'wp-graphql' ), ]; $fields['tagId'] = [ 'type' => 'String', 'description' => __( 'Use Tag ID', 'wp-graphql' ), ]; $fields['tagIn'] = [ 'type' => [ 'list_of' => 'ID', ], 'description' => __( 'Array of tag IDs, used to display objects from one tag OR another', 'wp-graphql' ), ]; $fields['tagNotIn'] = [ 'type' => [ 'list_of' => 'ID', ], 'description' => __( 'Array of tag IDs, used to display objects from one tag OR another', 'wp-graphql' ), ]; $fields['tagSlugAnd'] = [ 'type' => [ 'list_of' => 'String', ], 'description' => __( 'Array of tag slugs, used to display objects from one tag OR another', 'wp-graphql' ), ]; $fields['tagSlugIn'] = [ 'type' => [ 'list_of' => 'String', ], 'description' => __( 'Array of tag slugs, used to exclude objects in specified tags', 'wp-graphql' ), ]; } } elseif ( $post_type_object instanceof WP_Taxonomy ) { /** * Taxonomy-specific Content Type $args * * @see : https://developer.wordpress.org/reference/classes/wp_query/#post-type-parameters */ $args['contentTypes'] = [ 'type' => [ 'list_of' => 'ContentTypesOf' . \WPGraphQL\Utils\Utils::format_type_name( $post_type_object->graphql_single_name ) . 'Enum' ], 'description' => __( 'The Types of content to filter', 'wp-graphql' ), ]; } else { /** * Handle cases when the connection is for many post types */ /** * Content Type $args * * @see : https://developer.wordpress.org/reference/classes/wp_query/#post-type-parameters */ $args['contentTypes'] = [ 'type' => [ 'list_of' => 'ContentTypeEnum' ], 'description' => __( 'The Types of content to filter', 'wp-graphql' ), ]; } return array_merge( $fields, $args ); } ``` <br> <br> ## ACF の API実装 同プラグインのチームが開発している [wp-graphql-acf](https://github.com/wp-graphql/wp-graphql-acf) を利用して検証しました。 ### 1. プラグインの追加 `Composer`を利用してプラグインを管理している Wordpress なら該当するディレクトリで ```shell % composer require wp-graphql/wp-graphql-acf ``` まだ Wordpress.org から プラグイン > 新規追加では追加できないので、Composer、もしくは Zipファイルにて[リポジトリ](https://github.com/wp-graphql/wp-graphql-acf)からプルする必要があります。 ### 2. フィールドの定義を追加する ![](https://i.imgur.com/pq5FErf.png) #### Show in GraphQL 各フィールドグループ設定の下に追加され、APIへフィールドグループの追加ができます。 フィールドグループのどのフィールドを露出させるかも、同じ画面内で設定でき、デフォルトでは `Show in GraphQL` 設定によって、フィールドグループ内のフィールド全てが露出します。 #### graphql_field_name GraphQLでのフィールド名を設定し、フィールドとして使用できるようになります。 キャメルケースを使うように注釈がされており、内部のフィールド名がスネークケースやケバブケースだった場合は、自動でキャメルケースに変換され GraphQL のフィールド名としても定義されます。 > index.tsx : query ```diff= { events( where: { orderby: {field: DATE, order: ASC}, status: PUBLISH, dateQuery: {before: {month: 5, year: 2020}}} ) { edges { node { title + eventFileds { + eventEyecatch { + mediaItemUrl + altText + } + eventLogo { + altText + mediaItemUrl + } + eventReportTax { + link + name + } + } } } } } ``` > Responce ( json ) ```json= { "data": { "events": { "edges": [ { "node": { "title": "きらピカおばけがでた!", "eventFileds": { "eventEyecatch": { "mediaItemUrl": "http://localhost/wp-content/uploads/2020/05/thumb_event_obake.png", "altText": "" }, "eventLogo": { "altText": "", "mediaItemUrl": "http://localhost/wp-content/uploads/2019/04/logo_wawawa_liq.png" }, "eventReportTax": [ { "link": "http://localhost/report_tax/obake/", "name": "きらピカおばけがでた!" } ] } } }, { "node": { "title": "くうそうこんちゅうのせかい", "eventFileds": { "eventEyecatch": { "mediaItemUrl": "http://localhost/wp-content/uploads/2020/05/thumb_event_konchu.png", "altText": "" }, "eventLogo": null, "eventReportTax": [ { "link": "http://localhost/report_tax/konchu/", "name": "くうそうこんちゅうのせかい" } ] } } } ] } } } ``` ### 3. option page を型に追加する `acf_add_option_page` にも `show_in_graphql` パラメータが追加され、設定することで型として追加されるようになっている。 > function.php など ```diff= if( function_exists('acf_add_options_page') ) { acf_add_options_page(array( 'page_title' => 'トップページ設定', 'menu_title' => 'トップページ設定', 'menu_slug' => 'option_home', 'capability' => 'edit_posts', 'parent_slug' => '', 'position' => false, 'redirect' => false, + 'show_in_graphql' => true, )); } ``` > index.ts : query ```graphql= { optionHome { home_fields { homePickup { __typename ... on Post { title link } ... on Iroiro { title link } } } } } ``` #### Union Type 上記の通り、ACFフィールドの関連フィールド(relationship)を使用した際は、複数の型のいずれかが出力されるような場面があります。 こういったフィールドの型は [Union Type](https://graphql.org/learn/schema/#union-types) として定義されており、型が返却されるように実装されています。 そのため、返却された型に合わせて [Fragments](https://graphql.org/learn/queries/#fragments)、`Inline Fragments` を利用して、出力を調整するような記述をします。上記は`Inline Fragments`の例で、`Fragments`で記述すると次のようになります。 > index.tsx : query ```graphql= { optionHome { home_fields { homePickup { __typename ... PichUpPost ... PichUpIroiro } } } } fragment PickUpPost on Post { title link } fragment PickUpIroiro on Iroiro { title link } ``` `Fragments` は階層化されたオブジェクトなど、再利用性が高い時にも有効な型です。  レスポンスは下記の通りです。 > Responce : json > ```json= { "data": { "optionHome": { "homeFields": { "homePickup": [ { "__typename": "Iroiro", "link": "http://localhost/iroiro/iroiro-1050/", "title": "おしょうがつにあそぼうセール" }, { "__typename": "Iroiro", "link": "http://localhost/iroiro/iroiro-1028/", "title": "「やさいのきもちかるた」のLINEスタンプリリースしました!" }, { "__typename": "Iroiro", "link": "http://localhost/iroiro/iroiro-951/", "title": "「いしころぼうやのおかあさん」連載しました!" } ] } } } } ``` #### 4. 各フィールドの出力について [`/docs/fields`](https://github.com/wp-graphql/wp-graphql-acf/tree/develop/docs/fields) にドキュメントが用意されているので、実装時はそちらを参照します。 <br> <br> ## クライアントサイドの実装 Next.js などのクライアントサイドでは、GraphQL を Fetch する実装が必要になります。 調査したところ、下記のような方法で実装する例を確認できました。 ### 比較 #### GraphQL クライアント ライブラリを用いた実装 人気なものとして、 [Apollo](https://www.apollographql.com/) や [urql](https://formidable.com/open-source/urql/) などが多く紹介されていました。機能的にはApollo の方が充実している印象だったのですが、現状シンプルな urql を用いて検証を進めています。 実装コストは大幅に違わない印象です。 #### Fetch API を用いた実装 ブラウザ標準の[Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)を使用して実装する例です。 フォームの実装なども充分 Fetch API でもシンプルに実装できるので、クライアントライブラリなしで実装する方法も多くあります。 <br> <br> #### WIP : クライアントライブラリを使用するメリット 今後さまざまな用途に応じて双方の機能・実装コスト・パフォーマンス比較をしていきたいと思っています。 主には ビルド・CSR時 のキャッシュ戦略などや、エラーハンドリングやフックなどデフォルトで用意されているものが充実しているためライブラリを使用するという意見が散見されました。 urqlの実装については、[公式のドキュメント](https://formidable.com/open-source/urql/docs/advanced/server-side-rendering/#nextjs)に沿って検証していきます。 ### パッケージの設定 #### 環境の next-create-app環境を作成します。 ```shell= yarn create next-app --ts ``` 次に依存したパッケージを追加します。 ```shell= yarn add next-urql react-is urql graphql ``` 環境ごとにFetchする対象が違うことがほとんどかと思うので、 env を追加する。 > next.config.js ```diff= const nextConfig = { reactStrictMode: true, + env: { + WORDPRESS_URL: 'http://localhost', + }, } ``` ### ページの設定 ライブラリをインポートする > pages/home.tsx ```jsx= import { useQuery } from 'urql'; import { withUrqlClient } from 'next-urql'; ``` データをフェッチして、表示する > pages/home.tsx ```jsx= // useQuery フック で レスポンス を監視できる const Home: NextPage = () => { const [result] = useQuery({ query: `{ events { ... } }` }); return (...) } // fetch を含んで export default (NEXT.jsのページコンポーネントを出力)できる関数 export default withUrqlClient((_ssrExchange, ctx) => ({ // ...add your Client options here url: process.env.WORDPRESS_URL + '/graphql', }))(Home); ``` ### GraphQL クエリを管理する 1. クエリを `pages/[page].tsx` に直接書いた方がファイルの管理は楽 2. クエリを `/queries/page` などにクエリを分割したほうが可読性は良い 上記の処理を分割してディレクトリ管理する例 > queries/home.tsx ```typescript= export const QUERY = `{ events ( where: { orderby: {field: DATE, order: ASC}, status: PUBLISH, dateQuery: {before: {month: 5, year: 2020}}} ) { edges { node { title eventFields { eventEyecatch { mediaItemUrl altText } eventLogo { altText mediaItemUrl } eventReportTax { link name } } } } } }`; ``` > pages/index.tsx ```diff= + import { QUERY } from './queries/home' const Home: NextPage = () => { const [result] = useQuery({ - query: `{ - ... - }` + query: QUERY }); ... ``` ### レスポンスデータの型を処理する `useQuery` で返却された時点では、`urql`が良きに処理してくれるので、`<any, object>` 出力時点での型を整理していく。 > models/EventListItem.d.ts ```typescript= interface EventListEdge { node : { title : string, eventFields : { eventEyecatch? : { mediaItemUrl : string, altText : string } eventLogo? : { mediaItemUrl : string altText : string } eventReportTax? : { link : string name : string } } } } ``` #### レスポンスの値を調べる ##### node `data.events.edges` まで 取得しているのであれば、 `node` => nullで出力されることはない(= edge自体が作成されない) ##### eventFields ( ACF Field Group ) `Field Group` => 全て未記入でも フィールドグループは出力される ##### eventEyecatch ( ACF Field ) `Field Object` => オブジェクトで返されるフィールド自体が未記入だと`null`なので、null判定の必要がある。 `Field` => フィールドは未記入だと `""` 空の文字列 > pages/home.tsx ```jsx= const Events = () => { return ( <ul> {data.events.edges.map((edge: EventListEdge, index: number) => { const { title, eventFields } = edge.node; const { eventEyecatch, eventLogo, eventReportTax } = eventFields; return ( <li key={index}> <img src={eventEyecatch?.mediaItemUrl} ></img> <img src={eventLogo?.mediaItemUrl} ></img> <h3>{title}</h3> </li> ) })} </ul> ) }; ``` 階層が多くなると可読性が下がっていくかと思うので、 L5,6のような形でオブジェクトから要素を取り出す。 `edge` の時点で型を指定するように用意しておく。 <br> <br> ## Mutation と Subscription GraphQLのリクエストには大きく3つあり、 通常時の Query と、 Mutation、そして Subscription があります。Subscription は状態をもつ通信を指し、まだ検証途中です。 ### Mutation Mutationの概要は下記の通りに公式ドキュメントに記載されています。 ``` GraphQLのほとんどの議論はデータのフェッチに焦点を当てていますが、 完全なデータプラットフォームはサーバー側のデータを変更する方法も必要とします。 RESTでは、どのようなリクエストもサーバー上で何らかの副作用を引き起こす可能性がありますが、 慣習として、データを変更するためにGETリクエストを使用しないことが推奨されています。 GraphQLも同様で、技術的にはどのようなクエリでもデータの書き込みを引き起こす実装が可能です。 しかし、書き込みを引き起こす操作はすべて Mutation を経由して明示的に送信されるべきである という慣習を確立することは有用です。 クエリの場合と同じように、 もしミューテーションフィールドがオブジェクトタイプを返すなら、 ネストしたフィールドを要求することができます。 これは、更新後のオブジェクトの新しい状態を取得するのに便利です。 ``` ##### つまり・・・ GraphQLのどんなクエリでのリクエストの際にも書き込みを行うような実装は可能だけれど、書き込みを行うような処理の際にはMutationでリクエストしよう、というような[説明](https://graphql.org/learn/queries/#mutations)がなされています。 そのため、 uqrl や Appolo などの GraphQLクライアントでは、useQueryと似た形の活用として、useMutation というフックを用意しており、下記のような実装がなされている。 - キャッシュがない - リトライしない - 再フェッチしない - 関数を返す - コールバックがある(<=>Priomiseを返す) 投稿、コメントなどに加えて、メール送信なども Mutation で実装されていることが多いようです。 <br> <br> ## メールフォームの実装 ### 1. プラグインを利用して追加する 公式で紹介されている、[プラグイン: wpgraphql-send-email](https://www.wpgraphql.com/extenstion-plugins/wpgraphql-send-email/)を通して検証します。 > index.tsx : mutation ```graphql= mutation SEND_EMAIL { sendEmail( input: { to: "test@test.com" from: "test@test.com" subject: "test email" body: "test email" clientMutationId: "test" } ) { origin sent message } } ``` #### 1. WPGraphQl Send Mail Settingsで GraphQLエンドポイントを露出させる プラグインによって、Allowed Originsで設定しているURLからのリクエストに対してエンドポイントを露出させるか判定しています。Arrowed Origins でリクエストを許可する NextJs の環境を指定します。 #### 2. Mutation リクエストと実行する関数を定義する > index.tsx ```typescript= const [res, executeMutation] = useMutation(` mutation SendEmail( $input: SendEmailInput! ) { sendEmail(input: $input) { message origin sent } } `); ``` urql の実装の場合、useMutation は配列で 0: リクエストのレスポンス, 1: フックする関数を返します。 クエリを書く際は 引数と型、デフォルト値などを指定することができる `mutation(){}`でラップしたMutationにフィールド sendEmail を`sendEmail(input: $input){}` のような形で記入する。 > index.tsx ```typescript import { useState } from "react"; const Home: NextPage = () => { const [subject, setSubject] = useState(""); const [body, setBody] = useState(""); const [to, setTo] = useState(""); ... } ``` 次に、React の `useState` フックを使用して、フォームから受け取った値を状態として保つことができるようにする。 > index.tsx ```typescript= const handleSubmit = async (e: FormEvent) => { e.preventDefault(); executeMutation({ input: { to: to, subject: subject, body: body, clientMutationId: "contact", from: "test@test.com", }, }).then((result) => { if (result) { // email was sent successfully! console.log(result); } }); }; ``` formがサブミットした時に、先ほど用意した実行関数 executeMutation に、variables を指定して実行する。 > index.tsx ```typescript= import { FormEvent, useState } from "react"; /* Page */ const Home: NextPage = () => { <form onSubmit={handleSubmit}> <div> <label>題名</label> <input name={"subject"} type={"text"} value={subject} onChange={(e) => setSubject(e.target.value)} /> </div> <div> <label>宛先</label> <input name={"to"} type={"text"} value={to} onChange={(e) => setTo(e.target.value)} /> </div> <div> <label>メッセージ</label> <input name={"body"} type={"text"} value={body} onChange={(e) => setBody(e.target.value)} /> </div> <input type={"submit"} value={"送信"} /> </form> ... } ``` form入力部分では onChange、onSubmitフックを利用して、formから関数へ変数を引き渡せるようにする。 ### 2. API に独自の Mutation の型を実装する 上記プラグインの Mutation 実装部分は下記の通りです。 フィルターフックなどを活用して,管理者宛メールなど、複雑なメール送信に紐づいたアクションを実装する場合は、独自のメール送信サービスクラスを作成してMutationを実装する形を検討する形になるか考えています。 ```php= # This is the action that is executed as the GraphQL Schema is being built add_action('graphql_register_types', function () { # This function registers a mutation to the Schema. # The first argument, in this case `emailMutation`, is the name of the mutation in the Schema # The second argument is an array to configure the mutation. # The config array accepts 3 key/value pairs for: inputFields, outputFields and mutateAndGetPayload. register_graphql_mutation('sendEmail', [ # inputFields expects an array of Fields to be used for inputting values to the mutation 'inputFields' => [ 'to' => [ 'type' => 'String', 'description' => __('Who to send the email to', 'wp-graphql-send-mail'), ], 'from' => [ 'type' => 'String', 'description' => __('Who to send the email from', 'wp-graphql-send-mail'), ], 'replyTo' => [ 'type' => 'String', 'description' => __('Reply to address', 'wp-graphql-send-mail'), ], 'subject' => [ 'type' => 'String', 'description' => __('Subject of email', 'wp-graphql-send-mail'), ], 'body' => [ 'type' => 'String', 'description' => __('Body of email', 'wp-graphql-send-mail'), ], ], # outputFields expects an array of fields that can be asked for in response to the mutation # the resolve function is optional, but can be useful if the mutateAndPayload doesn't return an array # with the same key(s) as the outputFields 'outputFields' => [ 'sent' => [ 'type' => 'Boolean', 'description' => __('Was the email sent', 'wp-graphql-send-mail'), 'resolve' => function ($payload, $args, $context, $info) { return isset($payload['sent']) ? $payload['sent'] : null; } ], 'origin' => [ 'type' => 'String', 'description' => __('Origin that sent the request', 'wp-graphql-send-mail'), 'resolve' => function ($payload, $args, $context, $info) { return isset($payload['origin']) ? $payload['origin'] : null; } ], 'to' => [ 'type' => 'String', 'description' => __('Who the email got sent to', 'wp-graphql-send-mail'), 'resolve' => function ($payload, $args, $context, $info) { return isset($payload['to']) ? $payload['to'] : null; } ], 'replyTo' => [ 'type' => 'String', 'description' => __('reply To address used', 'wp-graphql-send-mail'), 'resolve' => function ($payload, $args, $context, $info) { return isset($payload['replyTo']) ? $payload['replyTo'] : null; } ], 'message' => [ 'type' => 'String', 'description' => __('Message', 'wp-graphql-send-mail'), 'resolve' => function ($payload, $args, $context, $info) { return isset($payload['message']) ? $payload['message'] : null; } ] ], # mutateAndGetPayload expects a function, and the function gets passed the $input, $context, and $info # the function should return enough info for the outputFields to resolve with 'mutateAndGetPayload' => function ($input, $context, $info) { // Do any logic here to sanitize the input, check user capabilities, etc $options = get_option('wpgraphql_send_mail_settings'); $allowedOrigins = array_map('trim', explode(',', $options['wpgraphql_send_mail_allowed_origins'])); $cc = trim($options['wpgraphql_send_mail_cc']); $defaultFrom = trim($options['wpgraphql_send_mail_from']); $defaultTo = trim($options['wpgraphql_send_mail_to']); $http_origin = trim($_SERVER['HTTP_ORIGIN']); $message = null; $canSend = false; $to = isset($input['to']) ? trim($input['to']) : trim($defaultTo); $replyTo = trim($input['replyTo']) ; if ($allowedOrigins) { if (in_array($http_origin, $allowedOrigins)) { $canSend = true; } else { $message = __('Origin not allowed, set origin in settings', 'wp-graphql-send-mail'); } } else { // if they did not enter any then we will allow any $canSend = true; } // Above tests passed and there is a to address and an email body if ($canSend && !empty($to) && !empty($input['body'])) { $subject = trim($input['subject']); $body = $input['body']; $from = trim($input['from']); $headers = array('Content-Type: text/html; charset=UTF-8'); if (isset($cc) && !empty($cc)) { $headers[] = 'Cc: ' . $cc; } if (isset($replyTo) && !empty($replyTo)) { $headers[] = 'Reply-To: ' . $replyTo; } if (isset($from) && !empty($from)) { $headers[] = 'From: ' . $from; } else if (isset($defaultFrom) && !empty($defaultFrom)) { $headers[] = 'From: ' . $defaultFrom; } $sent = wp_mail($to, $subject, $body, $headers); $message = $sent ? __('Email Sent', 'wp-graphql-send-mail') : __('Email failed to send', 'wp-graphql-send-mail'); } else { $sent = false; $message = $message ? $message : __('Email Not Sent', 'wp-graphql-send-mail'); } return [ 'sent' => $sent, 'origin' => $http_origin, 'to' => $to, 'replyTo' => $replyTo, 'message' => $message, ]; } ]); }); ``` <br> <br> ## ページネーションの実装例 ### offset、totalCount の実装 ページネーションを実装する上で、クエリで取得する位置を指定する 変数( Variables )の使用が必要になります。その受け口として、構造上最もシンプルな例としては Cursor を利用する例が想像できるかと思いますが、Cursorに与えられるハッシュ値が取り扱いづらいため、 offset や totalCount の実装例があります。 GraphQLの`Plurals(複数形)` によるシンプルな `Connection` の実装には`totalCount` 、また `variables` の引数として `offset` も加えられる。 検証中のプラグイン`WP GraphQL` では、`totalCount` や `offset` がクエリのパフォーマンスの理由からレギュラーで実装されていません。そのため、必要に応じて追加実装する必要があります。 - offsetPagination 公式ドキュメントで紹介されている[プラグイン](https://www.wpgraphql.com/extenstion-plugins/wpgraphql-offset-pagination/)を使って検証しました。( ※こちらで紹介されているプラグインは開発が止まっていて、[こちら](https://github.com/valu-digital/wp-graphql-offset-pagination)を使用してほしい、と記載されています。 ) プラグイン内では、フィールド定義〜リゾルバまで一つのファイルに簡潔に記述されていて、wp-graphqlの各クエリのフィルターフックで、リゾルバに offset の値を渡しています。 ```graphql= query() { events ( # first : 2, 通常時 post_per_pages は first句,last句で指定できる where( offsetPagination: { # size は post_per_page として使用できる size: 2, # offset は現在のCursor位置の offset count offset: 0 }, ), ... ) { edges { ... } pageInfo { ... offsetPagination { # 現在のクエリの前後に投稿があるかどうか、真偽値で返却します。 hasMore hasPrevious # totalCount も実装されます。 total } } } } ``` ### クライアントサイドでの変数( variables )の実装 > event/index.tsx ```jsx= ... export const getStaticPaths = async () => { const repos = await client.get({ endpoint: "blog" }); const pageNumbers = []; const range = (start, end) => [...Array(end - start + 1)].map((_, i) => start + i); const paths = range(1, Math.ceil(repos.totalCount / PER_PAGE)).map((repo) => `/event/page/${repo}`); return { paths, fallback: false }; }; export const getStaticProps = async (context) => { const id = context.params.id; const data = await client.get({ endpoint: "blog", queries: { offset: (id - 1) * 5, limit: 5 } }); return { props: { blog: data.contents, totalCount: data.totalCount, }, }; }; ``` /event/page/[id] のような形でページリクエストがあった時に、offset を variables としてリクエストに含めます。こちらも実装例については検証中です。 ### その他検証予定の項目 続いて、下記の実装についても検証予定です。 #### metaQuery、taxQueryの実装 https://www.wpgraphql.com/extenstion-plugins/wpgraphql-meta-query/ https://www.wpgraphql.com/extenstion-plugins/wpgraphql-tax-query/ #### Yoast SEOの実装 https://www.wpgraphql.com/extenstion-plugins/wpgraphql-for-yoast-seo/