こんにちは、サーバーサイドエンジニアの竹若です。今回GraphQLにおけるエラーハンドリングを調査、Ruby on Railsとgraphql-rubyを使って実装する機会があったので、そこで得られた知見を共有させていただきたいと思います。(なお今回の実装はプロダクション環境には出ていません)
GraphQLの仕様とプラクティス
それではまず初めに、GraphQLが仕様に定めているレスポンスの返し方を見ていきましょう。
レスポンスのフォーマットに関するプラクティス
GraphQLのプラクティスの1つに、レスポンスのhttp statusを200で統一し、レスポンスのerrors
キーにエラーの詳細な情報を持たせるというものがあります。
なぜならGraphQLではリクエストに複数のクエリを含めることができるからです。
https://www.graph.cool/docs/faq/api-eep0ugh1wa/#how-does-error-handling-work-with-graphcool
Since GraphQL allows for multiple operations to be sent in the same request, it's well possible that a request only partially fails and returns actual data and errors.
これはあくまでプラクティスであり仕様ではないのですが、周辺ツール(Apolloやgraphql-ruby)がこのプラクティスに従っているため私たちも基本的には従うことになります。
レスポンスのフォーマットに関する仕様
ではGraphQLはどのようにしてエラーを表現するのでしょうか?
GraphQLの仕様を見てみましょう。
https://facebook.github.io/graphql/June2018/#sec-Errors
GraphQLの仕様ではレスポンスのフォーマットはハッシュであり、中にdata
とerrors
というキーを含みます。
{ "errors": [ { "message": "hogehoge", "extensions": { "bar": "bar" } } ], "data": { "user": { "name": "takewaka" } } }
data
クエリの実行結果が入るキーです。
クエリの実行前にエラーが発生した場合、data
キーはレスポンスに含まれません。
errors
クエリの実行中に発生したエラーが入るキーです。
クエリの実行中にエラーが発生しなかった場合、errors
キーはレスポンスに含まれません。
またレスポンスにdata
キーが含まれない場合、errors
キーは必ずレスポンスに含まれている必要があります。
なおerrors
のフォーマットも仕様で定められていて、中にmessage
,location
,path
というキーが含まれます。(location
とpath
はクエリの中にエラーの該当箇所が存在する場合にのみ含まれます)
上記3つのキーの他にキーを追加したい場合は、extensions
というキーを用意してその中に追加する仕様です。
なぜならmessage
やpath
などのキーと同じレベルにオリジナルのキーを追加してしまうと、そのオリジナルのキーと将来的に仕様に追加されるキーがバッティングを起こす可能性があるからです。
https://facebook.github.io/graphql/June2018/#sec-Errors
GraphQL services should not provide any additional entries to the error format since they could conflict with additional entries that may be added in future versions of this specification.
GraphQLを使っていて発生するエラーとその分類
さて、GraphQLがどのようにしてエラーを表現するかはわかりました。
次にGraphQLを使っていて起こるエラーにはどのようなものがあるのか見ていきましょう。
エラーを以下の2つの観点で見ていきます。
- エラーの原因はクライアントなのか、サーバーサイドなのか
- エラーはどこで発生したか
クライアントが原因のエラーは主に以下の3つに分類できます。
- パースエラー
- クエリのシンタックスエラー
- バリデーションエラー
- クエリが型チェックで引っかかる
- クエリ実行時エラー
- 認証失敗など
サーバーサイドが原因のエラーはRailsで実装したロジックのエラーです。
どのようなエラーが存在するかわかったところで、それぞれのエラーをどのような形のレスポンスで表現するのか見てみましょう。
Apolloなどのクライアントがレスポンスをパースしやすいように、レスポンスのフォーマットはGraphQLの仕様に準拠した形で統一したいです。
そこでerrors
キーの中のmessage
キーにエラーの詳細なメッセージを入れて、extensions
キーの中のcode
キーにステータスコードを入れる方法をここでは見ていきます。
これはGraphQLの仕様書に載っているエラーレスポンスの例と同じ方法です。
またApollo Serverでは、extensions
キーの中にcode
をはじめとしたエラーに関するキーを含める方法を採用しています。
例えば認証エラーであればこのようにcodeの中にAUTHENTICATION_ERROR
というステータスを入れます。
"errors": [ { "message": "permission denied", "locations": [], "extensions": { "code": "AUTHENTICATION_ERROR" } } ]
サーバーエラーの場合はこのようにcodeの中にINTERNAL_SERVER_ERROR
というステータスを入れます。
"errors": [ { "message": "undefined method 'hoge' for nil", "locations": [], "extensions": { "code": "INTERNAL_SERVER_ERROR" } } ]
graphql-rubyで実装する方法
エラーをどのような形のレスポンスで表現するか決まったところで、graphql-rubyで実際に実装していきましょう。
graphql-rubyではエラーをどう拾ってどう返すか
graphql-rubyではGraphQL::ExecutionError
かもしくはそのサブクラスをraiseすることでerrors
にエラーを含めることができます。
def resolve(name:) user = User.new(name: name) if user.save { user: user } else raise GraphQL::ExecutionError, user.errors.full_messages.join(", ") end end
認証エラー
例として認証エラーの実装を載せます。
この例ではログインをセッションで管理していてcurrent_userメソッドを呼ぶことでユーザーオブジェクトが取得できる設定です。
コントローラーでGraphQL::Schema#execute
を実行する際にユーザーのログイン情報をcontext
に入れて引数として持たせておきます。
class GraphqlController < ApplicationController def execute variables = ensure_hash(params[:variables]) query = params[:query] operation_name = params[:operationName] context = { current_user: current_user } result = SampleSchema.execute(query, variables: variables, context: context, operation_name: operation_name) render json: result #... end #... end
resolve
メソッドの中でcontext
に入っているユーザーのログイン情報を見て認証エラーを吐かせています。
GraphQL::ExecutionError
はキーワード引数としてextensions
を持っているのでオリジナルのキー(ここでいうcode
)を渡すことができます。
def resolve(name:, sex:) raise GraphQL::ExecutionError.new('permission denied', extensions: { code: 'AUTHENTICATION_ERROR' }) unless context[:current_user] #... end
そうすることで認証エラーが発生した場合、このようなフォーマットでレスポンスを返すことができます。
"errors": [ { "message": "permission denied", "locations": [ { "line": 3, "column": 3 } ], "path": [ "createUser" ], "extensions": { "code": "AUTHENTICATION_ERROR" } } ]
セーフティネット
GraphQLのレスポンスはクライアントがパースしやすいようにフォーマットを統一することが重要です。
発生したエラーが最後までrescue
されずにいるとRailsの一般的な500エラーが返ってしまいます。
そうなるとクライアント側はhttp status 200で返ってくるGraphQLのエラーと、Railsの一般的な500エラーの両方をパースする準備をしなければなりません。
そこでサーバーサイドでエラーを最終的に受け止めるセーフティネットを用意したくなります。
graphql-ruby 1.8まではrescue_from
メソッドを使ってこれを実現できます。
以下にrescue_from
メソッドを使った実装例を示します。
class SampleSchema < GraphQL::Schema rescue_from(StandardError) { 'INTERNAL_SERVER_ERROR' } #... end
こうすることでRailsの一般的な500エラーではなく、以下のようなGraphQLのエラーを返すことができます。
"errors": [ { "message": "INTERNAL_SERVER_ERROR", } ]
ただrescue_from
の欠点として、errors
内のmessage
キーの内容しか指定できないという点があります。
これはgraphql-errorsというgemを使ってrescue_from
にエラークラスのオブジェクトを渡すことで解決します。
GitHub - exAspArk/graphql-errors: Simple error handler for GraphQL Ruby
しかしgraphql-rubyの機能としてrescue_from
にエラーオブジェクトを渡せてもいいのではないかと考えたのでパッチを書きました。
extend GraphQL::Schema::RescueMiddleware#attempt_rescue by masakazutakewaka · Pull Request #2140 · rmosolgo/graphql-ruby · GitHub
このパッチは以下のようにブロックにGraphQL::ExecutionError
オブジェクトを渡せるようにすることでextensions
キーを使えるようにするものです。
class SampleSchema < GraphQL::Schema rescue_from(StandardError) do |message| GraphQL::ExecutionError.new(message, extensions: {code: 'INTERNAL_SERVER_ERROR'}) end #... end
またこのrescue_from
メソッドはgraphql-ruby 1.9から使えなくなります。
理由はrescue_from
メソッドが定義されているGraphQL::Schema::RescueMiddleware
クラスがgraphql-ruby 1.9から使えなくなるからです。
GraphQL - Interpreter
graphql-ruby 1.9では現状rescue_from
メソッドに変わる何かは存在せず、どのような実装が追加されるかも未定というステータスです。
GraphQL::Execution::Interpreter and rescue_from compatibility · Issue #2139 · rmosolgo/graphql-ruby · GitHub
個人的にはgraphql-errorsがgraphql-rubyに上手く取り込まれてくれたらいいなと思っています。
複数エラー
クエリを実行して発生した複数のエラーを1つのレスポンスに含めたい場合があります。
例えばユーザー登録などの複数の入力項目を持つMutationがあったとします。
入力内容が不正であった全ての入力項目にエラーメッセージを表示したい場合、複数のエラーをレスポンスに含めたくなります。
graphql-rubyにおいてはGraphQL::Schema::Context#add_error
を使うことで複数エラーをレスポンスに含めることができます。
https://www.rubydoc.info/github/rmosolgo/graphql-ruby/GraphQL%2FQuery%2FContext%2FFieldResolutionContext:add_error
以下は実装例です。
module Mutations class CreateUser < GraphQL::Schema::RelayClassicMutation argument :name, String, required: true argument :sex, String, required: true field :user, Types::UserType, null: true def resolve(name:, sex:) user = User.new({ name: name, sex: sex }) if user.save { user: user } else build_errors(user) return # これがないとrescue_fromに拾われてしまう end end def build_errors(user) user.errors.map do |attr, message| message = user[attr] + ' ' + message context.add_error(GraphQL::ExecutionError.new(message, extensions: { code: 'USER_INPUT_ERROR', attribute: attr })) end end end end
複数のエラーを含んだレスポンスは以下のようになります。
"errors": [ { "message": "hoge はすでに存在します", "extensions": { "code": "USER_INPUT_ERROR", "attribute": "name" } }, { "message": "fuge は一覧にありません", "extensions": { "code": "USER_INPUT_ERROR", "attribute": "sex" } } ]
また独自のエラータイプを定義してエラーの内容をdata
に含めるという方法も存在します。
https://github.com/rmosolgo/graphql-ruby/blob/master/guides/mutations/mutation_errors.md#errors-as-data
しかしGraphQL::Schema::Context#add_error
を使う方法を以下の理由で採用しました。
- GraphQLの仕様上
errors
はdata
と同じレベルに位置してる data
の中にエラーを入れる場合、クエリ内にエラーのフィールドを明示的に書かないとエラーの情報を得ることができないのでエラーの受け渡し方として優れていない
独自のエラータイプを定義してエラーの内容をdata
に含める方法にも以下のような利点があると思います。
- エラータイプを定義するのでエラーの構造をスキーマで共有できる
- クライアント側でエラーメッセージを表示したい場合に、エラーの情報をレスポンスから取り出すのが楽
まとめ
この記事ではGraphQLにおけるエラーハンドリングの仕方とgraphql-rubyを使った実装例を紹介しました。
この記事の初めにGraghQLの仕様に軽く触れましたが、GraphQLの仕様はとても簡潔にまとめられているので読むことをお勧めします。
また紹介したgraphql-ruby
には見やすい場所にドキュメントされていない隠れAPIがあったりするので、ソースコードやissueを読んでみると色々発見できると思います。
この記事の最後の方で紹介したadd_error
メソッドが隠れAPIの1つです。
将来的にはプロダクションに出してから得られる知見も発信したいです。
GraphQLを使った開発に興味のある方がいましたら、ぜひ以下のリンクからご応募ください。お待ちしております!
参考
- GraphQL
- API | Graphcool Docs
- Full Stack Error Handling with GraphQL + Apollo 🚀 – Apollo GraphQL
- graphql-ruby/overview.md at master · rmosolgo/graphql-ruby · GitHub
- graphql-ruby/execution_errors.md at master · rmosolgo/graphql-ruby · GitHub
- graphql-ruby/mutation_errors.md at master · rmosolgo/graphql-ruby · GitHub