Preserve original headers and cookies when mutating client response

Prior to this commit, the `DefaultClientResponseBuilder` would assume
that an original client HTTP response, when mutated, would not be reused
nor read anymore. While this is the advised use case, there was some
inconsistency with the builder API here when mutating: some data like
the response status would copied, but the HTTP headers and cookies would
refer directly to the previous entries, making all changes visible to
the previous response instance.

This commit ensures that deep copies are performed when mutating a
client response with the builder API.

Fixes gh-37086
This commit is contained in:
Brian Clozel
2026-08-31 18:36:09 +02:00
parent 772d361cf2
commit 41db0fe5a3
2 changed files with 13 additions and 4 deletions
@@ -18,6 +18,7 @@ package org.springframework.web.reactive.function.client;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Map;
import java.util.function.Consumer;
@@ -144,7 +145,7 @@ final class DefaultClientResponseBuilder implements ClientResponse.Builder {
@SuppressWarnings({"ConstantConditions", "NullAway"})
private HttpHeaders getHeaders() {
if (this.headers == null) {
this.headers = new HttpHeaders(this.originalResponse.headers().asHttpHeaders());
this.headers = HttpHeaders.copyOf(this.originalResponse.headers().asHttpHeaders());
}
return this.headers;
}
@@ -166,7 +167,10 @@ final class DefaultClientResponseBuilder implements ClientResponse.Builder {
@SuppressWarnings({"ConstantConditions", "NullAway"})
private MultiValueMap<String, ResponseCookie> getCookies() {
if (this.cookies == null) {
this.cookies = new LinkedMultiValueMap<>(this.originalResponse.cookies());
MultiValueMap<String, ResponseCookie> originalCookies = this.originalResponse.cookies();
this.cookies = new LinkedMultiValueMap<>(originalCookies.size());
originalCookies.forEach(
(name, values) -> this.cookies.put(name, new ArrayList<>(values)));
}
return this.cookies;
}
@@ -83,16 +83,21 @@ class DefaultClientResponseBuilderTests {
ClientResponse result = otherResponse.mutate()
.statusCode(HttpStatus.BAD_REQUEST)
.headers(headers -> headers.set("foo", "baar"))
.cookies(cookies -> cookies.set("baz", ResponseCookie.from("baz", "quux").build()))
.cookies(cookies -> cookies.add("baz", ResponseCookie.from("baz", "pop").build()))
.build();
assertThat(otherResponse.headers().asHttpHeaders().getFirst("foo")).isEqualTo("bar");
assertThat(otherResponse.headers().asHttpHeaders().getFirst("bar")).isEqualTo("baz");
assertThat(otherResponse.cookies().get("baz")).hasSize(1);
assertThat(result.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(result.headers().asHttpHeaders().size()).isEqualTo(3);
assertThat(result.headers().asHttpHeaders().getFirst("foo")).isEqualTo("baar");
assertThat(result.headers().asHttpHeaders().getFirst("bar")).isEqualTo("baz");
assertThat(result.cookies()).hasSize(1);
assertThat(result.cookies().getFirst("baz").getValue()).isEqualTo("quux");
assertThat(result.cookies().get("baz")).hasSize(2);
assertThat(result.cookies().getFirst("baz").getValue()).isEqualTo("qux");
assertThat(result.cookies().get("baz").get(1).getValue()).isEqualTo("pop");
assertThat(result.logPrefix()).isEqualTo("my-prefix");
StepVerifier.create(result.bodyToFlux(String.class))