Search code examples
spring-bootspring-webfluxclasscastexceptionwebflux

SpringBoot - Generic function for WebFlux getting ClassCastException


I'm trying to write a generic function to do some Webflux operations and I'm getting a class cast exception that I can't figure out

     * @param <T> Type of the contract
     * @param <U> Return type of this method
     * @param <V> Return type from the service
public <T, U, V> U sendRequest(String url, T contract, Function<V, U> transform) {

        ParameterizedTypeReference<T> contractType = new ParameterizedTypeReference<T>() {};
        ParameterizedTypeReference<V> returnType = new ParameterizedTypeReference<V>() {};
        final WebClient.ResponseSpec foo = webClient.post()
                .uri(url)
                .body(Mono.just(contract), contractType)
                .retrieve();

        Mono<V> mono =  foo.bodyToMono(returnType);

      final Mono<U> trans = mono.map(m -> transform.apply(m));
      return trans.block();
}

This code works fine in its non-generic form. But when I call this generic method with something like this

    requestRunner.<Contract, String, ViewModel>sendRequest(url, contract, v->(String)v.getResult().get("outputString"));

I get an exception:

java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to com.torchai.service.common.ui.ViewModel
    at com.torchai.service.orchestration.service.RequestRunner.lambda$sendRequest$0(RequestRunner.java:44)

I'm running version 2.4.5 of SpringBoot, so I don't believe this applies:https://github.com/spring-projects/spring-framework/issues/20574

Just for a little more context, in the example above, ViewModel (generic type <V>) is the format that the service returns its data. I'm then extracting just the piece I need, in this case a string (generic type <U>) The lambda function that is passed in gets the relevant string from the Response. But for some reason, the Mono is not being mapped properly to ViewModel. If I take out the map() and just return the ViewModel, it appears to work.

Again, if I do this in a non-generic way, it works fine. I can do the map() step and it properly returns a String

UPDATE

Just want to make it clear that this works fine with a non generic version like this:

public String sendRequest(String url, Contract contract, Function<ViewModel, String> transform) {

        ParameterizedTypeReference<Contract> contractType = new ParameterizedTypeReference<Contract>() {};
        ParameterizedTypeReference<ViewModel> returnType = new ParameterizedTypeReference<ViewModel>() {};
        final WebClient.ResponseSpec foo = webClient.post()
                .uri(url)
                .body(Mono.just(contract), contractType)
                .retrieve();

        Mono<ViewModel> mono = foo.bodyToMono(returnType);

        final Mono<String> trans = mono.map(m -> transform.apply(m));
        return trans.block();
}

It is called this way

requestRunner.<Contract, String, ViewModel>sendRequest(textExtractorUrl, cloudContract, v -> (String) v.getResult().get("outputString"));

It correctly returns a string, which is exactly what I wanted from the generic version


Solution

  • I was able to reproduce this issue locally and started some debugging but it is really hard to see what/where something happens in the webflux code.

    The type declaration ViewModel for V is only known at the caller but not in the method sendRequest. It seems that the use of ParameterizedTypeReference<V> is the problem. In this case V is just a generic placeholder for the real type, so spring just captures the V in a ParameterizedTypeReference and does not know that the response should be deserialized into an instance of ViewModel. Instead it tries to find a deserializer for the type V and the closest it could find is the type LinkedHashMap. So in your case you ended up with a top level LinkedHashMap (instead of ViewModel) containing a key result with a value of type LinkedHashMap which contains your result entries. Thats why your are getting the ClassCastException.

    I have removed the ParameterizedTypeReference<V> and used the explicit Class<V> version instead and it works. With this version you don't have to put in the generic types by yourself, just provide the parameters to the method and the generics are automatically derived from context.

    public <T, U, V> U sendRequest(String url, T contract, Class<V> responseType, Function<V, U> transform)
    {
        WebClient.ResponseSpec foo = webClient.post()
                .uri(url)
                .body(Mono.just(contract), contract.getClass())
                .retrieve();
        Mono<V> mono = foo.bodyToMono(responseType);
        Mono<U> trans = mono.map(transform);
        return trans.block();
    }
    

    Call:

    requestRunner.sendRequest(url, contract, ViewModel.class, v -> (String) v.getResult().get("outputString"));
    

    This is my minimal reproducable example if someone wants to do a deeper investigation.

    pom.xml

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>2.4.5</version>
            <relativePath/> <!-- lookup parent from repository -->
        </parent>
        <groupId>com.example</groupId>
        <artifactId>demo</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <name>demo</name>
        <description>Demo project for Spring Boot</description>
        <properties>
            <java.version>1.8</java.version>
        </properties>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-webflux</artifactId>
            </dependency>
    
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
                <scope>test</scope>
            </dependency>
            <dependency>
                <groupId>io.projectreactor</groupId>
                <artifactId>reactor-test</artifactId>
                <scope>test</scope>
            </dependency>
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                </plugin>
            </plugins>
        </build>
    </project>
    

    DemoApplication.java

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

    Controller.java

    @RestController
    public class Controller
    {
        private static class ViewModel
        {
            private LinkedHashMap<String, Object> result = new LinkedHashMap<>();
    
            public LinkedHashMap<String, Object> getResult()
            {
                return result;
            }
    
            public void setResult(LinkedHashMap<String, Object> result)
            {
                this.result = result;
            }
        }
    
        @PostMapping("/fetchViewModel")
        public ViewModel fetchViewModel()
        {
            ViewModel result = new ViewModel();
            result.getResult().put("outputString", "value");
            return result;
        }
    
        @GetMapping("/start")
        public Mono<String> startSuccess()
        {
            return this.sendRequest("fetchViewModel", "contract", ViewModel.class, v -> (String) v.getResult().get("outputString"));
        }
    
        private <T, U, V> Mono<U> sendRequest(String url, T contract, Class<V> responseType, Function<V, U> transform)
        {
            WebClient webClient = WebClient.create("http://localhost:8080/");
            WebClient.ResponseSpec foo = webClient.post()
                    .uri(url)
                    .body(Mono.just(contract), contract.getClass())
                    .retrieve();
            Mono<V> mono = foo.bodyToMono(responseType);
            Mono<U> trans = mono.map(transform);
            return trans;
        }
    
        @GetMapping("/startClassCastException")
        public Mono<String> startClassCastException()
        {
            return this.<String, String, ViewModel> sendRequestClassCastException("fetchViewModel", "contract", v -> (String) v.getResult().get("outputString"));
        }
    
        private <T, U, V> Mono<U> sendRequestClassCastException(String url, T contract, Function<V, U> transform)
        {
            ParameterizedTypeReference<T> contractType = new ParameterizedTypeReference<T>() {};
            ParameterizedTypeReference<V> responseType = new ParameterizedTypeReference<V>() {};
            WebClient webClient = WebClient.create("http://localhost:8080/");
            WebClient.ResponseSpec foo = webClient.post()
                    .uri(url)
                    .body(Mono.just(contract), contractType)
                    .retrieve();
            Mono<V> mono = foo.bodyToMono(responseType);
            Mono<U> trans = mono.map(transform);  // ClassCastException in Lambda
            return trans;
        }
    }