Introduce hints in RestClient API
This commit introduces hints in RestClient API for SmartHttpMessageConverters supporting them. Closes gh-34924
This commit is contained in:
@@ -214,7 +214,8 @@ final class DefaultRestClient implements RestClient {
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
private <T> @Nullable T readWithMessageConverters(
|
||||
ClientHttpResponse clientResponse, Runnable callback, Type bodyType, Class<T> bodyClass) {
|
||||
ClientHttpResponse clientResponse, Runnable callback, Type bodyType, Class<T> bodyClass,
|
||||
@Nullable Map<String, Object> 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<ClientHttpRequest> httpRequestConsumer;
|
||||
|
||||
private @Nullable Map<String, Object> 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<String, Object> getHints() {
|
||||
Map<String, Object> 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<String, Object> hints;
|
||||
|
||||
DefaultResponseSpec(RequestHeadersSpec<?> requestHeadersSpec) {
|
||||
this.requestHeadersSpec = requestHeadersSpec;
|
||||
this.statusHandlers.addAll(DefaultRestClient.this.defaultStatusHandlers);
|
||||
@@ -777,14 +797,14 @@ final class DefaultRestClient implements RestClient {
|
||||
|
||||
@Override
|
||||
public <T> @Nullable T body(Class<T> bodyType) {
|
||||
return executeAndExtract((request, response) -> readBody(request, response, bodyType, bodyType));
|
||||
return executeAndExtract((request, response) -> readBody(request, response, bodyType, bodyType, this.hints));
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> @Nullable T body(ParameterizedTypeReference<T> bodyType) {
|
||||
Type type = bodyType.getType();
|
||||
Class<T> 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 <T> ResponseEntity<T> toEntityInternal(Type bodyType, Class<T> bodyClass) {
|
||||
ResponseEntity<T> 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<String, Object> getHints() {
|
||||
Map<String, Object> hints = this.hints;
|
||||
if (hints == null) {
|
||||
hints = new ConcurrentHashMap<>(1);
|
||||
this.hints = hints;
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
public <T> @Nullable T executeAndExtract(RequestHeadersSpec.ExchangeFunction<T> exchangeFunction) {
|
||||
return this.requestHeadersSpec.exchange(exchangeFunction);
|
||||
}
|
||||
|
||||
private <T> @Nullable T readBody(HttpRequest request, ClientHttpResponse response, Type bodyType, Class<T> bodyClass) {
|
||||
private <T> @Nullable T readBody(HttpRequest request, ClientHttpResponse response, Type bodyType, Class<T> bodyClass, @Nullable Map<String, Object> 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<String, Object> hints;
|
||||
|
||||
public DefaultConvertibleClientHttpResponse(ClientHttpResponse delegate, @Nullable Map<String, Object> hints) {
|
||||
this.delegate = delegate;
|
||||
this.hints = hints;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> @Nullable T bodyTo(Class<T> bodyType) {
|
||||
return readWithMessageConverters(this.delegate, () -> {} , bodyType, bodyType);
|
||||
return readWithMessageConverters(this.delegate, () -> {} , bodyType, bodyType, this.hints);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> @Nullable T bodyTo(ParameterizedTypeReference<T> bodyType) {
|
||||
Type type = bodyType.getType();
|
||||
Class<T> bodyClass = bodyClass(type);
|
||||
return readWithMessageConverters(this.delegate, () -> {}, type, bodyClass);
|
||||
return readWithMessageConverters(this.delegate, () -> {}, type, bodyClass, this.hints);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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<Void> 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)}.
|
||||
|
||||
@@ -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<Foo>(...)` 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 <reified T : Any> RestClient.RequestBodySpec.bodyWithType(body: T): RestClient.RequestBodySpec =
|
||||
body(body, object : ParameterizedTypeReference<T>() {})
|
||||
hint(KType::class.jvmName, typeOf<T>()).body(body, object : ParameterizedTypeReference<T>() {})
|
||||
|
||||
|
||||
/**
|
||||
@@ -36,28 +42,39 @@ inline fun <reified T : Any> 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 <reified T : Any> RestClient.ResponseSpec.body(): T? =
|
||||
body(object : ParameterizedTypeReference<T>() {})
|
||||
hint(KType::class.jvmName, typeOf<T>()).body(object : ParameterizedTypeReference<T>() {})
|
||||
|
||||
/**
|
||||
* Extension for [RestClient.ResponseSpec.body] providing a `requiredBody<Foo>()` 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 <reified T : Any> RestClient.ResponseSpec.requiredBody(): T =
|
||||
body(object : ParameterizedTypeReference<T>() {}) ?: throw NoSuchElementException("Response body is required")
|
||||
hint(KType::class.jvmName, typeOf<T>()).body(object : ParameterizedTypeReference<T>() {}) ?:
|
||||
throw NoSuchElementException("Response body is required")
|
||||
|
||||
/**
|
||||
* Extension for [RestClient.ResponseSpec.toEntity] providing a `toEntity<Foo>()` 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 <reified T : Any> RestClient.ResponseSpec.toEntity(): ResponseEntity<T> =
|
||||
toEntity(object : ParameterizedTypeReference<T>() {})
|
||||
hint(KType::class.jvmName, typeOf<T>()).toEntity(object : ParameterizedTypeReference<T>() {})
|
||||
@@ -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) {}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<List<Foo>>()
|
||||
requestBodySpec.bodyWithType(body)
|
||||
verify { requestBodySpec.body(body, object : ParameterizedTypeReference<List<Foo>>() {}) }
|
||||
verify { requestBodySpec.hint(KType::class.jvmName, typeOf<List<Foo>>())
|
||||
.body(body, object : ParameterizedTypeReference<List<Foo>>() {})
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ResponseSpec#body with reified type parameters`() {
|
||||
responseSpec.body<List<Foo>>()
|
||||
verify { responseSpec.body(object : ParameterizedTypeReference<List<Foo>>() {}) }
|
||||
verify { responseSpec.hint(KType::class.jvmName, typeOf<List<Foo>>())
|
||||
.body(object : ParameterizedTypeReference<List<Foo>>() {}) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ResponseSpec#requiredBody with reified type parameters`() {
|
||||
responseSpec.requiredBody<List<Foo>>()
|
||||
verify { responseSpec.body(object : ParameterizedTypeReference<List<Foo>>() {}) }
|
||||
verify { responseSpec.hint(KType::class.jvmName, typeOf<List<Foo>>())
|
||||
.body(object : ParameterizedTypeReference<List<Foo>>() {}) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ResponseSpec#requiredBody with null response throws NoSuchElementException`() {
|
||||
every { responseSpec.body(any<ParameterizedTypeReference<Foo>>()) } returns null
|
||||
every { responseSpec.hint(KType::class.jvmName, any()).body(any<ParameterizedTypeReference<Foo>>()) } returns null
|
||||
assertThrows<NoSuchElementException> { responseSpec.requiredBody<Foo>() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ResponseSpec#toEntity with reified type parameters`() {
|
||||
responseSpec.toEntity<List<Foo>>()
|
||||
verify { responseSpec.toEntity(object : ParameterizedTypeReference<List<Foo>>() {}) }
|
||||
verify { responseSpec.hint(KType::class.jvmName, typeOf<List<Foo>>())
|
||||
.toEntity(object : ParameterizedTypeReference<List<Foo>>() {}) }
|
||||
}
|
||||
|
||||
private class Foo
|
||||
|
||||
Reference in New Issue
Block a user