Absinthe の max_complexity の話

# At First

Qiita Advent Calendar 2023 の Elixir その 10 の 12/15 記事です．
私はふだん業務では主に Rails を書いていて，趣味で Elixir ちょっと触ってるくらいの素人レベルです．

小ネタですが Elixir の GraphQL サーバ実装である Absinthe の max_complexity について書きます．

# complexity(複雑度)について

ここでの complexity とは，GraphQL クエリの複雑度で，つまり client が送り付けてくるクエリがどの程度複雑かを測るための指標です．GraphQL では，クライアントが複数のクエリを並べて送ってくることが可能であり，かつ，GraphQL Schema の定義次第ですが，ひとつのクエリについていくらでもネストして書くことができます．
なんの制限もつけないと，ひとつの HTTP リクエストで数百数千も GraphQL クエリを送りつけることもでき，API として公開している場合，DoS 攻撃につながる虞もあります．そういうわけで，GraphQL クエリの複雑度が高いクエリはサーバ負荷が高いクエリである可能性が高いとも言えると思います．

そこで，あまりにも複雑度の高いクエリについて GraphQL ランタイムがクエリを評価する段階で弾くための仕組みが max_complexity と捉えるとよいかと思います．

hexdoc にそれなりに親切に書いてあるのですが，実例込みで考えてみます．
https://hexdocs.pm/absinthe/complexity-analysis.html

GitHub GraphQL API です．
この API では，大雑把に Rate Limit と Nodes Limit で制限を行います．どちらも GraphQL クエリで取得できるデータフィールド数をコストに換算して評価するものです．
https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api

- Rate Limit: 時間軸で評価します．単位時間あたりに取得できるデータの数量を定義します．
- Nodes Limit: リクエスト軸で評価します．1 リクエストあたりに取得できるデータの数量を定義します．

Absinthe においては，max_complexity が Nodes Limit に，Token Limit が Rate Limit に該当します．
Token Limit は全然情報がないのでこっち調べたほうが人類に貢献できんじゃんと思いました・・(後記)

# max_complexity を試してみる

ドキュメントでは， `complexity/1` になにやら渡してカスタマイズしているので，もう少し単純に実行してみます．
雑に試すなら，Phoenix(ここでは Phoenix Framework + Absinthe 構成を前提とします) の router.ex に以下のように記述します．ここでは， プロジェクト名を api としています．

```elixir:lib/xxx_web/router.ex
defmodule ApiWeb.Router do
  use ApiWeb, :router
  (snip)

  # 適当に 5 とかにするとなんでも max_complexity にひっかかってエラーになります
  @max_complexity 5

  scope "/api" do
    pipe_through(:api)

    if Mix.env() == :dev do
      forward("/graphiql", Absinthe.Plug.GraphiQL, schema: ApiWeb.Schema, analyze_complexity: true, max_complexity: @max_complexity)
    end

    forward("/", Absinthe.Plug, [schema: ApiWeb.Schema, analyze_complexity: true, max_complexity: @max_complexity])
  end
```

イントロスペクション環境である GraphiQL(ここでは /api/graphiql ) を開いて試しに適当なクエリを打つと次のような要領で怒られます．

```
"message": "Field allCustomers is too complex: complexity is 6 and maximum is 5",
```

または，iex で動かしても同様です．こちらのほうが値やクエリを iex 上でごちゃごちゃいじれるので人によってはいいかもしれません．

```elixir
% iex -S mix phx.server
> doc = """
> query{
>   allCustomers{
>     id
>     name
>     staff{
>       id
>       name
>     }
>   }
> }
> """
> Absinthe.run(doc, ApiWeb.Schema, analyze_complexity: true, max_complexity: 5)
{:ok,
 %{
   errors: [
     %{
       message: "Field allCustomers is too complex: complexity is 6 and maximum is 5",
(snip)
```

# max_complexity の見積もり

ちなみに打ったクエリと complexity の計算はこんな感じになります．

```
query{
  allCustomers{  // +1
    id           // +1
    name         // +1
    staff{       // +1
      id         // +1
      name       // +1
    }
  }
}
```

※ complexity のデフォルトは field ごとに 1 です．
https://github.com/absinthe-graphql/absinthe/blob/bba6596f5928a08a78094cdf6a79ae6e7c23dcb7/lib/absinthe/phase/document/complexity/analysis.ex#L10

公式ドキュメントでは，ノードをネストさせたクエリを打つ際の見積もりに調整をかけたいときの方法が紹介されていました．ここまでの前提を踏まえて読むとちょっと理解が変わるのではないかと思います．

さて，max_complexity は実際のデータ量は考慮しません．あくまでクエリに対する制限であるため，実際のレコード数は評価時点では取得できていないことに留意する必要があります．そこで，デフォルトの limit を指定するなどと組み合わせて，クエリの時点で取得されるデータ量がどのくらいになるかを考慮する必要があります．
これは結構めんどくさくて，複雑度 30 のノードを 10 件取得するのと，複雑度 5 のノードを 50 件取得するのとでは前者のほうが複雑度が上になります．かつ，GraphQL Schema を実際に運用してみると，ノードの保持する field はどんどん増えていくし，GraphQL らしさを追求するならネストが深くなるのはある程度避けられません．
GraphQL だからといって雑にガバっと取るようなフロント実装を戒める意味で max_complexity を設定し，超えないように paginate してもらうのがいいのかなぁと思っています．


ちなみに，Ruby 実装のひとつである GraphQL-Ruby についてちょっと紹介すると，GraphQL の標準的な pagination 仕様である Relay の場合の complexity については GraphQL-Ruby の doc がちょこっと参考になると思います．
https://graphql-ruby.org/queries/complexity_and_depth.html#connection-fields

また，GraphQL-Ruby では max_complexity の他にクエリの深さを制限する max_depth も提供されています．
Absinthe の場合は AST 掘って計算しろや ということらしい．
https://stackoverflow.com/questions/53287893/absinthe-graphql-nested-queries-security

# 参考(書く際に参照しました)

GraphQLスキーマ設計ガイド 第 2 版
https://techbookfest.org/product/5680507710865408?productVariantID=5134263097753600