laravel
GraphQL
Lighthouse
LaravelDay 10

LaravelでGraphQLを使い倒してみた

はじめに

Laravel Advent Calendar 2018 - Qiitaの10日目の記事です!

1年前くらいから聞く機会がぐっと増え、最近懐疑的な意見がだんだん増えてきた印象を個人的に持っているGraphQLですが、

  • RestfulライクなAPIの代替案として有効なのか自分で触って確かめてみたかった
  • Laravelで使い倒す記事を見かけていなかった

のでこの機会に使い倒してみました(と言っても多少踏み込んだ程度ですが😅)。
※実装例は順次追加していきます。

なお、この記事では

  • GraphQLとは何か
  • GraphQLの諸々(クエリ、ミューテーション)の説明
  • クエリの記述方法の説明

などは記載しません。
(記述方法についての説明はしませんが、使い倒す中で実行するクエリは記載していきます)

前提

php: 7.1.3
laravel/framework: 5.7.16
nuwave/lighthouse: 2.6

つくるもの

記事投稿&SNS(News◯icksみたいな感じです)のAPIを題材に使い倒してみようと思います。
ER図はこんな感じです。
スクリーンショット 2018-12-10 7.45.55.png

使うライブラリ

LaravelでGraphQLを使う場合いくつかライブラリはありますが、今回は前提にあるlighthouseにしました。

lighthouseはこちらの記事が詳しく書かれていてわかりやすいと思います。

成果物

こちらに今回の成果物を置いてあります。

下準備

上記ER図のような構成をつくる為、マイグレーション、リレーション、シーダーを作成します。
説明は割愛します。
成果物内にありますので気になる方はそちらをご覧ください。

Lighthouse導入

ここからが本題です。
こちらを参考に入れていきます。

まずはライブラリのインストールします。

$ composer require nuwave/lighthouse

続いて、vendor:publishで初期設定をします。

$ php artisan vendor:publish --provider="Nuwave\Lighthouse\Providers\LighthouseServiceProvider" --tag=schema

routes/graphql/schema.graphqlが出来上がっていると思います。

DevToolsであるlaravel-graphql-playgroundも入れます。
(フロントエンド等で別途GraphQLクライアントを用意されている場合でもスキーマ定義確認など使いどころはあるかなと思います。)

$ composer require mll-lab/laravel-graphql-playground

最後に、playgroundもvendor:publishして初期設定をします。
playgroundはこちらに説明があります。

php artisan vendor:publish --provider="MLL\GraphQLPlayground\GraphQLPlaygroundServiceProvider"

以上で導入は完了です。
ファサードは今回は使わなかったので設定していません。

クエリを実行する

シンプルなクエリ

まずは簡単なクエリを実行してみます。
routes/graphql/schema.graphqlにクエリが登録されているのでそちらを実行します。

type Query {
    user(id: ID @eq): User @find(model: "App\\User")
}

laravel-graphql-playgroundで実行するのでアクセスします。
こちらのUsageにある通り、/graphql-playgroundというURLにアクセスすると表示されると思います。
(http://127.0.0.1:8000の環境の場合はhttp://127.0.0.1:8000/graphql-playgroundです)

アクセスするとこんな画面が表示されると思います。
スクリーンショット 2018-12-09 2.38.09.png

アクセス出来たら早速クエリを発行してみます。

query {
  user(id: 1) {
    id
    name
    email
  }
}

無事発行して取得出来ました!
スクリーンショット 2018-12-09 2.41.23.png

リレーションを使ったクエリ

次に、リレーションを使ったクエリも実行してみます。
Userに紐付いているArticlesを取得してみます。

まずはArticleのTypeを作成します。

type Article {
    id: ID!
    userId: Int! @rename(attribute: user_id)
    title: String!
    body: String!
    createdAt: DateTime! @rename(attribute: created_at)
    updatedAt: DateTime! @rename(attribute: updated_at)
}

実際にGraphQLを利用するのはフロントエンドがほとんどかなと思うので@renameディレクティブでカラム名をリネームしてキャメルケースに変換しています。

続いて、UserのTypeにarticlesを追加します。

type User {
    id: ID!
    name: String!
    email: String!
+   articles: [Article] @hasMany
    created_at: DateTime! @rename(attribute: created_at)
    updated_at: DateTime! @rename(attribute: updated_at)
}

準備出来たのでリレーションを使ったクエリを実行します。

query {
  user(id: 2) {
    id
    name
    email
    articles {
      id
      userId
      title
    }
  }
}

無事articlesも取得出来ました!
スクリーンショット 2018-12-09 2.57.06.png

paginatorのデータを取得するクエリ

最後に、複数件のデータを取得します。
こちらもroutes/graphql/schema.graphqlにクエリが登録されているのでそちらを実行します。

type Query {
    users: [User!]! @paginate(type: "paginator" model: "App\\User")
}

paginatorではcountの引数が必須なので入れてあげます。

query {
  users(
    count: 10
  ) {
    data {
      id
      name
      email
      articles {
        id
        userId
        title
      }
    }
    paginatorInfo {
      currentPage
    }
  }
}

ページネーションでも無事取得出来ました!
スクリーンショット 2018-12-09 9.13.05.png

複数件データを取得する際は、

  • ベースとなるモデルのhasMany等で取得する
  • paginatorで取得する(typeは2種類)

の2択しかないのかなと思います。
ベースとなるモデルをpaginatorなしの純粋なCollectionとして引っ張ってきたいなぁと思われる方いらっしゃるのかなと思いますが、その場合は自作のresolver登録になるのかなと思います。(resolver作らなくても出来るよ、という情報お持ちでしたらぜひコメント頂けますと幸いです)

playgroundはソースコードをもとにスキーマ情報を出力してくれているので、

  • どんなクエリがある?
  • このクエリの引数、戻り値は?

と悩んだ時はスキーマ情報を見てもらえればと思います。

Typeはドリルダウンして見ることが出来ます。
スクリーンショット 2018-12-09 9.18.42.png

サジェストが効いてくれるのもありがたいですね。
スクリーンショット 2018-12-09 9.26.31.png

ただし、ホットリロードには対応していないので、ソースコードを修正したら画面をリロードする必要があります。

ミューテーションを実行する

登録

クエリが出来たところでミューテーションも実行してみます。
まずはUserを登録します。

routes/graphql/schema.graphqlに登録されているミューテーションを少し加工します。

type Mutation {
    createUser(
        name: String
            @rules(apply: ["required"])
        email: String
            @rules(apply: ["required",
                "email",
                "unique:users,email"]
            )
        password: String
            @bcrypt
            @rules(apply: ["required"])
    ): User
        @create(model: "App\\User")
}

登録するミューションはこんな感じです。

mutation {
  createUser(
    name: "taro"
    email: "graphql@example.com"
    password: "secret"
  ) {
    id
    name
    email
  }
}

無事登録出来ました!
スクリーンショット 2018-12-09 9.36.17.png

更新

更新処理も行きます。
こちらはもともとあるものをそのまま使います。

type Mutation {
    updateUser(
        id: ID
            @rules(apply: ["required"])
        name: String
        email: String
            @rules(apply: ["email"])
    ): User
        @update(model: "App\\User")
}

更新してみます。

mutation {
  updateUser(
    id: 51
    name: "hanako"
  ) {
    id
    name
  }
}

更新も無事出来ました!
スクリーンショット 2018-12-09 9.36.17.png

削除

最後に削除もします。

こちらはもともとあるものをそのまま使います。

type Mutation {
    deleteUser(
        id: ID @rules(apply: ["required"])
    ): User @delete(model: "App\\User")
}

削除してみます。

mutation {
  deleteUser(
    id: 51
  ) {
    id
    name
  }
}

@deleteディレクティブではNonNullな引数を指定してね、と怒られてしまいます。
スクリーンショット 2018-12-09 9.44.34.png

メッセージに従って、戻り値を必須に変更して再実行します。

type Mutation {
    deleteUser(
-       id: ID @rules(apply: ["required"])
+       id: ID! @rules(apply: ["required"])
    ): User @delete(model: "App\\User")
}

無事削除出来ました!
スクリーンショット 2018-12-09 9.47.06.png

クエリ、ミューテーションを追加する

使い倒すために、いくつかクエリ、ミューテーションを追加してみます。

LIKE検索を使ったクエリ

定義はこうやって、

type Query {
    usersByEmail(email: String @where(operator: "like")): [User!]!
        @paginate(type: "paginator" model: "App\\User")
}

こんな感じで実行します。

query {
  usersByEmail(
    email: "%r%"
    count: 10
  ) {
    data {
      id
      email
    }
  }
}

@whereディレクティブ、公式のmasterブランチには記載されているのですがver2.6ブランチには記載されていないので少し戸惑いました。

ID以外を指定した削除ミューテーション

ソースコードを見るとID以外を指定した@deleteディレクティブの実行は出来なそうなので、resolverを自作します。

まずはartisanでファイルを生成します。

$ php artisan lighthouse:mutation DeleteUsersByEmail

今回は

  • 指定した文字列でEmailのLIKE検索を検索をし、該当したユーザーを削除する
  • 戻り値に適当な値をセットする

を満たすresolverを作ってみました。

app/Http/GraphQL/Mutations/DeleteUsersByEmail.php
<?php

namespace App\Http\GraphQL\Mutations;

use GraphQL\Type\Definition\ResolveInfo;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
use App\User;

class DeleteUsersByEmail
{
    /**
     * Return a value for the field.
     *
     * @param null $rootValue Usually contains the result returned from the parent field. In this case, it is always `null`.
     * @param array $args The arguments that were passed into the field.
     * @param GraphQLContext|null $context Arbitrary data that is shared between all fields of a single query.
     * @param ResolveInfo $resolveInfo Information about the query itself, such as the execution state, the field name, path to the field from the root, and more.
     *
     * @return mixed
     */
    public function resolve(
        $rootValue,
        array $args,
        GraphQLContext $context = null,
        ResolveInfo $resolveInfo
    ) {
        $email = $args['email'];
        User::where('email', 'like', $email)
            ->delete();

        $res = 'ok';

        return compact('res');
    }
}

続いて、自作したresolverを使って定義します。

type DummyResponse {
    res: String!
}

type Mutation {
    deleteUsersByEmail(
        email: String!  @rules(apply: ["required"])
    ): DummyResponse
        @field(resolver: "App\\Http\\GraphQL\\Mutations\\DeleteUsersByEmail@handle")
}

最後に、こんな感じで実行します。

mutation {
  deleteUsersByEmail(
    email: "%r%"
  ) {
    res
  }
}

定義ファイルを複数に分割する

routes/graphql/schema.graphqlだけでやり過ごすのは厳しいのでTypeごとに分割してみます。

まずはroutes/graphql/schema.graphqlをこんな感じにします。

routes/graphql/schema.graphql
scalar DateTime @scalar(class: "Nuwave\\Lighthouse\\Schema\\Types\\Scalars\\DateTime")

scalar Date @scalar(class: "Nuwave\\Lighthouse\\Schema\\Types\\Scalars\\Date")

# その他ファイルをextend typeする為にここに空のベースとなる定義を配置しておく
type Query
type Mutation

#import models/*.graphql

続いて、分割したファイルはこんな感じにします。

routes/graphql/models/user.graphql
extend type Query {
    users: [User!]! @paginate(type: "paginator" model: "App\\User")
    user(id: ID @eq): User @find(model: "App\\User")
    usersByEmail(email: String @where(operator: "like")): [User!]!
        @paginate(type: "paginator" model: "App\\User")
}

extend type Mutation {
    createUser(
        name: String
            @rules(apply: ["required"])
        email: String
            @rules(apply: ["required",
                "email",
                "unique:users,email"]
            )
        password: String
            @bcrypt
            @rules(apply: ["required"])
    ): User
    @create(model: "App\\User")
    updateUser(
        id: ID
            @rules(apply: ["required"])
        name: String
        email: String
            @rules(apply: ["email"])
    ): User
        @update(model: "App\\User")
    deleteUser(
        id: ID! @rules(apply: ["required"])
    ): User
        @delete(model: "App\\User")
    deleteUsersByEmail(
        email: String!  @rules(apply: ["required"])
    ): DummyResponse
        @field(resolver: "App\\Http\\GraphQL\\Mutations\\DeleteUsersByEmail@handle")
}

type DummyResponse {
    res: String!
}

type User {
    id: ID!
    name: String!
    email: String!
    password: String!
    articles: [Article] @hasMany
    createdAt: DateTime! @rename(attribute: created_at)
    updatedAt: DateTime! @rename(attribute: updated_at)
}

type Article {
    id: ID!
    userId: Int! @rename(attribute: user_id)
    title: String!
    body: String!
    createdAt: DateTime! @rename(attribute: created_at)
    updatedAt: DateTime! @rename(attribute: updated_at)
}

type Query type Mutation 等は一度しか呼べないので、schema.graphql側で

# その他ファイルをextend typeする為にここに空のベースとなる定義を配置しておく
type Query
type Mutation

空の定義をしておいて、分割したファイルでは常に extend type Query 等extendするようにしています。

エラーメッセージをカスタマイズする

実際に利用する場合、エラーメッセージのカスタマイズが必要になると思うので、Userを更新する処理でやってみます。

extend type Mutation
    updateUser(
        id: ID
            @rules(
                apply: ["required"],
                messages: { required: "idは必須です" }
            )
        name: String
        email: String
            @rules(apply: ["email"])
    ): User
        @update(model: "App\\User")
}

messageでなく、extensions.validation内にカスタマイズしたメッセージが入ってきます。
スクリーンショット 2018-12-09 10.44.51.png

graphql-playgroundが動かなくなったときの対処法

ミスした記述のクエリを実行したりしていると、ふとgraphql-playgroundが動かなくなるときがあります。

結構詰まって悩んでTwitterでつぶやいたところ、助けて頂きました!


誰しも一度は遭遇すると思うのでお気をつけください。

【2018/12/20追記】Datetimeを使う際の注意点

デフォルトで定義されている

scalar DateTime @scalar(class: "Nuwave\\Lighthouse\\Schema\\Types\\Scalars\\DateTime")

こちらのDatetimeですが、

type User {
    id: ID!
    ...省略
    emailVerifiedAt: DateTime @rename(attribute: email_verified_at)
    createdAt: DateTime @rename(attribute: created_at)
    updatedAt: DateTime @rename(attribute: updated_at)
}

typeで利用する際には注意が必要です。
$datesを指定しておかないとエラーを吐いてしまうので必ずModel側で$datesを指定しておきましょう。
(created_at, updated_atだけであれば明示的に付加しなくても良いですが他の項目もあるので全て付加しておいたほうが良いと思います)

app/User.php
class User extends Authenticatable
{
    // 省略

    // $datesで指定しておく
    protected $dates = [
        'email_verified_at', 'created_at', 'updated_at'
    ];
}

【2018/12/27追記】複数のクエリをまとめて実行する

GraphQLの最大のメリットである エンドポイントをまとめられる について記載していなかったので追記します。
いきなり答えですが、

query multiQuerySample(
  $user1Id: ID
  $user2Id: ID
) {
  user1: user(id: $user1Id) {
    id
  }
  user2: user(id: $user2Id) {
    id
  }
}

こうすれば複数のクエリをまとめて実行できます。

play graoundで実行するとこんな感じになります。
スクリーンショット 2018-12-27 15.55.17.png

RestAPIを何度も叩く苦痛がこれで一気に解消出来ますね!

【2019/01/10追記】複数のデータを一括で登録する

複数件のデータを一括で登録したいケースもあるのかなと思います。
この方法が正解かどうかはわかりませんが追記します。

最終的なゴール

今回は以下のようなクエリを実行してユーザーを複数件登録出来るようにしたいと思います。

mutation {
    createUsers(users: [
        {name: "taro", email: "taro@example.com", password: "xxxxxxxx"},
        {name: "hanako", email: "hanako@example.com", password: "xxxxxxxx"}
    ])
    {
      res
    }
}

配列型のデータを受け取る

複数件登録するには配列型のデータをGraphQLに渡してあげる必要があると思いますが、おそらくはScalarを定義しないと取得出来ないと思います。(間違っていたらご指摘頂けますと幸いです)

ですので、まずは配列の型を設定する為の独自のスカラーを定義します。

CLIからスカラーを作成します。

$ php artisan lighthouse:scalar Users

実行すると、 app/GraphQL/Scalars 配下にUsers.phpというファイルが作成されます。

続いて中身を実装していきます。

まず、作成したてのファイルの中身がこちらです。

app/GraphQL/Scalars/Users.php
<?php

namespace App\Http\GraphQL\Scalars;

use GraphQL\Language\AST\Node;
use GraphQL\Type\Definition\ScalarType;

/**
 * Read more about scalars here http://webonyx.github.io/graphql-php/type-system/scalar-types/
 */
class Users extends ScalarType
{
    /**
     * Serializes an internal value to include in a response.
     *
     * @param string $value
     * @return string
     */
    public function serialize($value)
    {
        // Assuming the internal representation of the value is always correct
        return $value;

        // TODO validate if it might be incorrect
    }

    /**
     * Parses an externally provided value (query variable) to use as an input
     *
     * @param mixed $value
     * @return mixed
     */
    public function parseValue($value)
    {
        // TODO implement validation

        return $value;
    }

    /**
     * Parses an externally provided literal value (hardcoded in GraphQL query) to use as an input.
     *
     * E.g.
     * {
     *   user(email: "user@example.com")
     * }
     *
     * @param Node $valueNode
     * @param array|null $variables
     *
     * @return mixed
     */
    public function parseLiteral($valueNode, array $variables = null)
    {
        // TODO implement validation

        return $valueNode->value;
    }
}

補足のコメントはありますがざっくりそれぞれの関数について説明しておきます。

  • serialize
    • DBから取得する際に値を変換する為の関数
  • parseValue
    • 引数を変数で受け取った値を検証、加工する為の関数
  • parseLiteral
    • 引数を直接受け取った値(変数定義せずクエリに直接指定する場合)を検証、加工する為の関数

最初から作成されている Nuwave\Lighthouse\Schema\Types\Scalars\Datetime を見ると参考になるかもと思ったので念の為載せておきます。

vendor/nuwave/lighthouse/src/Schema/Types/Scalars/DateTime.php
<?php

namespace Nuwave\Lighthouse\Schema\Types\Scalars;

use Carbon\Carbon;
use GraphQL\Error\Error;
use GraphQL\Utils\Utils;
use GraphQL\Type\Definition\ScalarType;
use GraphQL\Language\AST\StringValueNode;

class DateTime extends ScalarType
{
    public function serialize($value): string
    {
        return $value->toAtomString();
    }

    public function parseValue($value): Carbon
    {
        try {
            $dateTime = Carbon::createFromFormat(Carbon::DEFAULT_TO_STRING_FORMAT, $value);
        } catch (\Exception $e) {
            throw new Error(Utils::printSafeJson($e->getMessage()));
        }

        return $dateTime;
    }

    public function parseLiteral($valueNode, array $variables = null): Carbon
    {
        if (! $valueNode instanceof StringValueNode) {
            throw new Error('Query error: Can only parse strings got: '.$valueNode->kind, [$valueNode]);
        }

        try {
            $dateTime = Carbon::createFromFormat(Carbon::DEFAULT_TO_STRING_FORMAT, $valueNode->value);
        } catch (\Exception $e) {
            throw new Error(Utils::printSafeJson($e->getMessage()));
        }

        return $dateTime;
    }
}

では、実際に中身を実装していきます。
インターネット上にあるサンプルでは、日付型、メールアドレス等プリミティブな値に何かしらの制御を加えるケースが多いのかなと思うのですが、今回は配列の為GraphQL内部の型を意識する必要がありました。
いきなり最終的な結果ですが、今回はこんな感じにしてみました。

app/Http/GraphQL/Scalars/Users.php
<?php

namespace App\Http\GraphQL\Scalars;

use GraphQL\Language\AST\Node;
use GraphQL\Type\Definition\ScalarType;
use GraphQL\Error\Error;
use GraphQL\Language\AST\ListValueNode;
use GraphQL\Language\AST\ObjectValueNode;
use GraphQL\Language\AST\ObjectFieldNode;

class Users extends ScalarType
{
    public function serialize($value)
    {
        return $value;
    }

    public function parseValue($value)
    {
        return $value;
    }

    public function parseLiteral($valueNode, array $variables = null)
    {
        // 今回はデータが配列であるかどうかだけをチェックしていますが、実際はもう少ししっかりチェックすることになると思います
        if (!$valueNode instanceof ListValueNode) {
            throw new Error('Query error: Can only parse List got: '.$valueNode->kind, [$valueNode]);
        }

        return $this->argValue($valueNode);
    }

    /**
     * GraphQLが持つ型から最終的な配列データを取得する
     * 
     * @param $arg mixed
     * @return array
     */
    private function argValue($arg)
    {
        if ($arg instanceof ListValueNode) {
            return collect($arg->values)->map(function ($node) {
                return $this->argValue($node);
            })->toArray();
        }

        if ($arg instanceof ObjectValueNode) {
            return collect($arg->fields)->mapWithKeys(function ($field) {
                return [$field->name->value => $this->argValue($field)];
            })->toArray();
        }

        if ($arg instanceof ObjectFieldNode) {
            return $this->argValue($arg->value);
        }

        return $arg->value;
    }
}

上記例を見てわかる通り、argValueの内容が複雑で結構面倒でした。。
Nuwave\Lighthouse\Support\Traits\HandlesDirectives というTraitにあるargValueを参考に少し整形して作成しました。

ここまでで引数が受け取れるようになると思います。

複数件データを登録する為のミューテーションを作成する

こちらも自作のResolverを使わないでミューテーションを実装する方法はないのかなと思うのでミューテーションを作成します。
作成手順は上記に記載してあるので細かいことは割愛して、

$ php artisan lighthouse:mutation CreateUsers

上記コマンドでファイルを作成して、以下のように実装してみました。

app/Http/GraphQL/Mutations/CreateUsers.php
<?php

namespace App\Http\GraphQL\Mutations;

use GraphQL\Type\Definition\ResolveInfo;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
use App\User;

class CreateUsers
{
    public function resolve($rootValue, array $args, GraphQLContext $context = null, ResolveInfo $resolveInfo)
    {
        User::insert($args['users']);

        $res = 'ok';
        return compact('res');
    }
}

ミューテーションはシンプルにこれだけでOKです。

最後に実際のミューテーションの定義を記載すれば完成です。

routes/graphql/models/user.graphql
scalar Users @scalar(class: "App\\Http\\GraphQL\\Scalars\\Users")

extend type Mutation {
    createUsers(
        users: Users
    ): DummyResponse
    @field(resolver: "App\\Http\\GraphQL\\Mutations\\CreateUsers@resolve")
}

以上で実装は完了したので実際に実行してみます!

スクリーンショット 2019-01-10 17.00.45.png

無事複数件データを登録することが出来ました!

さいごに

使い倒すと言っておきながら大して使い倒さぬままさいごになってしまいました😵

今回、LaravelのGraphQLライブラリの中では一番活発そうなLighouthouseを使ってみましたが、それでもまだまだ枯れてはいないなという印象を受けました。
困った時はこのあたりのソースコードを見ることで解決出来ると思います。
解決出来ないならresolverを作成する方向で良いと思いますが、公式サイトに

スクリーンショット 2018-12-09 10.48.03.png

こうある通り、自らも一緒にOSSを育てていくくらいの感覚でいると面白みが出てくるかなと思います!(という自分もまだPRを上げられてませんが😣)

RestfulライクなAPIの代替案になりえるかという話、触る前は

  • まとめて操作出来るのでAPI発行回数が少なくて済むってメリットがあるから複数のAPIを叩かなきゃいけないところだけGraphQL使って、それ以外は今まで通りRestfulライクなAPIを叩く感じになるかぁ

と思っていましが、いざ触ってみると

  • SwaggerDocのように鬼のようなコードを書かずにドキュメントが生成出来る
  • DevToolsも使いやすい

という感動があったので、認証系のAPIを除いてGraphQLに統合してしまうのもアリかなと思いました。

まだCodeジェネレーター周りを触っていないのですが、codegenが整備されてくるとTypescriptとの相性も良くなって一気にRestfulAPIの代替案としての可能性が出てくるのかも?と考えています。
こちらの記事等に記載されているので、私もどこかのタイミングで触れてみたいと思います。

Lighouthouse自体はまだまだ枯れていないので成長の余地が大いにあると思いますが、Slackワークスペースに自由に参加出来ますし、PRも大歓迎なようなので少しでも成長に寄与出来たら良いなぁと少しだけ思ったりしています🙇

私自身、まだまだGraphQL初心者の域ですので、当記事の内容に対して何かありましたらぜひコメント頂けますと幸いです🙇🙇🙇!!!