【Spring Boot】【Java】入力フォームに良く登場する項目のバリデーションを実装付きで紹介

# はじめに
入力フォームで良く登場する項目を題材にそれぞれアノテーションを用いたバリデーションを紹介します。

# 前提
- SpringBootを使ったRESTful APIベースで作成してますがSSRでもほぼ流用可能です
- 日付(生年月日)や数値を入力する項目は、型宣言で不正な値を防ぎましょう
```Java
private LocalDate birthDay;
```
- 性別や住所(都道府県)など、値が限定される項目はEnumで不正な値を防ぎましょう
```Java
private PrefectureEnum prefectureCode;
// PrefectureEnumは独自Enum
```

# 紹介する項目
- 「全角」のみ
- 「全角ひらがな」のみ
- 「全角カタカナ」のみ
- 「半角」のみ
- 「メールアドレス」
- 「パスワード」
- 「郵便番号(ハイフンあり)」
- 「携帯電話番号(ハイフンあり)」

※基本的に型宣言やEnumで不正な値を防げます。

# 用意するもの
- build.gradle
    - spring-boot-starter-validation
    - commons-validator
    - 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'
  // メールアドレス形式バリデーションで用いるライブラリ
  implementation 'commons-validator:commons-validator:1.9.0'
  // lombok
  compileOnly 'org.projectlombok:lombok'
  ...
}
```

## ValidationMessages.properties
```Java
validation.Fullwidth.message=全角で入力してください
validation.FullwidthHira.message=全角ひらがなで入力してください
validation.FullwidthKana.message=全角カタカナで入力してください
validation.Halfwidth.message=半角で入力してください
validation.Mail.message=メールアドレス形式で入力してください
validation.Password.message=半角小文字英字、半角大文字英字、半角数字、半角記号をそれぞれ含めた8文字以上で入力してください
validation.ZipCode.message=ハイフンを含めた郵便番号形式で入力してください
validation.MobilePhone.message=ハイフンを含めた携帯電話形式で入力してください
```

## インターフェース
### テンプレート
- xxx、yyy部分を適宜設定してください
- messageはメッセージID、メッセージ本文どちらか指定できます
```Java
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
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;

// xxxはチェック実装クラスを指定
// 例：@Constraint(validatedBy = FullwidthValidator.class)
@Documented
@Constraint(validatedBy = xxx.class)
@Retention(RUNTIME)
@Target({FIELD, METHOD})
// yyyはインターフェース名を指定
// 例：public @interface Fullwidth {
public @interface yyy {
  // メッセージの指定
  // ValidationMessages.propertiesに定義したメッセージIDもしくはメッセージ文言を指定
  // 例：String message() default "{validation.Fullwidth.message}";
  // 例：String message() default "全角で入力してください";
  String message() default "{メッセージID} or メッセージ本文";

  // バリデーショングループの指定
  Class<?>[] groups() default {};

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

  // Listに適用させたい場合
  @Documented
  @Retention(RUNTIME)
  @Target({METHOD, FIELD})
  @interface List {
    // yyyはインターフェース名を指定
    // 例：Fullwidth[] value();
    yyy[] value();
  }
}
```

## 実装クラス
### 全角文字
```Java
import java.util.regex.Pattern;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class FullwidthValidator implements ConstraintValidator<Fullwidth, String> {

  private static final Pattern PATTERN = Pattern.compile("^[^ -~｡-ﾟ]*$");

  @Override
  public boolean isValid(String value, ConstraintValidatorContext context) {
    return PATTERN.matcher(value).matches();
  }
}
```

### 全角ひらがな
```Java
import java.util.regex.Pattern;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class FullwidthHiraValidator implements ConstraintValidator<FullwidthHira, String> {

  private static final Pattern PATTERN = Pattern.compile("^[ぁ-んー]*$");

  @Override
  public boolean isValid(String value, ConstraintValidatorContext context) {
    return PATTERN.matcher(value).matches();
  }
}
```

### 全角カタカナ
```Java
import java.util.regex.Pattern;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class FullwidthKanaValidator implements ConstraintValidator<FullwidthKana, String> {

  private static final Pattern FULL_WIDTH_PATTERN = Pattern.compile("^[ァ-ンヴー]*$");

  @Override
  public boolean isValid(String value, ConstraintValidatorContext context) {
    return FULL_WIDTH_PATTERN.matcher(value).matches();
  }
}
```

### 半角文字
```Java
import java.util.regex.Pattern;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class HalfwidthValidator implements ConstraintValidator<Halfwidth, String> {

  private static final Pattern PATTERN = Pattern.compile("^[ -~｡-ﾟ]*$");

  @Override
  public boolean isValid(String value, ConstraintValidatorContext context) {
    return PATTERN.matcher(value).matches();
  }
}
```

### メールアドレス(RFC822標準)
- メールアドレスは正規表現が非常に難しいので[EmailValidator](https://commons.apache.org/proper/commons-validator/apidocs/org/apache/commons/validator/routines/EmailValidator.html)を使うのがオススメです
- EmailValidatorは全角を許容するので必要に応じて半角文字チェックをしましょう
    - `new HalfwidthValidator().isValid(value, context)`のように直接、他の実装クラスも呼び出せます
```Java
import org.apache.commons.validator.routines.EmailValidator;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class MailValidator implements ConstraintValidator<Mail, String> {

  @Override
  public boolean isValid(String value, ConstraintValidatorContext context) {

    return EmailValidator.getInstance(false).isValid(value)
        && new HalfwidthValidator().isValid(value, context);
  }
}
```

### パスワード
- パスワードポリシー次第ですが複雑な正規表現になります。正規表現で定義すれば実装ステップは最小限で済みますが、個人的には可読性を優先してチェックを一つずつ実装してしまった方が無難だと考えています
- 可読性 >>> カッコよさ
- どうしても正規表現にしたい方は下記の記事が参考になります
    - <a href="https://qiita.com/tmasu/items/bf47383dec9c30e6cc74" target="_blank" rel="noopener noreferrer">パスワードのための正規表現</a>
    - [【正規表現】4種類の文字種のうち3種類を含む的なパスワードの正規表現
](https://zenn.dev/i_shinya/articles/cb3ec244762562)
```Java
import java.util.regex.Pattern;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class PasswordValidator implements ConstraintValidator<Password, String> {

  @Override
  public boolean isValid(String value, ConstraintValidatorContext context) {
    return isLength(value) && isFormat(value) && isContainsUpperCase(value)
        && isContainsLowerCase(value) && isContainsNumber(value) && isContainsSymbol(value);
  }

  /**
   * パスワードの文字数が「最低文字数以上」であるか？
   * 
   * @param value
   * @return
   */
  private static boolean isLength(String value) {
    return (value.length() >= 8);
  }

  /**
   * パスワードが半角英数字記号のみで構成されているか？
   * 
   * @param value
   * @return
   */
  private static boolean isFormat(String value) {
    Pattern pattern = Pattern.compile("^[a-zA-Z0-9\\!-\\/\\:-\\@\\[-\\`\\{-\\~]+$");
    return pattern.matcher(value).find();
  }

  /**
   * パスワードに大文字の英字が1文字以上含まれているか？
   * 
   * @param value
   * @return
   */
  private static boolean isContainsUpperCase(String value) {
    Pattern pattern = Pattern.compile("[A-Z]+");
    return pattern.matcher(value).find();
  }

  /**
   * パスワードに小文字の英字が1文字以上含まれているか？
   * 
   * @param value
   * @return
   */
  private static boolean isContainsLowerCase(String value) {
    Pattern pattern = Pattern.compile("[a-z]+");
    return pattern.matcher(value).find();
  }

  /**
   * パスワードに半角数字が1文字以上含まれているか？
   * 
   * @param value
   * @return
   */
  private static boolean isContainsNumber(String value) {
    Pattern pattern = Pattern.compile("[0-9]+");
    return pattern.matcher(value).find();
  }

  /**
   * パスワードに半角記号が1文字以上含まれているか？
   * 
   * @param value
   * @return
   */
  private static boolean isContainsSymbol(String value) {
    Pattern pattern = Pattern.compile("[\\!-\\/\\:-\\@\\[-\\`\\{-\\~]+");
    return pattern.matcher(value).find();
  }
}
```

### 郵便番号(ハイフンあり)
```Java
import java.util.regex.Pattern;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class ZipCodeValidator implements ConstraintValidator<ZipCode, String> {

  private static final Pattern PATTERN = Pattern.compile("^[0-9]{3}-[0-9]{4}$");

  @Override
  public boolean isValid(String value, ConstraintValidatorContext context) {
    return PATTERN.matcher(value).matches();
  }
}
```

### 携帯電話番号(ハイフンあり)
```Java
import java.util.regex.Pattern;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class MobilePhoneValidator implements ConstraintValidator<MobilePhone, String> {

  private static final Pattern PATTERN = Pattern.compile("^0[789]0-[0-9]{4}-[0-9]{4}$");

  @Override
  public boolean isValid(String value, ConstraintValidatorContext context) {
    return PATTERN.matcher(value).matches();
  }
}
```

# どうやって使うのか
- オブジェクトの各項目に適用したいバリデーションのアノテーションを付与してください
- コントローラーにてオブジェクトに@Validatedを宣言するとリクエスト発生時にバリデーションが実行されます
## オブジェクト
```Java
// 以下4つはlombokのアノテーション
@Data
@Builder
@AllArgsConstructor
@RequiredArgsConstructor
public class UserRequest {

  @NotBlank  // 必須項目
  @Fullwidth // 全角文字
  private String name;

  @FullwidthKana // 全角カタカナ
  private String kana;

  @NotBlank // 必須項目
  @Mail     // メールアドレス
  private String email;

  @NotBlank // 必須項目
  @Password // パスワード
  private String password;

  @MobilePhone // 携帯電話番号
  private String mobilePhone;

  @ZipCode // 郵便番号
  private String zipCode;
}
```

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

# 実際に試してみた
## リクエスト
```Java
POST /user HTTP/1.1
Content-Length: 140
Content-Type: application/json
Host: localhost:8080
{
    "name": "a",
    "kana": "あ",
    "email": "..@a.com",
    "password": "12345678",
    "zip": "110045"
    "phone": "09012345678",
}
```

## レスポンス
- バリデーションエラー内容を項目ごとに返却しています
    - 例外オブジェクトからバリデーションエラー内容が取得できます(※)
- フロントエンド側でエラーハンドリングしたい場合に活用できます

※下記記事を参考ください
<a href="https://qiita.com/rpc_tk/items/2ef482b4b0364c486103#%E4%BE%8B%E5%A4%96%E3%81%AE%E6%8D%95%E6%8D%89-1" target="_blank" rel="noopener noreferrer">【Spring Boot】RESTful APIにおいて発生し得る例外をリクエスト～レスポンスの一連の流れで見ながらハンドリングしてみる</a>
```Java
HTTP/1.1 400
Content-Type: application/problem+json
{
    "errorCode": "ERROR_CODE_VALID",
    "errors": [
        {
            "field": "name", // オブジェクトの項目名
            "code": "Fullwidth", // バリデーションNGになったアノテーション名
            "message": "全角で入力してください" // メッセージ
        },
        {
            "field": "kana", // オブジェクトの項目名
            "code": "FullwidthKana", // バリデーションNGになったアノテーション名
            "message": "全角カタカナで入力してください" // メッセージ
        },
        {
            "field": "email", // オブジェクトの項目名
            "code": "Mail", // バリデーションNGになったアノテーション名
            "message": "メールアドレス形式で入力してください" // メッセージ
        },
        {
            "field": "password", // オブジェクトの項目名
            "code": "Password", // バリデーションNGになったアノテーション名
            "message": "半角小文字英字、半角大文字英字、半角数字、半角記号をそれぞれ含めた8文字以上で入力してください" // メッセージ
        },
        {
            "field": "zip", // オブジェクトの項目名
            "code": "ZipCode", // バリデーションNGになったアノテーション名
            "message": "ハイフンを含めた郵便番号形式で入力してください" // メッセージ
        },
        {
            "field": "phone", // オブジェクトの項目名
            "code": "MobilePhone", // バリデーションNGになったアノテーション名
            "message": "ハイフンを含めた携帯電話形式で入力してください" // メッセージ
        },
    ]
}
```

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