Skip to content

Resolving generic enum types (Enum<T>) in nested bean properties #27760

Open
@djechelon

Description

Affects: 5.3.10


I am experiencing a problem with MVC method argument resolving in GET invocations. I have already reduced my problem to a bunch of classes and a reproducible example using Spring Test.

The idea is that I have a Filter Framework that heavily relies on generics. The first part of the FF, the one we are dealing with, is MVC argument resolution. The second part is translation into ORM predicates, which is out of scope.

@Data
public class SimpleFilter<T> {

    /**
     * Requires the property to be equal to the value
     */
    protected T eq;
    /**
     * Requires the property to be different from the value
     */
    protected T ne;
    /**
     * Requires the property to be any of the supplied values
     */
    protected T[] in;
    /**
     * Requires the property not to be any of the supplied values
     */
    protected T[] notIn;

    /**
     * Requires the property to be either not null or null.
     * The property can be used as:
     * <p>
     * - Unspecified: null check is ignored
     * - True: the property must be null
     * - False: the property must be not null
     */
    protected Boolean isNull;

}

Let's not talk about IntegerFilter and LongFilter, let's focus on a generic EnumFilter

public class EnumFilter<T extends Enum<T>> extends SimpleFilter<T> {
}

I know that Java erases generic types to the lower bound (java.lang.Enum), but the generic information is preserved in Java 11 when applied to a field.

Consider this particular filter

@Data
public class ExampleTestFilter implements Filter {


    private AmlObjectTypeFilter exampleObjectTypeEnum;

    private EnumFilter<AmlObjectType> exampleObjectTypeEnumGeneric;

    private SimpleFilter<AmlObjectType> exampleObjectTypeSimple;

    public static class AmlObjectTypeFilter extends SimpleFilter<AmlObjectType> {

    }
}

Consider the following controller:

@RestController
@RequestMapping("/exampleFiltering")
@Data
public class FilteringArgumentResolverTestController {

    private ExampleTestFilter filter;

    /**
     * Sample no-op function
     */
    @GetMapping
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void handleExampleTestFilter(@ModelAttribute("filter") ExampleTestFilter filter) {
        this.filter = filter;
    }
}

Now consider the following tests

@SpringBootTest(classes = AmlControlliBeApplication.class)
@ComponentScan(basePackageClasses = FilterArgumentResolverWeb2Test.class)
class FilterArgumentResolverWeb2Test extends AbstractOrbitE2eTest {

    private final FilteringArgumentResolverTestController controller;

    @Autowired
    FilterArgumentResolverWeb2Test(ConfigurableApplicationContext applicationContext, PlatformTransactionManager transactionManager, DataSource dataSource, MockMvc mockMvc, ObjectMapper objectMapper, FilteringArgumentResolverTestController controller) {
        super(applicationContext, transactionManager, dataSource, mockMvc, objectMapper);
        this.controller = controller;
    }


    @Test
    void resolveArgument_amlObjectTypeEnum_ok() {
        String value = "CONTROL";

        assertDoesNotThrow(() -> getMockMvc().perform(get("/exampleFiltering")
                        .queryParam("exampleObjectTypeEnum.eq", value))
                .andExpect(status().isNoContent())
        );

        assertAll(
                () -> assertThat(controller.getFilter(), is(notNullValue())),
                () -> assertThat(controller.getFilter(), is(instanceOf(ExampleTestFilter.class))),
                () -> assertThat(controller.getFilter(), hasProperty("exampleObjectTypeEnum", is(notNullValue()))),
                () -> assertThat(controller.getFilter(), hasProperty("exampleObjectTypeEnum", hasProperty("eq", equalTo(AmlObjectType.CONTROL))))
        );
    }

    @Test
    void resolveArgument_amlObjectTypeSimple_ok() {
        String value = "CONTROL";

        assertDoesNotThrow(() -> getMockMvc().perform(get("/exampleFiltering")
                        .queryParam("exampleObjectTypeSimple.eq", value))
                .andExpect(status().isNoContent())
        );
        assertAll(
                () -> assertThat(controller.getFilter(), is(notNullValue())),
                () -> assertThat(controller.getFilter(), is(instanceOf(ExampleTestFilter.class))),
                () -> assertThat(controller.getFilter(), hasProperty("exampleObjectTypeSimple", is(notNullValue()))),
                () -> assertThat(controller.getFilter(), hasProperty("exampleObjectTypeSimple", hasProperty("eq", equalTo(AmlObjectType.CONTROL))))
        );
    }
}

The problems here

If I declare (even as a static class) a boilerplate filter class bound to the AmlObjectType enum, it works. But if I declare a field as EnumFilter<AmlObjectType> Spring is unable to resolve the property as it thinks the property value should be cast to Enum, which is the generic bound of EnumFilter<T extends Enum<T>>.

E.g. using GET /api?exampleObjectTypeEnum.eq=CONTROL works, using GET /api?exampleObjectTypeEnumGeneric.eq=CONTROL or GET /api?exampleObjectTypeSimple.eq=CONTROL does not.

It would not happen using JSON POST with Jackson Object Mapper.


  assertDoesNotThrow(() -> getMockMvc().perform(get("/exampleFiltering")
                        .queryParam("exampleObjectTypeSimple.eq", value))
                .andExpect(status().isNoContent())
        );
Caused by: java.lang.AssertionError: 
Expected: hasProperty("exampleObjectTypeSimple", hasProperty("eq", <CONTROL>))
     but:  property 'exampleObjectTypeSimple'  property 'eq' was "CONTROL"
	at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:20)
	at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:6)
	at it.orbit.common.web.filtering.FilterArgumentResolverWeb2Test.lambda$resolveArgument_amlObjectTypeSimple_ok$44(FilterArgumentResolverWeb2Test.java:195)
	at org.junit.jupiter.api.AssertAll.lambda$assertAll$1(AssertAll.java:68)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:195)
	at java.base/java.util.stream.ReferencePipeline$11$1.accept(ReferencePipeline.java:442)
	at java.base/java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:484)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:474)
	at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:913)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:578)
	at org.junit.jupiter.api.AssertAll.assertAll(AssertAll.java:77)
	... 87 more

In this case, using SimpleFilter<AmlObjectType> results in Spring setting the value CONTROL (String) as an Object into the property, which fails the assertion because I am comparing an enum with a string.


  assertDoesNotThrow(() -> getMockMvc().perform(get("/exampleFiltering")
                        .queryParam("exampleObjectTypeEnumGeneric.eq", value))
                .andExpect(status().isNoContent())
        );
2021-12-02 16:40:48.514 DEBUG 2248 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : GET "/exampleFiltering?exampleObjectTypeEnumGeneric.eq=CONTROL", parameters={masked}
2021-12-02 16:40:48.520  WARN 2248 --- [    Test worker] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors
Field error in object 'filter' on field 'exampleObjectTypeEnumGeneric.eq': rejected value [CONTROL]; codes [typeMismatch.filter.exampleObjectTypeEnumGeneric.eq,typeMismatch.exampleObjectTypeEnumGeneric.eq,typeMismatch.eq,typeMismatch.java.lang.Enum,typeMismatch]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [filter.exampleObjectTypeEnumGeneric.eq,exampleObjectTypeEnumGeneric.eq]; arguments []; default message [exampleObjectTypeEnumGeneric.eq]]; default message [Failed to convert property value of type 'java.lang.String' to required type 'java.lang.Enum' for property 'exampleObjectTypeEnumGeneric.eq'; nested exception is java.lang.IllegalArgumentException: The target type java.lang.Enum does not refer to an enum]]

In this case, Spring detects the target type as Enum, and can't cast CONTROL to the root Enum class


  assertDoesNotThrow(() -> getMockMvc().perform(get("/exampleFiltering")
                        .queryParam("exampleObjectTypeEnum.eq", value))
                .andExpect(status().isNoContent())
        );

This succeeds because target property is of type AmlObjectTypeFilter which is bound directly to AmlObjectType

Debugging

org.springframework.beans.PropertyBatchUpdateException; nested PropertyAccessException details (1) are:
PropertyAccessException 1:
org.springframework.beans.TypeMismatchException: Failed to convert property value of type 'java.lang.String' to required type 'java.lang.Enum' for property 'exampleObjectTypeEnumGeneric.eq'; nested exception is java.lang.IllegalArgumentException: The target type java.lang.Enum does not refer to an enum
	at org.springframework.beans.AbstractNestablePropertyAccessor.convertIfNecessary(AbstractNestablePropertyAccessor.java:600)
	at org.springframework.beans.AbstractNestablePropertyAccessor.convertForProperty(AbstractNestablePropertyAccessor.java:609)
	at org.springframework.beans.AbstractNestablePropertyAccessor.processLocalProperty(AbstractNestablePropertyAccessor.java:458)
	at org.springframework.beans.AbstractNestablePropertyAccessor.setPropertyValue(AbstractNestablePropertyAccessor.java:278)
	at org.springframework.beans.AbstractNestablePropertyAccessor.setPropertyValue(AbstractNestablePropertyAccessor.java:266)
	at org.springframework.beans.AbstractPropertyAccessor.setPropertyValues(AbstractPropertyAccessor.java:104)
	at org.springframework.validation.DataBinder.applyPropertyValues(DataBinder.java:851)
	at org.springframework.validation.DataBinder.doBind(DataBinder.java:747)
	at org.springframework.web.bind.WebDataBinder.doBind(WebDataBinder.java:198)
	at org.springframework.web.bind.ServletRequestDataBinder.bind(ServletRequestDataBinder.java:118)
	at org.springframework.web.servlet.mvc.method.annotation.ServletModelAttributeMethodProcessor.bindRequestParameters(ServletModelAttributeMethodProcessor.java:158)
	at org.springframework.web.method.annotation.ModelAttributeMethodProcessor.resolveArgument(ModelAttributeMethodProcessor.java:171)
	at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:121)
	at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:179)
	at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:146)
	at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117)
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895)
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808)
	at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1067)
	at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963)
	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
	at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898)
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:655)
	at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
	at org.springframework.test.web.servlet.TestDispatcherServlet.service(TestDispatcherServlet.java:72)
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:764)
	at org.springframework.mock.web.MockFilterChain$ServletFilterProxy.doFilter(MockFilterChain.java:167)
	at org.springframework.mock.web.MockFilterChain.doFilter(MockFilterChain.java:134)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:327)
	at org.springframework.security.web.access.intercept.FilterSecurityInterceptor.invoke(FilterSecurityInterceptor.java:115)
	at org.springframework.security.web.access.intercept.FilterSecurityInterceptor.doFilter(FilterSecurityInterceptor.java:81)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336)
	at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:121)
	at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:115)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336)
	at org.springframework.security.web.authentication.AnonymousAuthenticationFilter.doFilter(AnonymousAuthenticationFilter.java:105)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336)
	at org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter.doFilter(SecurityContextHolderAwareRequestFilter.java:149)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336)
	at org.springframework.security.web.savedrequest.RequestCacheAwareFilter.doFilter(RequestCacheAwareFilter.java:63)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336)
	at org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter.doFilterInternal(BearerTokenAuthenticationFilter.java:121)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336)
	at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:103)
	at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:89)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336)
	at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336)
	at org.springframework.security.web.header.HeaderWriterFilter.doHeadersAfter(HeaderWriterFilter.java:90)
	at org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:75)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336)
	at org.springframework.security.web.context.SecurityContextPersistenceFilter.doFilter(SecurityContextPersistenceFilter.java:110)
	at org.springframework.security.web.context.SecurityContextPersistenceFilter.doFilter(SecurityContextPersistenceFilter.java:80)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336)
	at org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter.doFilterInternal(WebAsyncManagerIntegrationFilter.java:55)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:336)
	at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:211)
	at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:183)
	at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:358)
	at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:271)
	at org.springframework.mock.web.MockFilterChain.doFilter(MockFilterChain.java:134)
	at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
	at org.springframework.mock.web.MockFilterChain.doFilter(MockFilterChain.java:134)
	at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)
	at org.springframew

I also found that Spring could be aware of the actual generic type of a field at some point. I was spelunking deep into Introspector and found the below

Screenshot debugging

This shows that the generic type is known for the field, but then looks like Spring ignores the property generic binding and works only on the (raw....) type.

At AbstractNestablePropertyAccessors# variable ph has now lost any information about the generic binding of the filter class's property.

And at line 590 the "target class" is now just Enum.

Porkarounds

  • Use HTTP POST to deserialize the filter object
  • Define a subclass for each Enum you want to use in a filter

Metadata

Assignees

No one assigned

    Labels

    in: coreIssues in core modules (aop, beans, core, context, expression)status: feedback-providedFeedback has been providedtype: enhancementA general enhancement

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions