Suggestion for supporting single header fields.

Motivation:
At the moment if you want to return a HTTP header containing multiple
values you have to set/add that header once with the values wanted. If
you used set/add with an array/iterable multiple HTTP header fields will
be returned in the response.

Note, that this is indeed a suggestion and additional work and tests
should be added. This is mainly to bring up a discussion.

Modifications:
Added a flag to specify that when multiple values exist for a single
HTTP header then add them as a comma separated string.
In addition added a method to StringUtil to help escape comma separated
value charsequences.

Result:
Allows for responses to be smaller.
This commit is contained in:
Daniel Bevenius 2014-12-12 13:46:54 +01:00
parent 99bd43ed51
commit c53b8d5a85
13 changed files with 955 additions and 51 deletions

View File

@ -33,21 +33,31 @@ public class DefaultFullHttpResponse extends DefaultHttpResponse implements Full
}
public DefaultFullHttpResponse(HttpVersion version, HttpResponseStatus status, ByteBuf content) {
this(version, status, content, true);
this(version, status, content, false);
}
public DefaultFullHttpResponse(HttpVersion version, HttpResponseStatus status, boolean validateHeaders) {
this(version, status, Unpooled.buffer(0), validateHeaders);
this(version, status, Unpooled.buffer(0), validateHeaders, false);
}
public DefaultFullHttpResponse(HttpVersion version, HttpResponseStatus status, boolean validateHeaders,
boolean singleFieldHeaders) {
this(version, status, Unpooled.buffer(0), validateHeaders, singleFieldHeaders);
}
public DefaultFullHttpResponse(HttpVersion version, HttpResponseStatus status,
ByteBuf content, boolean validateHeaders) {
super(version, status, validateHeaders);
ByteBuf content, boolean singleFieldHeaders) {
this(version, status, content, true, singleFieldHeaders);
}
public DefaultFullHttpResponse(HttpVersion version, HttpResponseStatus status,
ByteBuf content, boolean validateHeaders, boolean singleFieldHeaders) {
super(version, status, validateHeaders, singleFieldHeaders);
if (content == null) {
throw new NullPointerException("content");
}
this.content = content;
trailingHeaders = new DefaultHttpHeaders(validateHeaders);
trailingHeaders = new DefaultHttpHeaders(validateHeaders, singleFieldHeaders);
this.validateHeaders = validateHeaders;
}

View File

@ -247,12 +247,17 @@ public class DefaultHttpHeaders extends HttpHeaders {
}
public DefaultHttpHeaders(boolean validate) {
this(true, validate? VALIDATE_NAME_CONVERTER : NO_VALIDATE_NAME_CONVERTER);
this(true, validate? VALIDATE_NAME_CONVERTER : NO_VALIDATE_NAME_CONVERTER, false);
}
protected DefaultHttpHeaders(boolean validate, NameConverter<CharSequence> nameConverter) {
public DefaultHttpHeaders(boolean validate, boolean singleHeaderFields) {
this(true, validate? VALIDATE_NAME_CONVERTER : NO_VALIDATE_NAME_CONVERTER, singleHeaderFields);
}
protected DefaultHttpHeaders(boolean validate, NameConverter<CharSequence> nameConverter,
boolean singleHeaderFields) {
headers = new DefaultTextHeaders(true,
validate ? VALIDATE_OBJECT_CONVERTER : NO_VALIDATE_OBJECT_CONVERTER, nameConverter);
validate ? VALIDATE_OBJECT_CONVERTER : NO_VALIDATE_OBJECT_CONVERTER, nameConverter, singleHeaderFields);
}
@Override

View File

@ -27,18 +27,18 @@ public abstract class DefaultHttpMessage extends DefaultHttpObject implements Ht
* Creates a new instance.
*/
protected DefaultHttpMessage(final HttpVersion version) {
this(version, true);
this(version, true, false);
}
/**
* Creates a new instance.
*/
protected DefaultHttpMessage(final HttpVersion version, boolean validateHeaders) {
protected DefaultHttpMessage(final HttpVersion version, boolean validateHeaders, boolean singleHeaderFields) {
if (version == null) {
throw new NullPointerException("version");
}
this.version = version;
headers = new DefaultHttpHeaders(validateHeaders);
headers = new DefaultHttpHeaders(validateHeaders, singleHeaderFields);
}
@Override

View File

@ -43,7 +43,7 @@ public class DefaultHttpRequest extends DefaultHttpMessage implements HttpReques
* @param validateHeaders validate the header names and values when adding them to the {@link HttpHeaders}
*/
public DefaultHttpRequest(HttpVersion httpVersion, HttpMethod method, String uri, boolean validateHeaders) {
super(httpVersion, validateHeaders);
super(httpVersion, validateHeaders, false);
if (method == null) {
throw new NullPointerException("method");
}

View File

@ -31,7 +31,7 @@ public class DefaultHttpResponse extends DefaultHttpMessage implements HttpRespo
* @param status the getStatus of this response
*/
public DefaultHttpResponse(HttpVersion version, HttpResponseStatus status) {
this(version, status, true);
this(version, status, true, false);
}
/**
@ -42,7 +42,21 @@ public class DefaultHttpResponse extends DefaultHttpMessage implements HttpRespo
* @param validateHeaders validate the header names and values when adding them to the {@link HttpHeaders}
*/
public DefaultHttpResponse(HttpVersion version, HttpResponseStatus status, boolean validateHeaders) {
super(version, validateHeaders);
this(version, status, validateHeaders, false);
}
/**
* Creates a new instance.
*
* @param version the HTTP version of this response
* @param status the getStatus of this response
* @param validateHeaders validate the header names and values when adding them to the {@link HttpHeaders}
* @param singleHeaderFields determines if HTTP headers with multiple values should be added as a single
* field or as multiple header fields.
*/
public DefaultHttpResponse(HttpVersion version, HttpResponseStatus status, boolean validateHeaders,
boolean singleHeaderFields) {
super(version, validateHeaders, singleHeaderFields);
if (status == null) {
throw new NullPointerException("status");
}

View File

@ -132,7 +132,7 @@ public class DefaultLastHttpContent extends DefaultHttpContent implements LastHt
NO_VALIDATE_NAME_CONVERTER = new TrailingHttpHeadersNameConverter(false);
TrailingHttpHeaders(boolean validate) {
super(validate, validate ? VALIDATE_NAME_CONVERTER : NO_VALIDATE_NAME_CONVERTER);
super(validate, validate ? VALIDATE_NAME_CONVERTER : NO_VALIDATE_NAME_CONVERTER, false);
}
}
}

View File

@ -65,7 +65,7 @@ public class CorsHandler extends ChannelDuplexHandler {
}
private void handlePreflight(final ChannelHandlerContext ctx, final HttpRequest request) {
final HttpResponse response = new DefaultFullHttpResponse(request.protocolVersion(), OK);
final HttpResponse response = new DefaultFullHttpResponse(request.protocolVersion(), OK, true, true);
if (setOrigin(response)) {
setAllowMethods(response);
setAllowHeaders(response);

View File

@ -81,7 +81,8 @@ public class CorsHandlerTest {
.build();
final HttpResponse response = preflightRequest(config, "http://localhost:8888", "content-type, xheader1");
assertThat(response.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN), is("http://localhost:8888"));
assertThat(response.headers().getAll(ACCESS_CONTROL_ALLOW_METHODS), hasItems("GET", "DELETE"));
assertThat(response.headers().get(ACCESS_CONTROL_ALLOW_METHODS), containsString("GET"));
assertThat(response.headers().get(ACCESS_CONTROL_ALLOW_METHODS), containsString("DELETE"));
assertThat(response.headers().get(VARY), equalTo(ORIGIN.toString()));
}
@ -93,8 +94,10 @@ public class CorsHandlerTest {
.build();
final HttpResponse response = preflightRequest(config, "http://localhost:8888", "content-type, xheader1");
assertThat(response.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN), is("http://localhost:8888"));
assertThat(response.headers().getAll(ACCESS_CONTROL_ALLOW_METHODS), hasItems("OPTIONS", "GET"));
assertThat(response.headers().getAll(ACCESS_CONTROL_ALLOW_HEADERS), hasItems("content-type", "xheader1"));
assertThat(response.headers().get(ACCESS_CONTROL_ALLOW_METHODS), containsString("OPTIONS"));
assertThat(response.headers().get(ACCESS_CONTROL_ALLOW_METHODS), containsString("GET"));
assertThat(response.headers().get(ACCESS_CONTROL_ALLOW_HEADERS), containsString("content-type"));
assertThat(response.headers().get(ACCESS_CONTROL_ALLOW_HEADERS), containsString("xheader1"));
assertThat(response.headers().get(VARY), equalTo(ORIGIN.toString()));
}
@ -119,21 +122,27 @@ public class CorsHandlerTest {
@Test
public void preflightRequestWithCustomHeaders() {
final String headerName = "CustomHeader";
final String value1 = "value1";
final String value2 = "value2";
final CorsConfig config = CorsConfig.withOrigin("http://localhost:8888")
.preflightResponseHeader("CustomHeader", "value1", "value2")
.preflightResponseHeader(headerName, value1, value2)
.build();
final HttpResponse response = preflightRequest(config, "http://localhost:8888", "content-type, xheader1");
assertThat(response.headers().getAll("CustomHeader"), hasItems("value1", "value2"));
assertValues(response, headerName, value1, value2);
assertThat(response.headers().get(VARY), equalTo(ORIGIN.toString()));
}
@Test
public void preflightRequestWithCustomHeadersIterable() {
final String headerName = "CustomHeader";
final String value1 = "value1";
final String value2 = "value2";
final CorsConfig config = CorsConfig.withOrigin("http://localhost:8888")
.preflightResponseHeader("CustomHeader", Arrays.asList("value1", "value2"))
.preflightResponseHeader(headerName, Arrays.asList(value1, value2))
.build();
final HttpResponse response = preflightRequest(config, "http://localhost:8888", "content-type, xheader1");
assertThat(response.headers().getAll("CustomHeader"), hasItems("value1", "value2"));
assertValues(response, headerName, value1, value2);
assertThat(response.headers().get(VARY), equalTo(ORIGIN.toString()));
}
@ -180,7 +189,8 @@ public class CorsHandlerTest {
final CorsConfig config = CorsConfig.withAnyOrigin().exposeHeaders("custom1", "custom2").build();
final HttpResponse response = simpleRequest(config, "http://localhost:7777");
assertThat(response.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN), equalTo("*"));
assertThat(response.headers().getAll(ACCESS_CONTROL_EXPOSE_HEADERS), hasItems("custom1", "custom1"));
assertThat(response.headers().get(ACCESS_CONTROL_EXPOSE_HEADERS), containsString("custom1"));
assertThat(response.headers().get(ACCESS_CONTROL_EXPOSE_HEADERS), containsString("custom2"));
}
@Test
@ -210,7 +220,8 @@ public class CorsHandlerTest {
public void simpleRequestExposeHeaders() {
final CorsConfig config = CorsConfig.withAnyOrigin().exposeHeaders("one", "two").build();
final HttpResponse response = simpleRequest(config, "http://localhost:7777");
assertThat(response.headers().getAll(ACCESS_CONTROL_EXPOSE_HEADERS), hasItems("one", "two"));
assertThat(response.headers().get(ACCESS_CONTROL_EXPOSE_HEADERS), containsString("one"));
assertThat(response.headers().get(ACCESS_CONTROL_EXPOSE_HEADERS), containsString("two"));
}
@Test
@ -306,7 +317,15 @@ public class CorsHandlerTest {
private static class EchoHandler extends SimpleChannelInboundHandler<Object> {
@Override
public void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
ctx.writeAndFlush(new DefaultFullHttpResponse(HTTP_1_1, OK));
ctx.writeAndFlush(new DefaultFullHttpResponse(HTTP_1_1, OK, true, true));
}
}
private static void assertValues(final HttpResponse response, final String headerName, final String... values) {
final String header = response.headers().get(headerName);
for (String value : values) {
assertThat(header, containsString(value));
}
}
}

View File

@ -1216,6 +1216,10 @@ public class DefaultHeaders<T> implements Headers<T> {
return builder.append(']').toString();
}
protected ValueConverter<T> valueConverter() {
return valueConverter;
}
private T convertName(T name) {
return nameConverter.convertName(checkNotNull(name, "name"));
}

View File

@ -17,11 +17,14 @@
package io.netty.handler.codec;
import io.netty.util.internal.PlatformDependent;
import io.netty.util.internal.StringUtil;
import java.text.ParseException;
import java.util.Comparator;
import java.util.Iterator;
import static io.netty.handler.codec.AsciiString.*;
import static io.netty.util.internal.StringUtil.COMMA;
public class DefaultTextHeaders extends DefaultConvertibleHeaders<CharSequence, String> implements TextHeaders {
private static final HashCodeGenerator<CharSequence> CHARSEQUECE_CASE_INSENSITIVE_HASH_CODE_GENERATOR =
@ -145,10 +148,10 @@ public class DefaultTextHeaders extends DefaultConvertibleHeaders<CharSequence,
}
}
private static final Headers.ValueConverter<CharSequence> CHARSEQUENCE_FROM_OBJECT_CONVERTER =
private static final ValueConverter<CharSequence> CHARSEQUENCE_FROM_OBJECT_CONVERTER =
new DefaultTextValueTypeConverter();
private static final ConvertibleHeaders.TypeConverter<CharSequence, String> CHARSEQUENCE_TO_STRING_CONVERTER =
new ConvertibleHeaders.TypeConverter<CharSequence, String>() {
private static final TypeConverter<CharSequence, String> CHARSEQUENCE_TO_STRING_CONVERTER =
new TypeConverter<CharSequence, String>() {
@Override
public String toConvertedType(CharSequence value) {
return value.toString();
@ -162,6 +165,12 @@ public class DefaultTextHeaders extends DefaultConvertibleHeaders<CharSequence,
private static final NameConverter<CharSequence> CHARSEQUENCE_IDENTITY_CONVERTER =
new IdentityNameConverter<CharSequence>();
/**
* An estimate of the size of a header value.
*/
private static final int DEFAULT_VALUE_SIZE = 10;
private final ValuesComposer valuesComposer;
public DefaultTextHeaders() {
this(true);
@ -171,12 +180,22 @@ public class DefaultTextHeaders extends DefaultConvertibleHeaders<CharSequence,
this(ignoreCase, CHARSEQUENCE_FROM_OBJECT_CONVERTER, CHARSEQUENCE_IDENTITY_CONVERTER);
}
public DefaultTextHeaders(boolean ignoreCase, Headers.ValueConverter<CharSequence> valueConverter,
public DefaultTextHeaders(boolean ignoreCase, boolean singleHeaderFields) {
this(ignoreCase, CHARSEQUENCE_FROM_OBJECT_CONVERTER, CHARSEQUENCE_IDENTITY_CONVERTER, singleHeaderFields);
}
public DefaultTextHeaders(boolean ignoreCase, ValueConverter<CharSequence> valueConverter,
NameConverter<CharSequence> nameConverter) {
this(ignoreCase, valueConverter, nameConverter, false);
}
public DefaultTextHeaders(boolean ignoreCase, ValueConverter<CharSequence> valueConverter,
NameConverter<CharSequence> nameConverter, boolean singleHeaderFields) {
super(comparator(ignoreCase), comparator(ignoreCase),
ignoreCase ? CHARSEQUECE_CASE_INSENSITIVE_HASH_CODE_GENERATOR
: CHARSEQUECE_CASE_SENSITIVE_HASH_CODE_GENERATOR, valueConverter,
CHARSEQUENCE_TO_STRING_CONVERTER, nameConverter);
valuesComposer = singleHeaderFields ? new SingleHeaderValuesComposer() : new MultipleFieldsValueComposer();
}
@Override
@ -191,38 +210,32 @@ public class DefaultTextHeaders extends DefaultConvertibleHeaders<CharSequence,
@Override
public TextHeaders add(CharSequence name, CharSequence value) {
super.add(name, value);
return this;
return valuesComposer.add(name, value);
}
@Override
public TextHeaders add(CharSequence name, Iterable<? extends CharSequence> values) {
super.add(name, values);
return this;
return valuesComposer.add(name, values);
}
@Override
public TextHeaders add(CharSequence name, CharSequence... values) {
super.add(name, values);
return this;
return valuesComposer.add(name, values);
}
@Override
public TextHeaders addObject(CharSequence name, Object value) {
super.addObject(name, value);
return this;
return valuesComposer.addObject(name, value);
}
@Override
public TextHeaders addObject(CharSequence name, Iterable<?> values) {
super.addObject(name, values);
return this;
return valuesComposer.addObject(name, values);
}
@Override
public TextHeaders addObject(CharSequence name, Object... values) {
super.addObject(name, values);
return this;
return valuesComposer.addObject(name, values);
}
@Override
@ -293,14 +306,12 @@ public class DefaultTextHeaders extends DefaultConvertibleHeaders<CharSequence,
@Override
public TextHeaders set(CharSequence name, Iterable<? extends CharSequence> values) {
super.set(name, values);
return this;
return valuesComposer.set(name, values);
}
@Override
public TextHeaders set(CharSequence name, CharSequence... values) {
super.set(name, values);
return this;
return valuesComposer.set(name, values);
}
@Override
@ -311,14 +322,12 @@ public class DefaultTextHeaders extends DefaultConvertibleHeaders<CharSequence,
@Override
public TextHeaders setObject(CharSequence name, Iterable<?> values) {
super.setObject(name, values);
return this;
return valuesComposer.setObject(name, values);
}
@Override
public TextHeaders setObject(CharSequence name, Object... values) {
super.setObject(name, values);
return this;
return valuesComposer.setObject(name, values);
}
@Override
@ -396,4 +405,228 @@ public class DefaultTextHeaders extends DefaultConvertibleHeaders<CharSequence,
private static Comparator<CharSequence> comparator(boolean ignoreCase) {
return ignoreCase ? CHARSEQUENCE_CASE_INSENSITIVE_ORDER : CHARSEQUENCE_CASE_SENSITIVE_ORDER;
}
/*
* This interface enables different implementations for adding/setting header values.
* Concrete implementations can control how values are added, for example to add all
* values for a header as a comma separated string instead of adding them as multiple
* headers with a single value.
*/
private interface ValuesComposer {
TextHeaders add(CharSequence name, CharSequence value);
TextHeaders add(CharSequence name, CharSequence... values);
TextHeaders add(CharSequence name, Iterable<? extends CharSequence> values);
TextHeaders addObject(CharSequence name, Iterable<?> values);
TextHeaders addObject(CharSequence name, Object... values);
TextHeaders set(CharSequence name, CharSequence... values);
TextHeaders set(CharSequence name, Iterable<? extends CharSequence> values);
TextHeaders setObject(CharSequence name, Object... values);
TextHeaders setObject(CharSequence name, Iterable<?> values);
}
/*
* Will add multiple values for the same header as multiple separate headers.
*/
private final class MultipleFieldsValueComposer implements ValuesComposer {
@Override
public TextHeaders add(CharSequence name, CharSequence value) {
DefaultTextHeaders.super.add(name, value);
return DefaultTextHeaders.this;
}
@Override
public TextHeaders add(CharSequence name, CharSequence... values) {
DefaultTextHeaders.super.add(name, values);
return DefaultTextHeaders.this;
}
@Override
public TextHeaders add(CharSequence name, Iterable<? extends CharSequence> values) {
DefaultTextHeaders.super.add(name, values);
return DefaultTextHeaders.this;
}
@Override
public TextHeaders addObject(CharSequence name, Iterable<?> values) {
DefaultTextHeaders.super.addObject(name, values);
return DefaultTextHeaders.this;
}
@Override
public TextHeaders addObject(CharSequence name, Object... values) {
DefaultTextHeaders.super.addObject(name, values);
return DefaultTextHeaders.this;
}
@Override
public TextHeaders set(CharSequence name, CharSequence... values) {
DefaultTextHeaders.super.set(name, values);
return DefaultTextHeaders.this;
}
@Override
public TextHeaders set(CharSequence name, Iterable<? extends CharSequence> values) {
DefaultTextHeaders.super.set(name, values);
return DefaultTextHeaders.this;
}
@Override
public TextHeaders setObject(CharSequence name, Object... values) {
DefaultTextHeaders.super.setObject(name, values);
return DefaultTextHeaders.this;
}
@Override
public TextHeaders setObject(CharSequence name, Iterable<?> values) {
DefaultTextHeaders.super.setObject(name, values);
return DefaultTextHeaders.this;
}
}
/**
* Will add multiple values for the same header as single header with a comma separated list of values.
*
* Please refer to section <a href="https://tools.ietf.org/html/rfc7230#section-3.2.2">3.2.2 Field Order</a>
* of RFC-7230 for details.
*/
private final class SingleHeaderValuesComposer implements ValuesComposer {
private final ValueConverter<CharSequence> valueConverter = valueConverter();
private CsvValueEscaper<Object> objectEscaper;
private CsvValueEscaper<CharSequence> charSequenceEscaper;
private CsvValueEscaper<Object> objectEscaper() {
if (objectEscaper == null) {
objectEscaper = new CsvValueEscaper<Object>() {
@Override
public CharSequence escape(Object value) {
return StringUtil.escapeCsv(valueConverter.convertObject(value));
}
};
}
return objectEscaper;
}
private CsvValueEscaper<CharSequence> charSequenceEscaper() {
if (charSequenceEscaper == null) {
charSequenceEscaper = new CsvValueEscaper<CharSequence>() {
@Override
public CharSequence escape(CharSequence value) {
return StringUtil.escapeCsv(value);
}
};
}
return charSequenceEscaper;
}
@Override
public TextHeaders add(CharSequence name, CharSequence value) {
return addEscapedValue(name, StringUtil.escapeCsv(value));
}
@Override
public TextHeaders add(CharSequence name, CharSequence... values) {
return addEscapedValue(name, commaSeparate(charSequenceEscaper(), values));
}
@Override
public TextHeaders add(CharSequence name, Iterable<? extends CharSequence> values) {
return addEscapedValue(name, commaSeparate(charSequenceEscaper(), values));
}
@Override
public TextHeaders addObject(CharSequence name, Iterable<?> values) {
return addEscapedValue(name, commaSeparate(objectEscaper(), values));
}
@Override
public TextHeaders addObject(CharSequence name, Object... values) {
return addEscapedValue(name, commaSeparate(objectEscaper(), values));
}
@Override
public TextHeaders set(CharSequence name, CharSequence... values) {
DefaultTextHeaders.super.set(name, commaSeparate(charSequenceEscaper(), values));
return DefaultTextHeaders.this;
}
@Override
public TextHeaders set(CharSequence name, Iterable<? extends CharSequence> values) {
DefaultTextHeaders.super.set(name, commaSeparate(charSequenceEscaper(), values));
return DefaultTextHeaders.this;
}
@Override
public TextHeaders setObject(CharSequence name, Object... values) {
DefaultTextHeaders.super.set(name, commaSeparate(objectEscaper(), values));
return DefaultTextHeaders.this;
}
@Override
public TextHeaders setObject(CharSequence name, Iterable<?> values) {
DefaultTextHeaders.super.set(name, commaSeparate(objectEscaper(), values));
return DefaultTextHeaders.this;
}
private TextHeaders addEscapedValue(CharSequence name, CharSequence escapedValue) {
CharSequence currentValue = DefaultTextHeaders.super.get(name);
if (currentValue == null) {
DefaultTextHeaders.super.add(name, escapedValue);
} else {
DefaultTextHeaders.super.set(name, commaSeparateEscapedValues(currentValue, escapedValue));
}
return DefaultTextHeaders.this;
}
private <T> CharSequence commaSeparate(CsvValueEscaper<T> escaper, T... values) {
StringBuilder sb = new StringBuilder(values.length * DEFAULT_VALUE_SIZE);
if (values.length > 0) {
int end = values.length - 1;
for (int i = 0; i < end; i++) {
sb.append(escaper.escape(values[i])).append(COMMA);
}
sb.append(escaper.escape(values[end]));
}
return sb;
}
private <T> CharSequence commaSeparate(CsvValueEscaper<T> escaper, Iterable<? extends T> values) {
StringBuilder sb = new StringBuilder();
Iterator<? extends T> iterator = values.iterator();
if (iterator.hasNext()) {
T next = iterator.next();
while (iterator.hasNext()) {
sb.append(escaper.escape(next)).append(COMMA);
next = iterator.next();
}
sb.append(escaper.escape(next));
}
return sb;
}
private CharSequence commaSeparateEscapedValues(CharSequence currentValue, CharSequence value) {
return new StringBuilder(currentValue.length() + 1 + value.length())
.append(currentValue)
.append(COMMA)
.append(value);
}
}
/**
* Escapes comma separated values (CSV).
*
* @param <T> The type that a concrete implementation handles
*/
private interface CsvValueEscaper<T> {
/**
* Appends the value to the specified {@link StringBuilder}, escaping if necessary.
*
* @param value the value to be appended, escaped if necessary
*/
CharSequence escape(T value);
}
}

View File

@ -0,0 +1,360 @@
/*
* Copyright 2014 The Netty Project
*
* The Netty Project licenses this file to you 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.netty.handler.codec;
import org.junit.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static io.netty.util.internal.StringUtil.COMMA;
import static io.netty.util.internal.StringUtil.DOUBLE_QUOTE;
import static org.junit.Assert.assertEquals;
public class DefaultTextHeadersTest {
private static final String HEADER_NAME = "testHeader";
@Test
public void addCharSequences() {
final TextHeaders headers = newDefaultTextHeaders();
headers.add(HEADER_NAME, HeaderValue.THREE.asArray());
assertDefaultValues(headers, HeaderValue.THREE);
}
@Test
public void addCharSequencesCsv() {
final TextHeaders headers = newCsvTextHeaders();
headers.add(HEADER_NAME, HeaderValue.THREE.asArray());
assertCsvValues(headers, HeaderValue.THREE);
}
@Test
public void addCharSequencesCsvWithExistingHeader() {
final TextHeaders headers = newCsvTextHeaders();
headers.add(HEADER_NAME, HeaderValue.THREE.asArray());
headers.add(HEADER_NAME, HeaderValue.FIVE.subset(4));
assertCsvValues(headers, HeaderValue.FIVE);
}
@Test
public void addCharSequencesCsvWithValueContainingComma() {
final TextHeaders headers = newCsvTextHeaders();
headers.add(HEADER_NAME, HeaderValue.SIX_QUOTED.subset(4));
assertEquals(HeaderValue.SIX_QUOTED.subsetAsCsvString(4), headers.getAndConvert(HEADER_NAME));
assertEquals(HeaderValue.SIX_QUOTED.subsetAsCsvString(4), headers.getAllAndConvert(HEADER_NAME).get(0));
}
@Test
public void addCharSequencesCsvWithValueContainingCommas() {
final TextHeaders headers = newCsvTextHeaders();
headers.add(HEADER_NAME, HeaderValue.EIGHT.subset(6));
assertEquals(HeaderValue.EIGHT.subsetAsCsvString(6), headers.getAndConvert(HEADER_NAME));
assertEquals(HeaderValue.EIGHT.subsetAsCsvString(6), headers.getAllAndConvert(HEADER_NAME).get(0));
}
@Test (expected = NullPointerException.class)
public void addCharSequencesCsvNullValue() {
final TextHeaders headers = newCsvTextHeaders();
final String value = null;
headers.add(HEADER_NAME, value);
}
@Test
public void addCharSequencesCsvMultipleTimes() {
final TextHeaders headers = newCsvTextHeaders();
for (int i = 0; i < 5; ++i) {
headers.add(HEADER_NAME, "value");
}
assertEquals("value,value,value,value,value", headers.getAndConvert(HEADER_NAME));
}
@Test
public void addCharSequenceCsv() {
final TextHeaders headers = newCsvTextHeaders();
addValues(headers, HeaderValue.ONE, HeaderValue.TWO, HeaderValue.THREE);
assertCsvValues(headers, HeaderValue.THREE);
}
@Test
public void addCharSequenceCsvSingleValue() {
final TextHeaders headers = newCsvTextHeaders();
addValues(headers, HeaderValue.ONE);
assertCsvValue(headers, HeaderValue.ONE);
}
@Test
public void addIterable() {
final TextHeaders headers = newDefaultTextHeaders();
headers.add(HEADER_NAME, HeaderValue.THREE.asList());
assertDefaultValues(headers, HeaderValue.THREE);
}
@Test
public void addIterableCsv() {
final TextHeaders headers = newCsvTextHeaders();
headers.add(HEADER_NAME, HeaderValue.THREE.asList());
assertCsvValues(headers, HeaderValue.THREE);
}
@Test
public void addIterableCsvWithExistingHeader() {
final TextHeaders headers = newCsvTextHeaders();
headers.add(HEADER_NAME, HeaderValue.THREE.asArray());
headers.add(HEADER_NAME, HeaderValue.FIVE.subset(4));
assertCsvValues(headers, HeaderValue.FIVE);
}
@Test
public void addIterableCsvSingleValue() {
final TextHeaders headers = newCsvTextHeaders();
headers.add(HEADER_NAME, HeaderValue.ONE.asList());
assertCsvValue(headers, HeaderValue.ONE);
}
@Test
public void addIterableCsvEmtpy() {
final TextHeaders headers = newCsvTextHeaders();
headers.add(HEADER_NAME, Collections.<CharSequence>emptyList());
assertEquals("", headers.getAllAndConvert(HEADER_NAME).get(0));
}
@Test
public void addObjectCsv() {
final TextHeaders headers = newCsvTextHeaders();
addObjectValues(headers, HeaderValue.ONE, HeaderValue.TWO, HeaderValue.THREE);
assertCsvValues(headers, HeaderValue.THREE);
}
@Test
public void addObjects() {
final TextHeaders headers = newDefaultTextHeaders();
headers.addObject(HEADER_NAME, HeaderValue.THREE.asArray());
assertDefaultValues(headers, HeaderValue.THREE);
}
@Test
public void addObjectsCsv() {
final TextHeaders headers = newCsvTextHeaders();
headers.addObject(HEADER_NAME, HeaderValue.THREE.asArray());
assertCsvValues(headers, HeaderValue.THREE);
}
@Test
public void addObjectsIterableCsv() {
final TextHeaders headers = newCsvTextHeaders();
headers.addObject(HEADER_NAME, HeaderValue.THREE.asList());
assertCsvValues(headers, HeaderValue.THREE);
}
@Test
public void addObjectsCsvWithExistingHeader() {
final TextHeaders headers = newCsvTextHeaders();
headers.addObject(HEADER_NAME, HeaderValue.THREE.asArray());
headers.addObject(HEADER_NAME, HeaderValue.FIVE.subset(4));
assertCsvValues(headers, HeaderValue.FIVE);
}
@Test
public void setCharSequences() {
final TextHeaders headers = newDefaultTextHeaders();
headers.set(HEADER_NAME, HeaderValue.THREE.asArray());
assertDefaultValues(headers, HeaderValue.THREE);
}
@Test
public void setCharSequenceCsv() {
final TextHeaders headers = newCsvTextHeaders();
headers.set(HEADER_NAME, HeaderValue.THREE.asArray());
assertCsvValues(headers, HeaderValue.THREE);
}
@Test
public void setIterable() {
final TextHeaders headers = newDefaultTextHeaders();
headers.set(HEADER_NAME, HeaderValue.THREE.asList());
assertDefaultValues(headers, HeaderValue.THREE);
}
@Test
public void setIterableCsv() {
final TextHeaders headers = newCsvTextHeaders();
headers.set(HEADER_NAME, HeaderValue.THREE.asList());
assertCsvValues(headers, HeaderValue.THREE);
}
@Test
public void setObjectObjects() {
final TextHeaders headers = newDefaultTextHeaders();
headers.setObject(HEADER_NAME, HeaderValue.THREE.asArray());
assertDefaultValues(headers, HeaderValue.THREE);
}
@Test
public void setObjectObjectsCsv() {
final TextHeaders headers = newCsvTextHeaders();
headers.setObject(HEADER_NAME, HeaderValue.THREE.asArray());
assertCsvValues(headers, HeaderValue.THREE);
}
@Test
public void setObjectIterable() {
final TextHeaders headers = newDefaultTextHeaders();
headers.setObject(HEADER_NAME, HeaderValue.THREE.asList());
assertDefaultValues(headers, HeaderValue.THREE);
}
@Test
public void setObjectIterableCsv() {
final TextHeaders headers = newCsvTextHeaders();
headers.setObject(HEADER_NAME, HeaderValue.THREE.asList());
assertCsvValues(headers, HeaderValue.THREE);
}
private static void assertDefaultValues(final TextHeaders headers, final HeaderValue headerValue) {
assertEquals(headerValue.asArray()[0], headers.get(HEADER_NAME));
assertEquals(headerValue.asList(), headers.getAll(HEADER_NAME));
}
private static void assertCsvValues(final TextHeaders headers, final HeaderValue headerValue) {
assertEquals(headerValue.asCsv(), headers.getAndConvert(HEADER_NAME));
assertEquals(headerValue.asCsv(), headers.getAllAndConvert(HEADER_NAME).get(0));
}
private static void assertCsvValue(final TextHeaders headers, final HeaderValue headerValue) {
assertEquals(headerValue.toString(), headers.getAndConvert(HEADER_NAME));
assertEquals(headerValue.toString(), headers.getAllAndConvert(HEADER_NAME).get(0));
}
private static TextHeaders newDefaultTextHeaders() {
return new DefaultTextHeaders();
}
private static TextHeaders newCsvTextHeaders() {
return new DefaultTextHeaders(true, true);
}
private static void addValues(final TextHeaders headers, HeaderValue... headerValues) {
for (HeaderValue v: headerValues) {
headers.add(HEADER_NAME, v.toString());
}
}
private static void addObjectValues(final TextHeaders headers, HeaderValue... headerValues) {
for (HeaderValue v: headerValues) {
headers.addObject(HEADER_NAME, v.toString());
}
}
private enum HeaderValue {
UNKNOWN("unknown", 0),
ONE("one", 1),
TWO("two", 2),
THREE("three", 3),
FOUR("four", 4),
FIVE("five", 5),
SIX_QUOTED("six,", 6),
SEVEN_QUOTED("seven; , GMT", 7),
EIGHT("eight", 8);
private final int nr;
private final String value;
private String[] array;
private static final String DOUBLE_QUOTE_STRING = String.valueOf(DOUBLE_QUOTE);
HeaderValue(final String value, final int nr) {
this.nr = nr;
this.value = value;
}
@Override
public String toString() {
return value;
}
public String[] asArray() {
if (array == null) {
final String[] arr = new String[nr];
for (int i = 1, y = 0; i <= nr; i++, y++) {
arr[y] = of(i).toString();
}
array = arr;
}
return array;
}
public String[] subset(final int from) {
final int size = from - 1;
final String[] arr = new String[nr - size];
System.arraycopy(asArray(), size, arr, 0, arr.length);
return arr;
}
public String subsetAsCsvString(final int from) {
final String[] subset = subset(from);
return asCsv(subset);
}
public List<CharSequence> asList() {
return Arrays.<CharSequence>asList(asArray());
}
public String asCsv(final String[] arr) {
final StringBuilder sb = new StringBuilder();
int end = arr.length - 1;
for (int i = 0; i < end; i++) {
final String value = arr[i];
quoted(sb, value).append(COMMA);
}
quoted(sb, arr[end]);
return sb.toString();
}
public String asCsv() {
return asCsv(asArray());
}
private static StringBuilder quoted(final StringBuilder sb, final String value) {
if (value.contains(String.valueOf(COMMA)) && !value.contains(DOUBLE_QUOTE_STRING)) {
return sb.append(DOUBLE_QUOTE).append(value).append(DOUBLE_QUOTE);
}
return sb.append(value);
}
public static String quoted(final String value) {
return quoted(new StringBuilder(), value).toString();
}
private static final Map<Integer, HeaderValue> MAP;
static {
final Map<Integer, HeaderValue> map = new HashMap<Integer, HeaderValue>();
for (HeaderValue v : values()) {
final int nr = v.nr;
map.put(Integer.valueOf(nr), v);
}
MAP = map;
}
public static HeaderValue of(final int nr) {
final HeaderValue v = MAP.get(Integer.valueOf(nr));
return v == null ? UNKNOWN : v;
}
}
}

View File

@ -15,21 +15,32 @@
*/
package io.netty.util.internal;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Formatter;
import java.util.List;
import static io.netty.util.internal.ObjectUtil.checkNotNull;
/**
* String utility class.
*/
public final class StringUtil {
public static final String NEWLINE;
public static final char DOUBLE_QUOTE = '\"';
public static final char COMMA = ',';
public static final char LINE_FEED = '\n';
public static final char CARRIAGE_RETURN = '\r';
private static final String[] BYTE2HEX_PAD = new String[256];
private static final String[] BYTE2HEX_NOPAD = new String[256];
private static final String EMPTY_STRING = "";
/**
* 2 - Quote character at beginning and end.
* 5 - Extra allowance for anticipated escape characters that may be added.
*/
private static final int CSV_NUMBER_ESCAPE_CHARACTERS = 2 + 5;
static {
// Determine the newline character of the current platform.
@ -314,6 +325,58 @@ public final class StringUtil {
}
}
/**
* Escapes the specified value, if necessary according to
* <a href="https://tools.ietf.org/html/rfc4180#section-2">RFC-4180</a>.
*
* @param value The value which will be escaped according to
* <a href="https://tools.ietf.org/html/rfc4180#section-2">RFC-4180</a>
* @return {@link CharSequence} the escaped value if nesessary, or the value unchanged
*/
public static CharSequence escapeCsv(CharSequence value) {
int length = checkNotNull(value, "value").length();
if (length == 0) {
return value;
}
int last = length - 1;
boolean quoted = isDoubleQuote(value.charAt(0)) && isDoubleQuote(value.charAt(last)) && length != 1;
boolean foundSpecialCharacter = false;
boolean escapedDoubleQuote = false;
StringBuilder escaped = new StringBuilder(length + CSV_NUMBER_ESCAPE_CHARACTERS).append(DOUBLE_QUOTE);
for (int i = 0; i < length; i++) {
char current = value.charAt(i);
switch (current) {
case DOUBLE_QUOTE:
if (i == 0 || i == last) {
if (!quoted) {
escaped.append(DOUBLE_QUOTE);
} else {
continue;
}
} else {
boolean isNextCharDoubleQuote = isDoubleQuote(value.charAt(i + 1));
if (!isDoubleQuote(value.charAt(i - 1)) &&
(!isNextCharDoubleQuote || isNextCharDoubleQuote && i + 1 == last)) {
escaped.append(DOUBLE_QUOTE);
escapedDoubleQuote = true;
}
break;
}
case LINE_FEED:
case CARRIAGE_RETURN:
case COMMA:
foundSpecialCharacter = true;
}
escaped.append(current);
}
return escapedDoubleQuote || foundSpecialCharacter && !quoted ?
escaped.append(DOUBLE_QUOTE) : value;
}
private static boolean isDoubleQuote(char c) {
return c == DOUBLE_QUOTE;
}
private StringUtil() {
// Unused.
}

View File

@ -81,4 +81,200 @@ public class StringUtilTest {
public void substringAfterTest() {
assertEquals("bar:bar2", substringAfter("foo:bar:bar2", ':'));
}
@Test (expected = NullPointerException.class)
public void escapeCsvNull() {
StringUtil.escapeCsv(null);
}
@Test
public void escapeCsvEmpty() {
CharSequence value = "";
CharSequence expected = value;
escapeCsv(value, expected);
}
@Test
public void escapeCsvUnquoted() {
CharSequence value = "something";
CharSequence expected = value;
escapeCsv(value, expected);
}
@Test
public void escapeCsvAlreadyQuoted() {
CharSequence value = "\"something\"";
CharSequence expected = "\"something\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithQuote() {
CharSequence value = "s\"";
CharSequence expected = "\"s\"\"\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithQuoteInMiddle() {
CharSequence value = "some text\"and more text";
CharSequence expected = "\"some text\"\"and more text\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithQuoteInMiddleAlreadyQuoted() {
CharSequence value = "\"some text\"and more text\"";
CharSequence expected = "\"some text\"\"and more text\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithQuotedWords() {
CharSequence value = "\"foo\"\"goo\"";
CharSequence expected = "\"foo\"\"goo\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithAlreadyEscapedQuote() {
CharSequence value = "foo\"\"goo";
CharSequence expected = "foo\"\"goo";
escapeCsv(value, expected);
}
@Test
public void escapeCsvEndingWithQuote() {
CharSequence value = "some\"";
CharSequence expected = "\"some\"\"\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithSingleQuote() {
CharSequence value = "\"";
CharSequence expected = "\"\"\"\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithSingleQuoteAndCharacter() {
CharSequence value = "\"f";
CharSequence expected = "\"\"\"f\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvAlreadyEscapedQuote() {
CharSequence value = "\"some\"\"";
CharSequence expected = "\"some\"\"\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvQuoted() {
CharSequence value = "\"foo,goo\"";
CharSequence expected = value;
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithLineFeed() {
CharSequence value = "some text\n more text";
CharSequence expected = "\"some text\n more text\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithSingleLineFeedCharacter() {
CharSequence value = "\n";
CharSequence expected = "\"\n\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithMultipleLineFeedCharacter() {
CharSequence value = "\n\n";
CharSequence expected = "\"\n\n\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithQuotedAndLineFeedCharacter() {
CharSequence value = " \" \n ";
CharSequence expected = "\" \"\" \n \"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithLineFeedAtEnd() {
CharSequence value = "testing\n";
CharSequence expected = "\"testing\n\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithComma() {
CharSequence value = "test,ing";
CharSequence expected = "\"test,ing\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithSingleComma() {
CharSequence value = ",";
CharSequence expected = "\",\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithSingleCarriageReturn() {
CharSequence value = "\r";
CharSequence expected = "\"\r\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithMultipleCarriageReturn() {
CharSequence value = "\r\r";
CharSequence expected = "\"\r\r\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithCarriageReturn() {
CharSequence value = "some text\r more text";
CharSequence expected = "\"some text\r more text\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithQuotedAndCarriageReturnCharacter() {
CharSequence value = "\"\r";
CharSequence expected = "\"\"\"\r\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithCarriageReturnAtEnd() {
CharSequence value = "testing\r";
CharSequence expected = "\"testing\r\"";
escapeCsv(value, expected);
}
@Test
public void escapeCsvWithCRLFCharacter() {
CharSequence value = "\r\n";
CharSequence expected = "\"\r\n\"";
escapeCsv(value, expected);
}
private static void escapeCsv(CharSequence value, CharSequence expected) {
CharSequence escapedValue = value;
for (int i = 0; i < 10; ++i) {
escapedValue = StringUtil.escapeCsv(escapedValue);
assertEquals(expected, escapedValue.toString());
}
}
}