GraphQL

GraphQL APIを叩いたときのエラーハンドリング方針を考える

こんにちは、@_mogamingです。この記事はDeNAその2 Advent Calendar 2018の17日目の記事です。

はじめに

iOSアプリ・Androidアプリ・GraphQL APIの開発を行っている自分が、チームのWebフロントエンドエンジニアの方にGraphQL APIのエラーハンドリング方法について相談された際に立てた方針についてです。今の所この方針でうまくいっていますが、もっとこうすればいいんじゃないの?とかあればコメントください。

TL;DR

まとめてみるとかなり単純です。

  • Queryの場合
    • 基本的にエラーは許容する
    • そのリクエストにおいて必ず必要なフィールドでエラーが起きていないかをチェックする
    • リストで返ってくるものは、要件に応じて、必要なフィールドでエラーが起きているデータはリストから弾いてエラーにはしない
  • Mutationの場合
    • エラーを許容しない
    • バックエンドのエンジニアと相談して、エラー種別を識別できるようななにかを追加してもらう
    • REST APIの場合と変わらない形でハンドリングを行う

前提

自チームでは、下記を利用しています。

  • サーバーサイド: GraphQL ruby
  • Webクライアント: ES2015でGraphQL APIクライアントライブラリは未使用
  • アプリ: 現状Mutationのみなのでクライアントライブラリは未使用

GraphQLとはなにか

公式を見ていただくか、手前味噌で大変恐縮ですがiOSDC 2018にて発表させていただいたスライドで説明しているので、そちらを参考にしていただければ大枠はつかめるかと思います。

iOS × GraphQLの嬉しみとツラミ

GraphQLのレスポンス

下記形式で返すことが決められています。

{
  "data": { ... }, // 取得しようとしたデータ
  "errors": [ ... ] // エラーが発生していた場合のみエラーオブジェクトが配列に入っている
}

よって、単純にエラーハンドリングしようとすれば下記のようにできてしまうわけです。

const response = await new GraphQLRequest().send()
if (response.errors) {
  throw new Error(...)
}

しかしこれでは、重要ではないフィールドでエラーが発生していた場合にもコンポーネントを描画することができず、ユーザーにエラー画面を表示してしまうことになりかねません。サービス要件にもよるかと思いますが、エラー画面はなるべく表示しない形で、かつ、描画できるデータは不整合が起きない範囲で描画するのがよいと私は思いました。

GraphQL APIからデータを取得するということ

下記の特徴があります。

  • 各リクエストにおいて、必要なフィールドのみを取得する
    • 各コンポーネントごとに必要なフィールドは異なるため、リクエストクエリも異なる
  • 1リクエストの中に複数のクエリを含めることができる
    • あるクエリ(フィールド)は取得に成功して、あるクエリ(フィールド)は失敗するということがあり得る

自分はこの1つ目の特徴からわかる 各コンポーネントごとに必要なフィールドは異なるため、リクエストクエリも異なる ということに着目しました。必要なフィールドが異なるということは、コンポーネントごとに「必須のデータ」 or 「必須ではないデータ」が決まるということです。

例えば、2018/12/16現在のQiitaのトップページ(下記画像)を描画するためのリクエストがあるとします。

スクリーンショット 2018-12-16 16.43.05.png

GraphQL APIでは、中央のフィードや右側のQiita:Zine最新記事、ユーザーランキングが1クエリで簡単に取得できます。レスポンスは下記のようなイメージになると思います。

{
  "data": {
    "trends": {
      "edges": [
        {
          "node": {
            // 中央の一覧の記事に必要なデータ
            "title": "...",
            ...
          }
        }
      ]
    },
    "qiitaZine": {
      ...
    },
    "userRanking": {
      ...
    }
  },
  "errors": ...
}

あくまで想像ですが、このトップページで最も重要そうなデータは、trendsでしょう。そして、それ以外のqiitaZineuserRankingフィールドは最悪、なくてもこの画面はなりたつと考えられます。そう決めたときに、このリクエストで必須なデータはtrends.edges.[INDEX].node.[各種フィールド] となるわけです。

エラーハンドリングの方針

以上のことを踏まえ、各リクエストで必須なフィールドを明示しておいて、そのデータがなければエラーとして扱い、画面自体をエラー表示としてしまうのがよいと考えました。一方、必須ではないフィールドを明示する方法も考えられますが、一言で言うと効率的・サービス的に本質的ではないため、除外しました。

我々のチームで採用しているGraphQL rubyの場合、エラーオブジェクトにpathというフィールドが存在しています。具体的には下記のような形になっています。この場合は、qiitaZineのリストの1件のtitleでエラーが発生したということなります。おそらく記事のタイトルはNon-Nullableでしょうから、node自体がnullになっていまいます。

{
  "data": {
    ...,
    "qiitaZine": {
      "edges": [{
        "node": null
      }, {
        "node": {
          "title": "...",
          ...
        }
      }]
    }
  }
  "errors": [
    {
      ...,
      "path": [
        "qiitaZine",
        "edges",
        0,
        "node",
        "title"
      ]
    }
  ]
}

このリクエストでの必須フィールドが、このpathとマッチしない場合はエラーとはしないという方針にしています。コード自体は単純で、response.errors.forEachpathが事前に定義しておいた必須フィールドとマッチするかどうかをチェックして、マッチした場合のみthrowするという感じです。イメージできますでしょうか?

以上のような形で、GraphQL APIからデータを取得する場合にエラーハンドリングを行っています。

GraphQL APIでデータを更新する

Mutationのエラーハンドリングも基本的には上記と同様です。しかし、どのようなエラーの種類かを判断するためのフィールドが不足しています(mutationに限らず、queryでもあったほうがいいかと思いますが)。

我々のチームでは、エラーオブジェクトのフィールドにtypeを追加しています。どういう理由でエラーが発生したのかを特定するためのフィールドです。クライアントはこのフィールドを見て、エラーメッセージを決めます。バックエンドのエンジニアと相談して、typeerrorCode的なものを付与できないか相談するのがよさそうです。

おわりに

上記で書いたものはあくまで「我々のチームではこうしている」というものです。ご意見等ございましたらお気軽にコメントもしくは私のTwitter宛にDMいただければと思います。

追記

@bannzai さんご指摘ありがとうございました!mutationの部分の記述を修正しました!