From 977792009dc62f628680ba4dea36112c9188f3ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Fri, 6 Jun 2025 12:27:46 +0200 Subject: [PATCH] Introduce hints in RestClient API This commit introduces hints in RestClient API for SmartHttpMessageConverters supporting them. Closes gh-34924 --- .../web/client/DefaultRestClient.java | 62 +++++++++++++++---- .../web/client/RestClient.java | 22 +++++++ .../web/client/RestClientExtensions.kt | 25 ++++++-- .../client/RestClientIntegrationTests.java | 35 +++++++++++ .../web/client/RestClientExtensionsTests.kt | 18 ++++-- 5 files changed, 141 insertions(+), 21 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java index d39e43bd5f..34d4e83898 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java @@ -214,7 +214,8 @@ final class DefaultRestClient implements RestClient { @SuppressWarnings({"rawtypes", "unchecked"}) private @Nullable T readWithMessageConverters( - ClientHttpResponse clientResponse, Runnable callback, Type bodyType, Class bodyClass) { + ClientHttpResponse clientResponse, Runnable callback, Type bodyType, Class bodyClass, + @Nullable Map hints) { MediaType contentType = getContentType(clientResponse); @@ -241,7 +242,7 @@ final class DefaultRestClient implements RestClient { if (logger.isDebugEnabled()) { logger.debug("Reading to [" + resolvableType + "]"); } - return (T) smartMessageConverter.read(resolvableType, responseWrapper, null); + return (T) smartMessageConverter.read(resolvableType, responseWrapper, hints); } } else if (messageConverter.canRead(bodyClass, contentType)) { @@ -308,6 +309,8 @@ final class DefaultRestClient implements RestClient { private @Nullable Consumer httpRequestConsumer; + private @Nullable Map hints; + public DefaultRequestBodyUriSpec(HttpMethod httpMethod) { this.httpMethod = httpMethod; } @@ -478,6 +481,21 @@ final class DefaultRestClient implements RestClient { return this; } + @Override + public DefaultRequestBodyUriSpec hint(String key, Object value) { + getHints().put(key, value); + return this; + } + + private Map getHints() { + Map hints = this.hints; + if (hints == null) { + hints = new ConcurrentHashMap<>(1); + this.hints = hints; + } + return hints; + } + @SuppressWarnings({"rawtypes", "unchecked"}) private void writeWithMessageConverters(Object body, Type bodyType, ClientHttpRequest clientRequest) throws IOException { @@ -497,7 +515,7 @@ final class DefaultRestClient implements RestClient { ResolvableType resolvableType = ResolvableType.forType(bodyType); if (smartMessageConverter.canWrite(resolvableType, bodyClass, contentType)) { logBody(body, contentType, smartMessageConverter); - smartMessageConverter.write(body, resolvableType, contentType, clientRequest, null); + smartMessageConverter.write(body, resolvableType, contentType, clientRequest, this.hints); return; } } @@ -581,7 +599,7 @@ final class DefaultRestClient implements RestClient { } clientResponse = clientRequest.execute(); observationContext.setResponse(clientResponse); - ConvertibleClientHttpResponse convertibleWrapper = new DefaultConvertibleClientHttpResponse(clientResponse); + ConvertibleClientHttpResponse convertibleWrapper = new DefaultConvertibleClientHttpResponse(clientResponse, this.hints); return exchangeFunction.exchange(clientRequest, convertibleWrapper); } catch (IOException ex) { @@ -745,6 +763,8 @@ final class DefaultRestClient implements RestClient { private final int defaultStatusHandlerCount; + private @Nullable Map hints; + DefaultResponseSpec(RequestHeadersSpec requestHeadersSpec) { this.requestHeadersSpec = requestHeadersSpec; this.statusHandlers.addAll(DefaultRestClient.this.defaultStatusHandlers); @@ -777,14 +797,14 @@ final class DefaultRestClient implements RestClient { @Override public @Nullable T body(Class bodyType) { - return executeAndExtract((request, response) -> readBody(request, response, bodyType, bodyType)); + return executeAndExtract((request, response) -> readBody(request, response, bodyType, bodyType, this.hints)); } @Override public @Nullable T body(ParameterizedTypeReference bodyType) { Type type = bodyType.getType(); Class bodyClass = bodyClass(type); - return executeAndExtract((request, response) -> readBody(request, response, type, bodyClass)); + return executeAndExtract((request, response) -> readBody(request, response, type, bodyClass, this.hints)); } @Override @@ -801,7 +821,7 @@ final class DefaultRestClient implements RestClient { private ResponseEntity toEntityInternal(Type bodyType, Class bodyClass) { ResponseEntity entity = executeAndExtract((request, response) -> { - T body = readBody(request, response, bodyType, bodyClass); + T body = readBody(request, response, bodyType, bodyClass, this.hints); try { return ResponseEntity.status(response.getStatusCode()) .headers(response.getHeaders()) @@ -838,13 +858,28 @@ final class DefaultRestClient implements RestClient { return entity; } + @Override + public ResponseSpec hint(String key, Object value) { + getHints().put(key, value); + return this; + } + + private Map getHints() { + Map hints = this.hints; + if (hints == null) { + hints = new ConcurrentHashMap<>(1); + this.hints = hints; + } + return hints; + } + public @Nullable T executeAndExtract(RequestHeadersSpec.ExchangeFunction exchangeFunction) { return this.requestHeadersSpec.exchange(exchangeFunction); } - private @Nullable T readBody(HttpRequest request, ClientHttpResponse response, Type bodyType, Class bodyClass) { + private @Nullable T readBody(HttpRequest request, ClientHttpResponse response, Type bodyType, Class bodyClass, @Nullable Map hints) { return DefaultRestClient.this.readWithMessageConverters( - response, () -> applyStatusHandlers(request, response), bodyType, bodyClass); + response, () -> applyStatusHandlers(request, response), bodyType, bodyClass, hints); } @@ -871,20 +906,23 @@ final class DefaultRestClient implements RestClient { private final ClientHttpResponse delegate; - public DefaultConvertibleClientHttpResponse(ClientHttpResponse delegate) { + private final @Nullable Map hints; + + public DefaultConvertibleClientHttpResponse(ClientHttpResponse delegate, @Nullable Map hints) { this.delegate = delegate; + this.hints = hints; } @Override public @Nullable T bodyTo(Class bodyType) { - return readWithMessageConverters(this.delegate, () -> {} , bodyType, bodyType); + return readWithMessageConverters(this.delegate, () -> {} , bodyType, bodyType, this.hints); } @Override public @Nullable T bodyTo(ParameterizedTypeReference bodyType) { Type type = bodyType.getType(); Class bodyClass = bodyClass(type); - return readWithMessageConverters(this.delegate, () -> {}, type, bodyClass); + return readWithMessageConverters(this.delegate, () -> {}, type, bodyClass, this.hints); } @Override diff --git a/spring-web/src/main/java/org/springframework/web/client/RestClient.java b/spring-web/src/main/java/org/springframework/web/client/RestClient.java index 9804bf55ba..3f29feda89 100644 --- a/spring-web/src/main/java/org/springframework/web/client/RestClient.java +++ b/spring-web/src/main/java/org/springframework/web/client/RestClient.java @@ -927,6 +927,17 @@ public interface RestClient { * @return this builder */ RequestBodySpec body(StreamingHttpOutputMessage.Body body); + + /** + * Set the hint with the given name to the given value for + * {@link org.springframework.http.converter.SmartHttpMessageConverter}s + * supporting them. + * @param key the key of the hint to add + * @param value the value of the hint to add + * @return this builder + * @since 7.0 + */ + RequestBodySpec hint(String key, Object value); } @@ -1026,6 +1037,17 @@ public interface RestClient { */ ResponseEntity toBodilessEntity(); + /** + * Set the hint with the given name to the given value for + * {@link org.springframework.http.converter.SmartHttpMessageConverter}s + * supporting them. + * @param key the key of the hint to add + * @param value the value of the hint to add + * @return this builder + * @since 7.0 + */ + ResponseSpec hint(String key, Object value); + /** * Used in {@link #onStatus(Predicate, ErrorHandler)}. diff --git a/spring-web/src/main/kotlin/org/springframework/web/client/RestClientExtensions.kt b/spring-web/src/main/kotlin/org/springframework/web/client/RestClientExtensions.kt index 12092af8df..45fac9452a 100644 --- a/spring-web/src/main/kotlin/org/springframework/web/client/RestClientExtensions.kt +++ b/spring-web/src/main/kotlin/org/springframework/web/client/RestClientExtensions.kt @@ -18,17 +18,23 @@ package org.springframework.web.client import org.springframework.core.ParameterizedTypeReference import org.springframework.http.ResponseEntity +import kotlin.reflect.KType +import kotlin.reflect.jvm.jvmName +import kotlin.reflect.typeOf /** * Extension for [RestClient.RequestBodySpec.body] providing a `bodyWithType(...)` variant * leveraging Kotlin reified type parameters. This extension is not subject to type * erasure and retains actual generic type arguments. * + * It also provides a [KType] hint for [org.springframework.http.converter.SmartHttpMessageConverter]s + * supporting them. + * * @author Sebastien Deleuze * @since 6.1 */ inline fun RestClient.RequestBodySpec.bodyWithType(body: T): RestClient.RequestBodySpec = - body(body, object : ParameterizedTypeReference() {}) + hint(KType::class.jvmName, typeOf()).body(body, object : ParameterizedTypeReference() {}) /** @@ -36,28 +42,39 @@ inline fun RestClient.RequestBodySpec.bodyWithType(body: T): R * leveraging Kotlin reified type parameters. This extension is not subject to type * erasure and retains actual generic type arguments. * + * It also provides a [KType] hint for [org.springframework.http.converter.SmartHttpMessageConverter]s + * supporting them. + * * @author Sebastien Deleuze * @since 6.1 */ inline fun RestClient.ResponseSpec.body(): T? = - body(object : ParameterizedTypeReference() {}) + hint(KType::class.jvmName, typeOf()).body(object : ParameterizedTypeReference() {}) /** * Extension for [RestClient.ResponseSpec.body] providing a `requiredBody()` variant with a non-nullable * return value. + * + * It also provides a [KType] hint for [org.springframework.http.converter.SmartHttpMessageConverter]s + * supporting them. + * * @throws NoSuchElementException if there is no response body * @since 6.2 */ inline fun RestClient.ResponseSpec.requiredBody(): T = - body(object : ParameterizedTypeReference() {}) ?: throw NoSuchElementException("Response body is required") + hint(KType::class.jvmName, typeOf()).body(object : ParameterizedTypeReference() {}) ?: + throw NoSuchElementException("Response body is required") /** * Extension for [RestClient.ResponseSpec.toEntity] providing a `toEntity()` variant * leveraging Kotlin reified type parameters. This extension is not subject to type * erasure and retains actual generic type arguments. * + * It also provides a [KType] hint for [org.springframework.http.converter.SmartHttpMessageConverter]s + * supporting them. + * * @author Sebastien Deleuze * @since 6.1 */ inline fun RestClient.ResponseSpec.toEntity(): ResponseEntity = - toEntity(object : ParameterizedTypeReference() {}) \ No newline at end of file + hint(KType::class.jvmName, typeOf()).toEntity(object : ParameterizedTypeReference() {}) \ No newline at end of file diff --git a/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java b/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java index dced302e5b..a0d7943ba4 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java @@ -28,9 +28,11 @@ import java.util.Map; import java.util.function.Consumer; import java.util.stream.Stream; +import com.fasterxml.jackson.annotation.JsonView; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -528,6 +530,35 @@ class RestClientIntegrationTests { }); } + @ParameterizedRestClientTest + void postUserAsJsonWithJsonView(ClientHttpRequestFactory requestFactory) { + startServer(requestFactory); + + prepareResponse(response -> response.setHeader("Content-Type", "application/json") + .setBody("{\"username\":\"USERNAME\"}")); + + User result = this.restClient.post() + .uri("/user/capitalize") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .hint(JsonView.class.getName(), PublicView.class) + .body(new User("username", "password")) + .retrieve() + .body(User.class); + + assertThat(result).isNotNull(); + assertThat(result.username()).isEqualTo("USERNAME"); + assertThat(result.password()).isNull(); + + expectRequestCount(1); + expectRequest(request -> { + assertThat(request.getPath()).isEqualTo("/user/capitalize"); + assertThat(request.getBody().readUtf8()).isEqualTo("{\"username\":\"username\"}"); + assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json"); + }); + } + @ParameterizedRestClientTest // gh-31361 public void postForm(ClientHttpRequestFactory requestFactory) { startServer(requestFactory); @@ -1150,4 +1181,8 @@ class RestClientIntegrationTests { } } + interface PublicView {} + + record User(@JsonView(PublicView.class) String username, @Nullable String password) {} + } diff --git a/spring-web/src/test/kotlin/org/springframework/web/client/RestClientExtensionsTests.kt b/spring-web/src/test/kotlin/org/springframework/web/client/RestClientExtensionsTests.kt index 6e91590166..d11d976669 100644 --- a/spring-web/src/test/kotlin/org/springframework/web/client/RestClientExtensionsTests.kt +++ b/spring-web/src/test/kotlin/org/springframework/web/client/RestClientExtensionsTests.kt @@ -22,6 +22,9 @@ import io.mockk.verify import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.springframework.core.ParameterizedTypeReference +import kotlin.reflect.KType +import kotlin.reflect.jvm.jvmName +import kotlin.reflect.typeOf /** * Mock object based tests for [RestClient] Kotlin extensions @@ -38,31 +41,36 @@ class RestClientExtensionsTests { fun `RequestBodySpec#body with reified type parameters`() { val body = mockk>() requestBodySpec.bodyWithType(body) - verify { requestBodySpec.body(body, object : ParameterizedTypeReference>() {}) } + verify { requestBodySpec.hint(KType::class.jvmName, typeOf>()) + .body(body, object : ParameterizedTypeReference>() {}) + } } @Test fun `ResponseSpec#body with reified type parameters`() { responseSpec.body>() - verify { responseSpec.body(object : ParameterizedTypeReference>() {}) } + verify { responseSpec.hint(KType::class.jvmName, typeOf>()) + .body(object : ParameterizedTypeReference>() {}) } } @Test fun `ResponseSpec#requiredBody with reified type parameters`() { responseSpec.requiredBody>() - verify { responseSpec.body(object : ParameterizedTypeReference>() {}) } + verify { responseSpec.hint(KType::class.jvmName, typeOf>()) + .body(object : ParameterizedTypeReference>() {}) } } @Test fun `ResponseSpec#requiredBody with null response throws NoSuchElementException`() { - every { responseSpec.body(any>()) } returns null + every { responseSpec.hint(KType::class.jvmName, any()).body(any>()) } returns null assertThrows { responseSpec.requiredBody() } } @Test fun `ResponseSpec#toEntity with reified type parameters`() { responseSpec.toEntity>() - verify { responseSpec.toEntity(object : ParameterizedTypeReference>() {}) } + verify { responseSpec.hint(KType::class.jvmName, typeOf>()) + .toEntity(object : ParameterizedTypeReference>() {}) } } private class Foo