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

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

# 想定読者
本記事は以下の項目に当てはまっている人を想定読者としています。
- GraphQLサーバでエラーハンドリングを行いたい人
- GraphQLの基礎知識を理解している人

https://qiita.com/yoshii0110/items/fbee34d5c235a2a064f9

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

https://spring.pleiades.io/projects/spring-graphql


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

https://spring.pleiades.io/guides/gs/graphql-server/

## サンプルコードのGitHubリポジトリ
実装したコードはGitHub上から閲覧できます。実行方法はgithubリポジトリを参照してください。
 
https://github.com/masakinakata/GraphQLErrorHandling

変更点は以下に示すとおりになっています。
- 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部にエラーメッセージを入れることができるので、ステータスコードを指定するメリットがほとんどないため
- 一つのリクエストで複数の操作が可能で、複数のステータスコードが異なるエラーが発生する可能性があるため

https://medium.com/swlh/understanding-graphql-error-handling-mechanisms-in-spring-boot-604301c9bedb



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

~~~yml: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のエラー定義は次に示すように定義します。複数の業務エラーが発生しうるため、エラーはコレクションでまとめます。

```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についての詳細は以下のドキュメントを参照して下さい。

https://graphql.org/learn/schema/#union-types

https://qiita.com/NagaokaKenichi/items/d341dc092012e05d6606


### 業務エラーに関するクエリ
先ほど定義したスキーマに基づいたクエリは次のようになります。各エラー種別に応じて取得したデータを選択します。`errors`はunion型のコレクションであるため、それぞれの具象型に応じてどのフィールドを取得するか選択する必要あります（`... on`で指定した箇所）。
~~~graphql
query GetBook {
  bookById(id: "book-1") {
    book {
      name
      pageCount
      author {
        firstName
        lastName
      }
    }
    errors {
      ... on InputValidationError {
        message
        validationType
      }
      ... on NotFoundError {
        message
        resourceId
      }
    }
  }
}
~~~

### 業務エラーに関するレスポンス
先ほど定義したスキーマに基づいたGraphQLサーバからのレスポンスは次のようになります。
~~~json
{
  "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部だけ見て処理すればよくなる

### システムエラーに関するクエリ
システムエラーは特にスキーマ定義が必要ありません。そのため、クエリに関しても以下に示すように特にエラーに関する情報を含みません。
~~~graphql
query GetBook {
  bookById(id: "book-1") {
    book {
      name
      pageCount
      author {
        firstName
        lastName
      }
    }
  }
}
~~~

### システムエラーに関するレスポンス
システムエラーになるとerror部にエラー情報が含まれます。デフォルトだと以下に示すように`INTERNAL_ERROR`となります。data部のデータは一部取得できた場合は一部返ってきます。すべてのエラーが`INTERNAL_ERROR`と、ひとくくりになってしまうため、GraphQLサーバではerror部を変更して対処できるようにすることが求められます。
~~~json
{
  "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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584503/07d44586-d4ae-bbd6-330f-0959b89113e2.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`に対応づきます。

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

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

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

業務エラーをPOJOで定義したら、レスポンスにあたるPayloadの定義を行います。
~~~java:SelectBookPayload.java
@Data
@Builder
public class SelectBookPayload {
    private Book book;
    private List<GraphQLError> errors;
}
~~~

## エラーのUnion型への対応
GraphQLのUnion型とJavaにおけるクラスの対応関係が存在していないため、何も設定していない場合は次のようなエラーが出てきてしまいます。
~~~yml
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クラスの対応関係を行ったサンプルコードになります。

~~~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`構文を用いることで、複数の例外を捕捉することが可能になり、クライアント側は複数のエラーを取得できるようになります。

~~~java: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()`に対応しています。そのため、設定が必要なフィールドのみをメソッドチェーン方式で設定しオブジェクトを生成します。
```java
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`

https://javadoc.io/doc/com.graphql-java/graphql-java/4.0/graphql/ErrorType.html

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


https://spring.pleiades.io/spring-graphql/docs/current/api/org/springframework/graphql/execution/ErrorType.html

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

~~~java: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();
    }
}
~~~

:::note info
他の方法として例外リゾルバを実装してハンドリングする方法があります。内容は割愛しますが、以下のように実装して使用することができます。`resolveToSingleError`メソッドをオーバライドして定義します。
~~~java: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584503/a02133ef-5215-87c5-c542-2ef9f0b4e161.png)

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

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

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

~~~java: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によってハンドリングする
    - それ以外の場合はインターセプタを実装してエラーハンドリングする

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

https://graphql.org/

https://spring.pleiades.io/spring-graphql/reference/

https://spring.pleiades.io/spring-graphql/docs/current/api/

https://spring.pleiades.io/guides/gs/graphql-server/

https://www.graphql-java.com/documentation/getting-started/

https://zenn.dev/coconala/articles/e8172bdd3b342b

https://qiita.com/NagaokaKenichi/items/d341dc092012e05d6606

https://qiita.com/tomokiyao/items/3860c8e3cd3f21126b21

https://qiita.com/kazuki43zoo/items/757b557c05f548c6c5db

https://justforchangesake.wordpress.com/2014/05/07/spring-mvc-request-life-cycle/