はじめに
前回はMutation編ということでMutationの実装例を紹介させてもらいました。
今回は、エラーハンドリングの実装例を紹介します。
ただ、JavaでGraphQLの実装してもらうときのポイントとなるところを紹介するのがメインなので、ソースコードを作り込んでいるわけではないので、実務でそのまま使用するのは避けてくださいmm
(時短で結構適当にロジックや例外処理書いてあります)
環境
・SpringBoot:3.3.2
・Java:17
・GraphQL:22.1
ソースコード
エラー時のステータスコード
GraphQLはエラー時でも200コード返す仕様みたい。
ここに書いてあるように、ステータスコードは200固定が推奨なので、今回カスタム例外クラスでステータスコード返す必要なし。
エラーの種類
大きく分けると、以下2つ
・ドメインエラー
操作ミスなどで発生するエラー。
操作変更によって復帰可能
・システムエラー
システムの異常によるエラー
ユーザ操作による復帰が不可能
ドメインエラーのエラーハンドリング
ドメインエラーは、クライアントからの操作を変更して、エラーを回避できる場合があるので、レスポンスのdata部の中にエラー内容を入れることで、クライアント側で既知のドメインのエラーであることを検知できる。
スキーマ
type Mutation {
registerBook(title: String!, author: AuthorInput!): Book!
}
input AuthorInput {
id: ID!
firstName: String!
lastName: String!
}
type Book {
id: ID!
title: String!
author: Author
error: InvalidError
}
interface DomainError {
message: String
}
type InvalidError implements DomainError{
message: String
field: String
}
カスタムエクセプションクラス
public class BookRentalException extends RuntimeException {
public BookRentalException(String message) {
super(message);
}
}
public class InvalidBookException extends BookRentalException{
private final String field;
public InvalidBookException(String message, String field) {
super(message);
this.field = field;
}
}
ドメイン
public record Book(BookId id, String title, Author author) {
public Book {
if(title.length() < 1) {
throw new InvalidBookException("Invalid Book title length", "title");
}
}
}
ユースケース
public BookResponse registerBook(String title, Author author) {
Book book = null;
try {
UUID uuid = UUID.randomUUID();
book = new Book(new BookId(uuid.toString()), title, author);
} catch (InvalidBookException e) {
return new BookResponse("error-id", title, author, e);
}
if (!authorRepository.existById(author.id())) {
authorRepository.register(author);
}
bookRepository.registerBook(book);
return new BookResponse(book.id().getId().toString(), book.title(), book.author(), null);
}
クエリ
mutation {
registerBook(title: "",
author: {
id: "author-1",
firstName: "first-test-name"
lastName: "last-test-name"
}) {
id
title
author {
id
firstName
lastName
}
error {
message
field
}
}
}
レスポンス
{
"data": {
"registerBook": {
"id": "error-id",
"title": "",
"author": {
"id": "author-1",
"firstName": "first-test-name",
"lastName": "last-test-name"
},
"error": {
"message": "Invalid Book title length",
"field": "title"
}
}
}
}
data部にエラー内容を含めることができました!
システムエラーのエラーハンドリング
エラー内容
{
"errors": [
{
"message": "INTERNAL_ERROR for c9884c31-4698-8d1e-4c01-80f34f6aa844",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": [
"registerBook"
],
"extensions": {
"classification": "INTERNAL_ERROR"
}
}
],
"data": null
}
システムエラーはINTERNAL_ERROR
として出力されます。
意図して発生せず、クライアントの操作によって改善する可能性も低いです。
なので、data部に含めて返すのではなく、error部をカスタマイズして、返却する方向で考えます。
GlobalExceptionHandlerを用意して、リクエストを受け取った以降のクラスで発生したExceptionをキャッチして、GraphQLのerror部の内容をカスタムして返却していきます。
ExceptionHandler
@ControllerAdvice
public class GlobalExceptionHandler {
@GraphQlExceptionHandler
public GraphQLError handleCustomException(Exception e) {
return GraphQLError.newError()
.errorType(ErrorType.DataFetchingException)
.message("test error")
.build();
}
}
エラー結果
{
"errors": [
{
"message": "test error",
"locations": [],
"extensions": {
"classification": "DataFetchingException"
}
},
{
"message": "The field at path '/registerBook' was declared as a non null type, but the code involved in retrieving data has wrongly returned a null value. The graphql specification requires that the parent field be set to null, or if that is non nullable that it bubble up null to its parent and so on. The non-nullable type is 'Book' within parent type 'Mutation'",
"path": [
"registerBook"
],
"extensions": {
"classification": "NullValueInNonNullableField"
}
}
],
"data": null
}
error部にカスタムエラーを入れることができました。
さいごに
GraphQLを採用するかにあたって、ざっと必要そうなところをキャッチアップしてみました。
最近はAWSの環境構築とか構成考えたりばっかりだったので、久しぶりにプログラム書けて楽しかったです笑
参考文献