-
Notifications
You must be signed in to change notification settings - Fork 38.9k
Description
Description
We are using Spring’s HTTP Interface support (@HttpExchange, HttpServiceProxyFactory) together with HATEOAS (RepresentationModel) to consume a Spring Data REST service.
We have defined a generic base interface:
public interface CrudWebClient<T, K extends Number> {
@GetExchange
PagedModel<T> list(@RequestParam @ModelAttribute Pageable pageable);
@GetExchange(value="/{id}", accept=MediaTypes.VND_HAL_JSON_VALUE)
T getById(@PathVariable K id);
@PostExchange
T create(@RequestBody T body);
@PutExchange(value="/{id}")
T update(@PathVariable K id, @RequestBody T body);
@DeleteExchange(value="/{id}")
void delete(@PathVariable K id);
}This interface is intended to be extended by concrete entity clients.
Example:
@HttpExchange(url="/emails")
public interface EmailWebClient extends CrudWebClient<Email, Long> {
@GetExchange(value="/{id}")
Email foo(@PathVariable Long id);
@GetExchange(value="/search/findAllByActor")
CollectionModel<Email> findAllByActor(@RequestParam(value="actor") String actorSelfHref);
@GetExchange(value="/search/findByEmailAddress")
Email findByEmailAddress(@RequestParam(value="emailAddress") String emailAddress);
@GetExchange(value="/{id}/actor")
Actor getActor(@PathVariable Long id);
}Email extends RepresentationModel<Email>.
The WebClient and proxy factory configuration:
@Configuration
@EnableHypermediaSupport(type = EnableHypermediaSupport.HypermediaType.HAL)
public class WebClientConfig {
@Bean
public WebClient dataServiceWebClient(WebClient.Builder builder) {
return builder
.baseUrl(environment.getRequiredProperty("cloud.data-service.base-url"))
.build();
}
@Bean
public HttpServiceProxyFactory dataServiceProxyFactory(
@Qualifier("dataServiceWebClient") WebClient webClient) {
return HttpServiceProxyFactory.builder()
.exchangeAdapter(WebClientAdapter.create(webClient))
.build();
}
}Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>Controller Usage
@GetMapping("/emails/{id}")
public ResponseEntity<ApiResponse<Email>> getEmail(@PathVariable Long id) {
var foo = emailWebClient.foo(id);
System.out.println(foo.getEmailAddress());
System.out.println(foo.getLink(IanaLinkRelations.SELF).orElseThrow().getHref());
var email = emailWebClient.getById(id); // <-- problem occurs here
return ResponseEntity.ok(SuccessResponse.of(email));
}Observed Behavior
foo(id)works correctly and returns a properly deserializedEmailobject (including HATEOAS links).getById(id)(inherited fromCrudWebClient) does not return anEmail.- Instead, the generated proxy returns a
LinkedHashMap. - This leads to a
ClassCastExceptionor incorrect deserialization.
Expected Behavior
getById(id) should behave exactly like foo(id) and return a properly deserialized Email instance.
Notes
- Both methods use
@GetExchange("/{id}"). - The only difference is that
foo()is declared directly inEmailWebClient, whilegetById()is inherited from the generic parent interface. EmailextendsRepresentationModel<Email>.- The issue appears to be related to generic type resolution in
HttpServiceProxyFactorywhen the method is inherited from a parameterized parent interface.
Suspected Cause
It seems that Spring's HTTP interface proxy cannot correctly resolve the concrete generic type T from the parent interface (CrudWebClient<T, K>) at runtime.
As a result, the return type information is erased and deserialization falls back to LinkedHashMap.
This issue was reproduced on Spring Boot 4.0.2 and clearly indicates that generic return type resolution does not work correctly for inherited HTTP interface methods.