From b98aa8d33195ad1d0b149c7f76495256331064ae Mon Sep 17 00:00:00 2001 From: Thomas Kountis Date: Thu, 10 Dec 2020 10:13:52 +0000 Subject: [PATCH] Http compression integration tests; combatibility with Netty (#1239) * Http compression integration tests; combatibility with Netty --- .../http/api/ContentCodingTest.java | 330 ------------------ .../http/api/HeaderUtilsPassThrough.java | 36 ++ .../http/netty/BaseContentCodingTest.java | 185 ++++++++++ .../netty/ServiceTalkContentCodingTest.java | 298 ++++++++++++++++ ...ToNettyContentCodingCompatibilityTest.java | 152 ++++++++ 5 files changed, 671 insertions(+), 330 deletions(-) delete mode 100644 servicetalk-http-netty/src/test/java/io/servicetalk/http/api/ContentCodingTest.java create mode 100644 servicetalk-http-netty/src/test/java/io/servicetalk/http/api/HeaderUtilsPassThrough.java create mode 100644 servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/BaseContentCodingTest.java create mode 100644 servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/ServiceTalkContentCodingTest.java create mode 100644 servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/ServiceTalkToNettyContentCodingCompatibilityTest.java diff --git a/servicetalk-http-netty/src/test/java/io/servicetalk/http/api/ContentCodingTest.java b/servicetalk-http-netty/src/test/java/io/servicetalk/http/api/ContentCodingTest.java deleted file mode 100644 index 4cd3136ae5..0000000000 --- a/servicetalk-http-netty/src/test/java/io/servicetalk/http/api/ContentCodingTest.java +++ /dev/null @@ -1,330 +0,0 @@ -/* - * Copyright © 2020 Apple Inc. and the ServiceTalk project 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.servicetalk.http.api; - -import io.servicetalk.concurrent.api.Single; -import io.servicetalk.concurrent.internal.ServiceTalkTestTimeout; -import io.servicetalk.encoding.api.ContentCodec; -import io.servicetalk.http.netty.HttpClients; -import io.servicetalk.http.netty.HttpServers; -import io.servicetalk.transport.api.ServerContext; - -import org.junit.After; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.Timeout; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; - -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.List; -import java.util.function.Function; - -import static io.servicetalk.concurrent.api.Publisher.from; -import static io.servicetalk.concurrent.api.Single.succeeded; -import static io.servicetalk.encoding.api.ContentCodings.deflateDefault; -import static io.servicetalk.encoding.api.ContentCodings.gzipDefault; -import static io.servicetalk.encoding.api.ContentCodings.identity; -import static io.servicetalk.http.api.CharSequences.contentEquals; -import static io.servicetalk.http.api.HeaderUtils.encodingFor; -import static io.servicetalk.http.api.HttpHeaderNames.ACCEPT_ENCODING; -import static io.servicetalk.http.api.HttpHeaderNames.CONTENT_ENCODING; -import static io.servicetalk.http.api.HttpResponseStatus.UNSUPPORTED_MEDIA_TYPE; -import static io.servicetalk.http.api.HttpSerializationProviders.textDeserializer; -import static io.servicetalk.http.api.HttpSerializationProviders.textSerializer; -import static io.servicetalk.http.netty.HttpProtocolConfigs.h1Default; -import static io.servicetalk.http.netty.HttpProtocolConfigs.h2Default; -import static io.servicetalk.transport.netty.internal.AddressUtils.localAddress; -import static io.servicetalk.transport.netty.internal.AddressUtils.serverHostAndPort; -import static java.lang.String.valueOf; -import static java.util.Arrays.asList; -import static java.util.Arrays.stream; -import static java.util.Collections.disjoint; -import static java.util.Collections.emptyList; -import static java.util.Collections.singletonList; -import static java.util.stream.Collectors.toList; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -@RunWith(Parameterized.class) -public class ContentCodingTest { - - private static final int PAYLOAD_SIZE = 1024; - - private static final Function REQ_RESP_VERIFIER = (options) - -> new StreamingHttpServiceFilterFactory() { - @Override - public StreamingHttpServiceFilter create(final StreamingHttpService service) { - return new StreamingHttpServiceFilter(service) { - @Override - - public Single handle(final HttpServiceContext ctx, - final StreamingHttpRequest request, - final StreamingHttpResponseFactory responseFactory) { - final ContentCodec reqEncoding = options.requestEncoding; - final List clientSupportedEncodings = options.clientSupported; - - try { - - String requestPayload = request.payloadBody(textDeserializer()) - .collect(StringBuilder::new, StringBuilder::append) - .toFuture().get().toString(); - - assertEquals(payload((byte) 'a'), requestPayload); - - final List actualReqAcceptedEncodings = stream(request.headers() - .get(ACCEPT_ENCODING, "NOT_PRESENT").toString().split(",")) - .map((String::trim)).collect(toList()); - - final List expectedReqAcceptedEncodings = clientSupportedEncodings.stream() - .filter((enc) -> enc != identity()) - .map((ContentCodec::name)) - .map(CharSequence::toString) - .collect(toList()); - - if (reqEncoding != identity()) { - assertTrue("Request encoding should be present in the request headers", - contentEquals(reqEncoding.name(), - request.headers().get(ACCEPT_ENCODING, "null"))); - } - - if (!expectedReqAcceptedEncodings.isEmpty() && !actualReqAcceptedEncodings.isEmpty()) { - assertThat(actualReqAcceptedEncodings, equalTo(expectedReqAcceptedEncodings)); - } - - return super.handle(ctx, request, responseFactory); - } catch (Throwable t) { - t.printStackTrace(); - return succeeded(responseFactory.badRequest()); - } - } - }; - } - }; - - @Rule - public final Timeout timeout = new ServiceTalkTestTimeout(); - - private final HttpServerBuilder httpServerBuilder; - private final ServerContext serverContext; - private final HttpClient client; - protected final TestEncodingScenario testEncodingScenario; - private final boolean expectedSuccess; - - public ContentCodingTest(final List serverSupportedEncodings, - final List clientSupportedEncodings, - final ContentCodec requestEncoding, final boolean expectedSuccess, - final HttpProtocolConfig protocol) throws Exception { - this.testEncodingScenario = new TestEncodingScenario(requestEncoding, clientSupportedEncodings, - serverSupportedEncodings, protocol); - this.expectedSuccess = expectedSuccess; - - httpServerBuilder = HttpServers.forAddress(localAddress(0)); - serverContext = listenAndAwait(); - client = newClient(); - } - - @Parameterized.Parameters(name = - "server-supported-encodings={0} " + - "client-supported-encodings={1} " + - "request-encoding={2} " + - "expected-success={3} " + - "protocol={4}") - public static Object[][] params() { - return new Object[][] { - {emptyList(), emptyList(), identity(), true, h1Default()}, - {emptyList(), emptyList(), identity(), true, h2Default()}, - {emptyList(), asList(gzipDefault(), identity()), gzipDefault(), false, h1Default()}, - {emptyList(), asList(gzipDefault(), identity()), gzipDefault(), false, h2Default()}, - {emptyList(), asList(deflateDefault(), identity()), deflateDefault(), false, h1Default()}, - {emptyList(), asList(deflateDefault(), identity()), deflateDefault(), false, h2Default()}, - {asList(gzipDefault(), deflateDefault(), identity()), emptyList(), identity(), true, h1Default()}, - {asList(gzipDefault(), deflateDefault(), identity()), emptyList(), identity(), true, h2Default()}, - {asList(identity(), gzipDefault(), deflateDefault()), - asList(gzipDefault(), identity()), gzipDefault(), true, h1Default()}, - {asList(identity(), gzipDefault(), deflateDefault()), - asList(gzipDefault(), identity()), gzipDefault(), true, h2Default()}, - {asList(identity(), gzipDefault(), deflateDefault()), - asList(deflateDefault(), identity()), deflateDefault(), true, h1Default()}, - {asList(identity(), gzipDefault(), deflateDefault()), - asList(deflateDefault(), identity()), deflateDefault(), true, h2Default()}, - {asList(identity(), gzipDefault()), asList(deflateDefault(), identity()), - deflateDefault(), false, h1Default()}, - {asList(identity(), gzipDefault()), asList(deflateDefault(), identity()), - deflateDefault(), false, h2Default()}, - {asList(identity(), deflateDefault()), asList(gzipDefault(), identity()), - gzipDefault(), false, h1Default()}, - {asList(identity(), deflateDefault()), asList(gzipDefault(), identity()), - gzipDefault(), false, h2Default()}, - {asList(identity(), deflateDefault()), - asList(deflateDefault(), identity()), deflateDefault(), true, h1Default()}, - {asList(identity(), deflateDefault()), - asList(deflateDefault(), identity()), deflateDefault(), true, h2Default()}, - {asList(identity(), deflateDefault()), emptyList(), identity(), true, h1Default()}, - {asList(identity(), deflateDefault()), emptyList(), identity(), true, h2Default()}, - {asList(gzipDefault()), asList(identity()), identity(), true, h1Default()}, - {asList(gzipDefault()), asList(identity()), identity(), true, h2Default()}, - {asList(gzipDefault()), asList(gzipDefault(), identity()), identity(), true, h1Default()}, - {asList(gzipDefault()), asList(gzipDefault(), identity()), identity(), true, h2Default()}, - {asList(gzipDefault()), asList(gzipDefault(), identity()), identity(), true, h1Default()}, - {asList(gzipDefault()), asList(gzipDefault(), identity()), identity(), true, h2Default()}, - {asList(gzipDefault()), asList(gzipDefault(), identity()), gzipDefault(), true, h1Default()}, - {asList(gzipDefault()), asList(gzipDefault(), identity()), gzipDefault(), true, h2Default()}, - {emptyList(), asList(gzipDefault(), identity()), gzipDefault(), false, h1Default()}, - {emptyList(), asList(gzipDefault(), identity()), gzipDefault(), false, h2Default()}, - {emptyList(), asList(gzipDefault(), deflateDefault(), identity()), - deflateDefault(), false, h1Default()}, - {emptyList(), asList(gzipDefault(), deflateDefault(), identity()), - deflateDefault(), false, h2Default()}, - {emptyList(), asList(gzipDefault(), identity()), identity(), true, h1Default()}, - {emptyList(), asList(gzipDefault(), identity()), identity(), true, h2Default()}, - }; - } - - @After - public void tearDown() throws Exception { - try { - client.close(); - } finally { - serverContext.close(); - } - } - - private ServerContext listenAndAwait() throws Exception { - StreamingHttpService service = (ctx, request, responseFactory) -> Single.succeeded(responseFactory.ok() - .payloadBody(from(payload((byte) 'b')), textSerializer())); - - StreamingHttpServiceFilterFactory filterFactory = REQ_RESP_VERIFIER.apply(testEncodingScenario); - - return httpServerBuilder - .protocols(testEncodingScenario.protocol) - .appendServiceFilter(new ContentCodingHttpServiceFilter(testEncodingScenario.serverSupported, - testEncodingScenario.serverSupported)) - .appendServiceFilter(filterFactory) - .listenStreamingAndAwait(service); - } - - private HttpClient newClient() { - return HttpClients - .forSingleAddress(serverHostAndPort(serverContext)) - .appendClientFilter(new ContentCodingHttpRequesterFilter(testEncodingScenario.clientSupported)) - .protocols(testEncodingScenario.protocol) - .build(); - } - - @Test - public void test() throws Exception { - if (expectedSuccess) { - assertSuccessful(testEncodingScenario.requestEncoding); - } else { - assertNotSupported(testEncodingScenario.requestEncoding); - } - } - - private static String payload(byte b) { - byte[] payload = new byte[PAYLOAD_SIZE]; - Arrays.fill(payload, b); - return new String(payload, StandardCharsets.US_ASCII); - } - - private void assertSuccessful(final ContentCodec encoding) throws Exception { - assertResponse(client.request(client - .get("/") - .encoding(encoding) - .payloadBody(payload((byte) 'a'), textSerializer())).toFuture().get().toStreamingResponse()); - - final BlockingStreamingHttpClient blockingStreamingHttpClient = client.asBlockingStreamingClient(); - assertResponse(blockingStreamingHttpClient.request(blockingStreamingHttpClient - .get("/") - .encoding(encoding) - .payloadBody(singletonList(payload((byte) 'a')), textSerializer())).toStreamingResponse()); - - final StreamingHttpClient streamingHttpClient = client.asStreamingClient(); - assertResponse(streamingHttpClient.request(streamingHttpClient - .get("/") - .encoding(encoding) - .payloadBody(from(payload((byte) 'a')), textSerializer())).toFuture().get()); - } - - private void assertResponse(final StreamingHttpResponse response) throws Exception { - assertResponseHeaders(response.headers()); - - String responsePayload = response.payloadBody(textDeserializer()).collect(StringBuilder::new, - StringBuilder::append).toFuture().get().toString(); - - assertEquals(payload((byte) 'b'), responsePayload); - } - - private void assertResponseHeaders(final HttpHeaders headers) { - final List clientSupportedEncodings = testEncodingScenario.clientSupported; - final List serverSupportedEncodings = testEncodingScenario.serverSupported; - - final String respEncName = headers.get(CONTENT_ENCODING, "identity").toString(); - - if (disjoint(serverSupportedEncodings, clientSupportedEncodings)) { - assertEquals(identity().name().toString(), respEncName); - } else { - assertNotNull("Response encoding not in the client supported list " + - "[" + clientSupportedEncodings + "]", encodingFor(clientSupportedEncodings, - valueOf(headers.get(CONTENT_ENCODING, "identity")))); - - assertNotNull("Response encoding not in the server supported list " + - "[" + serverSupportedEncodings + "]", encodingFor(serverSupportedEncodings, - valueOf(headers.get(CONTENT_ENCODING, "identity")))); - } - } - - private void assertNotSupported(final ContentCodec encoding) throws Exception { - final BlockingStreamingHttpClient blockingStreamingHttpClient = client.asBlockingStreamingClient(); - final StreamingHttpClient streamingHttpClient = client.asStreamingClient(); - - assertEquals(UNSUPPORTED_MEDIA_TYPE, client.request(client - .get("/") - .encoding(encoding) - .payloadBody(payload((byte) 'a'), textSerializer())).toFuture().get().status()); - - assertEquals(UNSUPPORTED_MEDIA_TYPE, blockingStreamingHttpClient.request(blockingStreamingHttpClient - .get("/") - .encoding(encoding) - .payloadBody(singletonList(payload((byte) 'a')), textSerializer())).status()); - - assertEquals(UNSUPPORTED_MEDIA_TYPE, streamingHttpClient.request(streamingHttpClient - .get("/") - .encoding(encoding) - .payloadBody(from(payload((byte) 'a')), textSerializer())).toFuture().get().status()); - } - - static class TestEncodingScenario { - final ContentCodec requestEncoding; - final List clientSupported; - final List serverSupported; - final HttpProtocolConfig protocol; - - TestEncodingScenario(final ContentCodec requestEncoding, - final List clientSupported, - final List serverSupported, - final HttpProtocolConfig protocol) { - this.requestEncoding = requestEncoding; - this.clientSupported = clientSupported; - this.serverSupported = serverSupported; - this.protocol = protocol; - } - } -} diff --git a/servicetalk-http-netty/src/test/java/io/servicetalk/http/api/HeaderUtilsPassThrough.java b/servicetalk-http-netty/src/test/java/io/servicetalk/http/api/HeaderUtilsPassThrough.java new file mode 100644 index 0000000000..a3f94581fe --- /dev/null +++ b/servicetalk-http-netty/src/test/java/io/servicetalk/http/api/HeaderUtilsPassThrough.java @@ -0,0 +1,36 @@ +/* + * Copyright © 2020 Apple Inc. and the ServiceTalk project 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.servicetalk.http.api; + +import io.servicetalk.encoding.api.ContentCodec; + +import java.util.Collection; +import javax.annotation.Nullable; + +/** + * Pass-through for pkg-private function. + */ +public final class HeaderUtilsPassThrough { + + private HeaderUtilsPassThrough() { + } + + @Nullable + public static ContentCodec encodingFor(final Collection allowedList, + @Nullable final CharSequence name) { + return HeaderUtils.encodingFor(allowedList, name); + } +} diff --git a/servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/BaseContentCodingTest.java b/servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/BaseContentCodingTest.java new file mode 100644 index 0000000000..382ef4e0e4 --- /dev/null +++ b/servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/BaseContentCodingTest.java @@ -0,0 +1,185 @@ +/* + * Copyright © 2020 Apple Inc. and the ServiceTalk project 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.servicetalk.http.netty; + +import io.servicetalk.concurrent.internal.ServiceTalkTestTimeout; +import io.servicetalk.encoding.api.ContentCodec; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; +import org.junit.runners.Parameterized; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; + +import static io.servicetalk.encoding.api.ContentCodings.deflateDefault; +import static io.servicetalk.encoding.api.ContentCodings.gzipDefault; +import static io.servicetalk.encoding.api.ContentCodings.identity; +import static io.servicetalk.http.netty.HttpProtocol.HTTP_1; +import static io.servicetalk.http.netty.HttpProtocol.HTTP_2; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; + +public abstract class BaseContentCodingTest { + + private static final int PAYLOAD_SIZE = 1024; + + @Rule + public final Timeout timeout = new ServiceTalkTestTimeout(); + + protected final Scenario scenario; + + public BaseContentCodingTest(final HttpProtocol protocol, final Codings serverCodings, + final Codings clientCodings, final Compression compression, + final boolean valid) { + this.scenario = new Scenario(compression.codec, clientCodings.list, serverCodings.list, protocol, valid); + } + + @Parameterized.Parameters(name = "{index}, protocol={0}, server=[{1}], client=[{2}], request={3}, pass={4}") + public static Object[][] params() { + return new Object[][] { + {HTTP_1, Codings.DEFAULT, Codings.DEFAULT, Compression.ID, true}, + {HTTP_2, Codings.DEFAULT, Codings.DEFAULT, Compression.ID, true}, + {HTTP_1, Codings.DEFAULT, Codings.GZIP_ID, Compression.GZIP, false}, + {HTTP_2, Codings.DEFAULT, Codings.GZIP_ID, Compression.GZIP, false}, + {HTTP_1, Codings.DEFAULT, Codings.DEFLATE_ID, Compression.DEFLATE, false}, + {HTTP_2, Codings.DEFAULT, Codings.DEFLATE_ID, Compression.DEFLATE, false}, + {HTTP_1, Codings.GZIP_DEFLATE_ID, Codings.DEFAULT, Compression.ID, true}, + {HTTP_2, Codings.GZIP_DEFLATE_ID, Codings.DEFAULT, Compression.ID, true}, + {HTTP_1, Codings.ID_GZIP_DEFLATE, Codings.GZIP_ID, Compression.GZIP, true}, + {HTTP_2, Codings.ID_GZIP_DEFLATE, Codings.GZIP_ID, Compression.GZIP, true}, + {HTTP_1, Codings.ID_GZIP_DEFLATE, Codings.DEFLATE_ID, Compression.DEFLATE, true}, + {HTTP_2, Codings.ID_GZIP_DEFLATE, Codings.DEFLATE_ID, Compression.DEFLATE, true}, + {HTTP_1, Codings.ID_GZIP, Codings.DEFLATE_ID, Compression.DEFLATE, false}, + {HTTP_2, Codings.ID_GZIP, Codings.DEFLATE_ID, Compression.DEFLATE, false}, + {HTTP_1, Codings.ID_DEFLATE, Codings.GZIP_ID, Compression.GZIP, false}, + {HTTP_2, Codings.ID_DEFLATE, Codings.GZIP_ID, Compression.GZIP, false}, + {HTTP_1, Codings.ID_DEFLATE, Codings.DEFLATE_ID, Compression.DEFLATE, true}, + {HTTP_2, Codings.ID_DEFLATE, Codings.DEFLATE_ID, Compression.DEFLATE, true}, + {HTTP_1, Codings.ID_DEFLATE, Codings.DEFAULT, Compression.ID, true}, + {HTTP_2, Codings.ID_DEFLATE, Codings.DEFAULT, Compression.ID, true}, + {HTTP_1, Codings.GZIP_ONLY, Codings.ID_ONLY, Compression.ID, true}, + {HTTP_2, Codings.GZIP_ONLY, Codings.ID_ONLY, Compression.ID, true}, + {HTTP_1, Codings.GZIP_ONLY, Codings.GZIP_ID, Compression.ID, true}, + {HTTP_2, Codings.GZIP_ONLY, Codings.GZIP_ID, Compression.ID, true}, + {HTTP_1, Codings.GZIP_ONLY, Codings.GZIP_ID, Compression.ID, true}, + {HTTP_2, Codings.GZIP_ONLY, Codings.GZIP_ID, Compression.ID, true}, + {HTTP_1, Codings.GZIP_ONLY, Codings.GZIP_ID, Compression.GZIP, true}, + {HTTP_2, Codings.GZIP_ONLY, Codings.GZIP_ID, Compression.GZIP, true}, + {HTTP_1, Codings.DEFAULT, Codings.GZIP_ID, Compression.GZIP, false}, + {HTTP_2, Codings.DEFAULT, Codings.GZIP_ID, Compression.GZIP, false}, + {HTTP_1, Codings.DEFAULT, Codings.GZIP_DEFLATE_ID, Compression.DEFLATE, false}, + {HTTP_2, Codings.DEFAULT, Codings.GZIP_DEFLATE_ID, Compression.DEFLATE, false}, + {HTTP_1, Codings.DEFAULT, Codings.GZIP_ID, Compression.ID, true}, + {HTTP_2, Codings.DEFAULT, Codings.GZIP_ID, Compression.ID, true}, + }; + } + + @Test + public void testCompatibility() throws Throwable { + if (scenario.valid) { + assertSuccessful(scenario.requestEncoding); + } else { + assertNotSupported(scenario.requestEncoding); + } + } + + protected abstract void assertSuccessful(ContentCodec requestEncoding) throws Throwable; + + protected abstract void assertNotSupported(ContentCodec requestEncoding) throws Throwable; + + protected static byte[] payload(byte b) { + byte[] payload = new byte[PAYLOAD_SIZE]; + Arrays.fill(payload, b); + return payload; + } + + protected static String payloadAsString(byte b) { + return new String(payload(b), StandardCharsets.US_ASCII); + } + + protected enum Codings { + DEFAULT(emptyList()), + GZIP_ONLY(singletonList(gzipDefault())), + GZIP_ID(asList(gzipDefault(), identity())), + GZIP_DEFLATE_ID(asList(gzipDefault(), deflateDefault(), identity())), + ID_ONLY(singletonList(identity())), + ID_GZIP(asList(identity(), gzipDefault())), + ID_DEFLATE(asList(identity(), deflateDefault())), + ID_GZIP_DEFLATE(asList(identity(), gzipDefault(), deflateDefault())), + DEFLATE_ONLY(singletonList(deflateDefault())), + DEFLATE_ID(asList(deflateDefault(), identity())); + + List list; + + Codings(List list) { + this.list = list; + } + + public String toString() { + if (list.isEmpty()) { + return "identity"; + } + + StringBuilder b = new StringBuilder(); + for (ContentCodec c : list) { + if (b.length() > 1) { + b.append(", "); + } + + b.append(c.name()); + } + return b.toString(); + } + } + + protected enum Compression { + ID(identity()), + GZIP(gzipDefault()), + DEFLATE(deflateDefault()); + + ContentCodec codec; + + Compression(ContentCodec codec) { + this.codec = codec; + } + + public String toString() { + return codec.name().toString(); + } + } + + protected static class Scenario { + final ContentCodec requestEncoding; + final List clientSupported; + final List serverSupported; + final HttpProtocol protocol; + final boolean valid; + + Scenario(final ContentCodec requestEncoding, + final List clientSupported, final List serverSupported, + final HttpProtocol protocol, final boolean valid) { + this.requestEncoding = requestEncoding; + this.clientSupported = clientSupported; + this.serverSupported = serverSupported; + this.protocol = protocol; + this.valid = valid; + } + } +} diff --git a/servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/ServiceTalkContentCodingTest.java b/servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/ServiceTalkContentCodingTest.java new file mode 100644 index 0000000000..a8fdcf9a12 --- /dev/null +++ b/servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/ServiceTalkContentCodingTest.java @@ -0,0 +1,298 @@ +/* + * Copyright © 2020 Apple Inc. and the ServiceTalk project 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.servicetalk.http.netty; + +import io.servicetalk.concurrent.api.Single; +import io.servicetalk.encoding.api.ContentCodec; +import io.servicetalk.http.api.BlockingHttpClient; +import io.servicetalk.http.api.BlockingStreamingHttpClient; +import io.servicetalk.http.api.ContentCodingHttpRequesterFilter; +import io.servicetalk.http.api.ContentCodingHttpServiceFilter; +import io.servicetalk.http.api.FilterableStreamingHttpClient; +import io.servicetalk.http.api.HttpExecutionStrategy; +import io.servicetalk.http.api.HttpServerBuilder; +import io.servicetalk.http.api.HttpServiceContext; +import io.servicetalk.http.api.StreamingHttpClient; +import io.servicetalk.http.api.StreamingHttpClientFilter; +import io.servicetalk.http.api.StreamingHttpClientFilterFactory; +import io.servicetalk.http.api.StreamingHttpRequest; +import io.servicetalk.http.api.StreamingHttpRequester; +import io.servicetalk.http.api.StreamingHttpResponse; +import io.servicetalk.http.api.StreamingHttpResponseFactory; +import io.servicetalk.http.api.StreamingHttpService; +import io.servicetalk.http.api.StreamingHttpServiceFilter; +import io.servicetalk.http.api.StreamingHttpServiceFilterFactory; +import io.servicetalk.transport.api.HostAndPort; +import io.servicetalk.transport.api.ServerContext; + +import org.junit.After; +import org.junit.Before; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.BiFunction; + +import static io.servicetalk.concurrent.api.Publisher.from; +import static io.servicetalk.concurrent.api.Single.failed; +import static io.servicetalk.concurrent.api.Single.succeeded; +import static io.servicetalk.encoding.api.ContentCodings.identity; +import static io.servicetalk.http.api.CharSequences.contentEquals; +import static io.servicetalk.http.api.HeaderUtilsPassThrough.encodingFor; +import static io.servicetalk.http.api.HttpHeaderNames.ACCEPT_ENCODING; +import static io.servicetalk.http.api.HttpHeaderNames.CONTENT_ENCODING; +import static io.servicetalk.http.api.HttpResponseStatus.INTERNAL_SERVER_ERROR; +import static io.servicetalk.http.api.HttpResponseStatus.UNSUPPORTED_MEDIA_TYPE; +import static io.servicetalk.http.api.HttpSerializationProviders.textDeserializer; +import static io.servicetalk.http.api.HttpSerializationProviders.textSerializer; +import static io.servicetalk.transport.netty.internal.AddressUtils.localAddress; +import static io.servicetalk.transport.netty.internal.AddressUtils.serverHostAndPort; +import static java.util.Arrays.stream; +import static java.util.Collections.disjoint; +import static java.util.Collections.singletonList; +import static java.util.stream.Collectors.toList; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@RunWith(Parameterized.class) +public class ServiceTalkContentCodingTest extends BaseContentCodingTest { + + private static final BiFunction, StreamingHttpServiceFilterFactory> REQ_FILTER = + (scenario, errors) -> new StreamingHttpServiceFilterFactory() { + @Override + public StreamingHttpServiceFilter create(final StreamingHttpService service) { + return new StreamingHttpServiceFilter(service) { + @Override + + public Single handle(final HttpServiceContext ctx, + final StreamingHttpRequest request, + final StreamingHttpResponseFactory responseFactory) { + final ContentCodec reqEncoding = scenario.requestEncoding; + final List clientSupportedEncodings = scenario.clientSupported; + + try { + + String requestPayload = request.payloadBody(textDeserializer()) + .collect(StringBuilder::new, StringBuilder::append) + .toFuture().get().toString(); + + assertEquals(payloadAsString((byte) 'a'), requestPayload); + + final List actualReqAcceptedEncodings = stream(request.headers() + .get(ACCEPT_ENCODING, "NOT_PRESENT").toString().split(",")) + .map((String::trim)).collect(toList()); + + final List expectedReqAcceptedEncodings = clientSupportedEncodings.stream() + .filter((enc) -> enc != identity()) + .map((ContentCodec::name)) + .map(CharSequence::toString) + .collect(toList()); + + if (reqEncoding != identity()) { + assertTrue("Request encoding should be present in the request headers", + contentEquals(reqEncoding.name(), + request.headers().get(ACCEPT_ENCODING, "NOT_PRESENT"))); + } + + if (!expectedReqAcceptedEncodings.isEmpty() && !actualReqAcceptedEncodings.isEmpty()) { + assertThat(actualReqAcceptedEncodings, equalTo(expectedReqAcceptedEncodings)); + } + + return super.handle(ctx, request, responseFactory); + } catch (Throwable t) { + errors.add(t); + return failed(t); + } + } + }; + } + }; + + static final BiFunction, StreamingHttpClientFilterFactory> RESP_FILTER = + (scenario, errors) -> new StreamingHttpClientFilterFactory() { + @Override + public StreamingHttpClientFilter create(final FilterableStreamingHttpClient client) { + return new StreamingHttpClientFilter(client) { + @Override + protected Single request(final StreamingHttpRequester delegate, + final HttpExecutionStrategy strategy, + final StreamingHttpRequest request) { + return super.request(delegate, strategy, request).map(response -> { + if (INTERNAL_SERVER_ERROR.equals(response.status())) { + // Ignore any further validations + return response; + } + + List server = scenario.serverSupported; + List client = scenario.clientSupported; + + ContentCodec expected = identity(); + for (ContentCodec codec : client) { + if (server.contains(codec)) { + expected = codec; + break; + } + } + + try { + assertEquals(expected, encodingFor(client, response.headers() + .get(CONTENT_ENCODING, identity().name()))); + } catch (Throwable t) { + errors.add(t); + throw t; + } + return response; + }); + } + }; + } + }; + + private ServerContext serverContext; + private BlockingHttpClient client; + protected List errors = Collections.synchronizedList(new ArrayList<>()); + + public ServiceTalkContentCodingTest(final HttpProtocol protocol, final Codings serverCodings, + final Codings clientCodings, final Compression compression, + final boolean valid) { + super(protocol, serverCodings, clientCodings, compression, valid); + } + + @Before + public void start() throws Exception { + serverContext = newServiceTalkServer(scenario, errors); + client = newServiceTalkClient(serverHostAndPort(serverContext), scenario, errors); + } + + @After + public void finish() throws Exception { + client.close(); + serverContext.close(); + } + + protected BlockingHttpClient client() { + return client; + } + + @Override + public void testCompatibility() throws Throwable { + super.testCompatibility(); + verifyNoErrors(); + } + + private void verifyNoErrors() throws Throwable { + if (!errors.isEmpty()) { + throw errors.get(0); + } + } + + protected void assertSuccessful(final ContentCodec encoding) throws Throwable { + assertResponse(client().request(client() + .get("/") + .encoding(encoding) + .payloadBody(payloadAsString((byte) 'a'), textSerializer())).toStreamingResponse()); + + final BlockingStreamingHttpClient blockingStreamingHttpClient = client().asBlockingStreamingClient(); + assertResponse(blockingStreamingHttpClient.request(blockingStreamingHttpClient + .get("/") + .encoding(encoding) + .payloadBody(singletonList(payloadAsString((byte) 'a')), textSerializer())).toStreamingResponse()); + + final StreamingHttpClient streamingHttpClient = client().asStreamingClient(); + assertResponse(streamingHttpClient.request(streamingHttpClient + .get("/") + .encoding(encoding) + .payloadBody(from(payloadAsString((byte) 'a')), textSerializer())).toFuture().get()); + } + + private void assertResponse(final StreamingHttpResponse response) throws Throwable { + verifyNoErrors(); + + assertResponseHeaders(response.headers().get(CONTENT_ENCODING, "identity").toString()); + + String responsePayload = response.payloadBody(textDeserializer()).collect(StringBuilder::new, + StringBuilder::append).toFuture().get().toString(); + + assertEquals(payloadAsString((byte) 'b'), responsePayload); + } + + protected void assertNotSupported(final ContentCodec encoding) throws Exception { + final BlockingStreamingHttpClient blockingStreamingHttpClient = client().asBlockingStreamingClient(); + final StreamingHttpClient streamingHttpClient = client().asStreamingClient(); + + assertEquals(UNSUPPORTED_MEDIA_TYPE, client().request(client() + .get("/") + .encoding(encoding) + .payloadBody(payloadAsString((byte) 'a'), textSerializer())).status()); + + assertEquals(UNSUPPORTED_MEDIA_TYPE, blockingStreamingHttpClient.request(blockingStreamingHttpClient + .get("/") + .encoding(encoding) + .payloadBody(singletonList(payloadAsString((byte) 'a')), textSerializer())).status()); + + assertEquals(UNSUPPORTED_MEDIA_TYPE, streamingHttpClient.request(streamingHttpClient + .get("/") + .encoding(encoding) + .payloadBody(from(payloadAsString((byte) 'a')), textSerializer())).toFuture().get().status()); + } + + protected void assertResponseHeaders(final String contentEncodingValue) { + final List clientSupportedEncodings = scenario.clientSupported; + final List serverSupportedEncodings = scenario.serverSupported; + + if (disjoint(serverSupportedEncodings, clientSupportedEncodings)) { + assertEquals(identity().name().toString(), contentEncodingValue); + } else { + assertNotNull("Response encoding not in the client supported list " + + "[" + clientSupportedEncodings + "]", encodingFor(clientSupportedEncodings, contentEncodingValue)); + + assertNotNull("Response encoding not in the server supported list " + + "[" + serverSupportedEncodings + "]", encodingFor(serverSupportedEncodings, contentEncodingValue)); + } + } + + static ServerContext newServiceTalkServer(final Scenario scenario, final List errors) + throws Exception { + HttpServerBuilder httpServerBuilder = HttpServers.forAddress(localAddress(0)); + + StreamingHttpService service = (ctx, request, responseFactory) -> succeeded(responseFactory.ok() + .payloadBody(from(payloadAsString((byte) 'b')), textSerializer())); + + StreamingHttpServiceFilterFactory filterFactory = REQ_FILTER.apply(scenario, errors); + + return httpServerBuilder + .protocols(scenario.protocol.config) + .appendServiceFilter(new ContentCodingHttpServiceFilter(scenario.serverSupported, + scenario.serverSupported)) + .appendServiceFilter(filterFactory) + .listenStreamingAndAwait(service); + } + + static BlockingHttpClient newServiceTalkClient(final HostAndPort hostAndPort, final Scenario scenario, + final List errors) { + return HttpClients + .forSingleAddress(hostAndPort) + .appendClientFilter(RESP_FILTER.apply(scenario, errors)) + .appendClientFilter(new ContentCodingHttpRequesterFilter(scenario.clientSupported)) + .protocols(scenario.protocol.config) + .buildBlocking(); + } +} diff --git a/servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/ServiceTalkToNettyContentCodingCompatibilityTest.java b/servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/ServiceTalkToNettyContentCodingCompatibilityTest.java new file mode 100644 index 0000000000..2cce9f5801 --- /dev/null +++ b/servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/ServiceTalkToNettyContentCodingCompatibilityTest.java @@ -0,0 +1,152 @@ +/* + * Copyright © 2020 Apple Inc. and the ServiceTalk project 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.servicetalk.http.netty; + +import io.servicetalk.concurrent.api.DefaultThreadFactory; +import io.servicetalk.http.api.BlockingHttpClient; +import io.servicetalk.transport.api.HostAndPort; + +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpContentCompressor; +import io.netty.handler.codec.http.HttpContentDecompressor; +import io.netty.handler.codec.http.HttpObject; +import io.netty.handler.codec.http.HttpServerCodec; +import org.junit.After; +import org.junit.Before; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.net.InetSocketAddress; + +import static io.netty.buffer.Unpooled.wrappedBuffer; +import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH; +import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE; +import static io.netty.handler.codec.http.HttpHeaderValues.TEXT_PLAIN; +import static io.netty.handler.codec.http.HttpResponseStatus.OK; +import static io.servicetalk.http.api.HttpProtocolVersion.HTTP_2_0; +import static io.servicetalk.transport.netty.internal.AddressUtils.localAddress; +import static io.servicetalk.transport.netty.internal.BuilderUtils.serverChannel; +import static io.servicetalk.transport.netty.internal.NettyIoExecutors.createEventLoopGroup; +import static java.lang.Thread.NORM_PRIORITY; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.Assume.assumeFalse; +import static org.junit.Assume.assumeTrue; + +@RunWith(Parameterized.class) +public class ServiceTalkToNettyContentCodingCompatibilityTest extends ServiceTalkContentCodingTest { + + private EventLoopGroup serverEventLoopGroup; + private Channel serverAcceptorChannel; + private BlockingHttpClient client; + + public ServiceTalkToNettyContentCodingCompatibilityTest(final HttpProtocol protocol, + final Codings serverCodings, final Codings clientCodings, + final Compression compression, + final boolean valid) { + super(protocol, serverCodings, clientCodings, compression, valid); + } + + @Before + public void start() { + serverEventLoopGroup = createEventLoopGroup(2, new DefaultThreadFactory("server-io", true, NORM_PRIORITY)); + serverAcceptorChannel = newNettyServer(); + InetSocketAddress serverAddress = (InetSocketAddress) serverAcceptorChannel.localAddress(); + client = newServiceTalkClient(HostAndPort.of(serverAddress), scenario, errors); + } + + @After + public void finish() throws Exception { + serverAcceptorChannel.close().syncUninterruptibly(); + serverEventLoopGroup.shutdownGracefully(0, 0, MILLISECONDS).syncUninterruptibly(); + client.close(); + } + + private Channel newNettyServer() { + ServerBootstrap sb = new ServerBootstrap(); + sb.group(serverEventLoopGroup); + sb.channel(serverChannel(serverEventLoopGroup, InetSocketAddress.class)); + + sb.childHandler(new ChannelInitializer() { + @Override + protected void initChannel(final Channel ch) { + ChannelPipeline p = ch.pipeline(); + p.addLast(new HttpServerCodec()); + if (!scenario.serverSupported.isEmpty()) { + p.addLast(new HttpContentDecompressor()); + p.addLast(new HttpContentCompressor()); + } + p.addLast(EchoServerHandler.INSTANCE); + } + }); + return sb.bind(localAddress(0)).syncUninterruptibly().channel(); + } + + @Override + public void testCompatibility() throws Throwable { + assumeFalse("Only testing H1 scenarios yet.", scenario.protocol.version.equals(HTTP_2_0)); + assumeTrue("Only testing successful configurations; Netty doesn't have knowledge " + + "about unsupported compression types.", scenario.valid); + + super.testCompatibility(); + } + + @Override + protected BlockingHttpClient client() { + return client; + } + + @ChannelHandler.Sharable + static class EchoServerHandler extends SimpleChannelInboundHandler { + static final EchoServerHandler INSTANCE = new EchoServerHandler(); + + private static final byte[] CONTENT = payload((byte) 'b'); + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) { + ctx.flush(); + } + + @Override + public void channelRead0(ChannelHandlerContext ctx, HttpObject msg) { + if (msg instanceof io.netty.handler.codec.http.HttpRequest) { + io.netty.handler.codec.http.HttpRequest req = (io.netty.handler.codec.http.HttpRequest) msg; + FullHttpResponse response = new DefaultFullHttpResponse(req.protocolVersion(), + OK, wrappedBuffer(CONTENT)); + + response.headers() + .set(CONTENT_TYPE, TEXT_PLAIN) + .setInt(CONTENT_LENGTH, response.content().readableBytes()); + + ctx.write(response); + } + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + cause.printStackTrace(); + ctx.close(); + } + } +}