JMDC TECH BLOG

JMDCのエンジニアブログです

Mutation Error Handling in GraphQL with Rails

はじめに

こんにちは。プロダクト開発部でバックエンドエンジニアをやっている野田です。

今年、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"
        }
      ]
    }
  }
}

ここでのポイントは以下です。

  • errorsdataと同じ階層にある
  • 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


  1. https://productionreadygraphql.com/2020-08-01-guide-to-graphql-errors
  2. 簡単にするために実際にgeneratorでできるコードを改変しています。
  3. 継承やmoduleで実装することも可能です。しかし Rubyでは、型やインターフェースは心の中にあるのでダックタイピングな実装を採用しています。
  4. 他の方針として Result TypeをUnionで表現した実装もあります。本記事での紹介は省略しました。