カスタムEnumValidアノテーションで有効性検証を実装してみました。


## はじめて

最近の作業中にEnumValidを定義した話をしようと思います。

- 私は韓国人として、日本語の勉強とコンピュータの勉強を同時に行うために、ここに文章を書きます
- 翻訳機の助けを借りて書かれた文章なので、誤りがあるかもしれません


## なぜ作成したのか？

```java
@Operation(summary = "サブクエスト修正", description = "サブクエストを修正します。許可された値：ENABLE、DISABLE") 
@PatchMapping("/{questId}/{subQuestId}")
@ImageUploader
@AdminOnly
public ResponseEntity updateSubQuest(@PathVariable Long questId, 
    @PathVariable Long subQuestId, 
    @RequestBody @Valid SubQuestUpdateRequest subQuestUpdateRequest) {
        final SubQuestResponse subQuestResponse = adminQuestService.updateSubQuest(questId, subQuestId, subQuestUpdateRequest);
        return ResponseEntity.ok().body(subQuestResponse);
}
```

```java
public enum StatusType {
        ENABLE,
        DISABLE,
        DELETED
}
```
現在私たちのサービスでは、ソフト削除の可否を次のように
ENABLE、DISABLE、DELETEで区別しています。

管理者は誰かのクエストを有効化したり無効化したりすることができます。
しかし、いくら管理者でも削除したものは再度復元できないようにするべきです。

```java

public record SubQuestUpdateRequest(
        @Size(max = 50, message = "タイトルは50文字まで許可されます。")
        String title,

        @Size(max = 400, message = "説明は400文字まで許可されます。")
        String description,

        @NotNull(message = "状態値はnullであってはなりません。")
        @Size(max = 10, message = "状態値は10文字以内でなければなりません。")
        String status,

        Long mainQuestId,

        String imageUrl

)
```
次のようにrequest dtoでStringで管理者の要求値を受け取り

```java
public enum StatusType {
    ENABLE,
    DISABLE,
    DELETED;

    public static StatusType fromString(String status) {
        switch (status.toUpperCase()) {
            case "ENABLE":
                return ENABLE;
            case "DISABLE":
                return DISABLE;
            case "DELETED":
                return DELETED;
            default:
                throw new IllegalArgumentException("有効ではない状態値です。");
        }
    }
}
```

次のようにenumでfromStringを使って有効なStatusに変換するロジックを最初に考えました。

しかし、大きく2つの問題があると判断しました。

## 私が考えた2つの問題

### アプリケーションロジックに有効性検証コードが侵入する問題を回避

- fromString()メソッドを使ってEnumの有効性を検証する方式では、アプリケーションロジックに直接的に有効性検証ロジックが含まれます.（検証ロジックがアプリケーションロジックに侵入的です）
- 状態値を検証するたびに同じロジックを直接呼び出す必要があり、これによりコードの重複や複雑さが増す可能性があります。（他のEnumを定義した場合、同じ検証ロジックを再び記述する問題が発生します。）

### 有効性検証の一貫性
- 従来の方式では、Enumの状態値をコントローラ層で検証するために、fromString()のようなメソッドを使用して状態値を変換し、追加のロジックを通じて不変式を検証する必要がありました。要約すると、@ Validで正しい値が入力されたか検証した後、さらにfromString()を使用して検証を行うため、不要な重複が発生し、責任の所在が明確でないと感じました

## どのように改善したのか？

```java
/**
 * Jakarta Bean Validation制約として注釈をマークします。
 * <p>
 * 特定の制約注釈は、対応する制約バリデーション実装のリストを参照する{@code @Constraint}注釈で注釈されている必要があります。
 * <p>
 * 各制約注釈は、次の属性を持つ必要があります:
 * <ul>
 *     <li>{@code String message() default [...];} は、完全修飾クラス名に{@code .message}を続けたエラーメッセージキーに
 *     デフォルト設定されている必要があります。例えば、{@code "{com.acme.constraints.NotSafe.message}"} のようにします。</li>
 *     <li>{@code Class<?>[] groups() default {};} は、ユーザーが対象グループをカスタマイズできるようにするものです。</li>
 *     <li>{@code Class<? extends Payload>[] payload() default {};} は、拡張性のためのものです。</li>
 * </ul>
 * <p>
 * 制約が汎用的かつクロスパラメータのものである場合、制約注釈には{@code validationAppliesTo()}プロパティを持たせる必要があります。
 * 制約が汎用的であるとは、注釈された要素を対象とするものであり、クロスパラメータのものはメソッドやコンストラクタの
 * パラメータ配列を対象とすることを意味します。
 * <pre>
 *     ConstraintTarget validationAppliesTo() default ConstraintTarget.IMPLICIT;
 * </pre>
 * このプロパティにより、制約のユーザーが制約が実行可能な型の戻り値を対象とするのか、それともそのパラメータ配列を対象とするのかを選択できます。
 *
 * 制約が汎用的かつクロスパラメータである場合は
 * <ul>
 *     <li>2種類の{@code ConstraintValidator}が制約に添付されており、1つは{@link ValidationTarget#ANNOTATED_ELEMENT}を対象とし、
 *     もう1つは{@link ValidationTarget#PARAMETERS}を対象とする場合、</li>
 *     <li>または、{@code ConstraintValidator}が{@code ANNOTATED_ELEMENT}と{@code PARAMETERS}の両方を対象とする場合です。</li>
 * </ul>
 *
 * このような二重の制約は稀です。詳細は{@link SupportedValidationTarget}を参照してください。
 * <p>
 * 以下は制約定義の例です:
 * <pre>
 * &#64;Documented
 * &#64;Constraint(validatedBy = OrderNumberValidator.class)
 * &#64;Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
 * &#64;Retention(RUNTIME)
 * public &#64;interface OrderNumber {
 *     String message() default "{com.acme.constraint.OrderNumber.message}";
 *     Class&lt;?&gt;[] groups() default {};
 *     Class&lt;? extends Payload&gt;[] payload() default {};
 * }
 * </pre>
 *
 * @author Emmanuel Bernard
 * @author Gavin King
 * @author Hardy Ferentschik
 */
@Documented
@Target({ ANNOTATION_TYPE })
@Retention(RUNTIME)
public @interface Constraint {
```
@ Constraintは、ジャカルタBeanバリデーションで制約条件アノテーションであることを示すアノテーションです。
私はこれを活用して、自分が望む制約条件を直接定義することにしました。



### EnumValidを定義

```java
/*
* EnumValid.java
* ENUMタイプを検証するために使用するアノテーション
* 特定
 */
@Constraint(validatedBy = EnumValidator.class)
@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
public @interface EnumValid {
    String message() default "This value is not allowed.";

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

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

    String[] acceptedValues();
}

```
- @ Constraint(validatedBy = EnumValidator.class): この部分は、このアノテーションを通じてどのバリデーターを使用するかを定義します. ここではEnumValidatorというクラスをバリデーターとして指定しています。つまり、このアノテーションが付いたフィールドはEnumValidatorクラスで有効性検査を行うことになります。（これは次のコードでさらに説明されています。）

- acceptedValues(): このメソッドは、アノテーションを使用する際に、検証に使用するEnumの値を指定できるフィールドです。アノテーションを使用する際に "ENABLE"、"DISABLE" などの値をここに渡します

- @ Target: このアノテーションが適用される対象を定義します。ここではメソッド、フィールド、パラメータなどに適用できるようにしています

- @ Retention(RetentionPolicy.RUNTIME): アノテーションがランタイムまで保持されることを意味します。したがって、ランタイムでこのアノテーションを読み取り、使用することができます

---


### 検証のためのEnumValidatorを定義

```java
public class EnumValidator implements ConstraintValidator<EnumValid, Enum<?>> {

    private String[] acceptedValues;

    @Override
    public void initialize(EnumValid annotation) {
        this.acceptedValues = annotation.acceptedValues();
    }

    @Override
    public boolean isValid(Enum<?> value, ConstraintValidatorContext context) {
        if (value == null) {
            return true;
        }
        return Arrays.asList(acceptedValues).contains(value.name());
    }
}
```

当該クラスは@ EnumValidアノテーションが宣言されたフィールドが検証される際に、実際に検証ロジックを実行する部分です。

```java
    @Override
    public void initialize(EnumValid annotation) {
        this.acceptedValues = annotation.acceptedValues();
    }
```

このメソッドを通じて、アノテーションに定義されたacceptedValuesの値を読み込みます。



```java
    @Override
    public boolean isValid(Enum<?> value, ConstraintValidatorContext context) {
        if (value == null) {
            return true;
        }
        return Arrays.asList(acceptedValues).contains(value.name());
    }
```

このロジックを通じて、ユーザーが入力した値が以前に定義されたacceptedValuesのリストに含まれているかどうかを確認します。もし値がacceptedValuesに含まれている場合、検証は成功（trueを返す）として処理されます。逆に、値が含まれていない場合、検証は失敗（falseを返す）として処理されます。

> 私はnullに関しては@NotNullで定義するのが正しいと判断しているため、nullが入力された場合はそのまま通過させます。（この責任はこのロジックにあるべきではないと考えます。）

:::note warn
Enumへの変換処理はどこにありますか？
- Enumは Jacksonによって自動的にStatusType Enumに変換されます

- Spring MVCはJacksonを使用してリクエスト本文（JSON）をJavaオブジェクトに変換し、この過程でEnum型フィールドはその文字列をEnum値に変換します
:::

## 改善されたコード
```java
public record SubQuestUpdateRequest(
        @Size(max = 50, message = "タイトルは50文字まで許可されます。")
        String title,

        @Size(max = 400, message = "説明は400文字まで許可されます。")
        String description,

        @EnumValid(acceptedValues = {"ENABLE", "DISABLE"}, message = "無効なステータス値です。")
        StatusType status,

        Long mainQuestId,

        String imageUrl
)
```
許可する値のみをacceptedValues = {}に定義します。私の場合、以前のルールに従ってDELETEを除いて定義しました。

無効な値を入力すると、'無効なステータス値です'というメッセージも返されます。

![E735B6E2-3B9B-499C-A2F8-D29161C2081A.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/5dcd2529-fd92-b986-731b-f7036e960a3a.jpeg)

---

### リファレンス
https://www.baeldung.com/javax-validations-enums