はじめに
こんにちは。プロダクト開発部でバックエンドエンジニアをやっている野田です。
今年、JMDCではアドベントカレンダーに参加しています。 qiita.com
本記事は、JMDC Advent Calendar 2023 3日目の記事です。
パーソナルヘルスレコードサービス「Pep Up」では、バックエンドはRuby on Rails、フロントエンドはReactで、新規機能開発時はAPIとしてGraphQLを積極的に利用しています。BFF(Backend For Frontend)としての利用ではなく、GraphQL with Railsの構成です。Railsでのエラーハンドリングを踏襲しつつ、GraphQL RubyのMutation Errorを扱うにはどうすればいいか考えてみました。特にGraphQL Rubyでの実装を中心に考察した内容を、一般化して紹介します。
TL;DR
- GraphQL Mutationのエラーハンドリングには様々な方法1がある
- 絶対的な正解はなくどのエラーハンドリングを選ぶかはプロダクト次第
GraphQL Specification
まずはじめに、GraphQLの仕様としてエラー結果のフォーマットが定まっているので読んでみます。
以下のようなQueryを発行し名前が取得できない場合、
{ hero(episode: $episode) { name heroFriends: friends { id name } } }
レスポンスは以下の通りです。
{ "errors": [ { "message": "Name for character with ID 1002 could not be fetched.", "locations": [{ "line": 6, "column": 7 }], "path": ["hero", "heroFriends", 1, "name"] } ], "data": { "hero": { "name": "R2-D2", "heroFriends": [ { "id": "1000", "name": "Luke Skywalker" }, { "id": "1002", "name": null }, { "id": "1003", "name": "Leia Organa" } ] } } }
ここでのポイントは以下です。
errors
はdata
と同じ階層にあるerrors
はGraphQL Schemaで表現されていないmessage
は開発者がエラーを理解し修正するためのメッセージ
errors
のレスポンスはextensions
キーで拡張でき内容に制限はありません。以下のようにエラー内容を表す独自のコードなどを含めることができます。
{ "errors": [ { "message": "Name for character with ID 1002 could not be fetched.", "locations": [{ "line": 6, "column": 7 }], "path": ["hero", "heroFriends", 1, "name"], "extensions": { "code": "CAN_NOT_FETCH_BY_ID", "timestamp": "Fri Feb 9 14:33:09 UTC 2018" } } ] }
GraphQL Rubyでの実装
Mutation実行時にGraphQL Specificationに沿ったエラー結果のフォーマットを返すよう実装します。ActiveRecordでのMutationを作成するgeneratorでupdateのMutationを生成するとresolverは以下のようになります2。
def resolve(id:, title:, content:) post = Post.find(id) if post.update(title:, content:) { post: post } else raise GraphQL::ExecutionError.new "Error updating post", extensions: post.errors.to_hash end end
モデルを定義して、
class Post < ApplicationRecord validates :title, presence: true, length: { maximum: 5 } validates :content, presence: true end
バリデーションを満たさないMutationを実行すると、
mutation { postUpdate(input: {id: 1, title: "foobar", content: ""}) { post { title content } } }
レスポンスは以下となります。
{ "data": { "postUpdate": null }, "errors": [ { "message": "Error updating post", "locations": [ { "line": 2, "column": 3 } ], "path": [ "postUpdate" ], "extensions": { "title": [ "is too long (maximum is 5 characters)" ], "content": [ "can't be blank" ] } } ] }
ActiveModel::Errors
に含まれるユーザー向けのエラーメッセージをextensions
に含めることができています。
Errors as Data
GraphQL Rubyに囚われないRailsでのエラーハンドリングについてみてみます。 https://qiita.com/jnchito/items/3ef95ea144ed15df3637 にあるようにエラーは
- 業務エラー
- 文字数超過などユーザーがどうにかできるエラー
- システムエラー
- データベースがダウンしているなどユーザーでどうにもできないエラー
に大別できます。業務エラーの場合はユーザーにフィードバックを表示し正しい入力を促すのが一般的です。システムエラーの場合は例外をハンドリングするようなことはせずRailsの共通処理に任せることが一般的です。
ここでGraphQLの Co-creatorであるLee Byronの発言を引用します。曰く、 https://github.com/graphql/graphql-spec/issues/117#issuecomment-170180628
If your users can do something that needs to provide negative guidance, then you should represent that kind of information in GraphQL as Data not as an Error. Errors should always represent either developer errors or exceptional circumstances (e.g. the database was offline).
とあります。フィードバックの表示によってユーザーに再行動を促す場合、つまり業務エラーはerrorでなくdataで表現すべきで、データベースがダウンしてる場合、つまりシステムエラーはerrorで表現すべきと読めます。
GraphQL Rubyの例を参考に実装してみます。 errorを表すSchemaを用意して、
type UserError { field: [String!] message: String! }
mutationにerrorの配列フィールドを用意して、
... field :post, Types::PostType, null: false field :errors, [Types::UserError], null: false ...
resolverを変更します。
def resolve(id:, title:, content:) post = Post.find(id) if post.update(title:, content:) { post: post, errors: [] } else user_errors = post.errors.map do |error| field = [error.attribute.to_s.camelize(:lower)] { field:, message: error.message, } end { post: post, errors: user_errors, } end end
以下のmutationを実行すると、
mutation { postUpdate(input: {id: 1, title: "foobar", content: ""}) { post { title content } errors { field message } } }
レスポンスは以下となります。
{ "data": { "postUpdate": { "post": { "title": "foobar", "content": "" }, "errors": [ { "field": [ "title" ], "message": "is too long (maximum is 5 characters)" }, { "field": [ "content" ], "message": "can't be blank" } ] } } }
UserError
として業務エラーを取得することができました。
インターフェースの導入
GraphQL Schemaでエラーを表現することができました。しかし単一のUserError
で表現されているのでエラー自体の区別がSchemaでできないことが課題です。
Shopifyの例を見てみます。https://shopify.dev/docs/api/admin-graphql/2023-07/objects/UserError を見るとDisplayableError
interfaceを実装しています。UserError
以外にもこのinterfaceを実装しているエラーがあります。たいていのエラーはUserError
でざっくり扱いたいが、一部細やかなハンドリングが必要なエラーはUserError
とは区別して扱いたい、といったユースケースにおいてインターフェースが使えそうです。
例えば文字数超過エラーをUserError
とは異なるエラーとして定義すると以下のようになります。
interface DisplayableError { field: [String!] message: String! } type UserError implements DisplayableError { field: [String!] message: String! } type PostContentTooLongUserError implements DisplayableError { field: [String!] length: Int! # 入力可能な文字数 message: String! }
GraphQL Rubyで実装してみましょう。 各errorのPOROを実装します3。
class UserError def initialize(error) @error = error end def field [error.attribute.to_s.camelize(:lower)] end def message error.message end private attr_reader :error end
class PostContentTooLongUserError def initialize(error) @error = error end def field [error.attribute.to_s.camelize(:lower)] end def message error.message end def length error.options[:count] end private attr_reader :error end
ErrorによってDisplayableError
の返すtypeを分岐します。
module Types::DisplayableError include Types::BaseInterface field :field, [String] field :message, String, null: false orphan_types Types::UserError, Types::PostContentTooLongUserError definition_methods do def resolve_type(object, context) if object.is_a?(::PostContentTooLongUserError) Types::PostContentTooLongUserError else Types::UserError end end end end
定義したInterfaceをimplementsします。
class Types::UserError < Types::BaseObject implements Types::DisplayableError end
class Types::PostContentTooLongUserError < Types::BaseObject implements Types::DisplayableError field :length, Integer, null: false end
MutationのfieldとしてDisplayableError
を持つようにします。
... field :post, Types::PostType, null: false field :errors, [Types::DisplayableError], null: false ...
どちらのerrorを返すかは ActiveModel::Error#type
で判別することにします。
def resolve(id:, title:, content:) post = Post.find(id) if post.update(title:, content:) { post: post, errors: [] } else # エラー生成用のクラスを作るべきだが、省略 displayable_errors = post.errors.map do |error| if error.type == :too_long PostContentTooLongUserError.new(error) else UserError.new(error) end end { post: post, errors: displayable_errors, } end end
Mutationを実行すると、
mutation { postUpdate(input: {id: 1, title: "foobar", content: ""}) { post { title content } errors { message field ...on PostContentTooLongUserError { length } } } }
レスポンスは以下となります。
{ "data": { "postUpdate": { "post": { "title": "foobar", "content": "" }, "errors": [ { "message": "is too long (maximum is 5 characters)", "field": [ "title" ], "length": 5 }, { "message": "can't be blank", "field": [ "content" ] } ] } } }
文字数超過エラーをUserError
とは異なるエラーとして取得できました。
考察
- GraphQL Specificationに沿った実装
- Error as Dataな実装
- Error Interfaceを導入した実装
をみてきました4。のちに紹介したものほどエラーの内容を細やかに表現できますが、扱う概念が多くなり、複雑性が増加し実装量が増加しています。
ここでRailsでGraphQLを採用するモチベーションについて考えてみます。GraphQLを採用するということはフロントエンドとバックエンドをGraphQLレイヤーで完全に分離した構成にすることも可能です。ActiveModel::Error#message
はエラーがなんであるかを示す概念をユーザーへの説明文言として実装したものなのでそれをそのまま渡すとフロントエンドと結合してしまいます。フロントエンドとバックエンドを分離したい場合はエラーがなんであるかを示す概念そのものを渡すのが適切であると言えます。GraphQL Specificationの場合extensions
でエラー内容のコードを渡したり、Error Interfaceであれば各エラーを各typeとして表現するなどです。しかしRailsを採用する理由は、フロントエンドとバックエンドを分離したアーキテクチャが目的でなく、分離が必要ない部分はあえて密結合にすることで開発スピードを向上しユーザーへ早く価値を届けるためでないでしょうか。エラーハンドリングのような偶発的複雑性はRailsにomakaseしてユーザーの持つ課題といった本質的複雑性に向き合う時間を増やすためRailsを採用するのではないでしょうか。その観点で考えると偶発的複雑性が一番小さくなるのはどの方針か、というのをプロダクトの特性、状況に応じて都度考えてプロダクトに応じた結論を出すしかないのかな、と思います。
おわりに
GraphQL with RailsのMutation Errorについて実装を中心に考察した内容を紹介しました。RailsでGraphQLを使いたい/使っている方々の一助になれば幸いです。
明日4日目は、森山さんです!お楽しみに。
JMDCでは、ヘルスケア領域の課題解決に一緒に取り組んでいただける方を積極採用中です!
フロントエンド /バックエンド/ データベースエンジニア等、様々なポジションで募集をしています。
詳細は下記の募集一覧からご確認ください。
hrmos.co
まずはカジュアルにJMDCメンバーと話してみたい/経験が活かせそうなポジションの話を聞いてみたい等ございましたら、下記よりエントリーいただけますと幸いです。
hrmos.co
★最新記事のお知らせはぜひ X(Twitter)をご覧ください!
https://twitter.com/jmdc_tech
- https://productionreadygraphql.com/2020-08-01-guide-to-graphql-errors↩
- 簡単にするために実際にgeneratorでできるコードを改変しています。↩
- 継承やmoduleで実装することも可能です。しかし Rubyでは、型やインターフェースは心の中にあるのでダックタイピングな実装を採用しています。↩
- 他の方針として Result TypeをUnionで表現した実装もあります。本記事での紹介は省略しました。↩