29
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

NRI OpenStandiaAdvent Calendar 2023

Day 5

全網羅!Spring for GraphQLのエラーハンドリングを徹底攻略!!

Last updated at Posted at 2023-12-04

はじめに

今回は、GraphQLサーバをSpring Bootで実装する際のエラーハンドリングをどう行うか紹介したいと思います。
GraphQLの一般的なエラーハンドリングの仕方から、その方針に応じたSpring Bootでの実装方法を説明します。

想定読者

本記事は以下の項目に当てはまっている人を想定読者としています。

  • GraphQLサーバでエラーハンドリングを行いたい人
  • GraphQLの基礎知識を理解している人

  • Spring Bootでサーバを構築した経験がある人
  • Spring BootでGraphQLサーバを構築した経験がある人

サンプルコード (参考)

本記事では実装したものに焦点を当てませんが、サンプルコードを実行しながら読むと理解が深まると思います。今回はSpring for GraphQLが公式で出しているコードを変更・拡張する形で実装しました。公式コードはGraphQLのクエリから本(Book)の情報および本著者(Author)の情報を取得するサーバの構築するコードです。

サンプルコードのGitHubリポジトリ

実装したコードはGitHub上から閲覧できます。実行方法はgithubリポジトリを参照してください。

変更点は以下に示すとおりになっています。

  • Controller, Service, Repository層の3層アーキテクチャによる実装に変更
  • 各層のデータのやり取りをDTOによって行うように変更
  • エラーに関するコードの追加

環境

環境は以下のようになっています。

  • JDK: OpenJDK 17
  • Spring Boot: 3.1.5
  • Gradle: 8.4

GraphQLのエラー

概要

エラーは大きく分けて二つに分類できます。

  • 業務エラー
  • システムエラー

それぞれのエラーに応じてエラーハンドリングの仕方も変えることが望ましいです。以降の章で、それぞれのエラーについて詳細に触れていきます。

業務エラー

業務エラーとは、ユーザ側の操作の変更で復帰できるエラーのことを指します。
以下のような場合が業務エラーに該当します。

  • 必須項目に項目が指定されていない場合に発生するエラー
  • 入力された項目の型が不正な場合に発生するエラー
  • すでに登録されている項目を入力したときに発生するエラー

業務エラーが発生した場合には、クライアント側がユーザに再度入力を促し、サーバにアクセスすることで解決することができます。

システムエラー

システムエラーとはユーザがいかなる操作をしても復帰できないエラーのことを指します。
以下のような場合がシステムエラーに該当します。

  • サーバがダウンしている際のエラー
  • サーバがデータベースへのアクセスに失敗したときに発生するエラー

システムエラーが発生した際には、サーバ管理者がエラー時のログ等をみて解決することになります。

エラーレスポンスの方針

GraphQLでは、エラーが発生すると取得したいデータを入れるdata部のほかにerror部にエラー情報が格納されます。GraphQLではHTTPのステータスコードは200(OK)を使うことが推奨されています。
理由は以下のようなものが挙げられます。

  • error部にエラーメッセージを入れることができるので、ステータスコードを指定するメリットがほとんどないため
  • 一つのリクエストで複数の操作が可能で、複数のステータスコードが異なるエラーが発生する可能性があるため

GraphQLの標準のエラーフォーマット

GraphQLのerror部には標準フォーマットが存在します。
フォーマットは以下のような形式です。

GraphQL リスポンスデータ形式
{
    "errors": [
        "message": "error message"
        "locations": {
            "line": "15"
            "column": "10"
        }
        "path": []
        "extensions": {
            "classification": "errorType"
            ...
        }
    ]
}

各項目を簡単に表にまとめると次のようになります。

エントリ名 サブエントリ名 説明
message エラーメッセージ
locations line エラーが起きたクエリの行数
locations column エラーが起きたクエリの列数
path エラーが起きたフィールドのパス情報
extensions ユーザが定義できる拡張項目

extensionsのサブエントリのclassificationはデフォルトのエラーメッセージでエラータイプとして表示されます。

業務エラーにおけるエラーレスポンスの設計

GraphQLのerror部はスキーマ定義できないため、クライアントはerror部のデータを見て柔軟に対応することが難しいです。そこで、復帰が可能な業務エラーにおいてはerror部を用いずdata部を用いる方針があります。この方針のメリットは次のようなものがあります。

  • クライアントとサーバの干渉が少なくエラーハンドリングの実装ができる
  • クライアントは欲しいエラー情報のみを指定することができる
  • GraphQLのdata部だけを見てエラーハンドリングできる
  • 各エラーに対して柔軟に定義できる
  • 警告レスポンスも定義できる

GraphQLのエラーに関するスキーマ定義

GraphQLのエラー定義は次に示すように定義します。複数の業務エラーが発生しうるため、エラーはコレクションでまとめます。

schema.graphqls
type SelectBookPayload {
    book: Book!
    errors: [GraphQLError]
}

interface Error {
    message: String!
}

type InputValidationError implements Error {
    message: String!
    validationType: String
}

type NotFoundError implements Error {
    message: String!
    resourceId: Int
}

union GraphQLError = InputValidationError | NotFoundError

union型のエラーを定義することで、エラーの一覧リストを定義することができます。unionについての詳細は以下のドキュメントを参照して下さい。

業務エラーに関するクエリ

先ほど定義したスキーマに基づいたクエリは次のようになります。各エラー種別に応じて取得したデータを選択します。errorsはunion型のコレクションであるため、それぞれの具象型に応じてどのフィールドを取得するか選択する必要あります(... onで指定した箇所)。

query GetBook {
  bookById(id: "book-1") {
    book {
      name
      pageCount
      author {
        firstName
        lastName
      }
    }
    errors {
      ... on InputValidationError {
        message
        validationType
      }
      ... on NotFoundError {
        message
        resourceId
      }
    }
  }
}

業務エラーに関するレスポンス

先ほど定義したスキーマに基づいたGraphQLサーバからのレスポンスは次のようになります。

{
  "data": {
    "bookById": {
      "book": null,
      "errors": [
        {
          "message": "Custom Not Found Error",
          "resourceId": "book-1"
        },
        {
          "message": "Custom Input Validation Error",
          "validationType": "miss type input: book-1"
        }
      ]
    }
  }
}

システムエラーにおけるエラーレスポンスの設計

システムエラーが発生した場合はerror部にエラーの詳細を記載していきます。システムエラーをerror部に記載するメリットは次のようなものがあります。

  • extensionsエントリにサーバ側が自由に設定したデータを入れることができる
  • クライアント側のエラーハンドリングは基本的にdata部だけ見て処理すればよくなる

システムエラーに関するクエリ

システムエラーは特にスキーマ定義が必要ありません。そのため、クエリに関しても以下に示すように特にエラーに関する情報を含みません。

query GetBook {
  bookById(id: "book-1") {
    book {
      name
      pageCount
      author {
        firstName
        lastName
      }
    }
  }
}

システムエラーに関するレスポンス

システムエラーになるとerror部にエラー情報が含まれます。デフォルトだと以下に示すようにINTERNAL_ERRORとなります。data部のデータは一部取得できた場合は一部返ってきます。すべてのエラーがINTERNAL_ERRORと、ひとくくりになってしまうため、GraphQLサーバではerror部を変更して対処できるようにすることが求められます。

{
  "errors": [
    {
      "message": "INTERNAL_ERROR for df936d34-ec18-732a-1f3e-a2564cc06d29",
      "locations": [
        {
          "line": 37,
          "column": 7
        }
      ],
      "path": [
        "bookById",
        "book",
        "author"
      ],
      "extensions": {
        "classification": "INTERNAL_ERROR"
      }
    }
  ],
  "data": {
    "bookById": {
      "book": {
        "id": "book-1",
        "pageCount": 416,
        "author": null
      }
    }
  }
}

エラーハンドリングする箇所

Spring でGraphQLサーバを構築すると構成は下の画像のようになっています。 GraphQLのリクエスト・レスポンスを制御するのがInterceptorにあたります。サーバ処理のメインはController・Service・Repository層にあたり、この層で例外が発生するとExceptionHandlerを用いてエラーハンドリングします。この結果はインターセプタを介してクライアントに結果を返します。各エラーに応じて、どのフェーズでハンドリングするか異なります。

chain.png

業務エラー時のエラーハンドリング箇所

業務エラーはdata部にerror情報を入れるためインターセプタやExceptionHandlerで十分に対処できません。インターセプタでの処理は、スキーマ定義に依存せずにエラー情報を入れることができるため、今回のエラーレスポンス方針に適していません。また、後ほど詳細に説明しますが、GraphQLのExceptionHandlerは戻り値がerror部のみとなっておりdata部にエラー情報を詰める処理を入れることができません。そのため、Controller・Service・Repository層のいずれかの層でtry-catch構文等を用いてエラーハンドリングを行います。

システムエラー時のエラーハンドリング箇所

Controller・Service・Repository層内での処理は基本的にGraphQLのdata部のみを対象にした処理であるため、error部処理に適しません。そこで、システムエラーはController・Service・Repository層で発生した例外に関するエラーハンドリングはExceptionHandlerを用いて対処します。それ以外の例外はインターセプタを用いてハンドリングします。

業務エラーにおけるSpring Bootの実装

業務エラーの定義

GraphQLのリスポンスのdata部と同様のコードを実装していきます。まずは、GraphQLのスキーマ定義したクラスに対応するようにPOJOを定義していきます。GraphQLErrorクラスのオブジェクトはGraphQLにおけるinterfaceおよびunionに対応づきます。

GraphQLError.java
public class GraphQLError {
    private String message;
}
GraphQLInputValidationError.java
public class GraphQLInputValidationError extends GraphQLError {
    private String validationType;

    public GraphQLInputValidationError(String message, String validationType) {
        super(message);
        this.validationType = validationType;
    }
}
GraphQLNotFoundError.java
public class GraphQLNotFoundError extends GraphQLError {
    private final String resourceId;

    public GraphQLNotFoundError(String message, String resourceId) {
        super(message);
        this.resourceId = resourceId;
    }
}

業務エラーをPOJOで定義したら、レスポンスにあたるPayloadの定義を行います。

SelectBookPayload.java
@Data
@Builder
public class SelectBookPayload {
    private Book book;
    private List<GraphQLError> errors;
}

エラーのUnion型への対応

GraphQLのUnion型とJavaにおけるクラスの対応関係が存在していないため、何も設定していない場合は次のようなエラーが出てきてしまいます。

java.util.concurrent.CompletionException: graphql.AssertException: You have asked for named object type 'GraphQLError' but it's not an object type but rather a 'graphql.schema.GraphQLUnionType'

そこで、GraphQLのUnion型がどのクラスに対応しているのかをConfigファイルに設定していく必要があります。次のコードはGraphQLConfig.javaというConfigファイルを作成してUnion型とJavaクラスの対応関係を行ったサンプルコードになります。

GraphQLConfig.java
@Configuration
public class GraphQLConfig {
    @Bean
    public GraphQlSourceBuilderCustomizer sourceBuilderCustomizer() {
        return (builder) -> {
            ClassNameTypeResolver classNameTypeResolver = new ClassNameTypeResolver();
            classNameTypeResolver.addMapping(CustomNotFoundGraphQLError.class, "NotFoundError");
            classNameTypeResolver.addMapping(CustomInputValidationGraphQLError.class, "InputValidationError");
            
            builder.defaultTypeResolver(classNameTypeResolver);
        };
    }
}

実装コードの概要は次のようなものです。

  • JavaのCustomNotFoundGraphQLErrorクラスをGraphQLスキーマのNotFoundErrorと紐づける
  • JavaのCustomInputValidationGraphQLErrorクラスをGraphQLスキーマのInputValidationErrorに紐づける

このsourceBuilderCustomizerメソッドのコードはスキーマを拡張できる機能です。SchemaResourceBuilderクラスのオブジェクトbuilderを受け取り、builderに対して拡張していきます。

業務エラーの捕捉

業務エラーはController・Service・Repository層でハンドリングします。現在業務エラーに対するベストプラクティスが存在しないため、ここでは実装の一例を紹介します。try-catch構文を用いてdata部内のerror型にエラー情報を詰め込みます。try-catch構文を用いることで、複数の例外を捕捉することが可能になり、クライアント側は複数のエラーを取得できるようになります。

BookRepository.java
public SelectBookRepositoryOutDto bookById(SelectBookRepositoryInDto selectBookRepositoryInDto) {
    Book foundBook = null;
    List<CustomGraphQLError> errors = new ArrayList<>();

    try {
        foundBook = books.stream()
                .filter(book -> book.getId().equals(selectBookRepositoryInDto.getId()))
                .findFirst()
                .orElse(null);
    } catch(Exception e) {
        errors.add(new CustomNotFoundGraphQLError("book is not found", 404));
    }
    return SelectBookRepositoryOutDto.builder()
            .book(foundBook)
            .errors(errors)
            .build();
}

システムエラーにおけるSpring Bootの実装

GraphQLのerror部に対応するJavaオブジェクト

Spring Bootではerror部のデータはGraphQLErrorオブジェクトで実装できます。GraphQLErrorオブジェクトは次のように定義して生成します。newErorrメソッドはbuilder()に対応しています。そのため、設定が必要なフィールドのみをメソッドチェーン方式で設定しオブジェクトを生成します。

GraphQLError graphQLError = GraphQLError.newError()
                                        .errorType(ErrorType.INTERNAL_ERROR)
                                        .message(e.getMessage())
                                        .path(List.of("path1")) // オプション
                                        .locations(List.of(new SourceLocation(1,10))) // オプション
                                        .extensions(Map.of("classification", "errorType")) // オプション
                                        .build();

各JSONエントリに対応するメソッドの説明を次の表で示します。

メソッド名(JSON エントリ名) 引数の型
errorType ErrorClassification
message String
path List<Object>
location または locations SourceLocation または List<SourceLocation>
extensions Map<String, Object>

以降、型の詳細に触れていきます。

ErrorClassification

errorTypeメソッドの引数はErrorClassification インタフェースですが、そのままでは使用できません。使用する際はErrorTypeという列挙型を用います。GraphQLのErrorTypeは二つ存在します。

  • graphql.ErrorType
    • エラー時のデフォルトのerror部のextensionsエントリのclassificationに入っている
    • InvalidSyntax, ValidationError, DataFetchingException, NullValueInNonNullableField, OperationNotSupported, ExecutionAborted

  • org.springframework.graphql.execution.ErrorType
    • HTTPレスポンスのステータスコードに対応
    • BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, INTERNAL_ERROR

SourceLocation

SourceLocationはエラーが発生したクエリの詳細位置を設定するためのクラスです。このクラスのフィールド変数はline, columnがあります。ログを記録するために3つめのフィールド変数としてsourceNameがあります。状況に応じてどちらのコンストラクタを用いるか選択してください。

SourceLocation.java
@PublicApi
public class SourceLocation implements Serializable {

    public static final SourceLocation EMPTY = new SourceLocation(-1, -1);

    private final int line;
    private final int column;
    private final String sourceName;

    public SourceLocation(int line, int column) {
        this(line, column, null);
    }

    public SourceLocation(int line, int column, String sourceName) {
        this.line = line;
        this.column = column;
        this.sourceName = sourceName;
    }
    ...
}

GraphQLExceptionHandlerを用いた実装

REST APIでエラーハンドリングする際にExceptionHandlerを用いたのと同じように、GraphQLでもGraphQLExceptionHandlerを用いて簡単にエラーハンドリングできます。

GraphQLExceptionHandler.java
@ControllerAdvice
public class GraphQLExceptionHandler {    
    @GraphQlExceptionHandler
    public GraphQLError exceptionHandle(Exception e) {
        return GraphQLError.newError().errorType(ErrorType.BAD_REQUEST).message(e.getMessage()).build();
    }
}

他の方法として例外リゾルバを実装してハンドリングする方法があります。内容は割愛しますが、以下のように実装して使用することができます。resolveToSingleErrorメソッドをオーバライドして定義します。

CustomExceptionResolver.java
@Component
public class CustomExceptionResolver extends DataFetcherExceptionResolverAdapter {
    @Override
    protected GraphQLError resolveToSingleError(@NonNull Throwable e, @NonNull DataFetchingEnvironment env) {
        if (e instanceof RuntimeJsonMappingException) {
            return GraphQLError.newError().message(e.getMessage()).errorType(ErrorType.FORBIDDEN).build();
        }
        return GraphQLError.newError().message(e.getMessage()).errorType(ErrorType.INTERNAL_ERROR).build();
    }
}

Interceptorを用いた実装

Controller・Service・Repository層で発生した例外はGraphQLExceptionHandlerでエラーハンドリングできますが、それ以前での例外はエラーハンドリングできません。例えば

  • クエリの形式が不正
    image.png

  • リクエストのメソッドが不正

といったものの場合は、エラーハンドリングできません。こういうときのエラーはデフォルトのエラーが返ってきます。このようなエラーの場合にもエラーハンドリングしたい場合はインターセプタを実装します。

インターセプタの実装は以下のように行います。

RequestErrorInterceptor.java
@Component
public class RequestErrorInterceptor implements WebGraphQlInterceptor {

    @Override
    public @NonNull Mono<WebGraphQlResponse> intercept(@NonNull WebGraphQlRequest request, Chain chain) {
        return chain.next(request).map(response -> {
            if (response.isValid())
                return response;

            List<GraphQLError> errors = response.getErrors().stream()
                    .map(error -> GraphQLError.newError()
                            .errorType(ErrorType.BAD_REQUEST)
                            .message(error.getMessage())
                            .extensions(error.getExtensions())
                            .locations(error.getLocations())
                            .build()).toList();

            return response.transform(builder -> builder.errors(errors).build());
        });
    }
}

このコードは次のような処理を行っています。

  1. リスポンスにエラーがない場合はそのまま返す
  2. エラーが存在する場合はエラータイプをBAD_REQUESTに書き換えて返す

インターセプタでは、エラーハンドリングだけでなくレスポンスの形式を大幅に変更することが可能であるため注意が必要です。

まとめ

Spring Bootで実装したGraphQLサーバのエラーハンドリングの仕方について紹介しました。ハンドリングを行う際には、エラーの種類やどこでエラーが発生するかで分類しました。まとめると以下のようになります。

  1. 業務エラーならば、スキーマ定義しdata部にエラーの内容を付加する
  2. システムエラーならば、二つのパターンによってerror部にエラー内容を付加する
    • Controller層で捕捉できるものはGraphQLExceptionHandlerによってハンドリングする
    • それ以外の場合はインターセプタを実装してエラーハンドリングする

参考文献

最後に本記事を執筆するにあたり参考にした文献を載せます。

29
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
29
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?