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

@ -0,0 +1,124 @@
/*
* Copyright 2002-2024 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.http.support;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import io.netty.handler.codec.http.DefaultHttpHeaders;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.eclipse.jetty.http.HttpFields;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.infra.Blackhole;
import org.springframework.http.HttpHeaders;
import org.springframework.util.MultiValueMap;
/**
* Benchmark for implementations of MultiValueMap adapters over native HTTP
* headers implementations.
* <p>Run JMH with {@code -p implementation=Netty,Netty5,HttpComponents,Jetty}
* to cover all implementations
* @author Simon Baslé
*/
@BenchmarkMode(Mode.Throughput)
public class HeadersAdapterBenchmark {
@Benchmark
public void iterateEntries(BenchmarkData data, Blackhole bh) {
for (Map.Entry<String, List<String>> entry : data.entriesProvider.apply(data.headers)) {
bh.consume(entry.getKey());
for (String s : entry.getValue()) {
bh.consume(s);
}
}
}
@Benchmark
public void toString(BenchmarkData data, Blackhole bh) {
bh.consume(data.headers.toString());
}
@State(Scope.Benchmark)
public static class BenchmarkData {
@Param({"NONE"})
public String implementation;
@Param({"true"})
public boolean duplicate;
public MultiValueMap<String, String> headers;
public Function<MultiValueMap<String, String>, Set<Map.Entry<String, List<String>>>> entriesProvider;
//Uncomment the following line and comment the similar line for setupImplementationBaseline below
//to benchmark current implementations
@Setup(Level.Trial)
public void initImplementationNew() {
this.entriesProvider = map -> new HttpHeaders(map).headerSet();
this.headers = switch (this.implementation) {
case "Netty" -> new Netty4HeadersAdapter(new DefaultHttpHeaders());
case "HttpComponents" -> new HttpComponentsHeadersAdapter(new HttpGet("https://example.com"));
case "Netty5" -> new Netty5HeadersAdapter(io.netty5.handler.codec.http.headers.HttpHeaders.newHeaders());
case "Jetty" -> new JettyHeadersAdapter(HttpFields.build());
//FIXME tomcat/undertow implementations (in another package)
// case "Tomcat" -> new TomcatHeadersAdapter(new MimeHeaders());
// case "Undertow" -> new UndertowHeadersAdapter(new HeaderMap());
default -> throw new IllegalArgumentException("Unsupported implementation: " + this.implementation);
};
initHeaders();
}
//Uncomment the following line and comment the similar line for setupImplementationNew above
//to benchmark old implementations
// @Setup(Level.Trial)
public void setupImplementationBaseline() {
this.entriesProvider = MultiValueMap::entrySet;
this.headers = switch (this.implementation) {
case "Netty" -> new HeadersAdaptersBaseline.Netty4(new DefaultHttpHeaders());
case "HttpComponents" -> new HeadersAdaptersBaseline.HttpComponents(new HttpGet("https://example.com"));
case "Netty5" -> new HeadersAdaptersBaseline.Netty5(io.netty5.handler.codec.http.headers.HttpHeaders.newHeaders());
case "Jetty" -> new HeadersAdaptersBaseline.Jetty(HttpFields.build());
default -> throw new IllegalArgumentException("Unsupported implementation: " + this.implementation);
};
initHeaders();
}
private void initHeaders() {
this.headers.add("TestHeader", "first");
this.headers.add("SecondHeader", "value");
if (this.duplicate) {
this.headers.add("TestHEADER", "second");
}
else {
this.headers.add("TestHeader", "second");
}
this.headers.add("TestHeader", "third");
}
}
}

View File

@ -30,14 +30,17 @@ import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.AbstractSet;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.StringJoiner;
import java.util.function.BiConsumer;
@ -63,7 +66,23 @@ import org.springframework.util.StringUtils;
* <li>{@link #set(String, String)} sets the header value to a single string value</li>
* </ul>
*
* <p>Note that {@code HttpHeaders} generally treats header names in a case-insensitive manner.
* <p>Note that {@code HttpHeaders} instances created by the default constructor
* treat header names in a case-insensitive manner. Instances created with the
* {@link #HttpHeaders(MultiValueMap)} constructor like those instantiated
* internally by the framework to adapt to existing HTTP headers data structures
* do guarantee per-header get/set/add operations to be case-insensitive as
* mandated by the HTTP specification. However, it is not necessarily the case
* for operations that deal with the collection as a whole (like {@code size()},
* {@code values()}, {@code keySet()} and {@code entrySet()}). Prefer using
* {@link #headerSet()} for these cases.
*
* <p>Some backing implementations can store header names in a case-sensitive
* manner, which will lead to duplicates during the entrySet() iteration where
* multiple occurrences of a header name can surface depending on letter casing
* but each such entry has the full {@code List} of values. &mdash; This can be
* problematic for example when copying headers into a new instance by iterating
* over the old instance's {@code entrySet()} and using
* {@link #addAll(String, List)} rather than {@link #put(String, List)}.
*
* @author Arjen Poutsma
* @author Sebastien Deleuze
@ -71,6 +90,7 @@ import org.springframework.util.StringUtils;
* @author Juergen Hoeller
* @author Josh Long
* @author Sam Brannen
* @author Simon Baslé
* @since 3.0
*/
public class HttpHeaders implements MultiValueMap<String, String>, Serializable {
@ -418,8 +438,8 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
/**
* Construct a new, empty instance of the {@code HttpHeaders} object.
* <p>This is the common constructor, using a case-insensitive map structure.
* Construct a new, empty instance of the {@code HttpHeaders} object
* using an underlying case-insensitive map.
*/
public HttpHeaders() {
this(CollectionUtils.toMultiValueMap(new LinkedCaseInsensitiveMap<>(8, Locale.ROOT)));
@ -1748,11 +1768,6 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
// Map implementation
@Override
public int size() {
return this.headers.size();
}
@Override
public boolean isEmpty() {
return this.headers.isEmpty();
@ -1794,29 +1809,81 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
this.headers.clear();
}
@Override
public List<String> putIfAbsent(String key, List<String> value) {
return this.headers.putIfAbsent(key, value);
}
// Map/MultiValueMap methods that can have duplicate header names: size/keySet/values/entrySet/forEach
/**
* Return the number of headers in the collection. This can be inflated,
* see {@link HttpHeaders class level javadoc}.
*/
@Override
public int size() {
return this.headers.size();
}
/**
* Return a {@link Set} view of header names. This can include multiple
* casing variants of a given header name, see
* {@link HttpHeaders class level javadoc}.
*/
@Override
public Set<String> keySet() {
return this.headers.keySet();
}
/**
* Return a {@link Collection} view of all the header values, reconstructed
* from iterating over the {@link #keySet()}. This can include duplicates if
* multiple casing variants of a given header name are tracked, see
* {@link HttpHeaders class level javadoc}.
*/
@Override
public Collection<List<String>> values() {
return this.headers.values();
}
/**
* Return a {@link Set} views of header entries, reconstructed from
* iterating over the {@link #keySet()}. This can include duplicate entries
* if multiple casing variants of a given header name are tracked, see
* {@link HttpHeaders class level javadoc}.
* @see #headerSet()
*/
@Override
public Set<Entry<String, List<String>>> entrySet() {
return this.headers.entrySet();
}
/**
* Perform an action over each header, as when iterated via
* {@link #entrySet()}. This can include duplicate entries
* if multiple casing variants of a given header name are tracked, see
* {@link HttpHeaders class level javadoc}.
* @param action the action to be performed for each entry
*/
@Override
public void forEach(BiConsumer<? super String, ? super List<String>> action) {
this.headers.forEach(action);
}
@Override
public List<String> putIfAbsent(String key, List<String> value) {
return this.headers.putIfAbsent(key, value);
/**
* Return a view of the headers as an entry {@code Set} of key-list pairs.
* Both {@link Iterator#remove()} and {@link java.util.Map.Entry#setValue}
* are supported and mutate the headers.
* <p>This collection is guaranteed to contain one entry per header name
* even if the backing structure stores multiple casing variants of names,
* at the cost of first copying the names into a case-insensitive set for
* filtering the iteration.
* @return a {@code Set} view that iterates over all headers in a
* case-insensitive manner
* @since 6.1.15
*/
public Set<Entry<String, List<String>>> headerSet() {
return new CaseInsensitiveEntrySet(this.headers);
}
@ -1882,19 +1949,29 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
* Helps to format HTTP header values, as HTTP header values themselves can
* contain comma-separated values, can become confusing with regular
* {@link Map} formatting that also uses commas between entries.
* <p>Additionally, this method displays the native list of header names
* with the mention {@code with native header names} if the underlying
* implementation stores multiple casing variants of header names (see
* {@link HttpHeaders class level javadoc}).
* @param headers the headers to format
* @return the headers to a String
* @since 5.1.4
*/
public static String formatHeaders(MultiValueMap<String, String> headers) {
return headers.entrySet().stream()
.map(entry -> {
List<String> values = entry.getValue();
return entry.getKey() + ":" + (values.size() == 1 ?
Set<String> headerNames = toCaseInsensitiveSet(headers.keySet());
String suffix = "]";
if (headerNames.size() != headers.size()) {
suffix = "] with native header names " + headers.keySet();
}
return headerNames.stream()
.map(headerName -> {
List<String> values = headers.get(headerName);
return headerName + ":" + (values.size() == 1 ?
"\"" + values.get(0) + "\"" :
values.stream().map(s -> "\"" + s + "\"").collect(Collectors.joining(", ")));
})
.collect(Collectors.joining(", ", "[", "]"));
.collect(Collectors.joining(", ", "[", suffix));
}
/**
@ -1947,4 +2024,103 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
return DATE_FORMATTER.format(time);
}
private static Set<String> toCaseInsensitiveSet(Set<String> originalSet) {
final Set<String> deduplicatedSet = Collections.newSetFromMap(
new LinkedCaseInsensitiveMap<>(originalSet.size(), Locale.ROOT));
// add/addAll (put/putAll in LinkedCaseInsensitiveMap) retain the casing of the last occurrence.
// Here we prefer the first.
for (String header : originalSet) {
//noinspection RedundantCollectionOperation
if (!deduplicatedSet.contains(header)) {
deduplicatedSet.add(header);
}
}
return deduplicatedSet;
}
private static final class CaseInsensitiveEntrySet extends AbstractSet<Entry<String, List<String>>> {
private final MultiValueMap<String, String> headers;
private final Set<String> deduplicatedNames;
public CaseInsensitiveEntrySet(MultiValueMap<String, String> headers) {
this.headers = headers;
this.deduplicatedNames = toCaseInsensitiveSet(headers.keySet());
}
@Override
public Iterator<Map.Entry<String, List<String>>> iterator() {
return new CaseInsensitiveIterator(this.deduplicatedNames.iterator());
}
@Override
public int size() {
return this.deduplicatedNames.size();
}
private final class CaseInsensitiveIterator implements Iterator<Map.Entry<String, List<String>>> {
private final Iterator<String> namesIterator;
@Nullable
private String currentName;
private CaseInsensitiveIterator(Iterator<String> namesIterator) {
this.namesIterator = namesIterator;
this.currentName = null;
}
@Override
public boolean hasNext() {
return this.namesIterator.hasNext();
}
@Override
public Map.Entry<String, List<String>> next() {
this.currentName = this.namesIterator.next();
return new CaseInsensitiveEntry(this.currentName);
}
@Override
public void remove() {
if (this.currentName == null) {
throw new IllegalStateException("No current Header in iterator");
}
if (!CaseInsensitiveEntrySet.this.headers.containsKey(this.currentName)) {
throw new IllegalStateException("Header not present: " + this.currentName);
}
CaseInsensitiveEntrySet.this.headers.remove(this.currentName);
}
}
private final class CaseInsensitiveEntry implements Map.Entry<String, List<String>> {
private final String key;
CaseInsensitiveEntry(String key) {
this.key = key;
}
@Override
public String getKey() {
return this.key;
}
@Override
public List<String> getValue() {
return Objects.requireNonNull(CaseInsensitiveEntrySet.this.headers.get(this.key));
}
@Override
public List<String> setValue(List<String> value) {
List<String> previousValues = Objects.requireNonNull(
CaseInsensitiveEntrySet.this.headers.get(this.key));
CaseInsensitiveEntrySet.this.headers.put(this.key, value);
return previousValues;
}
}
}
}

View File

@ -21,7 +21,9 @@ import java.util.Collection;
import java.util.Collections;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@ -38,6 +40,7 @@ import org.springframework.util.MultiValueMap;
*
* @author Brian Clozel
* @author Sam Brannen
* @author Simon Baslé
* @since 5.1.1
*/
class TomcatHeadersAdapter implements MultiValueMap<String, String> {
@ -90,12 +93,11 @@ class TomcatHeadersAdapter implements MultiValueMap<String, String> {
@Override
public int size() {
Enumeration<String> names = this.headers.names();
int size = 0;
Set<String> deduplicated = new LinkedHashSet<>();
while (names.hasMoreElements()) {
size++;
names.nextElement();
deduplicated.add(names.nextElement().toLowerCase(Locale.ROOT));
}
return size;
return deduplicated.size();
}
@Override
@ -185,7 +187,7 @@ class TomcatHeadersAdapter implements MultiValueMap<String, String> {
@Override
public int size() {
return headers.size();
return TomcatHeadersAdapter.this.size();
}
};
}
@ -289,11 +291,17 @@ class TomcatHeadersAdapter implements MultiValueMap<String, String> {
if (this.currentName == null) {
throw new IllegalStateException("No current Header in iterator");
}
int index = headers.findHeader(this.currentName, 0);
if (index == -1) {
//implement a mix of removeHeader(String) and removeHeader(int)
boolean found = false;
for (int i = 0; i < headers.size(); i++) {
if (headers.getName(i).equalsIgnoreCase(this.currentName)) {
headers.removeHeader(i--);
found = true;
}
}
if (!found) {
throw new IllegalStateException("Header not present: " + this.currentName);
}
headers.removeHeader(index);
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* Copyright 2002-2024 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.
@ -22,8 +22,8 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@ -33,7 +33,7 @@ import org.apache.hc.core5.http.HttpMessage;
import org.springframework.http.HttpHeaders;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.LinkedCaseInsensitiveMap;
import org.springframework.util.MultiValueMap;
/**
@ -41,12 +41,13 @@ import org.springframework.util.MultiValueMap;
* HttpClient headers.
*
* @author Rossen Stoyanchev
* @author Simon Baslé
* @since 6.1
*/
public final class HttpComponentsHeadersAdapter implements MultiValueMap<String, String> {
private final HttpMessage message;
private final Set<String> headerNames;
/**
* Create a new {@code HttpComponentsHeadersAdapter} based on the given
@ -55,6 +56,11 @@ public final class HttpComponentsHeadersAdapter implements MultiValueMap<String,
public HttpComponentsHeadersAdapter(HttpMessage message) {
Assert.notNull(message, "Message must not be null");
this.message = message;
this.headerNames = Collections.newSetFromMap(new LinkedCaseInsensitiveMap<>(
message.getHeaders().length, Locale.ROOT));
for (Header header : message.getHeaders()) {
this.headerNames.add(header.getName());
}
}
@ -67,6 +73,7 @@ public final class HttpComponentsHeadersAdapter implements MultiValueMap<String,
@Override
public void add(String key, @Nullable String value) {
this.headerNames.add(key);
this.message.addHeader(key, value);
}
@ -82,6 +89,7 @@ public final class HttpComponentsHeadersAdapter implements MultiValueMap<String,
@Override
public void set(String key, @Nullable String value) {
this.headerNames.add(key);
this.message.setHeader(key, value);
}
@ -92,14 +100,14 @@ public final class HttpComponentsHeadersAdapter implements MultiValueMap<String,
@Override
public Map<String, String> toSingleValueMap() {
Map<String, String> map = CollectionUtils.newLinkedHashMap(size());
Map<String, String> map = new LinkedCaseInsensitiveMap<>(this.message.getHeaders().length, Locale.ROOT);
this.message.headerIterator().forEachRemaining(h -> map.putIfAbsent(h.getName(), h.getValue()));
return map;
}
@Override
public int size() {
return this.message.getHeaders().length;
return this.headerNames.size();
}
@Override
@ -145,6 +153,7 @@ public final class HttpComponentsHeadersAdapter implements MultiValueMap<String,
public List<String> remove(Object key) {
if (key instanceof String headerName) {
List<String> oldValues = get(key);
this.headerNames.remove(headerName);
this.message.removeHeaders(headerName);
return oldValues;
}
@ -158,23 +167,20 @@ public final class HttpComponentsHeadersAdapter implements MultiValueMap<String,
@Override
public void clear() {
this.headerNames.clear();
this.message.setHeaders();
}
@Override
public Set<String> keySet() {
Set<String> keys = new LinkedHashSet<>(size());
for (Header header : this.message.getHeaders()) {
keys.add(header.getName());
}
return keys;
return new HeaderNames();
}
@Override
public Collection<List<String>> values() {
Collection<List<String>> values = new ArrayList<>(size());
for (Header header : this.message.getHeaders()) {
values.add(get(header.getName()));
Collection<List<String>> values = new ArrayList<>(this.message.getHeaders().length);
for (String headerName : keySet()) {
values.add(get(headerName));
}
return values;
}
@ -200,10 +206,22 @@ public final class HttpComponentsHeadersAdapter implements MultiValueMap<String,
return HttpHeaders.formatHeaders(this);
}
private class HeaderNames extends AbstractSet<String> {
@Override
public Iterator<String> iterator() {
return new HeaderNamesIterator(headerNames.iterator());
}
@Override
public int size() {
return headerNames.size();
}
}
private class EntryIterator implements Iterator<Entry<String, List<String>>> {
private final Iterator<Header> iterator = message.headerIterator();
private final Iterator<String> iterator = keySet().iterator();
@Override
public boolean hasNext() {
@ -212,7 +230,7 @@ public final class HttpComponentsHeadersAdapter implements MultiValueMap<String,
@Override
public Entry<String, List<String>> next() {
return new HeaderEntry(this.iterator.next().getName());
return new HeaderEntry(this.iterator.next());
}
}
@ -244,4 +262,40 @@ public final class HttpComponentsHeadersAdapter implements MultiValueMap<String,
}
}
private final class HeaderNamesIterator implements Iterator<String> {
private final Iterator<String> iterator;
@Nullable
private String currentName;
private HeaderNamesIterator(Iterator<String> iterator) {
this.iterator = iterator;
}
@Override
public boolean hasNext() {
return this.iterator.hasNext();
}
@Override
public String next() {
this.currentName = this.iterator.next();
return this.currentName;
}
@Override
public void remove() {
if (this.currentName == null) {
throw new IllegalStateException("No current Header in iterator");
}
if (!message.containsHeader(this.currentName)) {
throw new IllegalStateException("Header not present: " + this.currentName);
}
headerNames.remove(this.currentName);
message.removeHeaders(this.currentName);
}
}
}

View File

@ -20,6 +20,7 @@ import java.util.AbstractSet;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@ -29,7 +30,7 @@ import org.eclipse.jetty.http.HttpFields;
import org.springframework.http.HttpHeaders;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.LinkedCaseInsensitiveMap;
import org.springframework.util.MultiValueMap;
/**
@ -38,6 +39,7 @@ import org.springframework.util.MultiValueMap;
* @author Rossen Stoyanchev
* @author Juergen Hoeller
* @author Sam Brannen
* @author Simon Baslé
* @since 6.1
*/
public final class JettyHeadersAdapter implements MultiValueMap<String, String> {
@ -97,7 +99,8 @@ public final class JettyHeadersAdapter implements MultiValueMap<String, String>
@Override
public Map<String, String> toSingleValueMap() {
Map<String, String> singleValueMap = CollectionUtils.newLinkedHashMap(this.headers.size());
Map<String, String> singleValueMap = new LinkedCaseInsensitiveMap<>(
this.headers.size(), Locale.ROOT);
Iterator<HttpField> iterator = this.headers.iterator();
iterator.forEachRemaining(field -> {
if (!singleValueMap.containsKey(field.getName())) {
@ -189,7 +192,7 @@ public final class JettyHeadersAdapter implements MultiValueMap<String, String>
}
@Override
public int size() {
return headers.size();
return headers.getFieldNamesCollection().size();
}
};
}

View File

@ -20,6 +20,7 @@ import java.util.AbstractSet;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@ -27,7 +28,7 @@ import io.netty.handler.codec.http.HttpHeaders;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.LinkedCaseInsensitiveMap;
import org.springframework.util.MultiValueMap;
/**
@ -35,6 +36,7 @@ import org.springframework.util.MultiValueMap;
*
* @author Rossen Stoyanchev
* @author Sam Brannen
* @author Simon Baslé
* @since 6.1
*/
public final class Netty4HeadersAdapter implements MultiValueMap<String, String> {
@ -89,7 +91,8 @@ public final class Netty4HeadersAdapter implements MultiValueMap<String, String>
@Override
public Map<String, String> toSingleValueMap() {
Map<String, String> singleValueMap = CollectionUtils.newLinkedHashMap(this.headers.size());
Map<String, String> singleValueMap = new LinkedCaseInsensitiveMap<>(
this.headers.size(), Locale.ROOT);
this.headers.entries()
.forEach(entry -> {
if (!singleValueMap.containsKey(entry.getKey())) {

View File

@ -22,6 +22,7 @@ import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.StreamSupport;
@ -30,13 +31,14 @@ import io.netty5.handler.codec.http.headers.HttpHeaders;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.LinkedCaseInsensitiveMap;
import org.springframework.util.MultiValueMap;
/**
* {@code MultiValueMap} implementation for wrapping Netty HTTP headers.
*
* @author Violeta Georgieva
* @author Simon Baslé
* @since 6.1
*/
public final class Netty5HeadersAdapter implements MultiValueMap<String, String> {
@ -92,7 +94,8 @@ public final class Netty5HeadersAdapter implements MultiValueMap<String, String>
@Override
public Map<String, String> toSingleValueMap() {
Map<String, String> singleValueMap = CollectionUtils.newLinkedHashMap(this.headers.size());
Map<String, String> singleValueMap = new LinkedCaseInsensitiveMap<>(
this.headers.size(), Locale.ROOT);
this.headers.forEach(entry -> singleValueMap.putIfAbsent(
entry.getKey().toString(), entry.getValue().toString()));
return singleValueMap;

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))))
);
}