Skip to content

Commit b94ab90

Browse files
committed
Merge branch '6.2.x'
2 parents dd888ed + 671d972 commit b94ab90

File tree

5 files changed

+128
-45
lines changed

5 files changed

+128
-45
lines changed

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

+7
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,13 @@ public ResponseSpec retrieve() {
537537
return exchangeInternal(exchangeFunction, close);
538538
}
539539

540+
@Override
541+
public <T> T exchangeForRequiredValue(RequiredValueExchangeFunction<T> exchangeFunction, boolean close) {
542+
T value = exchangeInternal(exchangeFunction, close);
543+
Assert.state(value != null, "The exchanged value must not be null");
544+
return value;
545+
}
546+
540547
private <T> @Nullable T exchangeInternal(ExchangeFunction<T> exchangeFunction, boolean close) {
541548
Assert.notNull(exchangeFunction, "ExchangeFunction must not be null");
542549

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

+83-8
Original file line numberDiff line numberDiff line change
@@ -679,8 +679,8 @@ interface RequestHeadersSpec<S extends RequestHeadersSpec<S>> {
679679
ResponseSpec retrieve();
680680

681681
/**
682-
* Exchange the {@link ClientHttpResponse} for a type {@code T}. This
683-
* can be useful for advanced scenarios, for example to decode the
682+
* Exchange the {@link ClientHttpResponse} for a value of type {@code T}.
683+
* This can be useful for advanced scenarios, for example to decode the
684684
* response differently depending on the response status:
685685
* <pre class="code">
686686
* Person person = client.get()
@@ -700,15 +700,44 @@ interface RequestHeadersSpec<S extends RequestHeadersSpec<S>> {
700700
* function has been invoked.
701701
* @param exchangeFunction the function to handle the response with
702702
* @param <T> the type the response will be transformed to
703-
* @return the value returned from the exchange function
703+
* @return the value returned from the exchange function, potentially {@code null}
704+
* @see RequestHeadersSpec#exchangeForRequiredValue(RequiredValueExchangeFunction)
704705
*/
705706
default <T> @Nullable T exchange(ExchangeFunction<T> exchangeFunction) {
706707
return exchange(exchangeFunction, true);
707708
}
708709

709710
/**
710-
* Exchange the {@link ClientHttpResponse} for a type {@code T}. This
711-
* can be useful for advanced scenarios, for example to decode the
711+
* Exchange the {@link ClientHttpResponse} for a value of type {@code T}.
712+
* This can be useful for advanced scenarios, for example to decode the
713+
* response differently depending on the response status:
714+
* <pre class="code">
715+
* Person person = client.get()
716+
* .uri("/people/1")
717+
* .accept(MediaType.APPLICATION_JSON)
718+
* .exchange((request, response) -&gt; {
719+
* if (response.getStatusCode().equals(HttpStatus.OK)) {
720+
* return deserialize(response.getBody());
721+
* }
722+
* else {
723+
* throw new BusinessException();
724+
* }
725+
* });
726+
* </pre>
727+
* <p><strong>Note:</strong> The response is
728+
* {@linkplain ClientHttpResponse#close() closed} after the exchange
729+
* function has been invoked.
730+
* @param exchangeFunction the function to handle the response with
731+
* @param <T> the type the response will be transformed to
732+
* @return the value returned from the exchange function, never {@code null}
733+
*/
734+
default <T> T exchangeForRequiredValue(RequiredValueExchangeFunction<T> exchangeFunction) {
735+
return exchangeForRequiredValue(exchangeFunction, true);
736+
}
737+
738+
/**
739+
* Exchange the {@link ClientHttpResponse} for a value of type {@code T}.
740+
* This can be useful for advanced scenarios, for example to decode the
712741
* response differently depending on the response status:
713742
* <pre class="code">
714743
* Person person = client.get()
@@ -731,10 +760,40 @@ interface RequestHeadersSpec<S extends RequestHeadersSpec<S>> {
731760
* @param close {@code true} to close the response after
732761
* {@code exchangeFunction} is invoked, {@code false} to keep it open
733762
* @param <T> the type the response will be transformed to
734-
* @return the value returned from the exchange function
763+
* @return the value returned from the exchange function, potentially {@code null}
764+
* @see RequestHeadersSpec#exchangeForRequiredValue(RequiredValueExchangeFunction, boolean)
735765
*/
736766
<T> @Nullable T exchange(ExchangeFunction<T> exchangeFunction, boolean close);
737767

768+
/**
769+
* Exchange the {@link ClientHttpResponse} for a value of type {@code T}.
770+
* This can be useful for advanced scenarios, for example to decode the
771+
* response differently depending on the response status:
772+
* <pre class="code">
773+
* Person person = client.get()
774+
* .uri("/people/1")
775+
* .accept(MediaType.APPLICATION_JSON)
776+
* .exchange((request, response) -&gt; {
777+
* if (response.getStatusCode().equals(HttpStatus.OK)) {
778+
* return deserialize(response.getBody());
779+
* }
780+
* else {
781+
* throw new BusinessException();
782+
* }
783+
* });
784+
* </pre>
785+
* <p><strong>Note:</strong> If {@code close} is {@code true},
786+
* then the response is {@linkplain ClientHttpResponse#close() closed}
787+
* after the exchange function has been invoked. When set to
788+
* {@code false}, the caller is responsible for closing the response.
789+
* @param exchangeFunction the function to handle the response with
790+
* @param close {@code true} to close the response after
791+
* {@code exchangeFunction} is invoked, {@code false} to keep it open
792+
* @param <T> the type the response will be transformed to
793+
* @return the value returned from the exchange function, never {@code null}
794+
*/
795+
<T> T exchangeForRequiredValue(RequiredValueExchangeFunction<T> exchangeFunction, boolean close);
796+
738797

739798
/**
740799
* Defines the contract for {@link #exchange(ExchangeFunction)}.
@@ -744,15 +803,31 @@ interface RequestHeadersSpec<S extends RequestHeadersSpec<S>> {
744803
interface ExchangeFunction<T> {
745804

746805
/**
747-
* Exchange the given response into a type {@code T}.
806+
* Exchange the given response into a value of type {@code T}.
748807
* @param clientRequest the request
749808
* @param clientResponse the response
750-
* @return the exchanged type
809+
* @return the exchanged value, potentially {@code null}
751810
* @throws IOException in case of I/O errors
752811
*/
753812
@Nullable T exchange(HttpRequest clientRequest, ConvertibleClientHttpResponse clientResponse) throws IOException;
754813
}
755814

815+
/**
816+
* Variant of {@link ExchangeFunction} returning a non-null required value.
817+
* @param <T> the type the response will be transformed to
818+
*/
819+
@FunctionalInterface
820+
interface RequiredValueExchangeFunction<T> extends ExchangeFunction<T> {
821+
822+
/**
823+
* Exchange the given response into a value of type {@code T}.
824+
* @param clientRequest the request
825+
* @param clientResponse the response
826+
* @return the exchanged value, never {@code null}
827+
* @throws IOException in case of I/O errors
828+
*/
829+
T exchange(HttpRequest clientRequest, ConvertibleClientHttpResponse clientResponse) throws IOException;
830+
}
756831

757832
/**
758833
* Extension of {@link ClientHttpResponse} that can convert the body.

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

+2-14
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2025 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -18,8 +18,6 @@ package org.springframework.web.client
1818

1919
import org.springframework.core.ParameterizedTypeReference
2020
import org.springframework.http.ResponseEntity
21-
import org.springframework.web.client.RestClient.RequestHeadersSpec
22-
import org.springframework.web.client.RestClient.RequestHeadersSpec.ExchangeFunction
2321

2422
/**
2523
* Extension for [RestClient.RequestBodySpec.body] providing a `bodyWithType<Foo>(...)` variant
@@ -53,15 +51,6 @@ inline fun <reified T : Any> RestClient.ResponseSpec.body(): T? =
5351
inline fun <reified T : Any> RestClient.ResponseSpec.requiredBody(): T =
5452
body(object : ParameterizedTypeReference<T>() {}) ?: throw NoSuchElementException("Response body is required")
5553

56-
/**
57-
* Extension for [RestClient.RequestHeadersSpec.exchange] providing a `requiredExchange(...)` variant with a
58-
* non-nullable return value.
59-
* @throws NoSuchElementException if there is no response value
60-
* @since 6.2.6
61-
*/
62-
fun <T: Any> RequestHeadersSpec<*>.requiredExchange(exchangeFunction: ExchangeFunction<T>, close: Boolean = true): T =
63-
exchange(exchangeFunction, close) ?: throw NoSuchElementException("Response value is required")
64-
6554
/**
6655
* Extension for [RestClient.ResponseSpec.toEntity] providing a `toEntity<Foo>()` variant
6756
* leveraging Kotlin reified type parameters. This extension is not subject to type
@@ -71,5 +60,4 @@ fun <T: Any> RequestHeadersSpec<*>.requiredExchange(exchangeFunction: ExchangeFu
7160
* @since 6.1
7261
*/
7362
inline fun <reified T : Any> RestClient.ResponseSpec.toEntity(): ResponseEntity<T> =
74-
toEntity(object : ParameterizedTypeReference<T>() {})
75-
63+
toEntity(object : ParameterizedTypeReference<T>() {})

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

+35-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -59,6 +59,7 @@
5959
import static java.nio.charset.StandardCharsets.UTF_8;
6060
import static org.assertj.core.api.Assertions.assertThat;
6161
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
62+
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
6263
import static org.junit.jupiter.api.Assumptions.assumeFalse;
6364
import static org.junit.jupiter.params.provider.Arguments.argumentSet;
6465

@@ -765,6 +766,39 @@ void exchangeFor404(ClientHttpRequestFactory requestFactory) {
765766
expectRequest(request -> assertThat(request.getPath()).isEqualTo("/greeting"));
766767
}
767768

769+
@ParameterizedRestClientTest
770+
void exchangeForRequiredValue(ClientHttpRequestFactory requestFactory) {
771+
startServer(requestFactory);
772+
773+
prepareResponse(response -> response.setBody("Hello Spring!"));
774+
775+
String result = this.restClient.get()
776+
.uri("/greeting")
777+
.header("X-Test-Header", "testvalue")
778+
.exchangeForRequiredValue((request, response) -> new String(RestClientUtils.getBody(response), UTF_8));
779+
780+
assertThat(result).isEqualTo("Hello Spring!");
781+
782+
expectRequestCount(1);
783+
expectRequest(request -> {
784+
assertThat(request.getHeader("X-Test-Header")).isEqualTo("testvalue");
785+
assertThat(request.getPath()).isEqualTo("/greeting");
786+
});
787+
}
788+
789+
@ParameterizedRestClientTest
790+
@SuppressWarnings("DataFlowIssue")
791+
void exchangeForNullRequiredValue(ClientHttpRequestFactory requestFactory) {
792+
startServer(requestFactory);
793+
794+
prepareResponse(response -> response.setBody("Hello Spring!"));
795+
796+
assertThatIllegalStateException().isThrownBy(() -> this.restClient.get()
797+
.uri("/greeting")
798+
.header("X-Test-Header", "testvalue")
799+
.exchangeForRequiredValue((request, response) -> null));
800+
}
801+
768802
@ParameterizedRestClientTest
769803
void requestInitializer(ClientHttpRequestFactory requestFactory) {
770804
startServer(requestFactory);

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

+1-22
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2025 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -19,12 +19,9 @@ package org.springframework.web.client
1919
import io.mockk.every
2020
import io.mockk.mockk
2121
import io.mockk.verify
22-
import org.assertj.core.api.Assertions.assertThat
2322
import org.junit.jupiter.api.Test
2423
import org.junit.jupiter.api.assertThrows
2524
import org.springframework.core.ParameterizedTypeReference
26-
import org.springframework.http.HttpRequest
27-
import org.springframework.web.client.RestClient.RequestHeadersSpec
2825

2926
/**
3027
* Mock object based tests for [RestClient] Kotlin extensions
@@ -62,24 +59,6 @@ class RestClientExtensionsTests {
6259
assertThrows<NoSuchElementException> { responseSpec.requiredBody<Foo>() }
6360
}
6461

65-
@Test
66-
fun `RequestHeadersSpec#requiredExchange`() {
67-
val foo = Foo()
68-
every { requestBodySpec.exchange(any<RequestHeadersSpec.ExchangeFunction<Foo>>(), any()) } returns foo
69-
val exchangeFunction: (HttpRequest, RequestHeadersSpec.ConvertibleClientHttpResponse) -> Foo? =
70-
{ _, _ -> foo }
71-
val value = requestBodySpec.requiredExchange(exchangeFunction)
72-
assertThat(value).isEqualTo(foo)
73-
}
74-
75-
@Test
76-
fun `RequestHeadersSpec#requiredExchange with null response throws NoSuchElementException`() {
77-
every { requestBodySpec.exchange(any<RequestHeadersSpec.ExchangeFunction<Foo>>(), any()) } returns null
78-
val exchangeFunction: (HttpRequest, RequestHeadersSpec.ConvertibleClientHttpResponse) -> Foo? =
79-
{ _, _ -> null }
80-
assertThrows<NoSuchElementException> { requestBodySpec.requiredExchange(exchangeFunction) }
81-
}
82-
8362
@Test
8463
fun `ResponseSpec#toEntity with reified type parameters`() {
8564
responseSpec.toEntity<List<Foo>>()

0 commit comments

Comments
 (0)