patorashのブログ

方向性はまだない

GraphQLでActiveRecord::RecordNotFoundをいい感じに処理する

GraphQLを使ってWebAPIの構築をやっているのですが、対象のデータが存在しない(ActiveRecord::RecordNotFound)場合にどうすればいいかがわからなかったので調べました。

結論

graphql-rubyのエラーハンドリングのところに書いてありました。

graphql-ruby.org

該当箇所を抜粋すると、こういうことです。rescue_fromを使って、ActiveRecord::RecordNotFoundの場合にはGraphQL::ExecutionErrorを発行すればOK。

class MySchema < GraphQL::Schema
  # ...

  rescue_from(ActiveRecord::RecordNotFound) do |_err, _obj, _args, _ctx, field|
    # Raise a graphql-friendly error with a custom message
    raise GraphQL::ExecutionError, "#{field.type.unwrap.graphql_name} not found"
  end
end

おーし、これでええかな😊と思っていたのですが、graphql-batchを使ってデータをロードしていたので、思ったように機能せず…😢

graphql-batchに対応する

graphql-batchはGraphQLでデータを取得する際にN+1問題が発生しないようにクエリを解析して、うまいことpreloadを呼んでくれるやつです。

github.com

READMEを読んで、その通りにやっていくと、RecordLoaderを定義して、loadメソッドを呼ぶことになります。READMEからコピーしてくると、以下のような感じ。

field :product, Types::Product, null: true do
  argument :id, ID, required: true
end

def product(id:)
  RecordLoader.for(Product).load(id)
end

これだと、null: trueなので、nullを許可したくないので削除します。

field :product, Types::Product do
  argument :id, ID, required: true
end

def product(id:)
  RecordLoader.for(Product).load(id)
end

でもこの状態だと、loadはnilを返すので値がnullになってしまい、GraphQLが返すエラーメッセージがおかしなことに…😵

{
  "data": null,
  "errors": [
    {
      "message": "Cannot return null for non-nullable field Query.product"
    }
  ]
}

Not NullなフィールドなのにNull返すなよ!っていう実装側へのメッセージですね。クライアントは悪くない。

じゃあこれを直します!thenを使っていきます。これは、graphql-batchのREADMEに書いてあるような対応方法です。

field :product, Types::Product do
  argument :id, ID, required: true
end

def product(id:)
  RecordLoader.for(Product).load(id).then do |product|
    raise ActiveRecord::RecordNotFound if product.nil?

    product
  end
end

こうすると、GraphQLが返すエラーはこのようになります。GraphQLのクエリのこの辺りがおかしいよというメッセージ付きに。

{
  "data": null,
  "errors": [
    {
      "message": "Product not found",
      "locations": [
        {
          "line": 6,
          "column": 3
        }
      ],
      "path": [
        "product"
      ]
    }
  ]
}

やったぜ!👍👍👍

load!メソッドを定義する

しかし、これだとloadメソッドを呼ぶところ全てでnilチェックをしないといけなくなるので、面倒…。ということで、RecordLoaderにload!メソッドを定義しました。

class RecordLoader < GraphQL::Batch::Loader
  # ...

  def load!(key)
    load(key).then do |record|
      raise ActiveRecord::RecordNotFound if record.nil?

      record
    end
  end

  # ...
end

これを使うと、フィールドの定義が簡潔になります。loadに!を追加するだけですからね。

field :product, Types::Product do
  argument :id, ID, required: true
end

def product(id:)
  RecordLoader.for(Product).load!(id)
end

まとめ

GraphQLでレコードが見つからなかった場合にエラーメッセージを出すようにしました。

しかし、graphql-batchのサンプルコードのように、そもそもnullを許可したほうがいいのだろうか?と、ちょっとAPI設計的にどちらがよいのか疑問です🤔

もっと記事を読んだり本を読んだりしながら、ベストプラクティスを知りたいところ。いい情報があったらお願いします!