Skip to content

Commit 9777920

Browse files
committed
Introduce hints in RestClient API
This commit introduces hints in RestClient API for SmartHttpMessageConverters supporting them. Closes gh-34924
1 parent 72601b6 commit 9777920

File tree

5 files changed

+141
-21
lines changed

5 files changed

+141
-21
lines changed

spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,8 @@ public Builder mutate() {
214214

215215
@SuppressWarnings({"rawtypes", "unchecked"})
216216
private <T> @Nullable T readWithMessageConverters(
217-
ClientHttpResponse clientResponse, Runnable callback, Type bodyType, Class<T> bodyClass) {
217+
ClientHttpResponse clientResponse, Runnable callback, Type bodyType, Class<T> bodyClass,
218+
@Nullable Map<String, Object> hints) {
218219

219220
MediaType contentType = getContentType(clientResponse);
220221

@@ -241,7 +242,7 @@ else if (messageConverter instanceof SmartHttpMessageConverter smartMessageConve
241242
if (logger.isDebugEnabled()) {
242243
logger.debug("Reading to [" + resolvableType + "]");
243244
}
244-
return (T) smartMessageConverter.read(resolvableType, responseWrapper, null);
245+
return (T) smartMessageConverter.read(resolvableType, responseWrapper, hints);
245246
}
246247
}
247248
else if (messageConverter.canRead(bodyClass, contentType)) {
@@ -308,6 +309,8 @@ private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec {
308309

309310
private @Nullable Consumer<ClientHttpRequest> httpRequestConsumer;
310311

312+
private @Nullable Map<String, Object> hints;
313+
311314
public DefaultRequestBodyUriSpec(HttpMethod httpMethod) {
312315
this.httpMethod = httpMethod;
313316
}
@@ -478,6 +481,21 @@ public RequestBodySpec body(StreamingHttpOutputMessage.Body body) {
478481
return this;
479482
}
480483

484+
@Override
485+
public DefaultRequestBodyUriSpec hint(String key, Object value) {
486+
getHints().put(key, value);
487+
return this;
488+
}
489+
490+
private Map<String, Object> getHints() {
491+
Map<String, Object> hints = this.hints;
492+
if (hints == null) {
493+
hints = new ConcurrentHashMap<>(1);
494+
this.hints = hints;
495+
}
496+
return hints;
497+
}
498+
481499
@SuppressWarnings({"rawtypes", "unchecked"})
482500
private void writeWithMessageConverters(Object body, Type bodyType, ClientHttpRequest clientRequest)
483501
throws IOException {
@@ -497,7 +515,7 @@ else if (messageConverter instanceof SmartHttpMessageConverter smartMessageConve
497515
ResolvableType resolvableType = ResolvableType.forType(bodyType);
498516
if (smartMessageConverter.canWrite(resolvableType, bodyClass, contentType)) {
499517
logBody(body, contentType, smartMessageConverter);
500-
smartMessageConverter.write(body, resolvableType, contentType, clientRequest, null);
518+
smartMessageConverter.write(body, resolvableType, contentType, clientRequest, this.hints);
501519
return;
502520
}
503521
}
@@ -581,7 +599,7 @@ public <T> T exchangeForRequiredValue(RequiredValueExchangeFunction<T> exchangeF
581599
}
582600
clientResponse = clientRequest.execute();
583601
observationContext.setResponse(clientResponse);
584-
ConvertibleClientHttpResponse convertibleWrapper = new DefaultConvertibleClientHttpResponse(clientResponse);
602+
ConvertibleClientHttpResponse convertibleWrapper = new DefaultConvertibleClientHttpResponse(clientResponse, this.hints);
585603
return exchangeFunction.exchange(clientRequest, convertibleWrapper);
586604
}
587605
catch (IOException ex) {
@@ -745,6 +763,8 @@ private class DefaultResponseSpec implements ResponseSpec {
745763

746764
private final int defaultStatusHandlerCount;
747765

766+
private @Nullable Map<String, Object> hints;
767+
748768
DefaultResponseSpec(RequestHeadersSpec<?> requestHeadersSpec) {
749769
this.requestHeadersSpec = requestHeadersSpec;
750770
this.statusHandlers.addAll(DefaultRestClient.this.defaultStatusHandlers);
@@ -777,14 +797,14 @@ private ResponseSpec onStatusInternal(StatusHandler statusHandler) {
777797

778798
@Override
779799
public <T> @Nullable T body(Class<T> bodyType) {
780-
return executeAndExtract((request, response) -> readBody(request, response, bodyType, bodyType));
800+
return executeAndExtract((request, response) -> readBody(request, response, bodyType, bodyType, this.hints));
781801
}
782802

783803
@Override
784804
public <T> @Nullable T body(ParameterizedTypeReference<T> bodyType) {
785805
Type type = bodyType.getType();
786806
Class<T> bodyClass = bodyClass(type);
787-
return executeAndExtract((request, response) -> readBody(request, response, type, bodyClass));
807+
return executeAndExtract((request, response) -> readBody(request, response, type, bodyClass, this.hints));
788808
}
789809

790810
@Override
@@ -801,7 +821,7 @@ public <T> ResponseEntity<T> toEntity(ParameterizedTypeReference<T> bodyType) {
801821

802822
private <T> ResponseEntity<T> toEntityInternal(Type bodyType, Class<T> bodyClass) {
803823
ResponseEntity<T> entity = executeAndExtract((request, response) -> {
804-
T body = readBody(request, response, bodyType, bodyClass);
824+
T body = readBody(request, response, bodyType, bodyClass, this.hints);
805825
try {
806826
return ResponseEntity.status(response.getStatusCode())
807827
.headers(response.getHeaders())
@@ -838,13 +858,28 @@ public ResponseEntity<Void> toBodilessEntity() {
838858
return entity;
839859
}
840860

861+
@Override
862+
public ResponseSpec hint(String key, Object value) {
863+
getHints().put(key, value);
864+
return this;
865+
}
866+
867+
private Map<String, Object> getHints() {
868+
Map<String, Object> hints = this.hints;
869+
if (hints == null) {
870+
hints = new ConcurrentHashMap<>(1);
871+
this.hints = hints;
872+
}
873+
return hints;
874+
}
875+
841876
public <T> @Nullable T executeAndExtract(RequestHeadersSpec.ExchangeFunction<T> exchangeFunction) {
842877
return this.requestHeadersSpec.exchange(exchangeFunction);
843878
}
844879

845-
private <T> @Nullable T readBody(HttpRequest request, ClientHttpResponse response, Type bodyType, Class<T> bodyClass) {
880+
private <T> @Nullable T readBody(HttpRequest request, ClientHttpResponse response, Type bodyType, Class<T> bodyClass, @Nullable Map<String, Object> hints) {
846881
return DefaultRestClient.this.readWithMessageConverters(
847-
response, () -> applyStatusHandlers(request, response), bodyType, bodyClass);
882+
response, () -> applyStatusHandlers(request, response), bodyType, bodyClass, hints);
848883

849884
}
850885

@@ -871,20 +906,23 @@ private class DefaultConvertibleClientHttpResponse implements RequestHeadersSpec
871906

872907
private final ClientHttpResponse delegate;
873908

874-
public DefaultConvertibleClientHttpResponse(ClientHttpResponse delegate) {
909+
private final @Nullable Map<String, Object> hints;
910+
911+
public DefaultConvertibleClientHttpResponse(ClientHttpResponse delegate, @Nullable Map<String, Object> hints) {
875912
this.delegate = delegate;
913+
this.hints = hints;
876914
}
877915

878916
@Override
879917
public <T> @Nullable T bodyTo(Class<T> bodyType) {
880-
return readWithMessageConverters(this.delegate, () -> {} , bodyType, bodyType);
918+
return readWithMessageConverters(this.delegate, () -> {} , bodyType, bodyType, this.hints);
881919
}
882920

883921
@Override
884922
public <T> @Nullable T bodyTo(ParameterizedTypeReference<T> bodyType) {
885923
Type type = bodyType.getType();
886924
Class<T> bodyClass = bodyClass(type);
887-
return readWithMessageConverters(this.delegate, () -> {}, type, bodyClass);
925+
return readWithMessageConverters(this.delegate, () -> {}, type, bodyClass, this.hints);
888926
}
889927

890928
@Override

spring-web/src/main/java/org/springframework/web/client/RestClient.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,17 @@ interface RequestBodySpec extends RequestHeadersSpec<RequestBodySpec> {
927927
* @return this builder
928928
*/
929929
RequestBodySpec body(StreamingHttpOutputMessage.Body body);
930+
931+
/**
932+
* Set the hint with the given name to the given value for
933+
* {@link org.springframework.http.converter.SmartHttpMessageConverter}s
934+
* supporting them.
935+
* @param key the key of the hint to add
936+
* @param value the value of the hint to add
937+
* @return this builder
938+
* @since 7.0
939+
*/
940+
RequestBodySpec hint(String key, Object value);
930941
}
931942

932943

@@ -1026,6 +1037,17 @@ ResponseSpec onStatus(Predicate<HttpStatusCode> statusPredicate,
10261037
*/
10271038
ResponseEntity<Void> toBodilessEntity();
10281039

1040+
/**
1041+
* Set the hint with the given name to the given value for
1042+
* {@link org.springframework.http.converter.SmartHttpMessageConverter}s
1043+
* supporting them.
1044+
* @param key the key of the hint to add
1045+
* @param value the value of the hint to add
1046+
* @return this builder
1047+
* @since 7.0
1048+
*/
1049+
ResponseSpec hint(String key, Object value);
1050+
10291051

10301052
/**
10311053
* Used in {@link #onStatus(Predicate, ErrorHandler)}.

spring-web/src/main/kotlin/org/springframework/web/client/RestClientExtensions.kt

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,46 +18,63 @@ package org.springframework.web.client
1818

1919
import org.springframework.core.ParameterizedTypeReference
2020
import org.springframework.http.ResponseEntity
21+
import kotlin.reflect.KType
22+
import kotlin.reflect.jvm.jvmName
23+
import kotlin.reflect.typeOf
2124

2225
/**
2326
* Extension for [RestClient.RequestBodySpec.body] providing a `bodyWithType<Foo>(...)` variant
2427
* leveraging Kotlin reified type parameters. This extension is not subject to type
2528
* erasure and retains actual generic type arguments.
2629
*
30+
* It also provides a [KType] hint for [org.springframework.http.converter.SmartHttpMessageConverter]s
31+
* supporting them.
32+
*
2733
* @author Sebastien Deleuze
2834
* @since 6.1
2935
*/
3036
inline fun <reified T : Any> RestClient.RequestBodySpec.bodyWithType(body: T): RestClient.RequestBodySpec =
31-
body(body, object : ParameterizedTypeReference<T>() {})
37+
hint(KType::class.jvmName, typeOf<T>()).body(body, object : ParameterizedTypeReference<T>() {})
3238

3339

3440
/**
3541
* Extension for [RestClient.ResponseSpec.body] providing a `body<Foo>()` variant
3642
* leveraging Kotlin reified type parameters. This extension is not subject to type
3743
* erasure and retains actual generic type arguments.
3844
*
45+
* It also provides a [KType] hint for [org.springframework.http.converter.SmartHttpMessageConverter]s
46+
* supporting them.
47+
*
3948
* @author Sebastien Deleuze
4049
* @since 6.1
4150
*/
4251
inline fun <reified T : Any> RestClient.ResponseSpec.body(): T? =
43-
body(object : ParameterizedTypeReference<T>() {})
52+
hint(KType::class.jvmName, typeOf<T>()).body(object : ParameterizedTypeReference<T>() {})
4453

4554
/**
4655
* Extension for [RestClient.ResponseSpec.body] providing a `requiredBody<Foo>()` variant with a non-nullable
4756
* return value.
57+
*
58+
* It also provides a [KType] hint for [org.springframework.http.converter.SmartHttpMessageConverter]s
59+
* supporting them.
60+
*
4861
* @throws NoSuchElementException if there is no response body
4962
* @since 6.2
5063
*/
5164
inline fun <reified T : Any> RestClient.ResponseSpec.requiredBody(): T =
52-
body(object : ParameterizedTypeReference<T>() {}) ?: throw NoSuchElementException("Response body is required")
65+
hint(KType::class.jvmName, typeOf<T>()).body(object : ParameterizedTypeReference<T>() {}) ?:
66+
throw NoSuchElementException("Response body is required")
5367

5468
/**
5569
* Extension for [RestClient.ResponseSpec.toEntity] providing a `toEntity<Foo>()` variant
5670
* leveraging Kotlin reified type parameters. This extension is not subject to type
5771
* erasure and retains actual generic type arguments.
5872
*
73+
* It also provides a [KType] hint for [org.springframework.http.converter.SmartHttpMessageConverter]s
74+
* supporting them.
75+
*
5976
* @author Sebastien Deleuze
6077
* @since 6.1
6178
*/
6279
inline fun <reified T : Any> RestClient.ResponseSpec.toEntity(): ResponseEntity<T> =
63-
toEntity(object : ParameterizedTypeReference<T>() {})
80+
hint(KType::class.jvmName, typeOf<T>()).toEntity(object : ParameterizedTypeReference<T>() {})

spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@
2828
import java.util.function.Consumer;
2929
import java.util.stream.Stream;
3030

31+
import com.fasterxml.jackson.annotation.JsonView;
3132
import okhttp3.mockwebserver.MockResponse;
3233
import okhttp3.mockwebserver.MockWebServer;
3334
import okhttp3.mockwebserver.RecordedRequest;
35+
import org.jspecify.annotations.Nullable;
3436
import org.junit.jupiter.api.AfterEach;
3537
import org.junit.jupiter.params.ParameterizedTest;
3638
import org.junit.jupiter.params.provider.Arguments;
@@ -528,6 +530,35 @@ void postPojoAsJson(ClientHttpRequestFactory requestFactory) {
528530
});
529531
}
530532

533+
@ParameterizedRestClientTest
534+
void postUserAsJsonWithJsonView(ClientHttpRequestFactory requestFactory) {
535+
startServer(requestFactory);
536+
537+
prepareResponse(response -> response.setHeader("Content-Type", "application/json")
538+
.setBody("{\"username\":\"USERNAME\"}"));
539+
540+
User result = this.restClient.post()
541+
.uri("/user/capitalize")
542+
.accept(MediaType.APPLICATION_JSON)
543+
.contentType(MediaType.APPLICATION_JSON)
544+
.hint(JsonView.class.getName(), PublicView.class)
545+
.body(new User("username", "password"))
546+
.retrieve()
547+
.body(User.class);
548+
549+
assertThat(result).isNotNull();
550+
assertThat(result.username()).isEqualTo("USERNAME");
551+
assertThat(result.password()).isNull();
552+
553+
expectRequestCount(1);
554+
expectRequest(request -> {
555+
assertThat(request.getPath()).isEqualTo("/user/capitalize");
556+
assertThat(request.getBody().readUtf8()).isEqualTo("{\"username\":\"username\"}");
557+
assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json");
558+
assertThat(request.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json");
559+
});
560+
}
561+
531562
@ParameterizedRestClientTest // gh-31361
532563
public void postForm(ClientHttpRequestFactory requestFactory) {
533564
startServer(requestFactory);
@@ -1150,4 +1181,8 @@ public void setContainerValue(T containerValue) {
11501181
}
11511182
}
11521183

1184+
interface PublicView {}
1185+
1186+
record User(@JsonView(PublicView.class) String username, @Nullable String password) {}
1187+
11531188
}

spring-web/src/test/kotlin/org/springframework/web/client/RestClientExtensionsTests.kt

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import io.mockk.verify
2222
import org.junit.jupiter.api.Test
2323
import org.junit.jupiter.api.assertThrows
2424
import org.springframework.core.ParameterizedTypeReference
25+
import kotlin.reflect.KType
26+
import kotlin.reflect.jvm.jvmName
27+
import kotlin.reflect.typeOf
2528

2629
/**
2730
* Mock object based tests for [RestClient] Kotlin extensions
@@ -38,31 +41,36 @@ class RestClientExtensionsTests {
3841
fun `RequestBodySpec#body with reified type parameters`() {
3942
val body = mockk<List<Foo>>()
4043
requestBodySpec.bodyWithType(body)
41-
verify { requestBodySpec.body(body, object : ParameterizedTypeReference<List<Foo>>() {}) }
44+
verify { requestBodySpec.hint(KType::class.jvmName, typeOf<List<Foo>>())
45+
.body(body, object : ParameterizedTypeReference<List<Foo>>() {})
46+
}
4247
}
4348

4449
@Test
4550
fun `ResponseSpec#body with reified type parameters`() {
4651
responseSpec.body<List<Foo>>()
47-
verify { responseSpec.body(object : ParameterizedTypeReference<List<Foo>>() {}) }
52+
verify { responseSpec.hint(KType::class.jvmName, typeOf<List<Foo>>())
53+
.body(object : ParameterizedTypeReference<List<Foo>>() {}) }
4854
}
4955

5056
@Test
5157
fun `ResponseSpec#requiredBody with reified type parameters`() {
5258
responseSpec.requiredBody<List<Foo>>()
53-
verify { responseSpec.body(object : ParameterizedTypeReference<List<Foo>>() {}) }
59+
verify { responseSpec.hint(KType::class.jvmName, typeOf<List<Foo>>())
60+
.body(object : ParameterizedTypeReference<List<Foo>>() {}) }
5461
}
5562

5663
@Test
5764
fun `ResponseSpec#requiredBody with null response throws NoSuchElementException`() {
58-
every { responseSpec.body(any<ParameterizedTypeReference<Foo>>()) } returns null
65+
every { responseSpec.hint(KType::class.jvmName, any()).body(any<ParameterizedTypeReference<Foo>>()) } returns null
5966
assertThrows<NoSuchElementException> { responseSpec.requiredBody<Foo>() }
6067
}
6168

6269
@Test
6370
fun `ResponseSpec#toEntity with reified type parameters`() {
6471
responseSpec.toEntity<List<Foo>>()
65-
verify { responseSpec.toEntity(object : ParameterizedTypeReference<List<Foo>>() {}) }
72+
verify { responseSpec.hint(KType::class.jvmName, typeOf<List<Foo>>())
73+
.toEntity(object : ParameterizedTypeReference<List<Foo>>() {}) }
6674
}
6775

6876
private class Foo

0 commit comments

Comments
 (0)