GraphQL APIエラー Go gqlgenで実装

今回はGraphQLのエラーについて復習してみました。 また、簡単ではありますがgqlgenを使ってカスタムエラーを実装してみたいと思います。

GraphQLのエラーハンドリングについては "GraphQL エラー" で検索するとトップにヒットするこちらの記事が大変分かりやすかったです。 実装部分以外は同じ内容になるので一読されることをおすすめします。

GraphQLにおけるエラーハンドリングの仕方 - ZOZO TECH BLOG

エラーの仕様とベストプラクティス

GraphQLはFacebook社によって開発されました。 公式のドキュメント(エラーについて)は以下になります。

GraphQL

ResponseのError仕様をおおまかに説明すると

  • Responseはmapで返す
  • errorが発生した時は "errors" フィールドを返す
  • Responseのdataフィールドがnullの時は必ず"erros"フィールドを返す
  • API仕様に対して不正なリクエストはRequest error を返す
  • errors は locations, path, 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"
      }
    }
  ]
  "data": null
}

ここまでが仕様となります。 今回実装するgqlgenやGraphQLのライブラリではこの仕様に従っている場合が多いので基本的にはドキュメント通りに実装していけば問題ありません。

そしてさらに主要なクライアントライブラリAPOLLO等のベストプラクティスを追加します。

ここで追加するのは

"extensions"内のフィールドとAPIのレスポンスコードです。

APOLLOのドキュメントは以下になります。

Handling operation errors - Apollo GraphQL Docs

エラーコード

  • resolver errors 200
  • server errors 4xx
  • network errors 5xx or 4xx

resolver内のエラーは 200 を返し errorsフィールドで詳細を表します。 その他サーバーやネットワークの問題でエラーになった時は 4xx や 5xxで処理します。

RestAPIはクライアント側で エラーコードによってエラーの種類を見分けますが GraphQLは詳細なエラー内容は実装でカスタマイズしていきます。

APOLLOのドキュメントでは extensions フィールドに code フィールドを作成してますね。

      "extensions": {
        "code": "CAN_NOT_FETCH_BY_ID",
      }

このようにしてエラー内容を見分けます。

以上が主な説明となります。 次は実装を行なっていきます。

gqlgenで実装

まずは簡単なスキーマを定義していきます。

type Query {
  task(id: ID!): Task!
}


type Task implements Node {
  id: ID!
  title: String!
  note: String!
}

タスクを作成するMutationを作成しました。

スキーマの詳細やDB接続の説明は省くので過去のブログ記事やGitHubをご覧ください。

GitHub - DaisukeMatsumoto0925/graph_practice

次にResolverを生成します。

$ gqlgen
func (r *queryResolver) Task(ctx context.Context, id string) (*gmodel.Task, error) {
    var task gmodel.Task

    if err := r.db.First(&task, id).Error; err != nil {
        return nil, err
    }

    return &task, nil
}

Queryのidから該当するタスクを探し出すResolverを実装しました。 しかし今のままでは エラーが発生した時に err をそのまま返すようになっています。

このままでは以下のようなレスポンスになってしまいます。

{
  "errors": [
    {
      "message": "record not found",
      "path": [
        "task"
      ]
    }
  ],
  "data": null
}

必須のmessage, path, data フィールドは gqlgenライブラリがデフォルトでカバーしてくれていますが、extensions内はさらに実装を追加する必要があります。

extensionsの追加

gqlgenのライブラリに従って実装してみましょう。

gqlgen.com

func (r *queryResolver) Task(ctx context.Context, id string) (*gmodel.Task, error) {
    var task gmodel.Task

    if err := r.db.First(&task, id).Error; err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            graphql.AddError(ctx, &gqlerror.Error{
                Path:    graphql.GetPath(ctx),
                Message: fmt.Sprintf("Error %s", err),
                Extensions: map[string]interface{}{
                    "code": graphErr.NOT_FOUND_ERR,
                },
            })
        } else {
            graphql.AddError(ctx, &gqlerror.Error{
                Path:    graphql.GetPath(ctx),
                Message: fmt.Sprintf("Error %s", err),
                Extensions: map[string]interface{}{
                    "code": graphErr.DATABASE_ERR,
                },
            })
        }
        return nil, nil
    }

    return &task, nil
}
package graphErr

type ErrCode string

const (
    NOT_FOUND_ERR ErrCode = "NOT_FOUND_ERROR"
    DATABASE_ERR  ErrCode = "DATABASE_ERROR"
)

かなり冗長ですがカスタマイズする事ができました。

一度APIを実行してエラーを返してみましょう。

無事にerrorsを取得することができました!

今回はORMのgormを使用しているので if errors.Is(err, gorm.ErrRecordNotFound) でnot Foundだった場合は

NOT_FOUND_ERR を返すようにしています。

それ以外のエラーはなんらかのデータベースエラーになるので DATABASE_ERR としています エラーコード自体は別パッケージに切り出しています。

また、err != nilreturn nil, nil としているところに注目してください。 先ほどまで err を返していましたが、 AddError()メソッドでは error を返さずに追加していく処理になるので nil を返す必要があります。

これでフロント側ではRestAPIのステータスコードの代わりに extensions.codeを取得してエラーの処理を分ける事ができますね。

プロダクションレベルではエラーの処理をパッケージ化してもう少し汎用的にしたいところですが、今回はここまでとさせていただきます。

最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。