From 96bc1f50c705272690934fc834b671f3fde60acc Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 1 Aug 2025 13:31:37 +0100 Subject: [PATCH] Add interceptors and converters to RestTestClient.Builder Closes gh-35268 --- .../server/DefaultWebTestClientBuilder.java | 5 -- .../web/reactive/server/WebTestClient.java | 56 +++++++++---------- .../servlet/client/DefaultRestTestClient.java | 36 +++++++++--- .../client/DefaultRestTestClientBuilder.java | 41 +++++++++++++- .../web/servlet/client/RestTestClient.java | 51 +++++++++++++++++ 5 files changed, 146 insertions(+), 43 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java index 49141b60989..d9693ca61fe 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java @@ -115,11 +115,6 @@ class DefaultWebTestClientBuilder implements WebTestClient.Builder { this(httpHandlerBuilder, null, sslInfo); } - /** Use given connector. */ - DefaultWebTestClientBuilder(ClientHttpConnector connector) { - this(null, connector, null); - } - private DefaultWebTestClientBuilder(@Nullable WebHttpHandlerBuilder httpHandlerBuilder, @Nullable ClientHttpConnector connector, @Nullable SslInfo sslInfo) { diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java index 2ffaa16168f..9fe4a75edde 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java @@ -242,7 +242,7 @@ public interface WebTestClient { * @since 5.0.2 */ static Builder bindToServer(ClientHttpConnector connector) { - return new DefaultWebTestClientBuilder(connector); + return new DefaultWebTestClientBuilder().clientConnector(connector); } @@ -467,33 +467,6 @@ public interface WebTestClient { */ Builder filters(Consumer> filtersConsumer); - /** - * Configure an {@code EntityExchangeResult} callback that is invoked - * every time after a response is fully decoded to a single entity, to a - * List of entities, or to a byte[]. In effect, equivalent to each and - * all of the below but registered once, globally: - *
-		 * client.get().uri("/accounts/1")
-		 *         .exchange()
-		 *         .expectBody(Person.class).consumeWith(exchangeResult -> ... ));
-		 *
-		 * client.get().uri("/accounts")
-		 *         .exchange()
-		 *         .expectBodyList(Person.class).consumeWith(exchangeResult -> ... ));
-		 *
-		 * client.get().uri("/accounts/1")
-		 *         .exchange()
-		 *         .expectBody().consumeWith(exchangeResult -> ... ));
-		 * 
- *

Note that the configured consumer does not apply to responses - * decoded to {@code Flux} which can be consumed outside the workflow - * of the test client, for example via {@code reactor.test.StepVerifier}. - * @param consumer the consumer to apply to entity responses - * @return the builder - * @since 5.3.5 - */ - Builder entityExchangeResultConsumer(Consumer> consumer); - /** * Configure the codecs for the {@code WebClient} in the * {@link #exchangeStrategies(ExchangeStrategies) underlying} @@ -533,6 +506,33 @@ public interface WebTestClient { */ Builder clientConnector(ClientHttpConnector connector); + /** + * Configure an {@code EntityExchangeResult} callback that is invoked + * every time after a response is fully decoded to a single entity, to a + * List of entities, or to a byte[]. In effect, equivalent to each and + * all of the below but registered once, globally: + *

+		 * client.get().uri("/accounts/1")
+		 *         .exchange()
+		 *         .expectBody(Person.class).consumeWith(exchangeResult -> ... ));
+		 *
+		 * client.get().uri("/accounts")
+		 *         .exchange()
+		 *         .expectBodyList(Person.class).consumeWith(exchangeResult -> ... ));
+		 *
+		 * client.get().uri("/accounts/1")
+		 *         .exchange()
+		 *         .expectBody().consumeWith(exchangeResult -> ... ));
+		 * 
+ *

Note that the configured consumer does not apply to responses + * decoded to {@code Flux} which can be consumed outside the workflow + * of the test client, for example via {@code reactor.test.StepVerifier}. + * @param consumer the consumer to apply to entity responses + * @return the builder + * @since 5.3.5 + */ + Builder entityExchangeResultConsumer(Consumer> consumer); + /** * Apply the given configurer to this builder instance. *

This can be useful for applying pre-packaged customizations. diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java index 45177fb058c..c46bdc39b5d 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java @@ -56,11 +56,20 @@ class DefaultRestTestClient implements RestTestClient { private final RestClient restClient; + private final Consumer> entityResultConsumer; + + private final DefaultRestTestClientBuilder restTestClientBuilder; + private final AtomicLong requestIndex = new AtomicLong(); - DefaultRestTestClient(RestClient.Builder builder) { + DefaultRestTestClient( + RestClient.Builder builder, Consumer> entityResultConsumer, + DefaultRestTestClientBuilder restTestClientBuilder) { + this.restClient = builder.build(); + this.entityResultConsumer = entityResultConsumer; + this.restTestClientBuilder = restTestClientBuilder; } @@ -108,9 +117,10 @@ class DefaultRestTestClient implements RestTestClient { return new DefaultRequestBodyUriSpec(this.restClient.method(httpMethod)); } + @SuppressWarnings("unchecked") @Override public > Builder mutate() { - return new DefaultRestTestClientBuilder<>(this.restClient.mutate()); + return (Builder) this.restTestClientBuilder; } @@ -242,7 +252,8 @@ class DefaultRestTestClient implements RestTestClient { public ResponseSpec exchange() { return new DefaultResponseSpec( this.requestHeadersUriSpec.exchangeForRequiredValue( - (request, response) -> new ExchangeResult(request, response, this.uriTemplate), false)); + (request, response) -> new ExchangeResult(request, response, this.uriTemplate), false), + DefaultRestTestClient.this.entityResultConsumer); } } @@ -251,8 +262,11 @@ class DefaultRestTestClient implements RestTestClient { private final ExchangeResult exchangeResult; - DefaultResponseSpec(ExchangeResult result) { + private final Consumer> entityResultConsumer; + + DefaultResponseSpec(ExchangeResult result, Consumer> entityResultConsumer) { this.exchangeResult = result; + this.entityResultConsumer = entityResultConsumer; } @Override @@ -280,25 +294,31 @@ class DefaultRestTestClient implements RestTestClient { @Override public BodySpec expectBody(ParameterizedTypeReference bodyType) { B body = this.exchangeResult.getBody(bodyType); - EntityExchangeResult result = new EntityExchangeResult<>(this.exchangeResult, body); + EntityExchangeResult result = initExchangeResult(body); return new DefaultBodySpec<>(result); } @Override public BodyContentSpec expectBody() { byte[] body = this.exchangeResult.getBody(byte[].class); - EntityExchangeResult result = new EntityExchangeResult<>(this.exchangeResult, body); + EntityExchangeResult result = initExchangeResult(body); return new DefaultBodyContentSpec(result); } @Override public EntityExchangeResult returnResult(Class elementClass) { - return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementClass)); + return initExchangeResult(this.exchangeResult.getBody(elementClass)); } @Override public EntityExchangeResult returnResult(ParameterizedTypeReference elementTypeRef) { - return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementTypeRef)); + return initExchangeResult(this.exchangeResult.getBody(elementTypeRef)); + } + + private EntityExchangeResult initExchangeResult(@Nullable B body) { + EntityExchangeResult result = new EntityExchangeResult<>(this.exchangeResult, body); + result.assertWithDiagnostics(() -> this.entityResultConsumer.accept(result)); + return result; } @Override diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java index 14b37ff0442..53168bf60d3 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java @@ -16,10 +16,13 @@ package org.springframework.test.web.servlet.client; +import java.util.List; import java.util.function.Consumer; import org.springframework.http.HttpHeaders; import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.converter.HttpMessageConverters; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvcBuilder; import org.springframework.test.web.servlet.client.RestTestClient.MockMvcSetupBuilder; @@ -30,6 +33,7 @@ import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; +import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; import org.springframework.web.client.ApiVersionInserter; import org.springframework.web.client.RestClient; @@ -49,15 +53,22 @@ class DefaultRestTestClientBuilder> implemen private final RestClient.Builder restClientBuilder; + private Consumer> entityResultConsumer = result -> {}; + DefaultRestTestClientBuilder() { - this.restClientBuilder = RestClient.builder(); + this(RestClient.builder()); } DefaultRestTestClientBuilder(RestClient.Builder restClientBuilder) { this.restClientBuilder = restClientBuilder; } + DefaultRestTestClientBuilder(DefaultRestTestClientBuilder other) { + this.restClientBuilder = other.restClientBuilder.clone(); + this.entityResultConsumer = other.entityResultConsumer; + } + @Override public T baseUrl(String baseUrl) { @@ -107,6 +118,31 @@ class DefaultRestTestClientBuilder> implemen return self(); } + @Override + public T requestInterceptor(ClientHttpRequestInterceptor interceptor) { + this.restClientBuilder.requestInterceptor(interceptor); + return self(); + } + + @Override + public T requestInterceptors(Consumer> interceptorsConsumer) { + this.restClientBuilder.requestInterceptors(interceptorsConsumer); + return self(); + } + + @Override + public T configureMessageConverters(Consumer configurer) { + this.restClientBuilder.configureMessageConverters(configurer); + return self(); + } + + @Override + public T entityExchangeResultConsumer(Consumer> entityResultConsumer) { + Assert.notNull(entityResultConsumer, "'entityResultConsumer' is required"); + this.entityResultConsumer = this.entityResultConsumer.andThen(entityResultConsumer); + return self(); + } + @SuppressWarnings("unchecked") protected T self() { return (T) this; @@ -118,7 +154,8 @@ class DefaultRestTestClientBuilder> implemen @Override public RestTestClient build() { - return new DefaultRestTestClient(this.restClientBuilder); + return new DefaultRestTestClient( + this.restClientBuilder, this.entityResultConsumer, new DefaultRestTestClientBuilder<>(this)); } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java index 426b8e5d8f5..f7f7bd69c6c 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java @@ -19,6 +19,7 @@ package org.springframework.test.web.servlet.client; import java.net.URI; import java.nio.charset.Charset; import java.time.ZonedDateTime; +import java.util.List; import java.util.Map; import java.util.function.Consumer; import java.util.function.Function; @@ -31,6 +32,8 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.converter.HttpMessageConverters; import org.springframework.test.json.JsonComparator; import org.springframework.test.json.JsonCompareMode; import org.springframework.test.json.JsonComparison; @@ -261,6 +264,54 @@ public interface RestTestClient { */ T apiVersionInserter(ApiVersionInserter apiVersionInserter); + /** + * Add the given request interceptor to the end of the interceptor chain. + * @param interceptor the interceptor to be added to the chain + */ + T requestInterceptor(ClientHttpRequestInterceptor interceptor); + + /** + * Manipulate the interceptors with the given consumer. The list provided to + * the consumer is "live", so that the consumer can be used to remove + * interceptors, change ordering, etc. + * @param interceptorsConsumer a function that consumes the interceptors list + * @return this builder + */ + T requestInterceptors(Consumer> interceptorsConsumer); + + /** + * Configure the message converters to use for the request and response body. + * @param configurer the configurer to apply on an empty {@link HttpMessageConverters.ClientBuilder}. + * @return this builder + */ + T configureMessageConverters(Consumer configurer); + + /** + * Configure an {@code EntityExchangeResult} callback that is invoked + * every time after a response is fully decoded to a single entity, to a + * List of entities, or to a byte[]. In effect, equivalent to each and + * all of the below but registered once, globally: + *

+		 * client.get().uri("/accounts/1")
+		 *         .exchange()
+		 *         .expectBody(Person.class).consumeWith(exchangeResult -> ... ));
+		 *
+		 * client.get().uri("/accounts")
+		 *         .exchange()
+		 *         .expectBodyList(Person.class).consumeWith(exchangeResult -> ... ));
+		 *
+		 * client.get().uri("/accounts/1")
+		 *         .exchange()
+		 *         .expectBody().consumeWith(exchangeResult -> ... ));
+		 * 
+ *

Note that the configured consumer does not apply to responses + * decoded to {@code Flux} which can be consumed outside the workflow + * of the test client, for example via {@code reactor.test.StepVerifier}. + * @param consumer the consumer to apply to entity responses + * @return the builder + */ + T entityExchangeResultConsumer(Consumer> consumer); + /** * Build the {@link RestTestClient} instance. */