/* * Copyright 2015 LINE Corporation * * LINE Corporation 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 com.linecorp.armeria.client.http; import static com.linecorp.armeria.common.http.HttpSessionProtocols.HTTP; import static com.linecorp.armeria.common.util.Functions.voidFunction; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.awaitility.Awaitility.await; import static org.junit.Assert.assertEquals; import java.io.IOException; import java.io.InputStream; import java.net.ServerSocket; import java.net.Socket; import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; import java.util.Locale; import java.util.concurrent.atomic.AtomicReference; import java.util.function.IntFunction; import org.junit.Before; import org.junit.ClassRule; import org.junit.Test; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; import com.google.common.base.Throwables; import com.google.common.io.ByteStreams; import com.google.common.io.Closeables; import com.linecorp.armeria.client.ClientBuilder; import com.linecorp.armeria.client.ClientFactory; import com.linecorp.armeria.client.ClientOption; import com.linecorp.armeria.client.ClientOptions; import com.linecorp.armeria.client.Clients; import com.linecorp.armeria.client.http.encoding.DeflateStreamDecoderFactory; import com.linecorp.armeria.client.http.encoding.HttpDecodingClient; import com.linecorp.armeria.common.MediaType; import com.linecorp.armeria.common.SerializationFormat; import com.linecorp.armeria.common.http.AggregatedHttpMessage; import com.linecorp.armeria.common.http.DefaultHttpResponse; import com.linecorp.armeria.common.http.HttpData; import com.linecorp.armeria.common.http.HttpHeaderNames; import com.linecorp.armeria.common.http.HttpHeaders; import com.linecorp.armeria.common.http.HttpMethod; import com.linecorp.armeria.common.http.HttpObject; import com.linecorp.armeria.common.http.HttpRequest; import com.linecorp.armeria.common.http.HttpResponse; import com.linecorp.armeria.common.http.HttpResponseWriter; import com.linecorp.armeria.common.http.HttpStatus; import com.linecorp.armeria.common.util.CompletionActions; import com.linecorp.armeria.internal.http.ByteBufHttpData; import com.linecorp.armeria.server.ServerBuilder; import com.linecorp.armeria.server.Service; import com.linecorp.armeria.server.ServiceRequestContext; import com.linecorp.armeria.server.SimpleDecoratingService; import com.linecorp.armeria.server.http.AbstractHttpService; import com.linecorp.armeria.server.http.encoding.HttpEncodingService; import com.linecorp.armeria.testing.server.ServerRule; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufUtil; public class HttpClientIntegrationTest { private static final String TEST_USER_AGENT_NAME = "ArmeriaTest"; private static final AtomicReference<ByteBuf> releasedByteBuf = new AtomicReference<>(); private static final class PoolUnawareDecorator extends SimpleDecoratingService<HttpRequest, HttpResponse> { private PoolUnawareDecorator( Service<? super HttpRequest, ? extends HttpResponse> delegate) { super(delegate); } @Override public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception { HttpResponse res = delegate().serve(ctx, req); DefaultHttpResponse decorated = new DefaultHttpResponse(); res.subscribe(new Subscriber<HttpObject>() { @Override public void onSubscribe(Subscription s) { s.request(Long.MAX_VALUE); } @Override public void onNext(HttpObject httpObject) { decorated.write(httpObject); } @Override public void onError(Throwable t) { decorated.close(t); } @Override public void onComplete() { decorated.close(); } }); return decorated; } } private static final class PoolAwareDecorator extends SimpleDecoratingService<HttpRequest, HttpResponse> { private PoolAwareDecorator( Service<? super HttpRequest, ? extends HttpResponse> delegate) { super(delegate); } @Override public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception { HttpResponse res = delegate().serve(ctx, req); DefaultHttpResponse decorated = new DefaultHttpResponse(); res.subscribe(new Subscriber<HttpObject>() { @Override public void onSubscribe(Subscription s) { s.request(Long.MAX_VALUE); } @Override public void onNext(HttpObject httpObject) { if (httpObject instanceof ByteBufHttpData) { ByteBuf buf = ((ByteBufHttpData) httpObject).buf(); try { decorated.write(HttpData.of(ByteBufUtil.getBytes(buf))); } finally { buf.release(); } } else { decorated.write(httpObject); } } @Override public void onError(Throwable t) { decorated.close(t); } @Override public void onComplete() { decorated.close(); } }, true); return decorated; } } private static class PooledContentService extends AbstractHttpService { @Override protected void doGet(ServiceRequestContext ctx, HttpRequest req, HttpResponseWriter res) throws Exception { ByteBuf buf = ctx.alloc().buffer(); buf.writeCharSequence("pooled content", StandardCharsets.UTF_8); releasedByteBuf.set(buf); res.respond(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, new ByteBufHttpData(buf, false)); } } @ClassRule public static final ServerRule server = new ServerRule() { @Override protected void configure(ServerBuilder sb) throws Exception { sb.port(0, HTTP); sb.serviceAt("/httptestbody", new AbstractHttpService() { @Override protected void doGet(ServiceRequestContext ctx, HttpRequest req, HttpResponseWriter res) { doGetOrPost(req, res); } @Override protected void doPost(ServiceRequestContext ctx, HttpRequest req, HttpResponseWriter res) { doGetOrPost(req, res); } private void doGetOrPost(HttpRequest req, HttpResponseWriter res) { final CharSequence contentType = req.headers().get(HttpHeaderNames.CONTENT_TYPE); if (contentType != null) { throw new IllegalArgumentException( "Serialization format is none, so content type should not be set: " + contentType); } final String accept = req.headers().get(HttpHeaderNames.ACCEPT); if (!"utf-8".equals(accept)) { throw new IllegalArgumentException( "Serialization format is none, so accept should not be overridden: " + accept); } req.aggregate().handle(voidFunction((aReq, cause) -> { if (cause != null) { res.respond(HttpStatus.INTERNAL_SERVER_ERROR, MediaType.PLAIN_TEXT_UTF_8, Throwables.getStackTraceAsString(cause)); return; } res.write(HttpHeaders.of(HttpStatus.OK) .set(HttpHeaderNames.CACHE_CONTROL, "alwayscache")); res.write(HttpData.ofUtf8(String.format( Locale.ENGLISH, "METHOD: %s|ACCEPT: %s|BODY: %s", req.method().name(), accept, aReq.content().toString(StandardCharsets.UTF_8)))); res.close(); })).exceptionally(CompletionActions::log); } }); sb.serviceAt("/not200", new AbstractHttpService() { @Override protected void doGet(ServiceRequestContext ctx, HttpRequest req, HttpResponseWriter res) { res.respond(HttpStatus.NOT_FOUND); } }); sb.serviceAt("/useragent", new AbstractHttpService() { @Override protected void doGet(ServiceRequestContext ctx, HttpRequest req, HttpResponseWriter res) { String ua = req.headers().get(HttpHeaderNames.USER_AGENT, "undefined"); res.respond(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, ua); } }); sb.serviceAt("/hello/world", new AbstractHttpService() { @Override protected void doGet(ServiceRequestContext ctx, HttpRequest req, HttpResponseWriter res) { res.respond(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, "success"); } }); sb.serviceAt("/encoding", new AbstractHttpService() { @Override protected void doGet(ServiceRequestContext ctx, HttpRequest req, HttpResponseWriter res) throws Exception { res.write(HttpHeaders.of(HttpStatus.OK)); res.write(HttpData.ofUtf8("some content to compress ")); res.write(HttpData.ofUtf8("more content to compress")); res.close(); } }.decorate(HttpEncodingService.class)); sb.serviceAt("/encoding-toosmall", new AbstractHttpService() { @Override protected void doGet(ServiceRequestContext ctx, HttpRequest req, HttpResponseWriter res) throws Exception { res.respond(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, "small content"); } }.decorate(HttpEncodingService.class)); sb.serviceAt("/pooled", new PooledContentService()); sb.serviceAt("/pooled-aware", new PooledContentService().decorate(PoolAwareDecorator::new)); sb.serviceAt("/pooled-unaware", new PooledContentService().decorate(PoolUnawareDecorator::new)); } }; private static final ClientFactory clientFactory = ClientFactory.DEFAULT; @Before public void clearError() { releasedByteBuf.set(null); } /** * When the content of a request is empty, the encoded request should never have 'content-length' or * 'transfer-encoding' header. */ @Test public void testRequestNoBodyWithoutExtraHeaders() throws Exception { testSocketOutput( "/foo", port -> "GET /foo HTTP/1.1\r\n" + "host: 127.0.0.1:" + port + "\r\n" + "user-agent: " + HttpHeaderUtil.USER_AGENT + "\r\n\r\n"); } @Test public void testRequestNoBody() throws Exception { HttpClient client = Clients.newClient(server.uri(SerializationFormat.NONE, "/"), HttpClient.class); AggregatedHttpMessage response = client.execute( HttpHeaders.of(HttpMethod.GET, "/httptestbody") .set(HttpHeaderNames.ACCEPT, "utf-8")).aggregate().get(); assertEquals(HttpStatus.OK, response.headers().status()); assertEquals("alwayscache", response.headers().get(HttpHeaderNames.CACHE_CONTROL)); assertEquals("METHOD: GET|ACCEPT: utf-8|BODY: ", response.content().toString(StandardCharsets.UTF_8)); } @Test public void testRequestWithBody() throws Exception { HttpClient client = Clients.newClient(server.uri(SerializationFormat.NONE, "/"), HttpClient.class); AggregatedHttpMessage response = client.execute( HttpHeaders.of(HttpMethod.POST, "/httptestbody") .set(HttpHeaderNames.ACCEPT, "utf-8"), "requestbody日本語").aggregate().get(); assertEquals(HttpStatus.OK, response.headers().status()); assertEquals("alwayscache", response.headers().get(HttpHeaderNames.CACHE_CONTROL)); assertEquals("METHOD: POST|ACCEPT: utf-8|BODY: requestbody日本語", response.content().toString(StandardCharsets.UTF_8)); } @Test public void testNot200() throws Exception { HttpClient client = Clients.newClient(server.uri(SerializationFormat.NONE, "/"), HttpClient.class); AggregatedHttpMessage response = client.get("/not200").aggregate().get(); assertEquals(HttpStatus.NOT_FOUND, response.headers().status()); } /** * When the request path contains double slashes, they should be replaced with single slashes. */ @Test public void testDoubleSlashSuppression() throws Exception { testDoubleSlashSuppression("/double//slashes", "/double/slashes"); // The double slashes in the query string should not be normalized. testDoubleSlashSuppression("/double//slashes?slashed//query", "/double/slashes?slashed//query"); } private static void testDoubleSlashSuppression(String path, String normalizedPath) throws IOException { testSocketOutput( path, port -> "GET " + normalizedPath + " HTTP/1.1\r\n" + "host: 127.0.0.1:" + port + "\r\n" + "user-agent: " + HttpHeaderUtil.USER_AGENT + "\r\n\r\n" ); } /** * User-agent header should be overridden by ClientOption.HTTP_HEADER */ @Test public void testUserAgentOverridableByClientOption() throws Exception { HttpHeaders headers = HttpHeaders.of(HttpHeaderNames.USER_AGENT, TEST_USER_AGENT_NAME); ClientOptions options = ClientOptions.of(ClientOption.HTTP_HEADERS.newValue(headers)); HttpClient client = Clients.newClient(server.uri(SerializationFormat.NONE, "/"), HttpClient.class, options); AggregatedHttpMessage response = client.get("/useragent").aggregate().get(); assertEquals(TEST_USER_AGENT_NAME, response.content().toStringUtf8()); } @Test public void testUserAgentOverridableByRequestHeader() throws Exception { HttpHeaders headers = HttpHeaders.of(HttpHeaderNames.USER_AGENT, TEST_USER_AGENT_NAME); ClientOptions options = ClientOptions.of(ClientOption.HTTP_HEADERS.newValue(headers)); HttpClient client = Clients.newClient(server.uri(SerializationFormat.NONE, "/"), HttpClient.class, options); final String OVERIDDEN_USER_AGENT_NAME = "Overridden"; AggregatedHttpMessage response = client.execute(HttpHeaders.of(HttpMethod.GET, "/useragent") .add(HttpHeaderNames.USER_AGENT, OVERIDDEN_USER_AGENT_NAME)) .aggregate().get(); assertEquals(OVERIDDEN_USER_AGENT_NAME, response.content().toStringUtf8()); } @Test public void httpDecoding() throws Exception { HttpClient client = new ClientBuilder( server.uri(SerializationFormat.NONE, "/")) .factory(clientFactory) .decorator(HttpRequest.class, HttpResponse.class, HttpDecodingClient.newDecorator()) .build(HttpClient.class); AggregatedHttpMessage response = client.execute(HttpHeaders.of(HttpMethod.GET, "/encoding")).aggregate().get(); assertThat(response.headers().get(HttpHeaderNames.CONTENT_ENCODING)).isEqualTo("gzip"); assertThat(response.content().toStringUtf8()).isEqualTo( "some content to compress more content to compress"); } @Test public void httpDecoding_deflate() throws Exception { HttpClient client = new ClientBuilder( server.uri(SerializationFormat.NONE, "/")) .factory(clientFactory) .decorator(HttpRequest.class, HttpResponse.class, HttpDecodingClient.newDecorator( new DeflateStreamDecoderFactory())) .build(HttpClient.class); AggregatedHttpMessage response = client.execute(HttpHeaders.of(HttpMethod.GET, "/encoding")).aggregate().get(); assertThat(response.headers().get(HttpHeaderNames.CONTENT_ENCODING)).isEqualTo("deflate"); assertThat(response.content().toStringUtf8()).isEqualTo( "some content to compress more content to compress"); } @Test public void httpDecoding_noEncodingApplied() throws Exception { HttpClient client = new ClientBuilder( server.uri(SerializationFormat.NONE, "/")) .factory(clientFactory) .decorator(HttpRequest.class, HttpResponse.class, HttpDecodingClient.newDecorator( new DeflateStreamDecoderFactory())) .build(HttpClient.class); AggregatedHttpMessage response = client.execute(HttpHeaders.of(HttpMethod.GET, "/encoding-toosmall")).aggregate().get(); assertThat(response.headers().get(HttpHeaderNames.CONTENT_ENCODING)).isNull(); assertThat(response.content().toStringUtf8()).isEqualTo("small content"); } private static void testSocketOutput(String path, IntFunction<String> expectedResponse) throws IOException { Socket s = null; try (ServerSocket ss = new ServerSocket(0)) { final int port = ss.getLocalPort(); final String expected = expectedResponse.apply(port); // Send a request. Note that we do not wait for a response anywhere because we are only interested // in testing what client sends. Clients.newClient(clientFactory, "none+h1c://127.0.0.1:" + port, HttpClient.class).get(path); ss.setSoTimeout(10000); s = ss.accept(); final byte[] buf = new byte[expected.length()]; final InputStream in = s.getInputStream(); // Read the encoded request. s.setSoTimeout(10000); ByteStreams.readFully(in, buf); // Ensure that the encoded request matches. assertThat(new String(buf, StandardCharsets.US_ASCII)).isEqualTo(expected); // Should not send anything more. s.setSoTimeout(1000); assertThatThrownBy(in::read).isInstanceOf(SocketTimeoutException.class); } finally { Closeables.close(s, true); } } @Test public void givenHttpClientUriPathAndRequestPath_whenGet_thenRequestToConcatenatedPath() throws Exception { HttpClient client = Clients.newClient(server.uri(SerializationFormat.NONE, "/hello"), HttpClient.class); AggregatedHttpMessage response = client.get("/world").aggregate().get(); assertEquals("success", response.content().toStringUtf8()); } @Test public void givenRequestPath_whenGet_thenRequestToPath() throws Exception { HttpClient client = Clients.newClient(server.uri(SerializationFormat.NONE, "/"), HttpClient.class); AggregatedHttpMessage response = client.get("/hello/world").aggregate().get(); assertEquals("success", response.content().toStringUtf8()); } @Test public void testPooledResponseDefaultSubscriber() throws Exception { HttpClient client = Clients.newClient(server.uri(SerializationFormat.NONE, "/"), HttpClient.class); AggregatedHttpMessage response = client.execute( HttpHeaders.of(HttpMethod.GET, "/pooled")).aggregate().get(); assertEquals(HttpStatus.OK, response.headers().status()); assertThat(response.content().toStringUtf8()).isEqualTo("pooled content"); await().untilAsserted(() -> assertThat(releasedByteBuf.get().refCnt()).isZero()); } @Test public void testPooledResponsePooledSubscriber() throws Exception { HttpClient client = Clients.newClient(server.uri(SerializationFormat.NONE, "/"), HttpClient.class); AggregatedHttpMessage response = client.execute( HttpHeaders.of(HttpMethod.GET, "/pooled-aware")).aggregate().get(); assertEquals(HttpStatus.OK, response.headers().status()); assertThat(response.content().toStringUtf8()).isEqualTo("pooled content"); await().untilAsserted(() -> assertThat(releasedByteBuf.get().refCnt()).isZero()); } @Test public void testUnpooledResponsePooledSubscriber() throws Exception { HttpClient client = Clients.newClient(server.uri(SerializationFormat.NONE, "/"), HttpClient.class); AggregatedHttpMessage response = client.execute( HttpHeaders.of(HttpMethod.GET, "/pooled-unaware")).aggregate().get(); assertEquals(HttpStatus.OK, response.headers().status()); assertThat(response.content().toStringUtf8()).isEqualTo("pooled content"); await().untilAsserted(() -> assertThat(releasedByteBuf.get().refCnt()).isZero()); } }