Spring バリデーションエラーを@ControllerAdviceで一括ハンドリング

# 結論
[@ControllerAdvice](https://spring.pleiades.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/annotation/ControllerAdvice.html)を付与したハンドラクラスを用意すれば、各コントローラのバリデーションエラーのハンドリングを一括で処理できる。

# 環境
`Java 11`
`SpringBoot 2.3.3`
# 解説
以下、検索パラメータを指定してユーザー一覧を取得するRESTコントローラにて解説する。
リクエストの入力バリデーションを設け、バリデーションエラー検出時には所定のレスポンスボディと併せて400エラーを返すようにする。

※package、import文など省略しています。

## コントローラクラス



引数に`@Validated`を付与し、バリデーションが実施されるようにする。
エラー時のハンドリングはここでは特にしない。

```java

@RestController
@RequiredArgsConstructor
public class UserController {

    @NonNull
    private final UserService userService;

    @NonNull
    private final GetUsersQueryValidator getUsersQueryValidator;

    @InitBinder
    public void initBinder(WebDataBinder binder) {
        binder.addValidators(getUsersQueryValidator);
    }

    /**
     * 検索条件を指定してユーザー情報を取得
     *
     * @param getUsersQuery 検索条件クエリパラメータ
     * @return 検索されたユーザー情報
     */
    @GetMapping(value = "/users")
    ResponseEntity<List<UserDto>> getUsers(@Validated GetUsersQuery getUsersQuery) {

        SearchUsersCondition searchUsersCondition = new SearchUsersCondition();
        searchUsersCondition.setName(getUsersQuery.getName());
        searchUsersCondition.setLowerLimitAge(getUsersQuery.getLowerLimitAge());
        searchUsersCondition.setUpperLimitAge(getUsersQuery.getUpperLimitAge());

        return ResponseEntity.ok(userService.searchUsers(searchUsersCondition));
    }
}
```

## クエリパラメータクラス

リクエストのクエリパラメータがバインドされるクラス。
各フィールドに`@NotBlank`、`@NotNull`を付与し、単項バリデーションが実施されるようにしている。

```java
/**
 * ユーザー検索条件を指定するクエリパラメータ
 */
@Data
public class GetUsersQuery {

    /**
     * ユーザー名
     */
    @NotBlank
    private String name;

    /**
     * 下限年齢
     */
    @NotNull
    private Integer lowerLimitAge;

    /**
     * 上限年齢
     */
    @NotNull
    private Integer upperLimitAge;

}
```

## 相関バリデータクラス
クエリパラメータの下限年齢が上限年齢を上回っている時にエラーとする。

```java
/**
 * {@link GetUsersQuery}の相関バリデータ
 */
@Component
public class GetUsersQueryValidator implements Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        return GetUsersQuery.class.isAssignableFrom(clazz);
    }

    /**
     * バリデーション実施
     *
     * @param target バリデーション対象
     * @param errors 検出されたエラー
     */
    @Override
    public void validate(Object target, Errors errors) {

        // 上限年齢、下限年齢のいずれかに単項エラーが発生している場合は相関バリデーションは実施しない
        if (errors.hasFieldErrors("lowerLimitAge") || errors.hasFieldErrors("upperLimitAge")) {
            return;
        }

        GetUsersQuery getUsersQuery = GetUsersQuery.class.cast(target);

        int lowerLimitAge = getUsersQuery.getLowerLimitAge();
        int upperLimitAge = getUsersQuery.getUpperLimitAge();

        // 上限年齢が下限年齢を超えていない場合はエラーとする
        if (lowerLimitAge >= upperLimitAge) {
            errors.reject("reverseLimitAge");
        }
    }
}

```

## レスポンスボディ用エラークラス
エラー時はレスポンスボディにこの型のオブジェクトを格納する。

```java
/**
 * リクエストボディにセットするエラー情報
 */
@Data
public class ApiError implements Serializable {

    private static final long serialVersionUID = 1L;

    private String message;
}
```

## 例外ハンドラクラス
以下のような、`@ControllerAdvice`を付与した例外ハンドラクラスを用意する。

```java

/**
 * コントローラで発生した例外のハンドラ
 */
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Autowired
    MessageSource messageSource;

    /**
     * {@link BindException}をハンドリング
     *
     * @param bindException {@link BindException}
     * @param httpHeaders   {@link HttpHeaders}
     * @param httpStatus    {@link HttpStatus}
     * @param webRequest    {@link WebRequest}
     * @return クライアントへのレスポンス
     */
    @Override
    protected ResponseEntity<Object> handleBindException(
            BindException bindException,
            HttpHeaders httpHeaders,
            HttpStatus httpStatus,
            WebRequest webRequest
    ) {
        // レスポンスボディに格納するエラーリスト
        List<ApiError> apiErrorList = new ArrayList<>();

        List<ObjectError> objectErrorList = bindException.getAllErrors();

        for (ObjectError objectError : objectErrorList) {

            // エラーコードからメッセージ取得
            String message = messageSource.getMessage(objectError, webRequest.getLocale());

            // レスポンスボディ用エラーオブジェクトを作成しリストに格納
            ApiError apiError = new ApiError();
            apiError.setMessage(message);
            apiErrorList.add(apiError);
        }

        return new ResponseEntity<>(apiErrorList, httpHeaders, httpStatus);
    }
}

```

バリデーションエラー発生時はコントローラからエラー情報が格納された`BindException`が投げられる。
`@ControllerAdvice`を付与したクラスには、各コントローラ共通に適用したい処理を実装する。`ResponseEntityExceptionHandler`を継承し、`handleBindException`メソッドをオーバーライドすることで、バリデーションエラー時のレスポンスを自由にカスタムすることができる。

ここでは以下のようにカスタムしている。

- レスポンスボディを`ApiError`型に指定。
- `objectError`のエラーコードからエラーメッセージに変換。

エラーコードは以下の形式で`objectError`に格納されている。

単項バリデーション：「アノテーション名＋クラス名（キャメルケース）＋フィールド名」
相関バリデーション：「相関バリデータでセットしたエラーコード＋クラス名（キャメルケース）」

`messages.properties`を以下のように用意すれば、メッセージへ変換される。

**messages.properties**

```
NotBlank.getUsersQuery.name=名前の入力は必須です。
NotNull.getUsersQuery.lowerLimitAge=下限年齢の入力は必須です。
NotNull.getUsersQuery.upperLimitAge=上限年齢の入力は必須です。
reverseLimitAge.getUsersQuery=上限年齢は下限年齢より大きい値を指定してください。
```