Peter Luttrell opened SPR-15371 and commented

If I create a RestController that uses BeanValidations to validate the input object such as the following:

@RestController
public class CarController {
  @PostMapping("/cars")
  public Car createCar(@RequestBody @Valid Car car) {
    return car;
  }
}

And the entity class contains a Java 8 Optional such as the following:

public class Car {

  @Valid
  private List<Driver> drivers;

  public Car() {
  }

  public Optional<List<Driver>> getDrivers() {
    return Optional.ofNullable(drivers);
  }

  public void setDrivers(List<Driver> drivers) {
    this.drivers = drivers;
  }
}

public class Driver {

  @NotEmpty()
  private String name;

  @Min(16)
  private int age;

  public Driver() {
  }

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

  public int getAge() { return age; }
  public void setAge(int age) { this.age = age; }
}

And you Post the following JSON to it:

{
    "drivers": [
        {
            "name": "driver1",
            "age": 14
        }
    ]
}

You'll get the following error:

org.springframework.beans.InvalidPropertyException: Invalid property 'drivers[0]' of bean class [example.Car]: Property referenced in indexed property path 'drivers[0]' is neither an array nor a List nor a Set nor a Map; returned value was [Optional[[Driver{name='driver1', age=14}]]]
    at org.springframework.beans.AbstractNestablePropertyAccessor.getPropertyValue(AbstractNestablePropertyAccessor.java:696) ~[spring-beans-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.beans.AbstractNestablePropertyAccessor.getNestedPropertyAccessor(AbstractNestablePropertyAccessor.java:850) ~[spring-beans-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.beans.AbstractNestablePropertyAccessor.getPropertyAccessorForPropertyPath(AbstractNestablePropertyAccessor.java:827) ~[spring-beans-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.beans.AbstractNestablePropertyAccessor.getPropertyValue(AbstractNestablePropertyAccessor.java:622) ~[spring-beans-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.validation.AbstractPropertyBindingResult.getActualFieldValue(AbstractPropertyBindingResult.java:99) ~[spring-context-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.validation.AbstractBindingResult.getRawFieldValue(AbstractBindingResult.java:283) ~[spring-context-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.validation.beanvalidation.SpringValidatorAdapter.getRejectedValue(SpringValidatorAdapter.java:260) ~[spring-context-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.validation.beanvalidation.SpringValidatorAdapter.processConstraintViolations(SpringValidatorAdapter.java:140) ~[spring-context-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.validation.beanvalidation.SpringValidatorAdapter.validate(SpringValidatorAdapter.java:109) ~[spring-context-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.boot.autoconfigure.web.WebMvcValidator.validate(WebMvcValidator.java:69) ~[spring-boot-autoconfigure-1.5.2.RELEASE.jar:1.5.2.RELEASE]
    at org.springframework.validation.DataBinder.validate(DataBinder.java:891) ~[spring-context-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodArgumentResolver.validateIfApplicable(AbstractMessageConverterMethodArgumentResolver.java:270) ~[spring-webmvc-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.resolveArgument(RequestResponseBodyMethodProcessor.java:133) ~[spring-webmvc-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:121) ~[spring-web-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:158) ~[spring-web-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:128) ~[spring-web-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:116) ~[spring-webmvc-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:827) ~[spring-webmvc-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:738) ~[spring-webmvc-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:85) ~[spring-webmvc-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:963) ~[spring-webmvc-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:897) ~[spring-webmvc-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:970) ~[spring-webmvc-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:872) ~[spring-webmvc-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:648) ~[tomcat-embed-core-8.5.11.jar:8.5.11]
    at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:846) ~[spring-webmvc-4.3.7.RELEASE.jar:4.3.7.RELEASE]
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:729) ~[tomcat-embed-core-8.5.11.jar:8.5.11]

This appears to be caused by the fact that org.springframework.beans.AbstractNestablePropertyAccessor doesn't unwrap Optional insances.

The request with this ticket is that it supports Optional return types for collection entities if running with Java 8.

I know that Spring wants to remain backward compatible to pre-Java 8, so a pluggable solution is likely going to be necessary to resolve this.


Affects: 4.3.7

Comment From: spring-projects-issues

Juergen Hoeller commented

Generally speaking, we have plenty of java.util.Optional support across the framework at this point, e.g. for injection points and handler method arguments. We introduced this back in the 4.x line, conditionally kicking in when running on Java 8.

As for your scenario, there is a mismatch with two design conventions: A JavaBeans property needs to have the same type declared on the setter and the getter (otherwise java.beans.Introspector is not going to recognize is as a valid property), and Collection types are generally considered "monadic enough", i.e. not combined with java.util.Optional since they arguably imply optionality through the notion of an empty collection already.

Comment From: spring-projects-issues

Peter Luttrell commented

I understand the points about the JavaBeans convention, though in looking at the code I don't see where it would fail as a result of this difference. I also understand that Collections provide an additional way given that they could (and arguably) should be implemented never as null, but instead initialized as an empty collection.

I took a look at the code in org.springframework.beans.AbstractNestablePropertyAccessor and wasn't sure what harm there would be to simply unwrap an Optional in the protected Object getPropertyValue(PropertyTokenHolder tokens) method right after the null check and right before else if (value.getClass().isArray()) {. If this would work, it might add a bit of flexibility to the API.

Comment From: Bas83

I would really love if something could be done about this too. I realize that using Optional as a field here isn't a convention. But it is an extremely simple way to implement PATCH requests while keeping the type-safety of using objects.

For example with this DTO here:

@Data
@NoArgsConstructor
public class UISettingsPatchDTO {

    private Optional<@NotNull ProjectTheme> theme;
    private Optional<@Valid LogoDTO> logo;
    private Optional<List<@Valid @NotNull SubjectReportColumnDTO>> subjectReportColumns;
}

it is fairly trivial to implement a PATCH request. Because when the optional field is null, it was never passed from the client. When the optional is not present, that means the client has set the value to null. It is the difference between

{
  "theme" : "DARK",
  "subjectReportColumns" : null
}

and

{
  "theme" : "DARK",
}

which is a big difference in meaning.

If you want to implement PATCH in a different way, you'll quickly end up with having to use untyped maps of values.

Unfortunately right now validation will throw the error mentioned in this ticket.

Comment From: lpeter91

I'm almost in the same boot as @Bas83 except that I use a custom wrapper type instead of Optional.

So in more general terms it should be possible to somehow integrate Spring's Property Accessors with Bean Validation Value Extractors (both built-in and custom ones). I assume Property Accessors are completely oblivious to Bean Validation, so this might not be a trivial task.

Comment From: tukez

We have a similar problem with Vavr collections. Right now we have a very ugly workaround, but I wonder if there is currently an easier way to solve this. Definitely needs an improvement.

Workaround:

public class BaseEnableWebMvcConfig extends DelegatingWebMvcConfiguration {

    @Override
    protected RequestMappingHandlerAdapter createRequestMappingHandlerAdapter() {
        return new RequestMappingHandlerAdapter() {

            @Override
            protected InitBinderDataBinderFactory createDataBinderFactory(List<InvocableHandlerMethod> binderMethods) throws Exception {
                return new ServletRequestDataBinderFactory(binderMethods, getWebBindingInitializer()) {

                    @Override
                    protected ServletRequestDataBinder createBinderInstance(@Nullable Object target,
                                                                            String objectName,
                                                                            NativeWebRequest request) throws Exception {
                        return new ExtendedServletRequestDataBinder(target, objectName) {

                            @Override
                            protected AbstractPropertyBindingResult createDirectFieldBindingResult() {
                                var result = new VavrSupportingDirectFieldBindingResult(getTarget(),
                                                                                        getObjectName(),
                                                                                        isAutoGrowNestedPaths());
                                if (getConversionService() != null) {
                                    result.initConversion(getConversionService());
                                }
                                if (getMessageCodesResolver() != null) {
                                    result.setMessageCodesResolver(getMessageCodesResolver());
                                }
                                return result;
                            }

                        };
                    }

                };
            }

        };
    }

    public static class VavrSupportingDirectFieldBindingResult extends DirectFieldBindingResult {

        private static final long serialVersionUID = 1L;

        public VavrSupportingDirectFieldBindingResult(Object target, String objectName, boolean autoGrowNestedPaths) {
            super(target, objectName, autoGrowNestedPaths);
        }

        public VavrSupportingDirectFieldBindingResult(Object target, String objectName) {
            super(target, objectName);
        }

        @Override
        protected ConfigurablePropertyAccessor createDirectFieldAccessor() {
            if (getTarget() == null) {
                throw new IllegalStateException("Cannot access fields on null target instance '" + getObjectName() +
                                                "'");
            }
            return new VavrSupportingDirectFieldAccessor(getTarget());
        }

    }

    /**
     * Adapted from {@link DirectFieldAccessor} to support convert Vavr collections to JDK collections because only they
     * are supported in the base class ({@link AbstractNestablePropertyAccessor}) and the behaviour is hard to
     * override/extend.
     */
    private static class VavrSupportingDirectFieldAccessor extends AbstractNestablePropertyAccessor {

        private final Map<String, FieldPropertyHandler> fieldMap = new HashMap<>();

        public VavrSupportingDirectFieldAccessor(Object object) {
            super(object);
        }

        protected VavrSupportingDirectFieldAccessor(Object object,
                                                    String nestedPath,
                                                    VavrSupportingDirectFieldAccessor parent) {
            super(object, nestedPath, parent);
        }

        @Override
        @Nullable
        protected FieldPropertyHandler getLocalPropertyHandler(String propertyName) {
            FieldPropertyHandler propertyHandler = this.fieldMap.get(propertyName);
            if (propertyHandler == null) {
                Field field = ReflectionUtils.findField(getWrappedClass(), propertyName);
                if (field != null) {
                    propertyHandler = new FieldPropertyHandler(field);
                    this.fieldMap.put(propertyName, propertyHandler);
                }
            }
            return propertyHandler;
        }

        @Override
        protected VavrSupportingDirectFieldAccessor newNestedPropertyAccessor(Object object, String nestedPath) {
            return new VavrSupportingDirectFieldAccessor(object, nestedPath, this);
        }

        @Override
        protected NotWritablePropertyException createNotWritablePropertyException(String propertyName) {
            PropertyMatches matches = PropertyMatches.forField(propertyName, getRootClass());
            throw new NotWritablePropertyException(getRootClass(),
                                                   getNestedPath() + propertyName,
                                                   matches.buildErrorMessage(),
                                                   matches.getPossibleMatches());
        }

        private class FieldPropertyHandler extends PropertyHandler {

            private final Field field;

            public FieldPropertyHandler(Field field) {
                super(field.getType(), true, true);
                this.field = field;
            }

            @Override
            public TypeDescriptor toTypeDescriptor() {
                return new TypeDescriptor(this.field);
            }

            @Override
            public ResolvableType getResolvableType() {
                return ResolvableType.forField(this.field);
            }

            @Override
            @Nullable
            public TypeDescriptor nested(int level) {
                return TypeDescriptor.nested(this.field, level);
            }

            @SuppressWarnings("rawtypes")
            @Override
            @Nullable
            public Object getValue() throws Exception {
                try {
                    ReflectionUtils.makeAccessible(this.field);
                    Object value = this.field.get(getWrappedInstance());
                    if (value instanceof io.vavr.collection.Seq) {
                        return ((io.vavr.collection.Seq) value).toJavaList();
                    }
                    if (value instanceof io.vavr.collection.Set) {
                        return ((io.vavr.collection.Set) value).toJavaSet();
                    }
                    if (value instanceof io.vavr.collection.Map) {
                        return ((io.vavr.collection.Map) value).toJavaMap();
                    }
                    return value;
                } catch (IllegalAccessException ex) {
                    throw new InvalidPropertyException(getWrappedClass(),
                                                       this.field.getName(),
                                                       "Field is not accessible",
                                                       ex);
                }
            }

            @Override
            public void setValue(@Nullable Object value) throws Exception {
                try {
                    ReflectionUtils.makeAccessible(this.field);
                    this.field.set(getWrappedInstance(), value);
                } catch (IllegalAccessException ex) {
                    throw new InvalidPropertyException(getWrappedClass(),
                                                       this.field.getName(),
                                                       "Field is not accessible",
                                                       ex);
                }
            }
        }

    }

}