
io.nats.client.impl.Headers Maven / Gradle / Ivy
// Copyright 2020 The NATS 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:
//
// http://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 io.nats.client.impl;
import io.nats.client.support.ByteArrayBuilder;
import java.util.*;
import java.util.function.BiConsumer;
import static io.nats.client.support.NatsConstants.*;
import static java.nio.charset.StandardCharsets.US_ASCII;
/**
* An object that represents a map of keys to a list of values. It does not accept
* null or invalid keys. It ignores null values, accepts empty string as a value
* and rejects invalid values.
* !!!
* THIS CLASS IS NOT THREAD SAFE
*/
public class Headers {
private static final String KEY_CANNOT_BE_EMPTY_OR_NULL = "Header key cannot be null.";
private static final String KEY_INVALID_CHARACTER = "Header key has invalid character: ";
private static final String VALUE_INVALID_CHARACTERS = "Header value has invalid character: ";
private final Map> valuesMap;
private final Map lengthMap;
private final boolean readOnly;
private byte[] serialized;
private int dataLength;
public Headers() {
this(null, false, null);
}
public Headers(Headers headers) {
this(headers, false, null);
}
public Headers(Headers headers, boolean readOnly) {
this(headers, readOnly, null);
}
public Headers(Headers headers, boolean readOnly, String[] keysNotToCopy) {
Map> tempValuesMap = new HashMap<>();
Map tempLengthMap = new HashMap<>();
if (headers != null) {
tempValuesMap.putAll(headers.valuesMap);
tempLengthMap.putAll(headers.lengthMap);
dataLength = headers.dataLength;
if (keysNotToCopy != null) {
for (String key : keysNotToCopy) {
if (key != null) {
if (tempValuesMap.remove(key) != null) {
dataLength -= tempLengthMap.remove(key);
}
}
}
}
}
this.readOnly = readOnly;
if (readOnly) {
valuesMap = Collections.unmodifiableMap(tempValuesMap);
lengthMap = Collections.unmodifiableMap(tempLengthMap);
}
else {
valuesMap = tempValuesMap;
lengthMap = tempLengthMap;
}
}
/**
* If the key is present add the values to the list of values for the key.
* If the key is not present, sets the specified values for the key.
* null values are ignored. If all values are null, the key is not added or updated.
*
* @param key the key
* @param values the values
* @return the Headers object
* @throws IllegalArgumentException if the key is null or empty or contains invalid characters
* -or- if any value contains invalid characters
*/
public Headers add(String key, String... values) {
if (readOnly) {
throw new UnsupportedOperationException();
}
if (values == null || values.length == 0) {
return this;
}
return _add(key, Arrays.asList(values));
}
/**
* If the key is present add the values to the list of values for the key.
* If the key is not present, sets the specified values for the key.
* null values are ignored. If all values are null, the key is not added or updated.
*
* @param key the entry key
* @param values a list of values to the entry
* @return the Header object
* @throws IllegalArgumentException if the key is null or empty or contains invalid characters
* -or- if any value contains invalid characters
*/
public Headers add(String key, Collection values) {
if (readOnly) {
throw new UnsupportedOperationException();
}
if (values == null || values.isEmpty()) {
return this;
}
return _add(key, values);
}
// the add delegate
private Headers _add(String key, Collection values) {
if (values != null) {
Checker checked = new Checker(key, values);
if (checked.hasValues()) {
// get values by key or compute empty if absent
// update the data length with the additional len
// update the lengthMap for the key to the old length plus the new length
List currentSet = valuesMap.computeIfAbsent(key, k -> new ArrayList<>());
currentSet.addAll(checked.list);
dataLength += checked.len;
int oldLen = lengthMap.getOrDefault(key, 0);
lengthMap.put(key, oldLen + checked.len);
serialized = null; // since the data changed, clear this so it's rebuilt
}
}
return this;
}
/**
* Associates the specified values with the key. If the key was already present
* any existing values are removed and replaced with the new list.
* null values are ignored. If all values are null, the put is ignored
*
* @param key the key
* @param values the values
* @return the Headers object
* @throws IllegalArgumentException if the key is null or empty or contains invalid characters
* -or- if any value contains invalid characters
*/
public Headers put(String key, String... values) {
if (readOnly) {
throw new UnsupportedOperationException();
}
if (values == null || values.length == 0) {
return this;
}
return _put(key, Arrays.asList(values));
}
/**
* Associates the specified values with the key. If the key was already present
* any existing values are removed and replaced with the new list.
* null values are ignored. If all values are null, the put is ignored
*
* @param key the key
* @param values the values
* @return the Headers object
* @throws IllegalArgumentException if the key is null or empty or contains invalid characters
* -or- if any value contains invalid characters
*/
public Headers put(String key, Collection values) {
if (readOnly) {
throw new UnsupportedOperationException();
}
if (values == null || values.isEmpty()) {
return this;
}
return _put(key, values);
}
/**
* Associates all specified values with their key. If the key was already present
* any existing values are removed and replaced with the new list.
* null values are ignored. If all values are null, the put is ignored
* @param map the map
* @return the Headers object
*/
public Headers put(Map> map) {
if (readOnly) {
throw new UnsupportedOperationException();
}
if (map == null || map.isEmpty()) {
return this;
}
for (String key : map.keySet() ) {
_put(key, map.get(key));
}
return this;
}
// the put delegate
private Headers _put(String key, Collection values) {
if (key == null || key.isEmpty()) {
throw new IllegalArgumentException("Key cannot be null or empty.");
}
if (values != null) {
Checker checked = new Checker(key, values);
if (checked.hasValues()) {
// update the data length removing the old length adding the new length
// put for the key
dataLength = dataLength - lengthMap.getOrDefault(key, 0) + checked.len;
valuesMap.put(key, checked.list);
lengthMap.put(key, checked.len);
serialized = null; // since the data changed, clear this so it's rebuilt
}
}
return this;
}
/**
* Removes each key and its values if the key was present
*
* @param keys the key or keys to remove
*/
public void remove(String... keys) {
if (readOnly) {
throw new UnsupportedOperationException();
}
for (String key : keys) {
_remove(key);
}
serialized = null; // since the data changed, clear this so it's rebuilt
}
/**
* Removes each key and its values if the key was present
*
* @param keys the key or keys to remove
*/
public void remove(Collection keys) {
if (readOnly) {
throw new UnsupportedOperationException();
}
for (String key : keys) {
_remove(key);
}
serialized = null; // since the data changed, clear this so it's rebuilt
}
// the remove delegate
private void _remove(String key) {
// if the values had a key, then the data length had a length
if (valuesMap.remove(key) != null) {
dataLength -= lengthMap.remove(key);
}
}
/**
* Returns the number of keys (case-sensitive) in the header.
*
* @return the number of header entries
*/
public int size() {
return valuesMap.size();
}
/**
* Returns ture if map contains no keys.
*
* @return true if there are no headers
*/
public boolean isEmpty() {
return valuesMap.isEmpty();
}
/**
* Removes all the keys The object map will be empty after this call returns.
*/
public void clear() {
if (readOnly) {
throw new UnsupportedOperationException();
}
valuesMap.clear();
lengthMap.clear();
dataLength = 0;
serialized = null;
}
/**
* Returns true if key (case-sensitive) is present (has values)
*
* @param key key whose presence is to be tested
* @return true if the key (case-sensitive) is present (has values)
*/
public boolean containsKey(String key) {
return valuesMap.containsKey(key);
}
/**
* Returns true if key (case-insensitive) is present (has values)
*
* @param key exact key whose presence is to be tested
* @return true if the key (case-insensitive) is present (has values)
*/
public boolean containsKeyIgnoreCase(String key) {
for (String k : valuesMap.keySet()) {
if (k.equalsIgnoreCase(key)) {
return true;
}
}
return false;
}
/**
* Returns a {@link Set} view of the keys (case-sensitive) contained in the object.
*
* @return a read-only set the keys contained in this map
*/
public Set keySet() {
return Collections.unmodifiableSet(valuesMap.keySet());
}
/**
* Returns a {@link Set} view of the keys (case-insensitive) contained in the object.
*
* @return a read-only set of keys (in lowercase) contained in this map
*/
public Set keySetIgnoreCase() {
HashSet set = new HashSet<>();
for (String k : valuesMap.keySet()) {
set.add(k.toLowerCase());
}
return Collections.unmodifiableSet(set);
}
/**
* Returns a {@link List} view of the values for the specific (case-sensitive) key.
* Will be {@code null} if the key is not found.
*
* @param key the key whose associated value is to be returned
* @return a read-only list of the values for the case-sensitive key.
*/
public List get(String key) {
List values = valuesMap.get(key);
return values == null ? null : Collections.unmodifiableList(values);
}
/**
* Returns the first value for the specific (case-sensitive) key.
* Will be {@code null} if the key is not found.
* @param key the key whose associated value is to be returned
* @return the first value for the case-sensitive key.
*/
public String getFirst(String key) {
List values = valuesMap.get(key);
return values == null ? null : values.get(0);
}
/**
* Returns the last value for the specific (case-sensitive) key.
* Will be {@code null} if the key is not found.
*
* @param key the key whose associated value is to be returned
* @return the last value for the case-sensitive key.
*/
public String getLast(String key) {
List values = valuesMap.get(key);
return values == null ? null : values.get(values.size() - 1);
}
/**
* Returns a {@link List} view of the values for the specific (case-insensitive) key.
* Will be {@code null} if the key is not found.
*
* @param key the key whose associated value is to be returned
* @return a read-only list of the values for the case-insensitive key.
*/
public List getIgnoreCase(String key) {
List values = new ArrayList<>();
for (String k : valuesMap.keySet()) {
if (k.equalsIgnoreCase(key)) {
values.addAll(valuesMap.get(k));
}
}
return values.isEmpty() ? null : Collections.unmodifiableList(values);
}
/**
* Performs the given action for each header entry (case-sensitive keys) until all entries
* have been processed or the action throws an exception.
* Any attempt to modify the values will throw an exception.
*
* @param action The action to be performed for each entry
* @throws NullPointerException if the specified action is null
* @throws ConcurrentModificationException if an entry is found to be
* removed during iteration
*/
public void forEach(BiConsumer> action) {
Collections.unmodifiableMap(valuesMap).forEach(action);
}
/**
* Returns a {@link Set} read only view of the mappings contained in the header (case-sensitive keys).
* The set is not modifiable and any attempt to modify will throw an exception.
*
* @return a set view of the mappings contained in this map
*/
public Set>> entrySet() {
return Collections.unmodifiableSet(valuesMap.entrySet());
}
/**
* Returns if the headers are dirty, which means the serialization
* has not been done so also don't know the byte length
*
* @return true if dirty
*/
public boolean isDirty() {
return serialized == null;
}
/**
* Returns the number of bytes that will be in the serialized version.
*
* @return the number of bytes
*/
public int serializedLength() {
return dataLength + NON_DATA_BYTES;
}
private static final int HVCRLF_BYTES = HEADER_VERSION_BYTES_PLUS_CRLF.length;
private static final int NON_DATA_BYTES = HVCRLF_BYTES + 2;
/**
* Returns the serialized bytes.
*
* @return the bytes
*/
public byte[] getSerialized() {
if (serialized == null) {
serialized = new byte[serializedLength()];
serializeToArray(0, serialized);
}
return serialized;
}
/**
* @deprecated
* Used for unit testing.
* Appends the serialized bytes to the builder.
*
* @param bab the ByteArrayBuilder to append
* @return the builder
*/
@Deprecated
public ByteArrayBuilder appendSerialized(ByteArrayBuilder bab) {
bab.append(HEADER_VERSION_BYTES_PLUS_CRLF);
for (String key : valuesMap.keySet()) {
for (String value : valuesMap.get(key)) {
bab.append(key);
bab.append(COLON_BYTES);
bab.append(value);
bab.append(CRLF_BYTES);
}
}
bab.append(CRLF_BYTES);
return bab;
}
/**
* Write the header to the byte array. Assumes that the caller has
* already validated that the destination array is large enough by using getSerialized()
* @param destPosition the position index in destination byte array to start
* @param dest the byte array to write to
* @return the length of the header
*/
public int serializeToArray(int destPosition, byte[] dest) {
System.arraycopy(HEADER_VERSION_BYTES_PLUS_CRLF, 0, dest, destPosition, HVCRLF_BYTES);
destPosition += HVCRLF_BYTES;
for (Map.Entry> entry : valuesMap.entrySet()) {
List values = entry.getValue();
for (String value : values) {
byte[] bytes = entry.getKey().getBytes(US_ASCII);
System.arraycopy(bytes, 0, dest, destPosition, bytes.length);
destPosition += bytes.length;
dest[destPosition++] = COLON;
bytes = value.getBytes(US_ASCII);
System.arraycopy(bytes, 0, dest, destPosition, bytes.length);
destPosition += bytes.length;
dest[destPosition++] = CR;
dest[destPosition++] = LF;
}
}
dest[destPosition++] = CR;
dest[destPosition] = LF;
return serializedLength();
}
/**
* Check the key to ensure it matches the specification for keys.
*
* @throws IllegalArgumentException if the key is null, empty or contains
* an invalid character
*/
private void checkKey(String key) {
// key cannot be null or empty and contain only printable characters except colon
if (key == null || key.isEmpty()) {
throw new IllegalArgumentException(KEY_CANNOT_BE_EMPTY_OR_NULL);
}
int len = key.length();
for (int idx = 0; idx < len; idx++) {
char c = key.charAt(idx);
if (c < 33 || c > 126 || c == ':') {
throw new IllegalArgumentException(KEY_INVALID_CHARACTER + "'" + c + "'");
}
}
}
/**
* Check a non-null value if it matches the specification for values.
*
* @throws IllegalArgumentException if the value contains an invalid character
*/
private void checkValue(String val) {
// Generally more permissive than HTTP. Allow only printable
// characters and include tab (0x9) to cover what's allowed
// in quoted strings and comments.
val.chars().forEach(c -> {
if ((c < 32 && c != 9) || c > 126) {
throw new IllegalArgumentException(VALUE_INVALID_CHARACTERS + c);
}
});
}
private class Checker {
List list = new ArrayList<>();
int len = 0;
Checker(String key, Collection values) {
checkKey(key);
if (!values.isEmpty()) {
for (String val : values) {
if (val != null) {
if (val.isEmpty()) {
list.add(val);
len += key.length() + 3; // for colon, cr, lf
}
else {
checkValue(val);
list.add(val);
len += key.length() + val.length() + 3; // for colon, cr, lf
}
}
}
}
}
boolean hasValues() {
return !list.isEmpty();
}
}
/**
* Whether the entire Headers is read only
* @return the read only state
*/
public boolean isReadOnly() {
return readOnly;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Headers headers = (Headers) o;
return Objects.equals(valuesMap, headers.valuesMap);
}
@Override
public int hashCode() {
return Objects.hash(valuesMap);
}
}