diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/DefaultFullHttpResponse.java b/codec-http/src/main/java/io/netty/handler/codec/http/DefaultFullHttpResponse.java index 859ba796bd..29d4495bd9 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/DefaultFullHttpResponse.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/DefaultFullHttpResponse.java @@ -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; } diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpHeaders.java b/codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpHeaders.java index 2122222f9c..52e7dd204a 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpHeaders.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpHeaders.java @@ -236,11 +236,17 @@ public class DefaultHttpHeaders extends DefaultTextHeaders implements HttpHeader } 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 nameConverter) { - super(true, validate ? VALIDATE_OBJECT_CONVERTER : NO_VALIDATE_OBJECT_CONVERTER, nameConverter); + protected DefaultHttpHeaders(boolean validate, boolean singleHeaderFields) { + this(true, validate? VALIDATE_NAME_CONVERTER : NO_VALIDATE_NAME_CONVERTER, singleHeaderFields); + } + + protected DefaultHttpHeaders(boolean validate, NameConverter nameConverter, + boolean singleHeaderFields) { + super(true, validate ? VALIDATE_OBJECT_CONVERTER : NO_VALIDATE_OBJECT_CONVERTER, nameConverter, + singleHeaderFields); } @Override diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpMessage.java b/codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpMessage.java index 25e1780d0e..efb6e0bd4a 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpMessage.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpMessage.java @@ -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 diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpRequest.java b/codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpRequest.java index 37e425198a..2a0a7ccd6d 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpRequest.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpRequest.java @@ -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"); } diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpResponse.java b/codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpResponse.java index 39b160a9a5..d43a16fca0 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpResponse.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpResponse.java @@ -29,7 +29,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); } /** @@ -40,7 +40,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"); } diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/DefaultLastHttpContent.java b/codec-http/src/main/java/io/netty/handler/codec/http/DefaultLastHttpContent.java index d8d54df2ac..935ec61a9c 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/DefaultLastHttpContent.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/DefaultLastHttpContent.java @@ -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); } } } diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsHandler.java b/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsHandler.java index d9728969b5..a79c3eb8b9 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsHandler.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsHandler.java @@ -65,7 +65,7 @@ public class CorsHandler extends ChannelHandlerAdapter { } 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); diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsHandlerTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsHandlerTest.java index 11e71efae2..7c8e818330 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsHandlerTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsHandlerTest.java @@ -15,8 +15,8 @@ */ package io.netty.handler.codec.http.cors; +import io.netty.channel.ChannelHandlerAdapter; import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.SimpleChannelInboundHandler; import io.netty.channel.embedded.EmbeddedChannel; import io.netty.handler.codec.http.DefaultFullHttpRequest; import io.netty.handler.codec.http.DefaultFullHttpResponse; @@ -82,7 +82,8 @@ public class CorsHandlerTest { .build(); final HttpResponse response = preflightRequest(config, "http://localhost:8888", "content-type, xheader1"); assertThat(response.headers().getAndConvert(ACCESS_CONTROL_ALLOW_ORIGIN), is("http://localhost:8888")); - assertThat(response.headers().getAllAndConvert(ACCESS_CONTROL_ALLOW_METHODS), hasItems("GET", "DELETE")); + assertThat(response.headers().getAndConvert(ACCESS_CONTROL_ALLOW_METHODS), containsString("GET")); + assertThat(response.headers().getAndConvert(ACCESS_CONTROL_ALLOW_METHODS), containsString("DELETE")); assertThat(response.headers().getAndConvert(VARY), equalTo(ORIGIN.toString())); } @@ -94,9 +95,10 @@ public class CorsHandlerTest { .build(); final HttpResponse response = preflightRequest(config, "http://localhost:8888", "content-type, xheader1"); assertThat(response.headers().getAndConvert(ACCESS_CONTROL_ALLOW_ORIGIN), is("http://localhost:8888")); - assertThat(response.headers().getAllAndConvert(ACCESS_CONTROL_ALLOW_METHODS), hasItems("OPTIONS", "GET")); - assertThat(response.headers().getAllAndConvert(ACCESS_CONTROL_ALLOW_HEADERS), - hasItems("content-type", "xheader1")); + assertThat(response.headers().getAndConvert(ACCESS_CONTROL_ALLOW_METHODS), containsString("OPTIONS")); + assertThat(response.headers().getAndConvert(ACCESS_CONTROL_ALLOW_METHODS), containsString("GET")); + assertThat(response.headers().getAndConvert(ACCESS_CONTROL_ALLOW_HEADERS), containsString("content-type")); + assertThat(response.headers().getAndConvert(ACCESS_CONTROL_ALLOW_HEADERS), containsString("xheader1")); assertThat(response.headers().getAndConvert(VARY), equalTo(ORIGIN.toString())); } @@ -121,21 +123,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().getAllAndConvert("CustomHeader"), hasItems("value1", "value2")); + assertValues(response, headerName, value1, value2); assertThat(response.headers().getAndConvert(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().getAllAndConvert("CustomHeader"), hasItems("value1", "value2")); + assertValues(response, headerName, value1, value2); assertThat(response.headers().getAndConvert(VARY), equalTo(ORIGIN.toString())); } @@ -182,7 +190,8 @@ public class CorsHandlerTest { final CorsConfig config = CorsConfig.withAnyOrigin().exposeHeaders("custom1", "custom2").build(); final HttpResponse response = simpleRequest(config, "http://localhost:7777"); assertThat(response.headers().getAndConvert(ACCESS_CONTROL_ALLOW_ORIGIN), equalTo("*")); - assertThat(response.headers().getAllAndConvert(ACCESS_CONTROL_EXPOSE_HEADERS), hasItems("custom1", "custom1")); + assertThat(response.headers().getAndConvert(ACCESS_CONTROL_EXPOSE_HEADERS), containsString("custom1")); + assertThat(response.headers().getAndConvert(ACCESS_CONTROL_EXPOSE_HEADERS), containsString("custom2")); } @Test @@ -212,7 +221,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().getAllAndConvert(ACCESS_CONTROL_EXPOSE_HEADERS), hasItems("one", "two")); + assertThat(response.headers().getAndConvert(ACCESS_CONTROL_EXPOSE_HEADERS), containsString("one")); + assertThat(response.headers().getAndConvert(ACCESS_CONTROL_EXPOSE_HEADERS), containsString("two")); } @Test @@ -305,10 +315,18 @@ public class CorsHandlerTest { return new DefaultFullHttpRequest(HTTP_1_1, method, "/info"); } - private static class EchoHandler extends SimpleChannelInboundHandler { + private static class EchoHandler extends ChannelHandlerAdapter { @Override - public void messageReceived(ChannelHandlerContext ctx, Object msg) throws Exception { - ctx.writeAndFlush(new DefaultFullHttpResponse(HTTP_1_1, OK)); + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + 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().getAndConvert(headerName); + for (String value : values) { + assertThat(header, containsString(value)); + } + } + } diff --git a/codec/src/main/java/io/netty/handler/codec/DefaultHeaders.java b/codec/src/main/java/io/netty/handler/codec/DefaultHeaders.java index 342149c218..60616415a8 100644 --- a/codec/src/main/java/io/netty/handler/codec/DefaultHeaders.java +++ b/codec/src/main/java/io/netty/handler/codec/DefaultHeaders.java @@ -1216,6 +1216,10 @@ public class DefaultHeaders implements Headers { return builder.append(']').toString(); } + protected ValueConverter valueConverter() { + return valueConverter; + } + private T convertName(T name) { return nameConverter.convertName(checkNotNull(name, "name")); } diff --git a/codec/src/main/java/io/netty/handler/codec/DefaultTextHeaders.java b/codec/src/main/java/io/netty/handler/codec/DefaultTextHeaders.java index 2be27ebc4e..28badbc647 100644 --- a/codec/src/main/java/io/netty/handler/codec/DefaultTextHeaders.java +++ b/codec/src/main/java/io/netty/handler/codec/DefaultTextHeaders.java @@ -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 implements TextHeaders { private static final HashCodeGenerator CHARSEQUECE_CASE_INSENSITIVE_HASH_CODE_GENERATOR = @@ -145,10 +148,10 @@ public class DefaultTextHeaders extends DefaultConvertibleHeaders CHARSEQUENCE_FROM_OBJECT_CONVERTER = + private static final ValueConverter CHARSEQUENCE_FROM_OBJECT_CONVERTER = new DefaultTextValueTypeConverter(); - private static final ConvertibleHeaders.TypeConverter CHARSEQUENCE_TO_STRING_CONVERTER = - new ConvertibleHeaders.TypeConverter() { + private static final TypeConverter CHARSEQUENCE_TO_STRING_CONVERTER = + new TypeConverter() { @Override public String toConvertedType(CharSequence value) { return value.toString(); @@ -162,21 +165,37 @@ public class DefaultTextHeaders extends DefaultConvertibleHeaders CHARSEQUENCE_IDENTITY_CONVERTER = new IdentityNameConverter(); + /** + * 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); } public DefaultTextHeaders(boolean ignoreCase) { - this(ignoreCase, CHARSEQUENCE_FROM_OBJECT_CONVERTER, CHARSEQUENCE_IDENTITY_CONVERTER); + this(ignoreCase, CHARSEQUENCE_FROM_OBJECT_CONVERTER, CHARSEQUENCE_IDENTITY_CONVERTER, false); + } + + public DefaultTextHeaders(boolean ignoreCase, boolean singleHeaderFields) { + this(ignoreCase, CHARSEQUENCE_FROM_OBJECT_CONVERTER, CHARSEQUENCE_IDENTITY_CONVERTER, singleHeaderFields); } protected DefaultTextHeaders(boolean ignoreCase, Headers.ValueConverter valueConverter, NameConverter nameConverter) { + this(ignoreCase, valueConverter, nameConverter, false); + } + + public DefaultTextHeaders(boolean ignoreCase, ValueConverter valueConverter, + NameConverter 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 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 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 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 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 values); + + TextHeaders addObject(CharSequence name, Iterable values); + TextHeaders addObject(CharSequence name, Object... values); + + TextHeaders set(CharSequence name, CharSequence... values); + TextHeaders set(CharSequence name, Iterable 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 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 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 3.2.2 Field Order + * of RFC-7230 for details. + */ + private final class SingleHeaderValuesComposer implements ValuesComposer { + + private final ValueConverter valueConverter = valueConverter(); + private CsvValueEscaper objectEscaper; + private CsvValueEscaper charSequenceEscaper; + + private CsvValueEscaper objectEscaper() { + if (objectEscaper == null) { + objectEscaper = new CsvValueEscaper() { + @Override + public CharSequence escape(Object value) { + return StringUtil.escapeCsv(valueConverter.convertObject(value)); + } + }; + } + return objectEscaper; + } + + private CsvValueEscaper charSequenceEscaper() { + if (charSequenceEscaper == null) { + charSequenceEscaper = new CsvValueEscaper() { + @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 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 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 CharSequence commaSeparate(CsvValueEscaper 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 CharSequence commaSeparate(CsvValueEscaper escaper, Iterable values) { + StringBuilder sb = new StringBuilder(); + Iterator 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 The type that a concrete implementation handles + */ + private interface CsvValueEscaper { + /** + * 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); + } } diff --git a/codec/src/test/java/io/netty/handler/codec/DefaultTextHeadersTest.java b/codec/src/test/java/io/netty/handler/codec/DefaultTextHeadersTest.java index 036c8bbf8c..8be40ab036 100644 --- a/codec/src/test/java/io/netty/handler/codec/DefaultTextHeadersTest.java +++ b/codec/src/test/java/io/netty/handler/codec/DefaultTextHeadersTest.java @@ -1,11 +1,11 @@ /* - * Copyright 2013 The Netty Project + * 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: + * 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 + * 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 @@ -18,11 +18,19 @@ package io.netty.handler.codec; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; - +import static io.netty.util.internal.StringUtil.COMMA; +import static io.netty.util.internal.StringUtil.DOUBLE_QUOTE; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.junit.Test; public class DefaultTextHeadersTest { + private static final String HEADER_NAME = "testHeader"; + @Test public void testEqualsMultipleHeaders() { DefaultTextHeaders h1 = new DefaultTextHeaders(); @@ -104,4 +112,331 @@ public class DefaultTextHeadersTest { assertEquals(expected, h1); } + + @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.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 asList() { + return Arrays.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 MAP; + + static { + final Map map = new HashMap(); + 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; + } + } } diff --git a/common/src/main/java/io/netty/util/internal/StringUtil.java b/common/src/main/java/io/netty/util/internal/StringUtil.java index 09b827ef38..2c94f2e3fa 100644 --- a/common/src/main/java/io/netty/util/internal/StringUtil.java +++ b/common/src/main/java/io/netty/util/internal/StringUtil.java @@ -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 + * RFC-4180. + * + * @param value The value which will be escaped according to + * RFC-4180 + * @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. } diff --git a/common/src/test/java/io/netty/util/internal/StringUtilTest.java b/common/src/test/java/io/netty/util/internal/StringUtilTest.java index 036f5b1c29..8a2575bbd4 100644 --- a/common/src/test/java/io/netty/util/internal/StringUtilTest.java +++ b/common/src/test/java/io/netty/util/internal/StringUtilTest.java @@ -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()); + } + } + }