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


結論

@ControllerAdviceを付与したハンドラクラスを用意すれば、各コントローラのバリデーションエラーのハンドリングを一括で処理できる。

環境

Java 11
SpringBoot 2.3.3

解説

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

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

コントローラクラス

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


@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を付与し、単項バリデーションが実施されるようにしている。

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

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

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

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

}

相関バリデータクラス

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

/**
 * {@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");
        }
    }
}

レスポンスボディ用エラークラス

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

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

    private static final long serialVersionUID = 1L;

    private String message;
}

例外ハンドラクラス

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


/**
 * コントローラで発生した例外のハンドラ
 */
@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=上限年齢は下限年齢より大きい値を指定してください。