Absinthe (のおよそ主要機能)を使って GraphQL サーバのプロトタイプを作った

Elixir で GraphQL サーバといえば [Absinthe](http://absinthe-graphql.org/) 一択ですが、日本語・英語を問わず、そのまま動作するサンプルがあまり世の中にありません。使ってみた＋α の情報はだんだんと増えてきましたが、プロダクトに導入してみようとなると途端に、どうやればいいのか、また調べなければならないことが急に増えます。

そこで実案件での事前調査もかねて、実際に使いそうな機能を使いそうな形で実装したプロトタイプを作成しました。

- [absinthe_showcase](https://github.com/hykw/absinthe_showcase)

# 特徴と機能

- Docker Compose で MySQL と Phoenix 環境を用意してあるため、コンテナをビルドすればすぐに動かしてみることができます。
- だいたい最新のライブラリで作られています。Absinthe 1.5 が一ヶ月以内にはリリースされそうな気がしますが、出たら 1.5 に書き換える予定です。
    - OTP 21, Elixir 1.7.4
    - Phoenix 1.4, Ecto 3.0
    - Absinthe 1.4.13
- [dataloader](https://hex.pm/packages/dataloader)で N+1 問題を解決
- 認証まわりを扱う middleware を用意
- LIMIT/OFFSET の指定の有無に関わらず、条件にマッチする最大レコード数を返すフィールドを用意（[1][2][3]みたいなページングを実現する時に、とても便利）
- いろんな query/mutation を使える
    - mutation
        - メアドとパスワードでログイン。アクセス権限は二種類
            - 一般ユーザ
            - 管理者
        - ユーザアカウントの作成
            - 管理者アカウントのみ作成可能
            - 非ログイン・一般ユーザアカウントの場合、エラーを返す
    - query
        - 最大レコード数を含む、各種 query
- query の入れ子に対応

```graphql
{
  questions {
    users {
    }
  }
}
```

```graphql
{
  me {
    users {
    }
    questions {
    }
    answers {
    }
  }
}
```  

```graphql

{
  questions {
    answers {
      users {
      }
    }
   }
  }
}
```

- ER図
    - N+1問題がついて回る、Q&A サイトで良くあるやつです
 
<img width="468" alt="er.png" src="https://qiita-image-store.s3.amazonaws.com/0/16022/5519129f-b8f9-7ffc-d72f-0e47f3d911e9.png">


# 動作させるには？
- Docker Compose でコンテナをビルド
    - `MYUID=${UID} docker-compose up -d --build`
    - 下記のような環境で動きます
        - コンテナ
            - showcase コンテナ：Phoenix 環境
            - showcase_mysql コンテナ：MySQL 環境
        - 各ポートの接続先
            - 5002 → MySQLコンテナの3306
            - 5001 → Phoenixコンテナの4000
        - volume のマウント
            - ローカルの `showcase/src` → Phoenixコンテナの `/showcase/src`
- Phoenix のコンテナに接続して、初期設定
    - `docker exec -it showcase /bin/bash`
    - `mix deps.get`
    - `mix deps.compile`
    - `mix ecto.setup`
- ブラウザから `http://localhost:5001/graphiql` に接続
    - テスト用 データは `lib/showcase/dev/support/seeds.ex` で登録しています
    - `mix test` の接続先は `/api` です

ポート番号や環境などは `docker-compose.yml` もしくは引数渡すなり、自身の環境に適当にあわせてください。

- 使える query などその他の情報は、GraphiQL に接続してスキーマのドキュメントを読むか、リポジトリの説明を参照ください
    - [absinthe_showcase](https://github.com/hykw/absinthe_showcase)

# 実装的な話
## ログイン
- アカウントのパスワードは `plain_password` という名前で平文で登録しています。基本誰でも [comeonin ](https://hex.pm/packages/comeonin)とか使うと思いますが、別の方法で実装したいときに逆に面倒なのと、テストデータのパスワードが何だったか調べるのに楽なので、あえて平文にしています。
- ログイン成功時のトークンは ` lib/showcase_web/graphql/authentication.ex` で `Phoenix.Token.sign/3` で生成しています。[Guardian](https://hex.pm/packages/guardian) とかを使う場合はそこを差し替えればいいでしょう。

```elixir
 def sign(data) do
    Phoenix.Token.sign(ShowcaseWeb.Endpoint, @user_salt, data)
  end

  def verify(token) do
    Phoenix.Token.verify(ShowcaseWeb.Endpoint, @user_salt, token, max_age: 365 * 24 * 3600)
  end
```

## 認証
- アカウント作成のとき（admin権限が必要としています）など、適切なアカウント権限を持ってるかどうかの確認は middleware で実装しています
    - 権限をチェックする時に必要となる（ログイン中の）ユーザ情報は、Plug の中で `Absinthe.Plug.put_options/2` を呼び出すことで Asbinstheのコンテキストに設定しています 
    - 適切な権限があるときは `res` をそのまま、そうでない場合は `Absinthe.Resolution.put_result/1` でエラーメッセージを追加します。
        - 権限は `:normal_user`, `:admin`, `:any` で、`:any` は権限は何でもいいけどログインが必要という権限です(自身の情報を取得する me query で使用）
    - 認証のトークン（login mutation から取得したもの）は、`authorization` ヘッダに `Bearer *トークン*` の形で渡します
        - UnitTest の中では [(conn_case.ex](https://github.com/hykw/absinthe_showcase/blob/master/src/test/support/conn_case.ex#L34)の中で `auth_user/2` というヘルパ用関数を用意してヘッダをセットしています
        - GraphiQL では `Headers` の `OAuth 2 Bearer Token` から設定できます

<img width="639" alt="スクリーンショット 2018-12-04 15.59.52.png" src="https://qiita-image-store.s3.amazonaws.com/0/16022/880ab844-20d8-7f89-9101-ed66bcdab7cc.png">
<img width="873" alt="スクリーンショット 2018-12-04 16.00.27.png" src="https://qiita-image-store.s3.amazonaws.com/0/16022/65f59c28-b440-d3a8-3d53-84540222f8ff.png">




```elixir
with %{current_user: current_user} <- res.context,
     true <- correct_permission?(current_user, permission) do
  res
else
  _ ->
    res
    |> Absinthe.Resolution.put_result({:error, "unauthorized"})
end
```

- middleware は schema の object の中で直接指定して呼び出しています。また、リターン値が「エラー」「追加したアカウントの情報」の両方のケースがあるため、他の object とは違い  `user_with_priv_result` という object で `user_with_priv` と `input_error` の両方の object をラップしています。


```elixir
object :create_user_mutations do
  @desc """
  create user account
  """

  field :create_user, :user_with_priv_result do
    arg(:email, non_null(:string))
    arg(:nickname, non_null(:string))
    arg(:password, non_null(:string))
    arg(:permission, non_null(:permission))

    middleware(Middleware.Authorize, :admin)
    resolve(&Resolvers.Accounts.create_user/3)
  end
end
```

## 最大レコード数を返す
- totalCount が指定されている場合、queryable に下記操作を行い `Repo.all/1` を呼び出すことで、query の結果とは別にレコード数を取得しています
    - `Query.exclude/2` で LIMIT と OFFSET を削除
    - `select/3` で count を追加
- また totalCount を格納する場所として、各モデル（コンテキストが導入されてからはモデルという名前じゃないんでしたっけ？）の virtual として `total_count` を追加しています

```elixir
schema "questions" do
  field(:total_count, :integer, virtual: true)

  field(:body, :string)
  field(:title, :string)
  belongs_to(:user, User)
  has_many(:answers, Answer)

  timestamps()
end
```

## dataloader
dataloader を使う時は、下記のように（Phoenixの）コンテキストで `data/1` を定義すると思いますが、ここに処理を追加してあげることで、queryable に WHERE, ORDER, LIMIT, OFFSET などの条件を追加することができます。

```elixir
def data() do
  Dataloader.Ecto.new(Repo, query: &query/2)
end
```

`query/2` の第一引数は queryable で、第二引数には GraphQL の query で指定された引数が Map で渡ってきます（例：`%{limit: 5}`）。LIMIT/OFFSET が未指定の時は LIMIT=5, OFFSET=0 が指定されたことにするために LIMIT/OFFSET のデフォルト値のセットと、queryable への追加を行っています。

```elixir
def query(Answer, params) do
  # LIMIT/OFFSET のデフォルト値をセット
  new_params =
    :answers
    |> Resolvers.QA.get_default_params()
    |> Resolvers.QA.set_default_params(params)

  # queryable に limit/offset を追加
  # リターン値は追加後の queryable
  ContextHelper.limit_offset(:dataloader, Answer, new_params)
end
```

### dataloader の結果をいじる
- 例えば下記のような N+1 問題が発生する query では dataloader を使いたくなりますが、単に `dataloader/2` を呼んでいるだけだと結果をいじる場所がありません。そのため下記のような子 query で totalCount が指定された時の処理では、`dataloader/2` の中身を展開してコンテキストに持っていき、その中で任意の値（今回の場合は `totalCount`）を追加しています。

```graphql

  questions(id:5) {
    id
    title
    answers {
      id
      totalCount
    }
}
```    

```elixir
def answers_for_question(source, key) do
  fn parent, args, %{context: %{loader: loader}} ->
    [total_count] =
      from(
        q in Answer,
        select: count(q.id),
        where: q.question_id == ^parent.id
      )
      |> Repo.all()

    loader
    |> Dataloader.load(source, {key, args}, parent)
    |> on_load(fn loader ->
      result =
        Dataloader.get(loader, source, {key, args}, parent)
        |> Enum.map(fn x ->
          Map.put(x, :total_count, Integer.to_string(total_count))
        end)

      {:ok, result}
    end)
  end
end
```

- ちなみに展開前の dataloader の dataloader/1 の実装は  https://github.com/absinthe-graphql/absinthe/blob/master/lib/absinthe/resolution/helpers.ex にあります。

# まとめ
なんかもうちょっと色々実装していたような気がしますが時間切れなので、思い出したらまたちょっと追記するかもしれません。

Absinthe の作者の Ben Wilson さんは普通に毎日 Slack で Q&A に答えてるので、absinthe の疑問点は Slack で聞くと速攻解決したりしますのでオススメです。