Introduce HttpHeaders#headerSet to guarantee case-insensitive iteration

The `HttpHeaders#headerSet` method is intended as a drop-in replacement
for `entrySet` that guarantees a single casing for all header names
reported during the iteration, as the cost of some overhead but with
support for iterator removal and entry value-setting.

The `formatHeaders` static method is also altered to do a similar
deduplication of casing variants, but now additionally mentions
"with native header names [native name set]" if the native name set
contains casing variants.

Closes gh-33823
This commit is contained in:
Simon Baslé
2024-10-22 16:59:03 +02:00
parent 4ef2b429e0
commit 9b3cb15389
9 changed files with 1623 additions and 52 deletions

View File

@@ -23,16 +23,23 @@ import java.lang.annotation.Target;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.stream.Stream;
import io.netty.handler.codec.http.DefaultHttpHeaders;
import io.undertow.util.HeaderMap;
import io.undertow.util.HttpString;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.tomcat.util.http.MimeHeaders;
import org.eclipse.jetty.http.HttpFields;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.support.HttpComponentsHeadersAdapter;
import org.springframework.http.support.JettyHeadersAdapter;
import org.springframework.http.support.Netty4HeadersAdapter;
import org.springframework.http.support.Netty5HeadersAdapter;
@@ -50,15 +57,109 @@ import static org.junit.jupiter.params.provider.Arguments.arguments;
*
* @author Brian Clozel
* @author Sam Brannen
* @author Simon Baslé
*/
class HeadersAdaptersTests {
@ParameterizedPopulatedHeadersTest
void toSingleValueMapIsCaseInsensitive(MultiValueMap<String, String> headers) {
assertThat(headers.toSingleValueMap()).as("toSingleValueMap")
.containsEntry("TestHeader", "first")
.containsEntry("SecondHeader", "value")
.hasSize(2);
}
@ParameterizedHeadersTest
void shouldRemoveCaseInsensitiveFromKeySet(MultiValueMap<String, String> headers) {
headers.add("TestHeader", "first");
headers.add("TestHEADER", "second");
headers.add("TestHeader", "third");
Iterator<String> iterator = headers.keySet().iterator();
iterator.next();
iterator.remove();
assertThat(headers)
.doesNotContainKey("TestHeader")
.doesNotContainKey("TestHEADER")
.doesNotContainKey("testheader")
.hasSize(0);
}
@ParameterizedPopulatedHeadersTest
void toString(MultiValueMap<String, String> headers) {
String expectedFirstHeader = "TestHeader:\"first\", \"second\", \"third\"";
String expectedSecondHeader = "SecondHeader:\"value\"";
int minimumLength = expectedFirstHeader.length() + expectedSecondHeader.length() + 4;
String result = headers.toString();
assertThat(result)
.startsWith("[").endsWith("]")
// Using contains here because some native headers iterate over names in reverse insertion order
.contains(expectedFirstHeader)
.contains(expectedSecondHeader)
.hasSizeGreaterThanOrEqualTo(minimumLength);
if (result.length() > minimumLength) {
String expectedEnd = " with native header names " + headers.keySet();
assertThat(result).as("toString() with a dump of native duplicate headers")
.endsWith(expectedEnd)
.hasSize(minimumLength + expectedEnd.length());
}
}
@ParameterizedPopulatedHeadersTest
void copyUsingHeaderSetAddAllIsCaseInsensitive(MultiValueMap<String, String> headers) {
HttpHeaders headers2 = new HttpHeaders();
for (Map.Entry<String, List<String>> entry : new HttpHeaders(headers).headerSet()) {
headers2.addAll(entry.getKey(), entry.getValue());
}
assertThat(headers2.get("TestHeader")).as("TestHeader")
.containsExactly("first", "second", "third");
// Using the headerSet approach, we keep the first encountered casing of any given key
assertThat(headers2.keySet()).as("first casing variant").containsExactlyInAnyOrder("TestHeader", "SecondHeader");
assertThat(headers2.toString()).as("similar toString, no 'with native headers' dump")
.isEqualTo(headers.toString().substring(0, headers.toString().indexOf(']') + 1));
}
@ParameterizedPopulatedHeadersTest
void copyUsingEntrySetPutRemovesDuplicates(MultiValueMap<String, String> headers) {
HttpHeaders headers2 = new HttpHeaders();
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
headers2.put(entry.getKey(), entry.getValue());
}
assertThat(headers2.get("TestHeader")).as("TestHeader")
.containsExactly("first", "second", "third");
// Ordering and casing are not guaranteed using the entrySet+put approach
assertThat(headers2).as("two keys")
.containsKey("testheader")
.containsKey("secondheader")
.hasSize(2);
assertThat(headers2.toString()).as("no 'with native headers' dump")
.doesNotContain("with native headers");
}
@ParameterizedPopulatedHeadersTest
void copyUsingPutAllRemovesDuplicates(MultiValueMap<String, String> headers) {
HttpHeaders headers2 = new HttpHeaders();
headers2.putAll(headers);
assertThat(headers2.get("TestHeader")).as("TestHeader")
.containsExactly("first", "second", "third");
// Ordering and casing are not guaranteed using the putAll approach
assertThat(headers2).as("two keys").containsOnlyKeys("testheader", "secondheader");
assertThat(headers2.toString()).as("similar toString, no 'with native headers' dump")
.isEqualToIgnoringCase(headers.toString().substring(0, headers.toString().indexOf(']') + 1));
}
@ParameterizedPopulatedHeadersTest
void getWithUnknownHeaderShouldReturnNull(MultiValueMap<String, String> headers) {
assertThat(headers.get("Unknown")).isNull();
}
@ParameterizedHeadersTest
@ParameterizedPopulatedHeadersTest
void getFirstWithUnknownHeaderShouldReturnNull(MultiValueMap<String, String> headers) {
assertThat(headers.getFirst("Unknown")).isNull();
}
@@ -78,9 +179,8 @@ class HeadersAdaptersTests {
assertThat(headers.keySet()).hasSize(2);
}
@ParameterizedHeadersTest
@ParameterizedPopulatedHeadersTest
void containsKeyShouldBeCaseInsensitive(MultiValueMap<String, String> headers) {
headers.add("TestHeader", "first");
assertThat(headers.containsKey("testheader")).isTrue();
}
@@ -127,6 +227,39 @@ class HeadersAdaptersTests {
assertThatThrownBy(names::remove).isInstanceOf(IllegalStateException.class);
}
@ParameterizedPopulatedHeadersTest
void headerSetEntryCanSetList(MultiValueMap<String, String> headers) {
for (Map.Entry<String, List<String>> entry : new HttpHeaders(headers).headerSet()) {
entry.setValue(List.of(entry.getKey()));
}
assertThat(headers).hasSize(2);
assertThat(headers.get("TestHeader")).containsExactly("TestHeader");
assertThat(headers.get("SecondHeader")).containsExactly("SecondHeader");
}
@ParameterizedPopulatedHeadersTest
void headerSetIteratorCanRemove(MultiValueMap<String, String> headers) {
Iterator<Map.Entry<String, List<String>>> iterator = new HttpHeaders(headers).headerSet().iterator();
while (iterator.hasNext()) {
iterator.next();
iterator.remove();
}
assertThat(headers).isEmpty();
}
static <T> T withHeaders(T nativeHeader, Function<T, BiConsumer<String, String>> addMethod) {
BiConsumer<String, String> add = addMethod.apply(nativeHeader);
add.accept("TestHeader", "first");
add.accept("TestHEADER", "second");
add.accept("SecondHeader", "value");
add.accept("TestHeader", "third");
return nativeHeader;
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@ParameterizedTest(name = "[{index}] {0}")
@@ -138,10 +271,33 @@ class HeadersAdaptersTests {
return Stream.of(
arguments(named("Map", CollectionUtils.toMultiValueMap(new LinkedCaseInsensitiveMap<>(8, Locale.ENGLISH)))),
arguments(named("Netty", new Netty4HeadersAdapter(new DefaultHttpHeaders()))),
arguments(named("Netty", new Netty5HeadersAdapter(io.netty5.handler.codec.http.headers.HttpHeaders.newHeaders()))),
arguments(named("Netty5", new Netty5HeadersAdapter(io.netty5.handler.codec.http.headers.HttpHeaders.newHeaders()))),
arguments(named("Tomcat", new TomcatHeadersAdapter(new MimeHeaders()))),
arguments(named("Undertow", new UndertowHeadersAdapter(new HeaderMap()))),
arguments(named("Jetty", new JettyHeadersAdapter(HttpFields.build())))
arguments(named("Jetty", new JettyHeadersAdapter(HttpFields.build()))),
arguments(named("HttpComponents", new HttpComponentsHeadersAdapter(new HttpGet("https://example.com"))))
);
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@ParameterizedTest
@MethodSource("nativeHeadersWithCasedEntries")
@interface ParameterizedPopulatedHeadersTest {
}
static Stream<Arguments> nativeHeadersWithCasedEntries() {
return Stream.of(
arguments(named("Netty", new Netty4HeadersAdapter(withHeaders(new DefaultHttpHeaders(), h -> h::add)))),
arguments(named("Netty5", new Netty5HeadersAdapter(withHeaders(io.netty5.handler.codec.http.headers.HttpHeaders.newHeaders(),
h -> h::add)))),
arguments(named("Tomcat", new TomcatHeadersAdapter(withHeaders(new MimeHeaders(),
h -> (k, v) -> h.addValue(k).setString(v))))),
arguments(named("Undertow", new UndertowHeadersAdapter(withHeaders(new HeaderMap(),
h -> (k, v) -> h.add(HttpString.tryFromString(k), v))))),
arguments(named("Jetty", new JettyHeadersAdapter(withHeaders(HttpFields.build(), h -> h::add)))),
arguments(named("HttpComponents", new HttpComponentsHeadersAdapter(withHeaders(new HttpGet("https://example.com"),
h -> h::addHeader))))
);
}