Skip to content

SpringValidatorAdapter fails in getRejectedValue if ValueExtractor used in property path to unwrap a container type #29043

@xak2000

Description

@xak2000

Affects: 5.3.22

Related issue: #16855.

This issue is basically the same as #16855, but for a custom wrapper (#16855 only handles Optional).

SpringValidatorAdapter couldn't process ConstraintViolations because it can't traverse property path. It throws this exception:

Caused by: java.lang.IllegalStateException: JSR-303 validated property 'location.name' does not have a corresponding accessor for Spring data binding - check your DataBinder's configuration (bean property versus direct field access)
	at org.springframework.validation.beanvalidation.SpringValidatorAdapter.processConstraintViolations(SpringValidatorAdapter.java:188) ~[spring-context-5.3.22.jar:5.3.22]
	at org.springframework.validation.beanvalidation.SpringValidatorAdapter.validate(SpringValidatorAdapter.java:109) ~[spring-context-5.3.22.jar:5.3.22]
	at com.example.demo.DemoApplication.lambda$run$0(DemoApplication.java:29) ~[classes/:na]
	at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:762) ~[spring-boot-2.7.3.jar:2.7.3]
	... 5 common frames omitted
Caused by: org.springframework.beans.NotReadablePropertyException: Invalid property 'location.name' of bean class [com.example.demo.UpdateRequest]: Bean property 'location.name' is not readable or has an invalid getter method: Does the return type of the getter match the parameter type of the setter?
	at org.springframework.beans.AbstractNestablePropertyAccessor.getPropertyValue(AbstractNestablePropertyAccessor.java:627) ~[spring-beans-5.3.22.jar:5.3.22]
	at org.springframework.beans.AbstractNestablePropertyAccessor.getPropertyValue(AbstractNestablePropertyAccessor.java:617) ~[spring-beans-5.3.22.jar:5.3.22]
	at org.springframework.validation.AbstractPropertyBindingResult.getActualFieldValue(AbstractPropertyBindingResult.java:104) ~[spring-context-5.3.22.jar:5.3.22]
	at org.springframework.validation.AbstractBindingResult.getRawFieldValue(AbstractBindingResult.java:284) ~[spring-context-5.3.22.jar:5.3.22]
	at org.springframework.validation.beanvalidation.SpringValidatorAdapter.getRejectedValue(SpringValidatorAdapter.java:318) ~[spring-context-5.3.22.jar:5.3.22]
	at org.springframework.validation.beanvalidation.SpringValidatorAdapter.processConstraintViolations(SpringValidatorAdapter.java:174) ~[spring-context-5.3.22.jar:5.3.22]
	... 8 common frames omitted

But the problem in this case not with getter/setter, but with the fact that actual field type is not simple POJO, but a custom wrapper class.

Example:

public class UpdateRequest {

    // Note the custom wrapper type
    @NotNull
    @Valid
    private NullableOptional<Location> location = NullableOptional.absent();

    public NullableOptional<Location> getLocation() {
        return location;
    }

    public void setLocation(NullableOptional<Location> location) {
        this.location = location;
    }

}

public class Location {

    @NotEmpty
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

}

public final class NullableOptional<T> {

	private static final NullableOptional<?> ABSENT = new NullableOptional<>(true, null);
	
	private final boolean absent;
	private final T value;

	private NullableOptional(boolean absent, T value) {
		this.absent = absent;
		this.value = !absent ? value : null;
	}

	public static <T> NullableOptional<T> absent() {
		@SuppressWarnings("unchecked")
		NullableOptional<T> t = (NullableOptional<T>) ABSENT;
		return t;
	}

	public static <T> NullableOptional<T> of(T value) {
		return new NullableOptional<>(false, value);
	}

	public T get() {
		if (absent) {
			throw new NoSuchElementException("No value present");
		}
		return value;
	}

	public boolean isPresent() {
		return !absent;
	}

	public boolean isAbsent() {
		return absent;
	}

	public void ifPresent(Consumer<? super T> action) {
		if (!absent) {
			action.accept(value);
		}
	}

	public void ifPresentOrElse(Consumer<? super T> action, Runnable absentAction) {
		if (!absent) {
			action.accept(value);
		} else {
			absentAction.run();
		}
	}

	public T orElse(T other) {
		return !absent ? value : other;
	}

	@Override
	public boolean equals(Object obj) {
		if (this == obj) {
			return true;
		}

		return obj instanceof NullableOptional<?> other
				&& absent == other.absent
				&& Objects.equals(value, other.value);
	}

	@Override
	public int hashCode() {
		return Objects.hash(absent, value);
	}

	@Override
	public String toString() {
		return !absent
				? String.format("NullableOptional[%s]", value)
				: "NullableOptional.absent";
	}

}

@UnwrapByDefault
public class NullableOptionalValueExtractor implements ValueExtractor<NullableOptional<@ExtractedValue ?>> {

	@Override
	public void extractValues(NullableOptional<?> originalValue, ValueReceiver receiver) {
		if (originalValue.isPresent()) {
			receiver.value(null, originalValue.get());
		}
	}

}

Note, that NullableOptionalValueExtractor is registered using META-INF/services/javax.validation.valueextraction.ValueExtractor as described here and correctly unwraps the field value for validation purposes. @UnwrapByDefault makes validation annotations (e.g. @NotNull) on NullableOptional field work as if they were on a wrapped type (Location in this example).

And the validation example, that throws mentioned exception:

@SpringBootApplication
public class DemoApplication {

	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
	}

	@Bean
	ApplicationRunner run(Validator validator) {
		return args -> {
			Location location = new Location();
			location.setName(null);

			UpdateRequest updateRequest = new UpdateRequest();
			updateRequest.setLocation(NullableOptional.of(location));

			BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(updateRequest, "updateRequest");
			validator.validate(updateRequest, bindingResult);
			System.out.println("bindingResult: " + bindingResult);
		};
	}

}

The validation itself that Hibernate-validator performs works fine. But then SpringValidatorAdapter throws. Looks like it isn't aware of ValueExtractor concept.

What can be done in this case to make it work (or at least not throw)? Can the support of custom wrapper types be added in some generic way that will work out of the box (just like it's done in Hibernate validator thanks to ValueExtractor)? Or, at least, skip the extraction of a value if not possible instead of throw the error.

What workaround exists for now? I tried to debug the source code and unfortunately didn't found any possibility for a workaround.

The main goal is to use NullableOptional<T> (or any other custom wrapper) for fields of JSON body of PATCH requests, where a field value could be either null, non-null or absent (when field is absent in JSON representation altogether) and business logic needs to distinguish between all 3 cases. Sometimes people use Optional<T> for this purpose, but it's error-prone and ugly, as third state of Optional field is the null-reference, that is anti-pattern and usually unexpected usage of Optional type. Custom wrapper type solves these problems as it's intended to be used specifically for the mentioned case and will never reference to null (but could contain null inside).

Minimal Demo Project: custom-container-validation-bug.zip

Metadata

Metadata

Assignees

Labels

in: coreIssues in core modules (aop, beans, core, context, expression)type: enhancementA general enhancement

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions