STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

graphql-ruby エラーの設計と実装

yubrot です。2024年11月14日に、STORES.rb Railsのはなしというイベントでgraphql-ruby エラーの設計と実装について話しました。内容がブログ記事向きだろうということで、ブログで改めて解説したいと思います。

本記事で取り上げているコードを含めた、graphql-rubyによるGraphQL サーバの実装例をyubrot/graphql-ruby-exampleで公開しています。こちらも合わせてご参照ください。

STORES とGraphQL

STORES はプロダクト間のやりとりにGraphQLによるAPI通信を多用しています。社内を見渡すと、GraphQLを長いこと使ってきたプロダクトもあれば、最近使い始めたプロダクトもありますが、エラーをどう扱うかは社内のあちこちで検討されてきたようです。いくつかの選択肢について紹介して、その中の手法の一つのgraphql-rubyでのサーバ実装について解説したいと思います。

STORES でのGraphQLとエラー表現の遷移

GraphQLについて詳細な解説はここでは省きますが、エラー表現においてはGraphQLの以下のような特徴が重要となります。

  • データを必要な分だけ、必要な深さまで一発で取得するようなクエリを発行できる
  • おおむね (要出典) HTTP上で提供される
  • GraphQLのレスポンス形式の中でエラーを返すためのエントリが定義されている
  • APIのためのスキーマ言語がある

HTTPで提供するのだから、HTTP ステータスコードを用いる?

はじめに、GraphQLをHTTPで提供する場合についてgraphql.orgのServing over HTTPでベストプラクティスが記載されているので、これに従うのが基本となります。ただ、このベストプラクティスが固まるまで紆余曲折あったようで、ベストプラクティス自体に少しややこしいところがあります。抜粋すると...

  • レスポンスのボディはGraphQLのレスポンス形式に沿ったJSONを返すべき
  • レスポンスのメディアタイプは application/graphql-response+json または application/json (for legacy clients)
  • メディアタイプが application/graphql-response+json ならば、おおよそHTTP ステータスコードのセマンティクスに沿ったステータスコードを返すべき
  • メディアタイプが application/json ならば、ステータスコード 2xxを返すべき

といったことが書かれています。注目すべきは、 レスポンスの形式が定まっていること (次節で見ていきます)、メディアタイプが2つ想定され、 メディアタイプによってステータスコードの扱いが異なるところです。1

GraphQL APIを提供する場合においても、レスポンスのHTTP ステータスコードをレスポンスの内容に沿ったものにしておく利点はあります。例えば、監視などの観点で、従来の仕組みに一定乗っかることができます。GraphQLを理解しないシステムやミドルウェアでも、HTTPは理解してうまく動作してくれるというものは多く存在するでしょう。

一方で、RESTfulなAPIと比較すると、

  • RESTfulなAPIではエンドポイントのパスからエラーの原因となっているリソースに一定目処が付くところ、GraphQLではエンドポイントは /graphql ただ一つで区別できない
  • GraphQLは必要十分なデータを一発でクエリできるようデザインされているが、レスポンス全体で1つのHTTP ステータスコードでは、クエリのどの部分に起因するステータスコードなのかわからない、クエリ中で複数のエラーが起きたときの扱いも難しい

といったことが言えます。意味のあるHTTP ステータスコードを返しても、 エラーの解像度の不足 がいずれにせよ課題になります。

graphql-ruby固有の事情

graphql-rubyを使うとき、はじめにRails上で /graphql をサーブするためのボイラープレートを rails g graphql:install で生成するかと思います。このボイラープレートにおいて、GraphQL APIのエントリポイントは素朴なAction Controller実装となっていますが、このコードは上でいうfor legacy clientsな振る舞いをするものとなっています。

class GraphqlController < ApplicationController
  def execute
    variables = prepare_variables(params[:variables])
    query = params[:query]
    operation_name = params[:operationName]
    context = {}
    result = GraphqlRubyExampleSchema.execute(query, variables:, context:, operation_name:)
    # 200 OK, Content-type: application/json なレスポンスを基本とする
    render json: result
  rescue StandardError => e
    raise e unless Rails.env.development?
    handle_error_in_development(e)
  end

うまくエラーをaggregateしつつ適切なステータスコードを計算する仕組みがないため、メディアタイプ application/graphql-response+json で適切に応答できるようにするには、ここからひと手間加える必要があります。こういった事情もあり、社内のGraphQL サーバはまだ application/json のレスポンス、つまりHTTP ステータスコードがほぼ200固定のレスポンスを返す実装が多い状態です。 2

GraphQLのレスポンス形式errors エントリを使用する?

上述のように、GraphQL サーバは、レスポンスを所定の形式で返すべきとされています。これは何らかのエラーが発生したときも同様です。そのため、このレスポンス形式の中に errors というエラーを返すためのエントリが定義されています。

{
  "errors": [
    {
      "message": "Name for character with ID 1002 could not be fetched.",
      "locations": [{ "line": 6, "column": 7 }],
      "path": ["hero", "heroFriends", 1, "name"]
    }
  ]
}

errors エントリは配列で、配列の各要素は一定の情報を持ったオブジェクトになっています。オブジェクトは規定のエントリとして message, locations, path といった情報を持つことができ、それ以外の情報も extensions エントリ上に含めることができます。

  • レスポンス中に複数のエラー情報を含められる
  • 最低限のエントリが定義されている
  • 追加でアプリケーション固有のエラー詳細も extension エントリに含められる

ということで、この形式の上で各種エラーを表現すれば良いだろうと思われましたが、実際にはいくつか課題がありました。

エラー詳細がスキーマレスでしんどい

GraphQLはせっかくスキーマ定義がある言語なんですが、このエラー形式はその外にあり、ほとんどスキーマレスです。エラーメッセージを表示するだけなら message エントリが規定されているので利用できますが、もうちょっと構造化された業務エラー情報を含めるとスキーマレスの苦しみに直面します。

data 中のエラーの発生箇所との対応付けがしんどい

レスポンス中に複数エラーを持てはするものの、クエリのどこで発生したエラーか、という情報を活用するのが困難です。エラー形式上は、エラーの path エントリからクエリのどこのエラーか、という情報を辿れることになっていますが、この情報を従来の静的型付け言語や既存のGraphQLクライアントでうまく使うのは骨が折れます。

errors エントリの使いどころ

こういった事情から、STORES の最近のGraphQL APIの開発では、 errors エントリは業務エラーの表現には使用せず、 クエリ全体をまったく処理できないときの問題の通知 に使用する傾向が強まっています。

graphql-rubyに処理を委ねていると自然とそうなる、という側面もあります。例えばgraphql-rubyは、max complexityを超過したクエリはデフォルトで {"data": null, "errors": [(...max complexityを超過している旨のエラー...)]} 形式の応答を返します。

業務エラーをGraphQL スキーマで扱う: unionの活用

では業務エラーをどう扱うかと考えて、GraphQLのスキーマ言語の表現力を使って、業務エラーもAPIスキーマ上で定義しようとなってきました。具体例を見るのが早いのでコードから紹介します。

type Mutation {
  # フィールド解決の結果がエラーになりうるフィールドは XXXResult を返す
  registerUser(input: RegisterUserInput!): RegisterUserResult!
}

# XXXResult はunionで、成功時のデータ型、またはいずれかのエラーをとる
union RegisterUserResult = User | BadRegisterUserInput | Conflict

# エラーもtypeで定義する
# 頻出のエラーは共通の (ex. Forbiddenとか)、固有のエラーは固有のtypeを定義する
type BadRegisterUserInput implements Error {
  code: Int!
  message: String!

  name: [String!]!
  email: [String!]!
}

# 業務エラーに相当する型は、すべて Error インターフェースを実装する
interface Error {
  code: Int!
  message: String!
}

union を積極的に使っている」以外はごく一般的なGraphQLスキーマとなっているかと思います。 「union は使わずにエラー情報をフィールドに持つ type を定義する」といったスキーマ定義も有力な選択肢ですが 3、本記事では union の使い勝手や実装について絞って深堀りしたいと思います。

クライアント観点では割と扱いやすい

union はクライアント観点では割と扱いやすい性質があります。特にTypeScriptでは __typename をタグとするDiscriminated unionの形となるため、エラーハンドリングの強制と型安全な分岐が手に入ります。

また、GraphQLの union は閉じた型なので、スキーマ定義によって取りうる型が定まるという利点もありますが、筆者はどちらかというとドキュメンテーションの側面のほうが強いかなと考えています。型が閉じていることに強く依拠したコードをどこまで許容するかは悩ましい課題です。

graphql.org のNullabilityに関するベストプラクティスからは外れている

GraphQLのスキーマの型がNullable by defaultで、 ! (non-null修飾) を付けていくのが煩わしいと思ったことはないでしょうか?これについてはgraphql.orgのGraphQL Best Practices: Nullabilityの節を読むとその思想が理解できます。大雑把に以下のようなことを言っています:

  • データの解決に失敗する要因は様々 (DBがダウンしている、非同期処理が失敗する、例外が投げられる、...)
  • こういった失敗について、単にフィールドは null を返すようにすると考え、データの解決に失敗しない保証があるフィールドだけnon-null修飾を付けよう

明記されていませんが、そうしてレスポンスの errors エントリを活用すると良いだろう、ということかと思います。

しかしながら、前述のように errors エントリを活用するのはしんどく、また我々は null というものを、単に「データがない」表現として使用することに慣れすぎていて、なかなか実践して浸透させるのが難しいプラクティスだと感じています。そういった理由からnon-nullを基本とし、 union で結果型を表現するようになりました。

graphql-rubyでunionでエラーを表現する実装

graphql-rubyでの実装について見ていきたいと思います。実際のコードはyubrot/graphql-ruby-exampleにあるので、ここでは要所だけ取り上げていきます。

スキーマ定義

スキーマ定義は一般的なgraphql-rubyの書き方そのままです (サンプルリポジトリではunionpossible_types をミューテーションやリゾルバにインラインに書けるヘルパーを導入したりしています)。

module GraphqlApp
  class RegisterUserResult < BaseUnion
    possible_types User, BadRegisterUserInput, Conflict
  end

  class BadRegisterUserInput < BaseObject
    implements Error
  
    field :name, [String], null: false
    field :email, [String], null: false
    # ...
  end

  class Conflict < BaseObject
    # ...
  end

  module Error
    include BaseInterface

    field :message, String, null: false
    field :code, Integer, null: false
  end
end

ひとつ特筆すべきところとして、エラーの型は Error インターフェースを実装する、という規約があります。これによって、 unionpossible_types は常に、 成功時の型(sole), エラー型1, エラー型2, ... の形を取ることになります。これはAPIスキーマ上のデザインの側面がありつつ、後述の .resolve_type の実装で役立ちます。

リゾルバの実装

元のエラーを考慮していなかったリゾルバの実装が以下だったとします。

module GraphqlApp
  class RegisterUser < BaseMutation
    argument :name, String, required: true
    argument :email, String, required: true

    type User, null: false

    def resolve(name:, email:)
      user = ::User.new(name:, email:)
      user.save!
      user
    end
  end
end

union を使用することで実装コストを上げたくないので、 union の使用時も以下のように結果またはエラーを返せるようにしたいと考えます。

-    type User, null: false
+    type RegisterUserResult, null: false

     def resolve(name:, email:)
       user = ::User.new(name:, email:)
       user.save!
       user
+    rescue ActiveRecord::RecordNotUnique
+      FieldError.new(Conflict)
+    rescue ActiveRecord::RecordInvalid
+      FieldError.new(
+        BadRegisterUserInput,
+        name: user.errors.messages[:name] || [],
+        email: user.errors.messages[:email] || [],
+      )
     end

これを実現するために、エラーを表現するデータ型 FieldError を導入しています。要所だけ抜粋します。

module GraphqlApp
  class FieldError
    def initialize(error_type, **details)
      unless error_type < BaseObject && error_type.implements.any? { _1.abstract_type == Error }
        raise ArgumentError, "error_type must be a GraphQL object type that implements Interfaces::Error interface"
      end

      @_error_type = error_type
      @_details = details
    end

    attr_reader :_error_type, :_details
    # ...
  end
end
  • PORO
  • 自身が スキーマ上のどのエラーに対応するか#_error_type として知っている

といったところが特徴です。このような型を導入すると、あるリゾルバの結果とスキーマ上の型に以下のような対応関係が出来ます。

リゾルバの結果 スキーマ上の型
エラーを返すとき FieldError インスタンス field_error._error_type
成功を返すとき 成功時の型に対応するオブジェクト union.possible_types のうち Error でないもの

この対応関係を、そのまま汎用的な .resolve_type の実装に活用することができます。

.resolve_type の実装

graphql-rubyunion あるいは interface を使ったことがない場合は、.resolve_type のドキュメントをあらかじめご参照ください。リゾルバが返したRubyのオブジェクトが、スキーマ上のどの型に対応するかを解決するためにgraphql-rubyから呼び出されるメソッドが .resolve_type です。

上述のような対応関係から、 union の定義を追加するたびに調整する必要のない 汎用的な .resolve_type を実装することができます。

module GraphqlApp
  class Schema < GraphQL::Schema
    # ...

    def self.resolve_type(abstract_type, obj, _ctx)
      possible_types = possible_types(abstract_type)

      # リゾルバの結果がFieldErrorインスタンス:
      # オブジェクト自身がスキーマ上のどの型に対応するか知っているのでそれを返す
      if obj.is_a?(FieldError)
        error_type = obj._error_type
        raise "Unexpected error #{error_type}" unless possible_types.include?(error_type)

        return error_type
      end

      # リゾルバの結果がFieldErrorインスタンスでない:
      # Errorインターフェースを実装していない型が対応するスキーマ上の型
      possible_types
        .reject { |ty| ty.implements.any? { _1.abstract_type == Error } }
        .sole
    rescue Enumerable::SoleItemExpectedError
      nil
    end
  end
end

このような実装から、取りうるエラーの表現に union を使用しても、そのたび .resolve_type を調整することなくミューテーションやクエリをサクサクと実装していくことができます。サンプルリポジトリでは、 FieldError をリゾルバ中から raise できるようにするなどの調整を加えていますが、コアなところはおおむね本記事で取り上げたものが全てです。

ただ、この計算はややopinionatedな以下の前提に基づいているため、導入時にはこの前提を許容できるか考慮すると良いでしょう。

  • エラーの型は常に Error インターフェースを実装する
  • リゾルバがエラーを返すときは常に is_a? FieldError なインスタンスを使用する
  • 結果を表現する unionpossible_types は常に 成功時の型(sole), エラー型1, エラー型2, ... の形を取る

一方で、既に union を使用していて .resolve_type の実装が存在する場合でも、上記の .resolve_type の実装は競合せず、補助的に加えることが可能です。サンプルリポジトリでは FieldError.filter_typesというフィルタメソッドとして上記の手続きを切り出し、それをSchema.resolve_typeから呼び出す形で使用しています。

最後に

GraphQL上で主に業務エラーをどのように扱うか、それをgraphql-rubyでどのようにラクに扱えるようにするかを見てきました。業務エラーはアプリケーションの重要な関心事の一つなので、大事に扱っていきたいですね。

参考資料


  1. 以前はGraphQL APIはHTTPのレベルのレスポンスとしては常にステータスコード 2xxで返すべき、とあったと記憶していたんですが、現在それはメディアタイプ application/json... for legacy clientsの場合、となっていました
  2. ただ、予期しない例外の発生といったシステムエラーはそのままクエリの解決を中断して5xx系で返すといったことはしています
  3. graphql-rubyでもMutation errorsで例示されています