yubrot です。2024年11月14日に、STORES.rb Railsのはなしというイベントでgraphql-ruby エラーの設計と実装について話しました。内容がブログ記事向きだろうということで、ブログで改めて解説したいと思います。
本記事で取り上げているコードを含めた、graphql-rubyによるGraphQL サーバの実装例をyubrot/graphql-ruby-exampleで公開しています。こちらも合わせてご参照ください。
STORES とGraphQL
STORES はプロダクト間のやりとりにGraphQLによるAPI通信を多用しています。社内を見渡すと、GraphQLを長いこと使ってきたプロダクトもあれば、最近使い始めたプロダクトもありますが、エラーをどう扱うかは社内のあちこちで検討されてきたようです。いくつかの選択肢について紹介して、その中の手法の一つのgraphql-rubyでのサーバ実装について解説したいと思います。
STORES でのGraphQLとエラー表現の遷移
GraphQLについて詳細な解説はここでは省きますが、エラー表現においてはGraphQLの以下のような特徴が重要となります。
- データを必要な分だけ、必要な深さまで一発で取得するようなクエリを発行できる
- おおむね (要出典) HTTP上で提供される
- GraphQLのレスポンス形式の中でエラーを返すためのエントリが定義されている
- APIのためのスキーマ言語がある
HTTPで提供するのだから、HTTP ステータスコードを用いる?
はじめに、GraphQLをHTTPで提供する場合についてgraphql.orgのServing over HTTPでベストプラクティスが記載されているので、これに従うのが基本となります。ただ、このベストプラクティスが固まるまで紆余曲折あったようで、ベストプラクティス自体に少しややこしいところがあります。抜粋すると...
- レスポンスのボディはGraphQLのレスポンス形式に沿ったJSONを返すべき
- レスポンスのメディアタイプは
application/graphql-response+json
またはapplication/json
(for legacy clients) - メディアタイプが
application/graphql-response+json
ならば、おおよそHTTP ステータスコードのセマンティクスに沿ったステータスコードを返すべき - メディアタイプが
application/json
ならば、ステータスコード 2xxを返すべき
といったことが書かれています。注目すべきは、 レスポンスの形式が定まっていること (次節で見ていきます)、メディアタイプが2つ想定され、 メディアタイプによってステータスコードの扱いが異なるところです。1
GraphQL APIを提供する場合においても、レスポンスのHTTP ステータスコードをレスポンスの内容に沿ったものにしておく利点はあります。例えば、監視などの観点で、従来の仕組みに一定乗っかることができます。GraphQLを理解しないシステムやミドルウェアでも、HTTPは理解してうまく動作してくれるというものは多く存在するでしょう。
一方で、RESTfulなAPIと比較すると、
- RESTfulなAPIではエンドポイントのパスからエラーの原因となっているリソースに一定目処が付くところ、GraphQLではエンドポイントは
/graphql
ただ一つで区別できない - GraphQLは必要十分なデータを一発でクエリできるようデザインされているが、レスポンス全体で1つのHTTP ステータスコードでは、クエリのどの部分に起因するステータスコードなのかわからない、クエリ中で複数のエラーが起きたときの扱いも難しい
といったことが言えます。意味のあるHTTP ステータスコードを返しても、 エラーの解像度の不足 がいずれにせよ課題になります。
graphql-ruby固有の事情
graphql-rubyを使うとき、はじめにRails上で /graphql
をサーブするためのボイラープレートを rails g graphql:install
で生成するかと思います。このボイラープレートにおいて、GraphQL APIのエントリポイントは素朴なAction Controller実装となっていますが、このコードは上でいうfor legacy clientsな振る舞いをするものとなっています。
class GraphqlController < ApplicationController def execute variables = prepare_variables(params[:variables]) query = params[:query] operation_name = params[:operationName] context = {} result = GraphqlRubyExampleSchema.execute(query, variables:, context:, operation_name:) # 200 OK, Content-type: application/json なレスポンスを基本とする render json: result rescue StandardError => e raise e unless Rails.env.development? handle_error_in_development(e) end
うまくエラーをaggregateしつつ適切なステータスコードを計算する仕組みがないため、メディアタイプ application/graphql-response+json
で適切に応答できるようにするには、ここからひと手間加える必要があります。こういった事情もあり、社内のGraphQL サーバはまだ application/json
のレスポンス、つまりHTTP ステータスコードがほぼ200固定のレスポンスを返す実装が多い状態です。 2
GraphQLのレスポンス形式の errors
エントリを使用する?
上述のように、GraphQL サーバは、レスポンスを所定の形式で返すべきとされています。これは何らかのエラーが発生したときも同様です。そのため、このレスポンス形式の中に errors
というエラーを返すためのエントリが定義されています。
{ "errors": [ { "message": "Name for character with ID 1002 could not be fetched.", "locations": [{ "line": 6, "column": 7 }], "path": ["hero", "heroFriends", 1, "name"] } ] }
errors
エントリは配列で、配列の各要素は一定の情報を持ったオブジェクトになっています。オブジェクトは規定のエントリとして message
, locations
, path
といった情報を持つことができ、それ以外の情報も extensions
エントリ上に含めることができます。
- レスポンス中に複数のエラー情報を含められる
- 最低限のエントリが定義されている
- 追加でアプリケーション固有のエラー詳細も
extension
エントリに含められる
ということで、この形式の上で各種エラーを表現すれば良いだろうと思われましたが、実際にはいくつか課題がありました。
エラー詳細がスキーマレスでしんどい
GraphQLはせっかくスキーマ定義がある言語なんですが、このエラー形式はその外にあり、ほとんどスキーマレスです。エラーメッセージを表示するだけなら message
エントリが規定されているので利用できますが、もうちょっと構造化された業務エラー情報を含めるとスキーマレスの苦しみに直面します。
data
中のエラーの発生箇所との対応付けがしんどい
レスポンス中に複数エラーを持てはするものの、クエリのどこで発生したエラーか、という情報を活用するのが困難です。エラー形式上は、エラーの path
エントリからクエリのどこのエラーか、という情報を辿れることになっていますが、この情報を従来の静的型付け言語や既存のGraphQLクライアントでうまく使うのは骨が折れます。
errors
エントリの使いどころ
こういった事情から、STORES の最近のGraphQL APIの開発では、 errors
エントリは業務エラーの表現には使用せず、 クエリ全体をまったく処理できないときの問題の通知 に使用する傾向が強まっています。
graphql-rubyに処理を委ねていると自然とそうなる、という側面もあります。例えばgraphql-rubyは、max complexityを超過したクエリはデフォルトで {"data": null, "errors": [(...max complexityを超過している旨のエラー...)]}
形式の応答を返します。
業務エラーをGraphQL スキーマで扱う: unionの活用
では業務エラーをどう扱うかと考えて、GraphQLのスキーマ言語の表現力を使って、業務エラーもAPIスキーマ上で定義しようとなってきました。具体例を見るのが早いのでコードから紹介します。
type Mutation { # フィールド解決の結果がエラーになりうるフィールドは XXXResult を返す registerUser(input: RegisterUserInput!): RegisterUserResult! } # XXXResult はunionで、成功時のデータ型、またはいずれかのエラーをとる union RegisterUserResult = User | BadRegisterUserInput | Conflict # エラーもtypeで定義する # 頻出のエラーは共通の (ex. Forbiddenとか)、固有のエラーは固有のtypeを定義する type BadRegisterUserInput implements Error { code: Int! message: String! name: [String!]! email: [String!]! } # 業務エラーに相当する型は、すべて Error インターフェースを実装する interface Error { code: Int! message: String! }
「union
を積極的に使っている」以外はごく一般的なGraphQLスキーマとなっているかと思います。 「union
は使わずにエラー情報をフィールドに持つ type
を定義する」といったスキーマ定義も有力な選択肢ですが 3、本記事では union
の使い勝手や実装について絞って深堀りしたいと思います。
クライアント観点では割と扱いやすい
union
はクライアント観点では割と扱いやすい性質があります。特にTypeScriptでは __typename
をタグとするDiscriminated unionの形となるため、エラーハンドリングの強制と型安全な分岐が手に入ります。
また、GraphQLの union
は閉じた型なので、スキーマ定義によって取りうる型が定まるという利点もありますが、筆者はどちらかというとドキュメンテーションの側面のほうが強いかなと考えています。型が閉じていることに強く依拠したコードをどこまで許容するかは悩ましい課題です。
graphql.org のNullabilityに関するベストプラクティスからは外れている
GraphQLのスキーマの型がNullable by defaultで、 !
(non-null修飾) を付けていくのが煩わしいと思ったことはないでしょうか?これについてはgraphql.orgのGraphQL Best Practices: Nullabilityの節を読むとその思想が理解できます。大雑把に以下のようなことを言っています:
- データの解決に失敗する要因は様々 (DBがダウンしている、非同期処理が失敗する、例外が投げられる、...)
- こういった失敗について、単にフィールドは
null
を返すようにすると考え、データの解決に失敗しない保証があるフィールドだけnon-null修飾を付けよう
明記されていませんが、そうしてレスポンスの errors
エントリを活用すると良いだろう、ということかと思います。
しかしながら、前述のように errors
エントリを活用するのはしんどく、また我々は null
というものを、単に「データがない」表現として使用することに慣れすぎていて、なかなか実践して浸透させるのが難しいプラクティスだと感じています。そういった理由からnon-nullを基本とし、 union
で結果型を表現するようになりました。
graphql-rubyでunionでエラーを表現する実装
graphql-rubyでの実装について見ていきたいと思います。実際のコードはyubrot/graphql-ruby-exampleにあるので、ここでは要所だけ取り上げていきます。
スキーマ定義
スキーマ定義は一般的なgraphql-rubyの書き方そのままです (サンプルリポジトリではunion
の possible_types
をミューテーションやリゾルバにインラインに書けるヘルパーを導入したりしています)。
module GraphqlApp class RegisterUserResult < BaseUnion possible_types User, BadRegisterUserInput, Conflict end class BadRegisterUserInput < BaseObject implements Error field :name, [String], null: false field :email, [String], null: false # ... end class Conflict < BaseObject # ... end module Error include BaseInterface field :message, String, null: false field :code, Integer, null: false end end
ひとつ特筆すべきところとして、エラーの型は Error
インターフェースを実装する、という規約があります。これによって、 union
の possible_types
は常に、 成功時の型(sole), エラー型1, エラー型2, ...
の形を取ることになります。これはAPIスキーマ上のデザインの側面がありつつ、後述の .resolve_type
の実装で役立ちます。
リゾルバの実装
元のエラーを考慮していなかったリゾルバの実装が以下だったとします。
module GraphqlApp class RegisterUser < BaseMutation argument :name, String, required: true argument :email, String, required: true type User, null: false def resolve(name:, email:) user = ::User.new(name:, email:) user.save! user end end end
union
を使用することで実装コストを上げたくないので、 union
の使用時も以下のように結果またはエラーを返せるようにしたいと考えます。
- type User, null: false + type RegisterUserResult, null: false def resolve(name:, email:) user = ::User.new(name:, email:) user.save! user + rescue ActiveRecord::RecordNotUnique + FieldError.new(Conflict) + rescue ActiveRecord::RecordInvalid + FieldError.new( + BadRegisterUserInput, + name: user.errors.messages[:name] || [], + email: user.errors.messages[:email] || [], + ) end
これを実現するために、エラーを表現するデータ型 FieldError
を導入しています。要所だけ抜粋します。
module GraphqlApp class FieldError def initialize(error_type, **details) unless error_type < BaseObject && error_type.implements.any? { _1.abstract_type == Error } raise ArgumentError, "error_type must be a GraphQL object type that implements Interfaces::Error interface" end @_error_type = error_type @_details = details end attr_reader :_error_type, :_details # ... end end
- PORO
- 自身が スキーマ上のどのエラーに対応するか を
#_error_type
として知っている
といったところが特徴です。このような型を導入すると、あるリゾルバの結果とスキーマ上の型に以下のような対応関係が出来ます。
リゾルバの結果 | スキーマ上の型 | |
---|---|---|
エラーを返すとき | FieldError インスタンス |
field_error._error_type |
成功を返すとき | 成功時の型に対応するオブジェクト | union.possible_types のうち Error でないもの |
この対応関係を、そのまま汎用的な .resolve_type
の実装に活用することができます。
.resolve_type
の実装
graphql-rubyで union
あるいは interface
を使ったことがない場合は、.resolve_type
のドキュメントをあらかじめご参照ください。リゾルバが返したRubyのオブジェクトが、スキーマ上のどの型に対応するかを解決するためにgraphql-rubyから呼び出されるメソッドが .resolve_type
です。
上述のような対応関係から、 union
の定義を追加するたびに調整する必要のない 汎用的な .resolve_type
を実装することができます。
module GraphqlApp class Schema < GraphQL::Schema # ... def self.resolve_type(abstract_type, obj, _ctx) possible_types = possible_types(abstract_type) # リゾルバの結果がFieldErrorインスタンス: # オブジェクト自身がスキーマ上のどの型に対応するか知っているのでそれを返す if obj.is_a?(FieldError) error_type = obj._error_type raise "Unexpected error #{error_type}" unless possible_types.include?(error_type) return error_type end # リゾルバの結果がFieldErrorインスタンスでない: # Errorインターフェースを実装していない型が対応するスキーマ上の型 possible_types .reject { |ty| ty.implements.any? { _1.abstract_type == Error } } .sole rescue Enumerable::SoleItemExpectedError nil end end end
このような実装から、取りうるエラーの表現に union
を使用しても、そのたび .resolve_type
を調整することなくミューテーションやクエリをサクサクと実装していくことができます。サンプルリポジトリでは、 FieldError
をリゾルバ中から raise
できるようにするなどの調整を加えていますが、コアなところはおおむね本記事で取り上げたものが全てです。
ただ、この計算はややopinionatedな以下の前提に基づいているため、導入時にはこの前提を許容できるか考慮すると良いでしょう。
- エラーの型は常に
Error
インターフェースを実装する - リゾルバがエラーを返すときは常に
is_a? FieldError
なインスタンスを使用する - 結果を表現する
union
のpossible_types
は常に成功時の型(sole), エラー型1, エラー型2, ...
の形を取る
一方で、既に union
を使用していて .resolve_type
の実装が存在する場合でも、上記の .resolve_type
の実装は競合せず、補助的に加えることが可能です。サンプルリポジトリでは FieldError.filter_types
というフィルタメソッドとして上記の手続きを切り出し、それをSchema.resolve_type
から呼び出す形で使用しています。
最後に
GraphQL上で主に業務エラーをどのように扱うか、それをgraphql-rubyでどのようにラクに扱えるようにするかを見てきました。業務エラーはアプリケーションの重要な関心事の一つなので、大事に扱っていきたいですね。
参考資料
- GraphQL Specification
- Production Ready GraphQL
- GraphQL error handling to the max with Typescript, codegen and fp-ts
- GraphQL Best Practices: Nullability
-
以前はGraphQL APIはHTTPのレベルのレスポンスとしては常にステータスコード 2xxで返すべき、とあったと記憶していたんですが、現在それはメディアタイプ
application/json
... for legacy clientsの場合、となっていました↩ - ただ、予期しない例外の発生といったシステムエラーはそのままクエリの解決を中断して5xx系で返すといったことはしています↩
- graphql-rubyでもMutation errorsで例示されています↩