Bean Validationで日付のフォーマット+存在チェックを行うアノテーションを作成する

# はじめに
Spring BootでREST API開発を行っている際に、パラメータの入力値が正しい日付であるか確認が必要になってきた。
nullチェックや最大値チェックなどは、チェックできるアノテーションがあらかじめ用意されているが、日付に関しては用意されていなかったためチェック方法を検討することに。
はじめは`@Pattern`でチェックできると思ったが、存在しない日付はチェックできなかったり、毎回正規表現を記載する必要があるため、他の方法を調査。
すると、独自のアノテーションを作成できることがわかったためその方法で実現してみることにした。

# アノテーションでやりたいこと
- nullの場合はチェックを行わない
- フォーマット（`yyyy/MM/dd HH:mm:ss`）に該当するかのチェックを行う
- 存在する日付かどうかチェックを行う
- フォーマット、存在する日付のチェックでそれぞれメッセージを変更する

# カスタムアノテーションを作成
[Hibernate Validatorのリファレンス](https://docs.jboss.org/hibernate/stable/validator/reference/en-US/html_single/?v=7.0)を参考にカスタムアノテーションを作成する。


## 依存関係
以下の依存関係が必要です。
Spring Bootの場合はもとから含まれているはずなので設定不要です。
必要があれば追加してください。
```xml:pom.xml
  <dependencies>
 	 <dependency>
	 	<groupId>org.hibernate.validator</groupId>
	    <artifactId>hibernate-validator</artifactId>
	    <version>7.0.4.Final</version>
     </dependency>

     <dependency>
	     <groupId>org.glassfish</groupId>
	     <artifactId>jakarta.el</artifactId>
	     <version>4.0.1</version>
	 </dependency>
  </dependencies>
```

## アノテーションの実装
今回作成するアノテーション名は`@TimeStamp`とする。

```java:TimeStamp.java
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

@Target({ ElementType.FIELD, ElementType.PARAMETER }) //アノテーションの使用できる箇所。今回はフィールドとパラメータのみ。必要であれば随時追加を。
@Retention(RetentionPolicy.RUNTIME) // 実行時に利用可能にする。
@Constraint(validatedBy = TimeStampValidator.class) // 検証を行うバリデーターを記載。後に記載。
@Documented
@Repeatable(TimeStamp.List.class) // 同じ場所で複数回使用できるようにするために必要。
public @interface TimeStamp {

	String message() default "デフォルトメッセージ";  // バリデーションのデフォルトでエラーメッセージ。ないと怒られる。

	String formatUnMatchMessage() default "yyyy/MM/dd HH:mm:ss形式で設定してください。"; // フォーマットチェック時のエラーメッセージ。独自に追加。

	String inCorrectDateMessage() default "存在する日付を指定してください。"; // 存在日付チェック時のエラーメッセージ。独自に追加。

	Class<?>[] groups() default {};  // アノテーションのグループ化に使用する。適用する優先順位を付けたいときなど。ないと怒られる。

	Class<? extends Payload>[] payload() default {};  // 同じアノテーションを割り当てたときの重要度の変更に用いる。詳しくはレファレンス参照。ないと怒られる。

	@Target({ ElementType.FIELD, ElementType.PARAMETER }) // @Repeatable(TimeStamp.List.class)を付与している際に必要。
	@Retention(RetentionPolicy.RUNTIME)
	@Documented
	@interface List {
		TimeStamp[] value();
	}
}
```

## バリデーターの実装
```java:TimeStampValidator.java
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class TimeStampValidator implements ConstraintValidator<TimeStamp, String> {

	private String formatUnMatchMessage;
	private String inCorrectDateMessage;

    // 初期化時にアノテーションクラスからエラーメッセージを取得
	@Override
	public void initialize(TimeStamp timeStamp) {
		this.formatUnMatchMessage = timeStamp.formatUnMatchMessage();
		this.inCorrectDateMessage = timeStamp.inCorrectDateMessage();
	}

    // 実際に検証を行うメソッド
	@Override
	public boolean isValid(String value, ConstraintValidatorContext context) { // valueに検証対象の値が格納される。
		// nullの場合はチェックしない
		if (value == null) {
			return true;
		}

		// 日付フォーマットチェック
		if (value.matches(
				"^[0-9]{4}/(0[1-9]|1[0-2])/(0[1-9]|[12][0-9]|3[01])\\s([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$") == false) {
			changeErrorMessage(context, formatUnMatchMessage);
			return false;
		}

		// 日付存在チェック
		try {
			LocalDate.parse(value, DateTimeFormatter.ofPattern("uuuu/MM/dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT));
		} catch (DateTimeParseException e) {
			changeErrorMessage(context, inCorrectDateMessage);
			return false;
		}
		return true;
	}

	// エラーメッセージの変更を行う
	void changeErrorMessage(ConstraintValidatorContext context, String message) {
		context.disableDefaultConstraintViolation(); // デフォルトのConstraintViolationを無効にする。
		context.buildConstraintViolationWithTemplate(message) // テンプレートにより設定したいエラーメッセージでConstraintViolationを作成。
			.addConstraintViolation();
	}

}
```

参考文献をもとにJava8以降のため、日付存在チェックでは[DateTimeFormatter](https://docs.oracle.com/javase/jp/8/docs/api/java/time/format/DateTimeFormatter.html)を使用。
このとき、`ResolverStyle.STRICT`を設定しないと、4/31 を 5/1 としてよしなにやってしまうので注意。

## アノテーションを使用するクラスの実装
以下のようにフィールドに対して設定します。

```java:AnnotationDemo.java
public class AnnotationDemo {

	@TimeStamp
	private String date;

	public AnnotationDemo(String date) {
		this.date = date;
	}

	public String getDate() {
		return this.date;
	}
}
```

アノテーションを付与するフィールドごとにエラーメッセージを変更したい場合は、以下のように設定します。

```java:AnnotationDemo.java
public class AnnotationDemo {

	@TimeStamp(formatUnMatchMessage = "フォーマットチェックカスタムメッセージ", inCorrectDateMessage = "存在チェックカスタムメッセージ")
	private String date;

	public AnnotationDemo(String date) {
		this.date = date;
	}

	public String getDate() {
		return this.date;
	}
}
```

# 検証
## テストクラス
以下のようにアノテーションが有効か確認しました。一部を記載します。
```java:
class AnnotationDemoTest {

	@Test
	@DisplayName("dateが2022/01/01 11:11:11のとき、バリエーション結果は存在しない")
	void dateが存在する日付のときバリエーション結果は存在しない() {
		// 準備
		ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
		Validator validator = factory.getValidator();
		// 実行
		boolean isValidateResult = validator
				.validateValue(AnnotationDemo.class, "date", "2022/01/01 11:11:11")
				.iterator()
				.hasNext();
		// 検証
		assertEquals(false, isValidateResult);
	}

    // 以下その他のテストメソッド
}
```

テスト結果から問題なく動作していることが確認できました。

# ソース
[こちら](https://github.com/MrMs1/CustomAnnotaion_with_BeanValidation)で公開しています。

# おわりに
Bean Validationを使ったアノテーション実装にはなりますが、実際にやってみるとやる前に比べてだいぶ心理的障壁がなくなりました。
何事も挑戦してみるのは大事ですね。

# 参考文献
- [Hibernate Validator 7.0.4.Final - Jakarta Bean Validation Reference Implementation: Reference Guide](https://docs.jboss.org/hibernate/stable/validator/reference/en-US/html_single/?v=7.0)
- [Javaにおける日付文字列の書式チェック方法](https://qiita.com/i_learnin/items/0e90036b2d7f664a249d)
- [日付および時刻の正規表現](https://www.javadrive.jp/regex-basic/sample/index6.html)