はじめに
こんにちは。開発2部の小松山です。
業務でGraphQLのエラーの設計をする機会があり、いろいろと調査・検討した結果、なかなか良いと思えるものができたので紹介します。
本記事はバックエンド寄りの話がメインです。GraphQLのエラーの扱いに迷っているバックエンドエンジニアの方の参考になれば幸いです。
コンテキスト
プロジェクトで採用している技術のなかで、本記事の内容に関連するものを以下に抜粋します。
採用技術 | 用途 |
---|---|
Hasura | メインの GraphQL サーバとして使用 |
gqlgen | Hasura の Remote Schemas となる GraphQL サーバの構築に使用 |
Apollo Client | GraphQL クライアント |
graphql-codegen | Apollo Client で使うコードの自動生成に使用 |
基本的にAPIはHasuraが自動生成したGraphQL APIを使っています。一部、複雑なバリデーションや外部APIへのリクエストが必要な部分は、自前のGraphQL APIを開発し、それをHasuraのRemote Schemasとしています。今回検討したのはこのRemote Schemasのエラーに関してでした。具体的には、APIのエラーメッセージをフロントエンドに表示させることを考慮した場合にサーバから返すエラーのフォーマットをどうしようか、ということを検討していきました。
「Hasura」「Remote Schemas」に関してはこの記事では解説しませんのでご了承ください。もし詳しく知りたい方は解説記事が既に上がっていますのでこちらをご覧ください。
本記事ではHasuraについてそこまで深く踏み込んだ話はしないので、上記記事の内容が頭に入っていれば趣旨は把握できるかと思います。
調査したこと
エラーの表現方法の検討にあたって参考にさせていただいた資料・事例を載せておきます。今回の記事に関連する要点だけ紹介しています。
GraphQL 標準仕様で定められたフォーマット
GraphQLの標準仕様 (October 2021 Edition) で決まっているエラーのフォーマットです。 エラーレスポンスの例と各フィールドが持つべき意味は下記のとおりです。
{ "errors": [ { // 開発者向けのエラーメッセージ "message": "Name for character with ID 1002 could not be fetched.", // もとのGraphQLクエリのなかでどこにエラーが起きたか "locations": [{ "line": 6, "column": 7 }], // レスポンスのどこにエラーが発生しているか "path": ["hero", "heroFriends", 1, "name"], // 何でも詰め込んでいいanyなフィールド "extensions": { "code": "CAN_NOT_FETCH_BY_ID", "timestamp": "Fri Feb 9 14:33:09 UTC 2018" } } ] }
extensions に独自のフォーマットを適用するパターン
GraphQL標準のエラーのextensionsフィールドをアプリケーション独自のものに拡張して使うやり方です。以下の記事を参考にさせていただきました。
エラーレスポンスは下記のようなものが返却されます。
// 引用元: https://note.com/tabelog_frontend/n/n4fc8d4e134d5#c34ea47a-7345-45b0-b0ce-e607579e2169 { "errors": [ { "message": "Invalid data inputted.", "extensions": { "code": "UNPROCESSABLE_ENTITY", "userMessage": "入力項目にエラーがあります", "errorDetails": [ { "message": "hoge@example.com はすでに存在します", "attribute": "email" }, { "message": "お名前の入力は必須です", "attribute": "lastName" } ] } } ] }
Apollo Serverのドキュメントにもこれに近い内容が記載されていました。
また、社内の他のプロジェクトでもこのようなエラーの返し方をしているところが多かったです。割と一般的な扱い方なのかなという印象を受けました。
Result 型にエラーのフィールドを作るパターン
通常のレスポンスフィールドにエラー用のフィールドを作るパターンです。こちらは Shopify の GraphQL API で採用されているやり方のようです。
クエリを書くときにエラーを明示的に取ってくるようにします。ShopifyのAPIではmutationのinputが不正な場合は [UserError!]!
型の userErrors
というフィールドでそのエラー内容を表現しています。RESTだとステータスコード400や422で扱うような情報をこのフィールドに込めているようですね。
GraphQL標準の errors
ではなく data
の中身でロジックのエラーを表現する点で先に紹介した extensions
を拡張するやり方と大きく異なります。
成功時のレスポンスは下記のようになります。「トップレベルの errors
フィールドが返ってきていない」「userErrors
が空配列である」を判定して成功レスポンスであると判断します。
{ "data": { "customerDelete": { "deletedCustomerId": "gid:\/\/shopify\/Customer\/5341881008292", "shop": { "name": "XXX" }, "userErrors": [] } } }
入力値が不正な場合のエラーは下記のようになります。userErrors
にはエラーの起きたフィールドとそれ対するメッセージが入っています。
{ "data": { "customerDelete": { "deletedCustomerId": null, "shop": { "name": "XXX" }, "userErrors": [ { "field": [ "id" ], "message": "Customer can't be found" } ] } } }
クエリレベルのエラー・サーバエラー・APIのレート上限に達した等のエラーは errors
が返されるようです。
// 引用元: https://shopify.dev/docs/api/admin-graphql#rate_limits { "errors": [ { "message": "Query cost is 2003, which exceeds the single query max cost limit (1000).See https://shopify.dev/concepts/about-apis/rate-limits for more information on how thecost of a query is calculated.To query larger amounts of data with fewer limits, bulk operations should be used instead.See https://shopify.dev/tutorials/perform-bulk-operations-with-admin-api for usage details.", "extensions": { "code": "MAX_COST_EXCEEDED", "cost": 2003, "maxCost": 1000, "documentation": "https://shopify.dev/api/usage/rate-limits" } } ] }
Result 型を成功レスポンスとエラーレスポンスの union として定義するパターン
レスポンスの型をunionを使って定義するやり方です。下記の記事を参考にしました。
こちらの例でもクライアント起因のエラーを data
で返すような仕様になっています。先に紹介したShopifyのGraphQL APIと大きく違うのは、エラーの型を UserError
のような画一的な型ではなく、それぞれ別の型として定義している点です。
# 引用元: https://techblog.gaudiy.com/entry/2022/02/17/215331 type Mutation { """ シリアルコードの入力チェック """ checkSerialCode(serialCode: String!): CheckSerialCodeResult! } union CheckSerialCodeResult = CheckSerialCodeSuccess | CheckSerialCodeNotApplicable | CheckSerialCodeAlreadyUsed | CheckSerialCodeUsageLimitExceeded """ 成功 """ type CheckSerialCodeSuccess {} """ エラー: 該当しないシリアルコードの場合 """ type CheckSerialCodeNotApplicable {} """ エラー: 既に使用されているシリアルコードの場合 """ type CheckSerialCodeAlreadyUsed {} """ エラー: シリアルコードの入力回数が上限を超えている場合 """ type CheckSerialCodeUsageLimitExceeded {}
クライアントは __typename
の値を見てどの型のResultが返ったかを判断し、それぞれの場合でハンドリングします。
mutation Mutation($serialCode: String!) { checkSerialCode(serialCode: $serialCode) { __typename ... on CheckSerialCodeSuccess { } ... on CheckSerialCodeNotApplicable { } ... on CheckSerialCodeAlreadyUsed { } ... on CheckSerialCodeUsageLimitExceeded { } } }
設計判断の軸
ここまでの調査とプロジェクトのコンテキストを踏まえ、以下のように設計方針を立てました。
errors[i].message は開発者向けの文言にする。このフィールドはユーザには見せない
Hasuraが生成したGraphQL APIはDBやサーバからのエラーメッセージを message
に入れてレスポンスを返します。ユーザに理解できないメッセージが入ってしまうため、Remote Schemasでもそれにならってユーザ向けのメッセージはこのフィールドへ入れないようにします。
そもそもGraphQL標準仕様では開発者のためのメッセージと明記されているので、当然といえば当然かもしれません。
Every error must contain an entry with the key message with a string description of the error intended for the developer as a guide to understand and correct the error. (引用元)
エラーのモデルも GraphQL スキーマで定義する
エラーモデルをGraphQLスキーマとして定義することで、フロントエンドで採用しているgraphql-codegenによる自動生成の恩恵を受けられます。社内で多く採用されていた extensions
を拡張していくやり方だと厳格にスキーマ定義が出来ません。フロントエンド・バックエンド両方で extensions
フィールドはany型として扱わなければならないので、認識齟齬等による実装ミスが起こり得てしまいます。
ユーザに見せていいエラーかどうかレスポンスだけをみて判断できるようにしたい
前述の通り、今回はサーバから返ってきたエラーメッセージを画面に表示させるという前提でのエラーの設計でした。それをユーザに見せるべきか、または「予期しないエラーが発生しました」程度の表示にしておくかをレスポンスだけを見て判別できるようにしておきたいです。
最終的な判断
上記の軸をベースに、最終的な判断としては、調査内容の節で紹介した「Result型にエラーのフィールドを作るパターン」「Result型を成功レスポンスとエラーレスポンスのunionとして定義するパターン」を組み合わせた案を採用しました。エラーモデルをGraphQLスキーマとして提供し、レスポンスの中身で処理が分岐しやすいかたちを追い求めていった結果、この判断に至りました。以下で詳細に解説していきます。
エラーの分類
今回検討しているのはサーバのエラーメッセージをクライアントに適切に伝えることでした。しかし一括りに「エラー」といってもすべてのエラーに型付けをしていくのは難しいです。そこでエラーを「例外エラー」「ユーザエラー」の2通りで区別するようにしました。
基本的にエラーは「例外エラー」としますが、フロントエンド開発の利便性を考えて、一部のロジックレベルのエラーを「ユーザエラー」として分離しています。
例外エラー
ユーザに詳細なフィードバックが必要ないエラーはすべて例外エラーに分類します。このエラー内容はGraphQLスキーマとして提供せず、GraphQL標準のエラーをトップレベルの errors
に詰めて返します。ここに含まれたエラー内容はユーザには見せる想定はありません。単に「予期しないエラーが発生しました」程度のメッセージを表示するようフロントエンドの実装をします。
また、Hasuraが自動生成するGraphQL APIからのエラーはすべて errors
で返るので例外エラーに分類されます。Hasuraの自動生成するAPIにはビジネスロジックは含まれていないので、返ってくるエラーはDBの制約違反・認証・認可系のエラーです。実装が正しく行われていれば起こり得ないエラーなので、実装ミスとしてエラー通知サービスに送信するような想定もしています。
ユーザエラー
クライアントに詳細を伝えるべきエラーをここに分類しています。「クライアントに詳細を伝えるべき」というのは、エラーの内容によってクライアントが処理を分岐してくれることを期待するかどうかを基準にしました。基本的にはバリデーションエラー・権限エラー等クライアント起因のエラーが分類されます。
前述したGraphQLスキーマとして提供するエラーはすべてユーザエラーに分類されます。
実装したスキーマ
実際の定義に近いかたちでのスキーマ定義を紹介します。
例として記事の投稿をする mutation createArticle
の定義をしています。このmutationは適切な権限を持ったユーザしか実行できません。また、入力値のバリデーションも行われます。
type Mutation { createArticle(input: CreateArticleInput!): CreateArticleResult! } input CreateArticleInput { title: String! body: String! openedAt: Time! } union CreateArticleResult = CreateArticleResultSuccess | CreateArticleResultError "query/mutation の返り値の Result 型はすべてこの interface を実装する。" interface ResultBase { "true の場合、処理成功のモデル、false の場合はエラーのモデルを返す" ok: Boolean! } "正常系レスポンス" type CreateArticleResultSuccess implements ResultBase { "作成した article のリソース" article: Article! ok: Boolean! } "createArticle のユーザエラーの型" union CreateArticleResultErrors = CreateArticleResultValidationFailed | ErrorUnauthorized "異常系レスポンス。error の型はエラーの種類によって異なる" type CreateArticleResultError implements ResultBase { error: CreateArticleResultErrors! ok: Boolean! } """ ユーザエラーを表す共通の interface。 エラーを表す type はすべてこの interface を実装する。 """ interface UserError { "エラーの種別を端的に表すフィールド。HTTPステータスのテキストを使う" code: String! "エラーの概要を表すフィールド。ユーザに見せて良い" message: String! } "入力値のバリデーションエラー。details にエラーが起きたフィールド名とそれに対するメッセージが入る" type CreateArticleResultValidationFailed implements UserError { code: String! message: String! "不正な input のフィールド名とそれに対するメッセージ" details: [ErrorDetail!]! } "権限不足のエラー。createArticle に限らず共通で使う" type ErrorUnauthorized implements UserError { code: String! message: String! }
ResultBase
や UserError
のような、どのquery/mutationにも共通のinterfaceを定義することで、クライアントのレスポンスのハンドリング方法を統一しやすくしています。
クライアント側はまず、返り値が成功・失敗のどちらなのかを判断します。返り値の __typename
を見ることでunionのどの型が返ってきたかがわかります。失敗だった場合はResult型に error
フィールドが存在するようになっています。error
フィールドもunionで定義されており、同様に __typename
でどのエラーが返ってきたかの判別が可能です。ちょっと分岐が多くなってしまっている感じが否めないですが、そこまで複雑なものではないため許容する判断をしました。
今回の例だと、起きうるエラーは「バリデーションエラー」「権限エラー」の2種類のみになっていますが、クライアント側の表示要件によってもっと細かく切っても良いと思います。(「バリデーションエラー」を「タイトルのバリデーションエラー」「本文のバリデーションエラー」に切り分ける等)
クエリのサンプルは下記です。
mutation { createArticle( input: { title: "自己紹介" body: "よろしくお願いします" openedAt: "2023-02-10T20:27:50+09:00" } ) { ... on CreateArticleResultSuccess { __typename ok article { id } } ... on CreateArticleResultError { __typename ok error { ... on CreateArticleResultValidationFailed { __typename code message details { fieldName message } } ... on ErrorUnauthorized { __typename code message } } } } }
おわりに
個人的になかなか良い仕組みができたと思っています。GraphQLスキーマとしてエラーの型も提供することで、仕様上どんなエラーが起きうるかをスキーマレベルでクライアントへ通達できるようになりました。graphql-codegen等の生成ツールを組み合わせて使うことでスキーマ駆動開発のうまみを存分に活かすことができるのではないでしょうか。
また、本記事で紹介したスキーマはgqlgenを使って実装していますが、GraphQLのinterfaceやunionを使った実装も大きなストレスを抱えることはなく開発ができました。gqlgenでinterfaceやunionを実装した事例があまり見当たらなかったので、機会があれば実装についても別で記事を書こうと思います。
最後に、BuySell Technologiesではエンジニアを募集しています。興味がある方はぜひご応募ください!