From e3568a3f0a4b7b886b3e9790c2f79eca8261c548 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 18 Feb 2026 17:26:44 +0000 Subject: [PATCH] Fix generic return type support in HttpServiceMethod Closes gh-36326 --- .../service/invoker/HttpServiceMethod.java | 61 ++++++++++++------- .../support/RestClientAdapterTests.java | 31 ++++++++++ .../client/support/WebClientAdapterTests.java | 31 ++++++++++ 3 files changed, 102 insertions(+), 21 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java index 4982c59471b..eff238874a7 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java @@ -18,6 +18,7 @@ package org.springframework.web.service.invoker; import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Method; +import java.lang.reflect.Type; import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -30,6 +31,7 @@ import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import org.springframework.core.GenericTypeResolver; import org.springframework.core.KotlinDetector; import org.springframework.core.MethodParameter; import org.springframework.core.ParameterizedTypeReference; @@ -84,7 +86,7 @@ final class HttpServiceMethod { HttpServiceMethod( - Method method, Class containingClass, List argumentResolvers, + Method method, Class serviceType, List argumentResolvers, HttpRequestValues.Processor valuesProcessor, HttpExchangeAdapter adapter, @Nullable StringValueResolver embeddedValueResolver) { @@ -97,12 +99,12 @@ final class HttpServiceMethod { this.requestValuesInitializer = HttpRequestValuesInitializer.create( - method, containingClass, embeddedValueResolver, + method, serviceType, embeddedValueResolver, (isReactorAdapter ? ReactiveHttpRequestValues::builder : HttpRequestValues::builder)); this.responseFunction = (isReactorAdapter ? - ReactorExchangeResponseFunction.create((ReactorHttpExchangeAdapter) adapter, method) : - ExchangeResponseFunction.create(adapter, method)); + ReactorExchangeResponseFunction.create((ReactorHttpExchangeAdapter) adapter, method, serviceType) : + ExchangeResponseFunction.create(adapter, method, serviceType)); } private static MethodParameter[] initMethodParameters(Method method) { @@ -403,13 +405,15 @@ final class HttpServiceMethod { /** * Create the {@code ResponseFunction} that matches the method return type. */ - public static ResponseFunction create(HttpExchangeAdapter client, Method method) { + public static ResponseFunction create( + HttpExchangeAdapter client, Method method, Class serviceType) { + if (KotlinDetector.isSuspendingFunction(method)) { throw new IllegalStateException( "Kotlin Coroutines are only supported with reactive implementations"); } - MethodParameter param = new MethodParameter(method, -1).nestedIfOptional(); + MethodParameter param = new MethodParameter(method, -1).withContainingClass(serviceType).nestedIfOptional(); Class paramType = param.getNestedParameterType(); Function responseFunction; @@ -429,15 +433,17 @@ final class HttpServiceMethod { asOptionalIfNecessary(client.exchangeForBodilessEntity(request), param); } else { + Type type = bodyParam.getNestedGenericParameterType(); ParameterizedTypeReference bodyTypeRef = - ParameterizedTypeReference.forType(bodyParam.getNestedGenericParameterType()); + ParameterizedTypeReference.forType(GenericTypeResolver.resolveType(type, serviceType)); responseFunction = request -> asOptionalIfNecessary(client.exchangeForEntity(request, bodyTypeRef), param); } } else { + Type type = param.getNestedGenericParameterType(); ParameterizedTypeReference bodyTypeRef = - ParameterizedTypeReference.forType(param.getNestedGenericParameterType()); + ParameterizedTypeReference.forType(GenericTypeResolver.resolveType(type, serviceType)); responseFunction = request -> asOptionalIfNecessary(client.exchangeForBody(request, bodyTypeRef), param); } @@ -486,8 +492,10 @@ final class HttpServiceMethod { /** * Create the {@code ResponseFunction} that matches the method return type. */ - public static ResponseFunction create(ReactorHttpExchangeAdapter client, Method method) { - MethodParameter returnParam = new MethodParameter(method, -1); + public static ResponseFunction create( + ReactorHttpExchangeAdapter client, Method method, Class serviceType) { + + MethodParameter returnParam = new MethodParameter(method, -1).withContainingClass(serviceType); Class returnType = returnParam.getParameterType(); boolean isSuspending = KotlinDetector.isSuspendingFunction(method); boolean hasFlowReturnType = COROUTINES_FLOW_CLASS_NAME.equals(returnType.getName()); @@ -512,18 +520,19 @@ final class HttpServiceMethod { responseFunction = client::exchangeForHeadersMono; } else if (actualType.equals(ResponseEntity.class)) { - MethodParameter bodyParam = isUnwrapped ? actualParam : actualParam.nested(); + MethodParameter bodyParam = (isUnwrapped ? actualParam : actualParam.nested()); Class bodyType = bodyParam.getNestedParameterType(); if (bodyType.equals(Void.class)) { responseFunction = client::exchangeForBodilessEntityMono; } else { ReactiveAdapter bodyAdapter = client.getReactiveAdapterRegistry().getAdapter(bodyType); - responseFunction = initResponseEntityFunction(client, bodyParam, bodyAdapter, isUnwrapped); + responseFunction = initResponseEntityFunction(client, bodyParam, serviceType, bodyAdapter, isUnwrapped); } } else { - responseFunction = initBodyFunction(client, actualParam, reactiveAdapter, isUnwrapped); + responseFunction = initBodyFunction( + client, actualParam, serviceType, reactiveAdapter, isUnwrapped); } return new ReactorExchangeResponseFunction( @@ -532,20 +541,26 @@ final class HttpServiceMethod { @SuppressWarnings("ConstantConditions") private static Function> initResponseEntityFunction( - ReactorHttpExchangeAdapter client, MethodParameter methodParam, + ReactorHttpExchangeAdapter client, MethodParameter methodParam, Class serviceType, @Nullable ReactiveAdapter reactiveAdapter, boolean isUnwrapped) { if (reactiveAdapter == null) { - return request -> client.exchangeForEntityMono( - request, ParameterizedTypeReference.forType(methodParam.getNestedGenericParameterType())); + return request -> { + Type type = methodParam.getNestedGenericParameterType(); + Type resolvedType = GenericTypeResolver.resolveType(type, serviceType); + return client.exchangeForEntityMono(request, ParameterizedTypeReference.forType(resolvedType)); + }; } Assert.isTrue(reactiveAdapter.isMultiValue(), "ResponseEntity body must be a concrete value or a multi-value Publisher"); + Type type = (isUnwrapped ? + methodParam.nested().getGenericParameterType() : + methodParam.nested().getNestedGenericParameterType()); + ParameterizedTypeReference bodyType = - ParameterizedTypeReference.forType(isUnwrapped ? methodParam.nested().getGenericParameterType() : - methodParam.nested().getNestedGenericParameterType()); + ParameterizedTypeReference.forType(GenericTypeResolver.resolveType(type, serviceType)); // Shortcut for Flux if (reactiveAdapter.getReactiveType().equals(Flux.class)) { @@ -562,12 +577,16 @@ final class HttpServiceMethod { } private static Function> initBodyFunction( - ReactorHttpExchangeAdapter client, MethodParameter methodParam, + ReactorHttpExchangeAdapter client, + MethodParameter methodParam, Class serviceType, @Nullable ReactiveAdapter reactiveAdapter, boolean isSuspending) { + Type type = (isSuspending ? + methodParam.getGenericParameterType() : + methodParam.getNestedGenericParameterType()); + ParameterizedTypeReference bodyType = - ParameterizedTypeReference.forType(isSuspending ? methodParam.getGenericParameterType() : - methodParam.getNestedGenericParameterType()); + ParameterizedTypeReference.forType(GenericTypeResolver.resolveType(type, serviceType)); return (reactiveAdapter != null && reactiveAdapter.isMultiValue() ? request -> client.exchangeForBodyFlux(request, bodyType) : diff --git a/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java index a9b11d04abd..95a8d6b8ec4 100644 --- a/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java @@ -200,6 +200,22 @@ class RestClientAdapterTests { assertThat(actualResponse).isEqualTo("Hello Spring 2!"); } + @Test // see gh-36326 + void getBodyWithGenericReturnType() { + prepareResponse(r -> r.setHeader("Content-Type", "application/json").body("{\"name\":\"Karl\"}")); + Person person = initService(PersonClient.class).getBody(); + + assertThat(person.name()).isEqualTo("Karl"); + } + + @Test // see gh-36326 + void getEntityWithGenericReturnType() { + prepareResponse(r -> r.setHeader("Content-Type", "application/json").body("{\"name\":\"Karl\"}")); + ResponseEntity entity = initService(PersonClient.class).getEntity(); + + assertThat(entity.getBody().name()).isEqualTo("Karl"); + } + @ParameterizedAdapterTest void getWithUriBuilderFactory(MockWebServer server, Service service) throws InterruptedException { prepareResponse(builder -> @@ -445,4 +461,19 @@ class RestClientAdapterTests { record Person(String name) { } + + private interface BaseClient { + + @GetExchange + T getBody(); + + @GetExchange + ResponseEntity getEntity(); + } + + + private interface PersonClient extends BaseClient { + + } + } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java index 241a998cbde..aad0fb9ffeb 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java @@ -129,6 +129,23 @@ class WebClientAdapterTests { assertThat(attributes).containsEntry("myAttribute", "myAttributeValue"); } + @Test // see gh-36326 + void getBodyWithGenericReturnType() { + prepareResponse(r -> r.setHeader("Content-Type", "application/json").body("{\"name\":\"Karl\"}")); + Person person = initService(PersonClient.class).getBody(); + + assertThat(person.getName()).isEqualTo("Karl"); + } + + + @Test // see gh-36326 + void getEntityWithGenericReturnType() { + prepareResponse(r -> r.setHeader("Content-Type", "application/json").body("{\"name\":\"Karl\"}")); + ResponseEntity entity = initService(PersonClient.class).getEntity(); + + assertThat(entity.getBody().getName()).isEqualTo("Karl"); + } + @Test // gh-29624 void uri() throws Exception { String expectedBody = "hello"; @@ -360,4 +377,18 @@ class WebClientAdapterTests { } } + + private interface BaseClient { + + @GetExchange + T getBody(); + + @GetExchange + ResponseEntity getEntity(); + } + + + private interface PersonClient extends BaseClient { + } + }