diff --git a/spring-web/src/main/java/org/springframework/http/client/BufferingClientHttpRequestFactory.java b/spring-web/src/main/java/org/springframework/http/client/BufferingClientHttpRequestFactory.java index ecf0a544757..f7843a81bb0 100644 --- a/spring-web/src/main/java/org/springframework/http/client/BufferingClientHttpRequestFactory.java +++ b/spring-web/src/main/java/org/springframework/http/client/BufferingClientHttpRequestFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,17 +18,22 @@ package org.springframework.http.client; import java.io.IOException; import java.net.URI; +import java.util.function.BiPredicate; import org.springframework.http.HttpMethod; /** - * Wrapper for a {@link ClientHttpRequestFactory} that buffers - * all outgoing and incoming streams in memory. + * {@link ClientHttpRequestFactory} that wraps another in order to buffer + * outgoing and incoming content in memory, making it possible to set a + * content-length on the request, and to read the + * {@linkplain ClientHttpResponse#getBody() response body} multiple times. * - *

Using this wrapper allows for multiple reads of the - * {@linkplain ClientHttpResponse#getBody() response body}. + *

Note: as of 7.0, buffering can be enabled through + * {@link org.springframework.web.client.RestClient.Builder#bufferContent(BiPredicate)} + * and therefore it is not necessary for applications to use this class directly. * * @author Arjen Poutsma + * @author Rossen Stoyanchev * @since 3.1 */ public class BufferingClientHttpRequestFactory extends AbstractClientHttpRequestFactoryWrapper { diff --git a/spring-web/src/main/java/org/springframework/http/client/InterceptingClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/InterceptingClientHttpRequest.java index 49d499b6d49..da3367cd2d1 100644 --- a/spring-web/src/main/java/org/springframework/http/client/InterceptingClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/InterceptingClientHttpRequest.java @@ -19,6 +19,7 @@ package org.springframework.http.client; import java.io.IOException; import java.net.URI; import java.util.List; +import java.util.function.BiPredicate; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -42,14 +43,18 @@ class InterceptingClientHttpRequest extends AbstractBufferingClientHttpRequest { private final URI uri; + private final BiPredicate bufferingPredicate; + protected InterceptingClientHttpRequest(ClientHttpRequestFactory requestFactory, - List interceptors, URI uri, HttpMethod method) { + List interceptors, URI uri, HttpMethod method, + BiPredicate bufferingPredicate) { this.requestFactory = requestFactory; this.interceptors = interceptors; this.method = method; this.uri = uri; + this.bufferingPredicate = bufferingPredicate; } @@ -76,6 +81,10 @@ class InterceptingClientHttpRequest extends AbstractBufferingClientHttpRequest { .orElse(execution); } + private boolean shouldBufferResponse(HttpRequest request) { + return this.bufferingPredicate.test(request.getURI(), request.getMethod()); + } + private class EndOfChainRequestExecution implements ClientHttpRequestExecution { @@ -90,7 +99,7 @@ class InterceptingClientHttpRequest extends AbstractBufferingClientHttpRequest { ClientHttpRequest delegate = this.requestFactory.createRequest(request.getURI(), request.getMethod()); request.getHeaders().forEach((key, value) -> delegate.getHeaders().addAll(key, value)); request.getAttributes().forEach((key, value) -> delegate.getAttributes().put(key, value)); - return executeWithRequest(delegate, body, false); + return executeWithRequest(delegate, body, shouldBufferResponse(request)); } } diff --git a/spring-web/src/main/java/org/springframework/http/client/InterceptingClientHttpRequestFactory.java b/spring-web/src/main/java/org/springframework/http/client/InterceptingClientHttpRequestFactory.java index 4de6f750424..7fa437794b7 100644 --- a/spring-web/src/main/java/org/springframework/http/client/InterceptingClientHttpRequestFactory.java +++ b/spring-web/src/main/java/org/springframework/http/client/InterceptingClientHttpRequestFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ package org.springframework.http.client; import java.net.URI; import java.util.Collections; import java.util.List; +import java.util.function.BiPredicate; import org.jspecify.annotations.Nullable; @@ -29,6 +30,7 @@ import org.springframework.http.HttpMethod; * {@link ClientHttpRequestInterceptor ClientHttpRequestInterceptors}. * * @author Arjen Poutsma + * @author Rossen Stoyanchev * @since 3.1 * @see ClientHttpRequestFactory * @see ClientHttpRequestInterceptor @@ -37,23 +39,41 @@ public class InterceptingClientHttpRequestFactory extends AbstractClientHttpRequ private final List interceptors; + private final BiPredicate bufferingPredicate; + /** - * Create a new instance of the {@code InterceptingClientHttpRequestFactory} with the given parameters. + * Create a new instance with the given parameters. * @param requestFactory the request factory to wrap * @param interceptors the interceptors that are to be applied (can be {@code null}) */ public InterceptingClientHttpRequestFactory(ClientHttpRequestFactory requestFactory, @Nullable List interceptors) { + this(requestFactory, interceptors, null); + } + + /** + * Constructor variant with an additional predicate to decide whether to + * buffer the response. + * @since 7.0 + */ + public InterceptingClientHttpRequestFactory(ClientHttpRequestFactory requestFactory, + @Nullable List interceptors, + @Nullable BiPredicate bufferingPredicate) { + super(requestFactory); this.interceptors = (interceptors != null ? interceptors : Collections.emptyList()); + this.bufferingPredicate = (bufferingPredicate != null ? bufferingPredicate : (uri, method) -> false); } @Override - protected ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod, ClientHttpRequestFactory requestFactory) { - return new InterceptingClientHttpRequest(requestFactory, this.interceptors, uri, httpMethod); + protected ClientHttpRequest createRequest( + URI uri, HttpMethod httpMethod, ClientHttpRequestFactory requestFactory) { + + return new InterceptingClientHttpRequest( + requestFactory, this.interceptors, uri, httpMethod, this.bufferingPredicate); } } 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 d548c14eb58..7158d6baf26 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,6 +29,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiPredicate; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; @@ -48,6 +49,7 @@ import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.StreamingHttpOutputMessage; +import org.springframework.http.client.BufferingClientHttpRequestFactory; import org.springframework.http.client.ClientHttpRequest; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.ClientHttpRequestInitializer; @@ -75,6 +77,7 @@ import org.springframework.web.util.UriBuilderFactory; * * @author Arjen Poutsma * @author Sebastien Deleuze + * @author Rossen Stoyanchev * @since 6.1 * @see RestClient#create() * @see RestClient#create(String) @@ -97,6 +100,8 @@ final class DefaultRestClient implements RestClient { private final @Nullable List interceptors; + private final @Nullable BiPredicate bufferingPredicate; + private final UriBuilderFactory uriBuilderFactory; private final @Nullable HttpHeaders defaultHeaders; @@ -118,6 +123,7 @@ final class DefaultRestClient implements RestClient { DefaultRestClient(ClientHttpRequestFactory clientRequestFactory, @Nullable List interceptors, + @Nullable BiPredicate bufferingPredicate, @Nullable List initializers, UriBuilderFactory uriBuilderFactory, @Nullable HttpHeaders defaultHeaders, @@ -132,6 +138,7 @@ final class DefaultRestClient implements RestClient { this.clientRequestFactory = clientRequestFactory; this.initializers = initializers; this.interceptors = interceptors; + this.bufferingPredicate = bufferingPredicate; this.uriBuilderFactory = uriBuilderFactory; this.defaultHeaders = defaultHeaders; this.defaultCookies = defaultCookies; @@ -643,10 +650,14 @@ final class DefaultRestClient implements RestClient { factory = DefaultRestClient.this.interceptingRequestFactory; if (factory == null) { factory = new InterceptingClientHttpRequestFactory( - DefaultRestClient.this.clientRequestFactory, DefaultRestClient.this.interceptors); + DefaultRestClient.this.clientRequestFactory, DefaultRestClient.this.interceptors, + DefaultRestClient.this.bufferingPredicate); DefaultRestClient.this.interceptingRequestFactory = factory; } } + else if (DefaultRestClient.this.bufferingPredicate != null) { + factory = new BufferingClientHttpRequestFactory(DefaultRestClient.this.clientRequestFactory); + } else { factory = DefaultRestClient.this.clientRequestFactory; } diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java index 0c682dde9e6..34dd663589c 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.BiPredicate; import java.util.function.Consumer; import java.util.function.Predicate; @@ -30,6 +31,7 @@ import io.micrometer.observation.ObservationRegistry; import org.jspecify.annotations.Nullable; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatusCode; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.ClientHttpRequestInitializer; @@ -137,6 +139,8 @@ final class DefaultRestClientBuilder implements RestClient.Builder { private @Nullable List interceptors; + private @Nullable BiPredicate bufferingPredicate; + private @Nullable List initializers; private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; @@ -172,6 +176,7 @@ final class DefaultRestClientBuilder implements RestClient.Builder { new ArrayList<>(other.messageConverters) : null); this.interceptors = (other.interceptors != null) ? new ArrayList<>(other.interceptors) : null; + this.bufferingPredicate = other.bufferingPredicate; this.initializers = (other.initializers != null) ? new ArrayList<>(other.initializers) : null; this.observationRegistry = other.observationRegistry; this.observationConvention = other.observationConvention; @@ -347,6 +352,12 @@ final class DefaultRestClientBuilder implements RestClient.Builder { return this.interceptors; } + @Override + public RestClient.Builder bufferContent(BiPredicate predicate) { + this.bufferingPredicate = predicate; + return this; + } + @Override public RestClient.Builder requestInitializer(ClientHttpRequestInitializer initializer) { Assert.notNull(initializer, "Initializer must not be null"); @@ -463,7 +474,7 @@ final class DefaultRestClientBuilder implements RestClient.Builder { (this.messageConverters != null ? this.messageConverters : initMessageConverters()); return new DefaultRestClient( - requestFactory, this.interceptors, this.initializers, + requestFactory, this.interceptors, this.bufferingPredicate, this.initializers, uriBuilderFactory, defaultHeaders, defaultCookies, this.defaultRequest, this.statusHandlers, 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 5371bdd3931..be5b21fcd4e 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ import java.nio.charset.Charset; import java.time.ZonedDateTime; import java.util.List; import java.util.Map; +import java.util.function.BiPredicate; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; @@ -384,6 +385,15 @@ public interface RestClient { */ Builder requestInterceptors(Consumer> interceptorsConsumer); + /** + * Enable buffering of request and response content making it possible to + * read the request and the response body multiple times. + * @param predicate to determine whether to buffer for the given request + * @return this builder + * @since 7.0 + */ + Builder bufferContent(BiPredicate predicate); + /** * Add the given request initializer to the end of the initializer chain. * @param initializer the initializer to be added to the chain 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 074fa8f36a0..81639730ccd 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 @@ -51,6 +51,7 @@ import org.springframework.http.client.JettyClientHttpRequestFactory; import org.springframework.http.client.ReactorClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.util.CollectionUtils; +import org.springframework.util.FileCopyUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.testfixture.xml.Pojo; @@ -811,6 +812,59 @@ class RestClientIntegrationTests { expectRequest(request -> assertThat(request.getHeader("foo")).isEqualTo("bar")); } + @ParameterizedRestClientTest + void requestInterceptorWithResponseBuffering(ClientHttpRequestFactory requestFactory) { + startServer(requestFactory); + + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + + RestClient interceptedClient = this.restClient.mutate() + .requestInterceptor((request, body, execution) -> { + ClientHttpResponse response = execution.execute(request, body); + byte[] result = FileCopyUtils.copyToByteArray(response.getBody()); + assertThat(result).isEqualTo("Hello Spring!".getBytes(UTF_8)); + return response; + }) + .bufferContent((uri, httpMethod) -> true) + .build(); + + String result = interceptedClient.get() + .uri("/greeting") + .retrieve() + .body(String.class); + + assertThat(result).isEqualTo("Hello Spring!"); + + expectRequestCount(1); + } + + @ParameterizedRestClientTest + void bufferContent(ClientHttpRequestFactory requestFactory) { + startServer(requestFactory); + + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + + RestClient bufferingClient = this.restClient.mutate() + .bufferContent((uri, httpMethod) -> true) + .build(); + + String result = bufferingClient.get() + .uri("/greeting") + .exchange((request, response) -> { + byte[] bytes = FileCopyUtils.copyToByteArray(response.getBody()); + assertThat(bytes).isEqualTo("Hello Spring!".getBytes(UTF_8)); + bytes = FileCopyUtils.copyToByteArray(response.getBody()); + assertThat(bytes).isEqualTo("Hello Spring!".getBytes(UTF_8)); + return new String(bytes, UTF_8); + }); + + assertThat(result).isEqualTo("Hello Spring!"); + + expectRequestCount(1); + } + @ParameterizedRestClientTest void retrieveDefaultCookiesAsCookieHeader(ClientHttpRequestFactory requestFactory) { startServer(requestFactory);