Bean Validationのエラーメッセージで任意の値を呼び出せるようにする

### 開発環境
- Java 1.8.0
- Spring Boot 2.0.2.RELEASE
 - Spring core 5.0.6.RELEASE
- Bean Validation 1.1

### はじめに

※Spring Bootを使っていますが、Spring要素はほぼありません。

#### Bean Validationの使い方
`Java`で入力チェックをするといったら`Bean Validation`ですよね。アノテーションを付与するだけなので手軽に使えて便利です:smile:。
ここで以下のような、プロパティ値が引数として渡された文字列の配列に含まれるかチェックするアノテーションを作成してみます。

```java:AcceptedStringValues.java
@Documented
@Constraint(validatedBy = {AcceptedStringValuesValidator.class})
@Target({METHOD, FIELD})
@Retention(RUNTIME)
public @interface AcceptedStringValues {
  String message() default "{com.neriudon.example.validator.AcceptedStringValues.message}";

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

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

  String[] value();

  @Target({METHOD, FIELD})
  @Retention(RUNTIME)
  @Documented
  public @interface List {
    AcceptedStringValues[] value();
  }
}
```

`Validator`では`AcceptedStringValues`アノテーションの`value()`に渡された文字列に、アノテーションを付与されたフィールドまたはメソッドの返り値が含まれるかチェックします。

```java:AcceptedStringValuesValidator.java
public class AcceptedStringValuesValidator
    implements ConstraintValidator<AcceptedStringValues, String> {

  // accepted values array
  private String[] validValues;

  @Override
  public void initialize(AcceptedStringValues constraintAnnotation) {
    validValues = constraintAnnotation.value();
  }

  @Override
  public boolean isValid(String value, ConstraintValidatorContext context) {
    if (value == null) {
      return true;
    }
    // check to exist value or not in accepted values array
    return Arrays.stream(validValues).anyMatch(s -> Objects.equals(value, s));
  }
}
```

テストコードは↓の感じです。

```java:ValidationSampleApplicationTests.java
public class ValidationSampleApplicationTests {

  private ValidatorFactory validatorFactory;
  private Validator validator;

  @Before
  public void setup() {
    validatorFactory = Validation.buildDefaultValidatorFactory();
    validator = validatorFactory.getValidator();
  }

  @Test
  public void acceptedStringValuesNormal() throws UnsupportedEncodingException {
    AcceptedStringValuesSample sample = new AcceptedStringValuesSample("1");
    Set<ConstraintViolation<AcceptedStringValuesSample>> result = validator.validate(sample);
    // no error
    assertThat(result.isEmpty(), is(true));
  }

  @Test
  public void acceptedStringValuesNg() throws Exception {
    AcceptedStringValuesSample sample = new AcceptedStringValuesSample("0");
    Set<ConstraintViolation<AcceptedStringValuesSample>> result = validator.validate(sample);
    // error
    assertThat(result.size(), is(1));
    // assert error value and message
    result.stream().forEach(r -> {
      assertThat(r.getInvalidValue(), is("0"));
      assertThat(r.getMessage(), is("not accepted value."));
    });
  }

  private static class AcceptedStringValuesSample {

    @AcceptedStringValues({"1", "2", "3", "4", "5"})
    private String code;

    public AcceptedStringValuesSample(String code) {
      this.code = code;
    }
  }
}
```
エラーメッセージは、クラスパス配下に`ValidationMessages.properties`を作成し、アノテーションクラスの`message`に指定した値をキーにメッセージを設定しておくと、自動的に呼び出されるのはご存知ですよね。
今回の例だと↓のような感じです。

```properties:ValidationMessages.properties
com.neriudon.example.validator.AcceptedStringValues.message = not accepted values.
```

#### エラーメッセージに値を埋め込む
↑のような感じでもいいのですが、メッセージが固定だと利用者にわかりづらいこともあると思います。
`Bean Validation`では、アノテーションクラスのプロパティ値をエラーメッセージに埋め込むことができるので、`{value}`でアノテーションクラスのvalueに設定された値を文字列として出力してみます。

```properties:ValidationMessages.properties
com.neriudon.example.validator.AcceptedStringValues.message = not contained accepted values: {value}.
```

とすると、`not contained accepted values: [1, 2, 3, 4, 5].` というメッセージになります。

#### エラーメッセージを賢くする
`Bean Validation`では1.1からEL式をサポートしています！
`${validatedValue}`を指定することでエラーになったオブジェクトを埋め込むことができるので……

```properties:ValidationMessages.properties
com.neriudon.example.validator.AcceptedStringValues.message = ${validatedValue} is not contained accepted values.
```

こうすると、`xxx is not contained accepted values.` というメッセージになります。(XXXはエラーになったオブジェクト)

**ただし`${validatedValue}`はエラーになったオブジェクトをそのまま出力するので、パスワードなどの秘匿情報を扱う場合は使わないようにしましょう。**

### 他の配列を埋め込めない問題
さて、ここからが本題。
これまでは`String`を扱ってきましたが、`Integer`で同じ機能を持つアノテーションを作成します。

```java:AcceptedIntegerValues.java
@Documented
@Constraint(validatedBy = { AcceptedIntegerValuesValidator.class })
@Target({ METHOD, FIELD })
@Retention(RUNTIME)
public @interface AcceptedIntegerValues {
	String message() default "{com.neriudon.example.validator.AcceptedIntegerValues.message}";

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

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

	int[] value();

	@Target({ METHOD, FIELD })
	@Retention(RUNTIME)
	@Documented
	@interface List {
		AcceptedIntegerValues[] value();
	}
}
```

```java:AcceptedIntegerValuesValidator.java
public class AcceptedIntegerValuesValidator implements ConstraintValidator<AcceptedIntegerValues, Integer> {

	// accepted values array
	private Integer[] validValues;

	@Override
	public void initialize(AcceptedIntegerValues constraintAnnotation) {
		validValues = ArrayUtils.toObject(constraintAnnotation.value());
	}

	@Override
	public boolean isValid(Integer value, ConstraintValidatorContext context) {
		if (value == null) {
			return true;
		}
		// check to exist value or not in accepted values array
		return Arrays.stream(validValues).anyMatch(s -> Objects.equals(value, s));
	}
}
```

エラーメッセージを設定して……

```properties:ValidationMessages.properties
com.neriudon.example.validator.AcceptedIntegerValues.message = not contained accepted values: {value}.
```

いざ、テスト

```java:TestCode.java
	@Test
	public void acceptedIntegerValuesNormal() {
		AcceptedIntegerValuesSample sample = new AcceptedIntegerValuesSample(1);
		Set<ConstraintViolation<AcceptedIntegerValuesSample>> result = validator.validate(sample);
		assertThat(result.isEmpty(), is(true));
	}

	@Test
	public void acceptedIntegerValuesNg() {
		AcceptedIntegerValuesSample sample = new AcceptedIntegerValuesSample(0);
		Set<ConstraintViolation<AcceptedIntegerValuesSample>> result = validator.validate(sample);
		assertThat(result.size(), is(1));
		result.stream().forEach(r -> {
			assertThat(r.getInvalidValue(), is(0));
		});
	}
	private static class AcceptedIntegerValuesSample {

		@AcceptedIntegerValues({ 1, 2, 3, 4, 5 })
		private int code;

		public AcceptedIntegerValuesSample(int code) {
			this.code = code;
		}
	}
```

すると……

```text
javax.validation.ValidationException: HV000149: An exception occurred during message interpolation
	at org.hibernate.validator.internal.engine.ValidationContext.interpolate(ValidationContext.java:477)
	at org.hibernate.validator.internal.engine.ValidationContext.createConstraintViolation(ValidationContext.java:322)
	at org.hibernate.validator.internal.engine.ValidationContext.lambda$createConstraintViolations$0(ValidationContext.java:279)
	at java.util.stream.ReferencePipeline$3$1.accept(Unknown Source)
	at java.util.Collections$2.tryAdvance(Unknown Source)
	at java.util.Collections$2.forEachRemaining(Unknown Source)
	at java.util.stream.AbstractPipeline.copyInto(Unknown Source)
	at java.util.stream.AbstractPipeline.wrapAndCopyInto(Unknown Source)
	at java.util.stream.ReduceOps$ReduceOp.evaluateSequential(Unknown Source)
	at java.util.stream.AbstractPipeline.evaluate(Unknown Source)
	at java.util.stream.ReferencePipeline.collect(Unknown Source)
	at org.hibernate.validator.internal.engine.ValidationContext.createConstraintViolations(ValidationContext.java:280)
	at org.hibernate.validator.internal.engine.constraintvalidation.ConstraintTree.validateSingleConstraint(ConstraintTree.java:182)
	at org.hibernate.validator.internal.engine.constraintvalidation.SimpleConstraintTree.validateConstraints(SimpleConstraintTree.java:68)
	at org.hibernate.validator.internal.engine.constraintvalidation.ConstraintTree.validateConstraints(ConstraintTree.java:73)
	at org.hibernate.validator.internal.metadata.core.MetaConstraint.doValidateConstraint(MetaConstraint.java:127)
	at org.hibernate.validator.internal.metadata.core.MetaConstraint.validateConstraint(MetaConstraint.java:120)
	at org.hibernate.validator.internal.engine.ValidatorImpl.validateMetaConstraint(ValidatorImpl.java:533)
	at org.hibernate.validator.internal.engine.ValidatorImpl.validateConstraintsForSingleDefaultGroupElement(ValidatorImpl.java:496)
	at org.hibernate.validator.internal.engine.ValidatorImpl.validateConstraintsForDefaultGroup(ValidatorImpl.java:465)
	at org.hibernate.validator.internal.engine.ValidatorImpl.validateConstraintsForCurrentGroup(ValidatorImpl.java:430)
	at org.hibernate.validator.internal.engine.ValidatorImpl.validateInContext(ValidatorImpl.java:380)
	at org.hibernate.validator.internal.engine.ValidatorImpl.validate(ValidatorImpl.java:169)
	at com.neriudon.example.ValidationSampleApplicationTests.acceptedIntegerValuesNg(ValidationSampleApplicationTests.java:98)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(Unknown Source)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
	at java.lang.reflect.Method.invoke(Unknown Source)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
	at org.springframework.test.context.junit4.statements.RunBeforeTestExecutionCallbacks.evaluate(RunBeforeTestExecutionCallbacks.java:73)
	at org.springframework.test.context.junit4.statements.RunAfterTestExecutionCallbacks.evaluate(RunAfterTestExecutionCallbacks.java:83)
	at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
	at org.springframework.test.context.junit4.statements.RunBeforeTestMethodCallbacks.evaluate(RunBeforeTestMethodCallbacks.java:75)
	at org.springframework.test.context.junit4.statements.RunAfterTestMethodCallbacks.evaluate(RunAfterTestMethodCallbacks.java:86)
	at org.springframework.test.context.junit4.statements.SpringRepeat.evaluate(SpringRepeat.java:84)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
	at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:251)
	at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:97)
	at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
	at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
	at org.springframework.test.context.junit4.statements.RunBeforeTestClassCallbacks.evaluate(RunBeforeTestClassCallbacks.java:61)
	at org.springframework.test.context.junit4.statements.RunAfterTestClassCallbacks.evaluate(RunAfterTestClassCallbacks.java:70)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
	at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.run(SpringJUnit4ClassRunner.java:190)
	at org.eclipse.jdt.internal.junit4.runner.JUnit4TestReference.run(JUnit4TestReference.java:86)
	at org.eclipse.jdt.internal.junit.runner.TestExecution.run(TestExecution.java:38)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:538)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:760)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run(RemoteTestRunner.java:460)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:206)
Caused by: java.lang.ClassCastException: [I cannot be cast to [Ljava.lang.Object;
	at org.hibernate.validator.internal.engine.messageinterpolation.ParameterTermResolver.interpolate(ParameterTermResolver.java:30)
	at org.hibernate.validator.internal.engine.messageinterpolation.InterpolationTerm.interpolate(InterpolationTerm.java:64)
	at org.hibernate.validator.messageinterpolation.ResourceBundleMessageInterpolator.interpolate(ResourceBundleMessageInterpolator.java:76)
	at org.hibernate.validator.messageinterpolation.AbstractMessageInterpolator.interpolateExpression(AbstractMessageInterpolator.java:385)
	at org.hibernate.validator.messageinterpolation.AbstractMessageInterpolator.interpolateMessage(AbstractMessageInterpolator.java:274)
	at org.hibernate.validator.messageinterpolation.AbstractMessageInterpolator.interpolate(AbstractMessageInterpolator.java:220)
	at org.hibernate.validator.internal.engine.ValidationContext.interpolate(ValidationContext.java:468)
	... 55 more
```

なんかエラーになりました:weary:。
「`[I cannot be cast to [Ljava.lang.Object;`」から`Integer`の配列からキャストに失敗しているようです。

試しにEL式で`String`にしてみます。

```properties:ValidationMessages.properties
com.neriudon.example.validator.AcceptedIntegerValues.message = not contained accepted values: ${value.toString()}.
```

これでテストしてみると、エラーにはなりませんでしたが、以下のようになってしまいます。

```text
not contained accepted values: [I@6e9319f. //Integer[]版
not contained accepted values: [1, 2, 3, 4, 5]. // String[]版
```

というわけで、`Arrays.toString`で`Integer[]`から`String`に変換してみます。

```properties:ValidationMessages.properties
com.neriudon.example.validator.AcceptedIntegerValues.message = not contained accepted values: ${java.util.Arrays.toString(value)}.
```

で、実行すると……

`javax.el.PropertyNotFoundException: ELResolver cannot handle a null base Object with identifier [java]`

javaなんてオブジェクトないぞ！というエラーが出ました:disappointed_relieved:。

ここで初心に帰って[Hibernate Validator 5のエラーメッセージのEL式](http://docs.jboss.org/hibernate/validator/5.2/reference/en-US/html/ch04.html#section-interpolation-with-message-expressions)について読んできましょう。
すると……

> The validation engine makes the following objects available in the EL context:

> the attribute values of the constraint mapped to the attribute names
the currently validated value (property, bean, method parameter etc.) under the name validatedValue
a bean mapped to the name formatter exposing the var-arg method format(String format, Object…​ args) which behaves like java.util.Formatter.format(String format, Object…​ args).

意訳すると……

validation engineは以下のオブジェクトをEL式で使用できます。

1. アノテーションに設定した属性値
2. 実際にエラーになった値
3. `java.util.Formatter.format(String format, Object…​ args)`ベースのフォーマッティング

つまり、それ以外は使えない……？
リンク先の下にあるサンプルを参照しても、Javaが提供するクラスを呼び出してメッセージを加工していないですね。／(^o^)＼ﾅﾝﾃｺｯﾀｲ。

### エラーメッセージで任意の値を呼び出せるようにする
しかし、方法がないわけではありません。
サンプルの下の書いてあるTipsで以下のように書いてあります。

> Only actual constraint attributes can be interpolated using message parameters in the form {attributeName}. When referring to the validated value or custom expression variables added to the interpolation context (see Section 11.9.1, “HibernateConstraintValidatorContext”), an EL expression in the form ${attributeName} must be used.

意訳すると……
コンテキストに使いたい値を設定しておけば、`${}`で参照できるよ！

とのこと。さっそく[11.9.1. HibernateConstraintValidatorContext](http://docs.jboss.org/hibernate/validator/5.2/reference/en-US/html/ch11.html#section-hibernateconstraintvalidatorcontext)に書いてあるサンプルをベースに設定してみましょう。

`Interger[]`を`String`に変換したものを`acceptedValuesToString`と名前を付けてコンテキストに格納する処理を`isValid`メソッドに追加します。

```java:AcceptedIntegerValuesValidator.java
	@Override
	public boolean isValid(Integer value, ConstraintValidatorContext context) {
		if (value == null) {
			return true;
		}
		// add acceptedValuesToString variable converted accepted integer values to string 
		context.unwrap(HibernateConstraintValidatorContext.class).addExpressionVariable("acceptedValuesToString", Arrays.toString(validValues));

		// check to exist value or not in accepted values array
		return Arrays.stream(validValues).anyMatch(s -> Objects.equals(value, s));
	}
```

で、`ValidationMessages.properties`で`${acceptedValuesToString}`を呼び出すと……

```properties:ValidationMessages.properties
com.neriudon.example.validator.AcceptedIntegerValues.message = not contained accepted values: ${acceptedValuesToString}.
```

`not contained accepted values: [1, 2, 3, 4, 5].`というエラーメッセージが表示されました!やったね:stuck_out_tongue_closed_eyes:！

### まとめ
この記事では、Bean Validationのエラーメッセージで任意の値を呼び出せるようにする方法を紹介しました。しかし、この方法には問題点が2つあります。

- 利用者に対して、エラーメッセージで使える値を説明する必要がある

デフォルトのValidation機能を拡張しているので、個人で開発する場合は問題ないのですが、例えばライブラリなどとして公開する場合は、どのような値が使えるのかをJavaDoc等に記載しておく必要があります。

- この機能自体が将来変更される可能性がある

~~HibernateConstraintValidatorContextのセクションの下にWARNで書かれていますが、この機能自体が将来変更される可能性があります。~~

~~記事を書いた本人が言うのもあれですが、あまり多用すべきではない機能ですね:rolling_eyes:。~~


[Hibernate Validator 6.0.13.Final](https://docs.jboss.org/hibernate/stable/validator/reference/en-US/html_single/#section-hibernateconstraintvalidatorcontext)では、上記の警告が削除されていたのでFixされたのでしょうか？

だとしたら、プロジェクトで`Hibernate Validator`を使用するのが決まっているのであれば、この機能は有用ですね（どっちだ）。