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:
parent
4ef2b429e0
commit
9b3cb15389
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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. — 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue