[JAVA] Allow arbitrary values to be called in Bean Validation error messages

Development environment

Introduction

How to use Bean Validation

If you want to check the input with Java, it is Bean Validation. It's easy to use and convenient because it only adds annotations: smile :. Now, let's create an annotation that checks if the property value is included in the array of strings passed as an argument, as shown below.

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 checks if the string passed tovalue ()of the ʻAcceptedStringValues` annotation contains the return value of the annotated field or method.

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));
  }
}

The test code looks like ↓.

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;
    }
  }
}

You know that error messages are automatically called if you create ValidationMessages.properties under the classpath and set the message with the value specified in message of the annotation class as the key. In this example, it looks like ↓.

ValidationMessages.properties


com.neriudon.example.validator.AcceptedStringValues.message = not accepted values.

Embed value in error message

It may look like ↑, but if the message is fixed, it may be difficult for the user to understand. In Bean Validation, the property value of the annotation class can be embedded in the error message, so try to output the value set in the value of the annotation class in{value}as a character string.

ValidationMessages.properties


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

Will result in the message not contained accepted values: [1, 2, 3, 4, 5] ..

Make error messages smarter

Bean Validation has supported EL expressions since 1.1! By specifying $ {validatedValue}, you can embed the object that caused the error ...

ValidationMessages.properties


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

This will result in the message xxx is not contained accepted values.. (XXX is the object that caused the error)

** However, $ {validatedValue} outputs the object in error as it is, so do not use it when handling confidential information such as passwords. ** **

Problem of not being able to embed other arrays

Well, the main subject is from here. So far we have dealt with String, but with ʻInteger` we create an annotation with the same functionality.

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();
	}
}

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));
	}
}

Set an error message ...

ValidationMessages.properties


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

Now, test

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;
		}
	}

Then ...

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

Something went wrong: weary :. It seems that casting from the array of ʻInteger from " [I cannot be cast to [Ljava.lang.Object; `" has failed.

Let's try to make it String with an EL expression.

ValidationMessages.properties


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

When I tested it with this, I didn't get an error, but it looks like this:

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

So, let's convert from ʻInteger [] to String with ʻArrays.toString.

ValidationMessages.properties


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

So, when you run it ...

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

There is no object like java! I got the error: disappointed_relieved :.

Going back to the beginning, [EL expression of Hibernate Validator 5 error message](http://docs.jboss.org/hibernate/validator/5.2/reference/en-US/html/ch04.html#section-interpolation- Read about with-message-expressions). Then ...

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).

If you translate it freely ...

The validation engine can use the following objects in EL expressions:

  1. Attribute value set in annotation
  2. The value that actually caused the error
  3. java.util.Formatter.format (String format, Object… args) based formatting

In other words, nothing else can be used ...? Even if you refer to the sample below the link, you didn't process the message by calling the class provided by Java. / (^ O ^) \ Nantekotai.

Allows arbitrary values to be called in error messages

But it's not without its way. In the tips written below the sample, it is written as follows.

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.

If you translate it freely ... If you set the value you want to use for the context, you can refer to it with $ {}!

And that. Set based on the sample written in 11.9.1. HibernateConstraintValidatorContext Let's try.

Add the process of converting ʻInterger []toString and storing it in the context as ʻacceptedValuesToString to the ʻisValid` method.

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));
	}

So, if you call $ {acceptedValuesToString} in ValidationMessages.properties ...

ValidationMessages.properties


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

I got the error message not contained accepted values: [1, 2, 3, 4, 5] .! I did it: stuck_out_tongue_closed_eyes :!

Summary

In this article, I showed you how to make arbitrary values callable in Bean Validation error messages. However, there are two problems with this method.

--It is necessary to explain to the user the values that can be used in the error message.

Since the default Validation function is extended, there is no problem if you develop it individually, but if you publish it as a library, for example, you need to describe what values can be used in JavaDoc etc. ..

--This feature itself may change in the future

~~ Written in WARN under the HibernateConstraintValidatorContext section, this feature itself is subject to change in the future. ~~

~~ As the person who wrote the article says, it's a feature that shouldn't be used too often: rolling_eyes :. ~~

In Hibernate Validator 6.0.13.Final, the above warning was removed. Was it fixed?

So if your project decides to use Hibernate Validator, this feature is useful (which one).

Recommended Posts

Allow arbitrary values to be called in Bean Validation error messages
How to display error messages in Japanese
In Bean Validation, if you want to include Field Name in Error Message, it cannot be output from LocalValidatorFactoryBean.
[Rails] Where to be careful in the description of validation
[Spring Boot] List of validation rules that can be used in the property file for error messages
[Rails] Unexpected validation error in devise