【Spring Boot】【Java】自作アノテーションを用いた相関（項目間）バリデーションを紹介

# はじめに
項目間で相関チェックを行う場合にアノテーションを用いたバリデーションを紹介します。
前回記事の相関（項目間）チェックバージョンです。
[【Spring Boot】【Java】入力フォームに良く登場する項目のバリデーションを実装付きで紹介](https://qiita.com/rpc_tk/items/43bdad1cd186d02fb4cb)

# 前提
- SpringBootを使ったRESTful APIベースで作成してますがSSRでもほぼ流用可能です
- AssertTrueアノテーションを使った相関チェックにしていません（理由は後述

# 紹介する相関チェック
- 「パスワード」、「確認パスワード」一致

# 用意するもの
- build.gradle
    - spring-boot-starter-validation
    - lombok
- ValidationMessages.properties(※1)
    - メッセージ(メッセージIDとメッセージ本文)の指定
- インターフェース
    - 実装クラスの指定
    - 引数の指定
    - バリデーショングループの指定
    - メッセージ(ValidationMessages.propertiesで指定したメッセージID or メッセージ本文)の指定
- 実装クラス
    - バリデーションの実装

※1 「/src/main/resources」などクラスパスが通っているディレクトリに「ValidationMessages.properties」を配置していればSpring Bootはデフォルトで参照するようになっているため準備作業は不要です。違うファイル名にする方法はありますが本記事では割愛します。

## build.gradle
```Java
dependencies {
  ...
  // SpringBootがデフォルトで用意しているバリデーション
  implementation 'org.springframework.boot:spring-boot-starter-validation'
  // lombok
  compileOnly 'org.projectlombok:lombok'
  ...
}
```

## ValidationMessages.properties
```Java
validation.ComparePassword.message=パスワードが一致しません
```

## インターフェース
- 「パスワード」、「確認パスワード」一致確認バリデーション

```Java
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;

@Documented
@Constraint(validatedBy = ComparePasswordValidator.class)
@Retention(RUNTIME)
@Target({FIELD, METHOD, TYPE})
public @interface ComparePassword {
  String message() default "{validation.ComparePassword.message}";

  Class<?>[] groups() default {};

  Class<? extends Payload>[] payload() default {};

  String password(); // アノテーションの引数：パスワードの項目名

  String comparePassword(); // アノテーションの引数：確認用パスワードの項目名
}
```

## 実装クラス
```Java
import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class ComparePasswordValidator implements ConstraintValidator<ComparePassword, Object> {

  private String password; // パスワード
  private String comparePassword; // 確認用パスワード

  // アノテーションの引数に指定した文字列を取得する
  @Override
  public void initialize(ComparePassword annotation) {
    this.password = annotation.password();
    this.comparePassword = annotation.comparePassword();
  }

  @Override
  public boolean isValid(Object value, ConstraintValidatorContext context) {
    // 文字列で指定した項目に動的にアクセスする機能
    BeanWrapper beanWrapper = new BeanWrapperImpl(value);
    // パスワードの入力値を取得
    Object passwordValue = beanWrapper.getPropertyValue(password);
    // 確認用パスワードの入力値を取得
    Object comparePasswordValue = beanWrapper.getPropertyValue(comparePassword);

    return passwordValue == comparePasswordValue;
  }
}
```

# どうやって使うのか
- クラスに「パスワード」、「確認パスワード」一致確認バリデーションのアノテーションを付与してください
- コントローラーにてオブジェクトに@Validatedを宣言するとリクエスト発生時にバリデーションが実行されます

## オブジェクト
```Java
// 以下4つはlombokのアノテーション
@Data
@Builder
@AllArgsConstructor
@RequiredArgsConstructor
// パスワード一致相関チェックアノテーション
// クラスにアノテーションを指定すると入力エラー時にグローバルエラーとして判定される
@ComparePassword(password = "password", comparePassword = "comparePassword")
public class UserRequest {

  // 項目にアノテーションを指定すると入力エラー時にフィールドエラーとして判定される
  // 下記アノテーションは前回記事を参照
  @Password
  private String password;

  private String comparePassword;
}
```

:::note info
### どうして`@AssertTrue`を使わないか
`@AssertTrue`を使う場合、使わない場合と比較すると実装量はかなり抑えることが可能です。
ただ個人的には以下がデメリットと感じているため、本記事では紹介しませんでした。
- 入力エラーが発生した場合、フィールドエラーとして判定される
    - 相関チェックは基本的に「複数フィールド間」での不整合で発生するものであるので、「単一フィールド」の意味合いが強いフィールドエラーと判定されるのは疑問
- オブジェクトに宣言していない項目のgetterメソッドが突如として現れるため、違和感が強い
    - オブジェクトは項目を定義する用途のみにしておきたい
    - オブジェクトに相関チェック用のメソッドを実装することとなり、オブジェクト自体に機能を持たせてしまっている

以下は`@AssertTrue`を使った場合の記述例です。詳しくは説明しませんがこういった方法もあるといった参考として紹介しておきます。
```Java
@Data
@Builder
@AllArgsConstructor
@RequiredArgsConstructor
public class UserRequest {

  private String password;

  private String comparePassword;

  @AssertTrue(message="パスワードが一致しません")
  public boolean isValidPassword() {
    if (password == null || password.isEmpty()) {
      return true;
    }
    return password.equals(comparePassword);
  }
}
```
:::

## コントローラー
```Java
@PostMapping("/user")
// @Validatedでバリデーションが実行される
public void register(@RequestBody @Validated UserRequest request) {
}
```

###
```Java
@RestControllerAdvice
public class RestApiControllerAdvice extends ResponseEntityExceptionHandler {

  // @Validatedによる入力エラーが発生した場合
  @Override
  protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
      HttpHeaders headers, HttpStatusCode status, WebRequest request) {

    HttpHeaders httpHeaders = new HttpHeaders();
    httpHeaders.setContentType(MediaType.APPLICATION_PROBLEM_JSON);

    // グローバルエラー
    // オブジェクトクラスにアノテーションを指定すると入力エラー時にグローバルエラーとして判定される
    List<GlobalValidationError> globalErrors =
        ex.getBindingResult().getGlobalErrors().stream().map(error -> {
          return new GlobalValidationError(error.getCode(), error.getDefaultMessage());
        }).toList();

    // フィールドエラー
    // オブジェクトクラスの項目にアノテーションを指定すると入力エラー時にフィールドエラーとして判定される
    List<FieldValidationError> fieldErrors =
        ex.getBindingResult().getFieldErrors().stream().map(error -> {
          return new FieldValidationError(error.getField(), error.getCode(),
              error.getDefaultMessage());
        }).toList();

    RestApiErrorResponse response = RestApiErrorResponse.builder().errorCode("ERROR_CODE_VALID")
        .globalErrors(globalErrors).fieldErrors(fieldErrors).build();

    // HTTPステータスコード400でレスポンス返却
    return super.handleExceptionInternal(ex, response, httpHeaders, HttpStatus.BAD_REQUEST,
        request);
  }
}
```
`@RestControllerAdvice`を使った例外ハンドリングについては[【Spring Boot】Spring Bootにおける例外ハンドリング](https://qiita.com/rpc_tk/items/cec5c322cecf8b48f78c#exceptionhandler%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%9F%E4%BE%8B%E5%A4%96%E3%81%AE%E6%8D%95%E6%8D%89)を参照ください。

# 実際に試してみた
## リクエスト
```Java
POST /user HTTP/1.1
Content-Length: 140
Content-Type: application/json
Host: localhost:8080
{
    "password": "12345678",
    "password": "123456789",
}
```

## レスポンス
- グローバルエラーとフィールドエラーをそれぞれ返却してます
- フィールドエラー内容は項目ごとに返却しています
    - フロントエンド側でエラーハンドリングしたい場合に活用できます

```Java
HTTP/1.1 400
Content-Type: application/problem+json
{
    "errorCode": "ERROR_CODE_VALID",
    "globalErrors": [
        {
            "code": "ComparePassword",
            "message": "パスワードが一致しません"
        }
    ],
    "fieldErrors": [
        {
            "field": "password",
            "code": "Password",
            "message": "半角小文字英字、半角大文字英字、半角数字、半角記号をそれぞれ含めた8文字以上で入力してください"
        }
    ]
}
```

# おわりに
有効活用していただければ幸いです。何か問題がありましたらコメントいただけると嬉しいです！