/* Copyright (C) 2013-2020 Expedia Inc. 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 com.hotels.styx.client; import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.client.WireMock; import com.hotels.styx.api.HttpRequest; import com.hotels.styx.api.HttpResponse; import com.hotels.styx.api.LiveHttpResponse; import com.hotels.styx.api.exceptions.ResponseTimeoutException; import com.hotels.styx.api.extension.Origin; import com.hotels.styx.api.extension.service.TlsSettings; import com.hotels.styx.client.netty.connectionpool.NettyConnectionFactory; import io.netty.handler.ssl.SslContext; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import reactor.core.publisher.Mono; import java.util.concurrent.ExecutionException; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; import static com.hotels.styx.api.HttpHeaderNames.HOST; import static com.hotels.styx.api.HttpHeaderNames.USER_AGENT; import static com.hotels.styx.api.HttpRequest.get; import static com.hotels.styx.api.HttpResponseStatus.OK; import static com.hotels.styx.common.StyxFutures.await; import static com.hotels.styx.support.server.UrlMatchingStrategies.urlStartingWith; import static java.lang.String.format; import static java.util.concurrent.TimeUnit.SECONDS; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Matchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class StyxHttpClientTest { private HttpRequest httpRequest; private HttpRequest secureRequest; private WireMockServer server; @BeforeEach public void setUp() { server = new WireMockServer(wireMockConfig().dynamicPort().dynamicHttpsPort()); server.start(); server.stubFor(WireMock.get(urlStartingWith("/")).willReturn(aResponse().withStatus(200))); httpRequest = get("/") .header(HOST, hostString(server.port())) .build(); secureRequest = get("/") .header(HOST, hostString(server.httpsPort())) .build(); } @AfterEach public void tearDown() { server.stop(); } /* * StyxHttpClient.Builder * - Cannot retrospectively modify user agent string */ @Test public void cannotBeModifiedAfterCreated() throws ExecutionException, InterruptedException { StyxHttpClient.Builder builder = new StyxHttpClient.Builder().userAgent("v1"); StyxHttpClient client = builder.build(); builder.userAgent("v2"); assertThat(client.send(httpRequest).get().status(), is(OK)); server.verify( getRequestedFor(urlEqualTo("/")) .withHeader("User-Agent", equalTo("v1")) ); } /* * StyxHttpClient.Builder */ @Test public void requiresValidTlsSettings() { assertThrows(NullPointerException.class, () -> new StyxHttpClient.Builder() .tlsSettings(null) .build()); } /* * StyxHttpClient * - Uses default user-agent string. */ @Test public void usesDefaultUserAgent() throws ExecutionException, InterruptedException { StyxHttpClient client = new StyxHttpClient.Builder() .userAgent("Simple-Client-Parent-Settings") .build(); HttpResponse response = client.send(httpRequest).get(); assertThat(response.status(), is(OK)); server.verify( getRequestedFor(urlEqualTo("/")) .withHeader("User-Agent", equalTo("Simple-Client-Parent-Settings")) ); } /* * StyxHttpClient * - Doesn't set any user-agent string if none is specified. */ @Test public void doesNotSetAnyUserAgentIfNotSpecified() throws ExecutionException, InterruptedException { StyxHttpClient client = new StyxHttpClient.Builder() .build(); client.send(httpRequest).get(); server.verify( getRequestedFor(urlEqualTo("/")) .withoutHeader("User-Agent") ); } /* * StyxHttpClient * - User-Agent string from the request takes precedence. */ @Test public void replacesUserAgentIfAlreadyPresentInRequest() throws ExecutionException, InterruptedException { StyxHttpClient client = new StyxHttpClient.Builder() .userAgent("My default user agent value") .build(); HttpRequest request = get("/") .header(HOST, hostString(server.port())) .header(USER_AGENT, "My previous user agent") .build(); client.send(request).get(); server.verify( getRequestedFor(urlEqualTo("/")) .withHeader("User-Agent", equalTo("My default user agent value")) ); } /* * StyxHttpClient * - Applies default TLS settings */ @Test public void usesDefaultTlsSettings() throws ExecutionException, InterruptedException { StyxHttpClient client = new StyxHttpClient.Builder() .tlsSettings(new TlsSettings.Builder().build()) .build(); HttpResponse response = client.send(secureRequest) .get(); assertThat(response.status(), is(OK)); } /* * StyxHttpClientTransaction * - secure() method applies default TLS settings */ @Test public void overridesTlsSettingsWithSecure() throws ExecutionException, InterruptedException { StyxHttpClient client = new StyxHttpClient.Builder() .build(); HttpResponse response = client .secure() .send(secureRequest) .get(); assertThat(response.status(), is(OK)); } /* * StyxHttpClientTransaction * - secure(true) applies default TLS settings */ @Test public void overridesTlsSettingsWithSecureBoolean() throws ExecutionException, InterruptedException { StyxHttpClient client = new StyxHttpClient.Builder() .build(); HttpResponse response = client .secure(true) .send(secureRequest) .get(); assertThat(response.status(), is(OK)); } /* * StyxHttpClientTransaction * - secure(false) disables TLS protection */ @Test public void overridesTlsSettingsWithSecureBooleanFalse() throws ExecutionException, InterruptedException { StyxHttpClient client = new StyxHttpClient.Builder() .tlsSettings(new TlsSettings.Builder().build()) .build(); HttpResponse response = client .secure(false) .send(httpRequest) .get(); assertThat(response.status(), is(OK)); } /* * StyxHttpClient * - Sends a request when HTTP "request-target" is in origin format. * - Ref: https://tools.ietf.org/html/rfc7230#section-5.3.1 */ @Test public void sendsMessagesInOriginUrlFormat() throws ExecutionException, InterruptedException { HttpResponse response = new StyxHttpClient.Builder() .build() .send( get("/index.html") .header(HOST, hostString(server.port())) .build()) .get(); assertThat(response.status(), is(OK)); server.verify( getRequestedFor(urlEqualTo("/index.html")) .withHeader("Host", equalTo(hostString(server.port()))) ); } /* * HttpClient.StreamingTransaction * - Sends LiveHttpRequest messages */ @Test public void sendsStreamingRequests() throws ExecutionException, InterruptedException { LiveHttpResponse response = new StyxHttpClient.Builder() .build() .streaming() .send(httpRequest.stream()) .get(); assertThat(response.status(), is(OK)); Mono.from(response.aggregate(10000)).block(); server.verify( getRequestedFor(urlEqualTo("/")) .withHeader("Host", equalTo(hostString(server.port()))) ); } /* * HttpClient.StreamingTransaction * - Sends LiveHttpRequest messages created from StyxHttpClientTransactions */ @Test public void sendsSecureStreamingRequests() throws ExecutionException, InterruptedException { LiveHttpResponse response = new StyxHttpClient.Builder() .build() .secure(true) .streaming() .send(secureRequest.stream()) .get(); assertThat(response.status(), is(OK)); Mono.from(response.aggregate(10000)).block(); server.verify( getRequestedFor(urlEqualTo("/")) .withHeader("Host", equalTo(hostString(server.httpsPort()))) ); } /* * StyxHttpClient * - Applies response timeout */ @Test public void defaultResponseTimeout() throws Throwable { StyxHttpClient client = new StyxHttpClient.Builder() .responseTimeout(1, SECONDS) .build(); server.stubFor(WireMock.get(urlStartingWith("/slowResponse")) .willReturn(aResponse() .withStatus(200) .withFixedDelay(3000) )); Exception e = assertThrows(ExecutionException.class, () -> client.send( get("/slowResponse") .header(HOST, hostString(server.port())) .build()) .get(2, SECONDS)); assertEquals(ResponseTimeoutException.class, e.getCause().getClass()); } @Disabled @Test /* * Wiremock (or Jetty server) origin converts an absolute URL to an origin * form. Therefore we are unable to use an origin to verify that client used * an absolute URL. However I (Mikko) have verified with WireShark that the * request is indeed sent in absolute form. */ public void sendsMessagesInAbsoluteUrlFormat() throws ExecutionException, InterruptedException { HttpResponse response = new StyxHttpClient.Builder() .build() .send(get(format("http://%s/index.html", hostString(server.port()))).build()) .get(); assertThat(response.status(), is(OK)); server.verify( getRequestedFor(urlEqualTo(format("http://%s/index.html", hostString(server.port())))) .withHeader("Host", equalTo(hostString(server.port()))) ); } private String hostString(int port) { return "localhost:" + port; } /* * StyxHttpClient * - Rejects requests without URL authority or host header */ @Test public void requestWithNoHostOrUrlAuthorityCausesException() { HttpRequest request = get("/foo.txt").build(); StyxHttpClient client = new StyxHttpClient.Builder().build(); assertThrows(IllegalArgumentException.class, () -> await(client.send(request))); } /* * StyxHttpClient.sendRequestInternal * - Uses default HTTP port 8080 when not specified in Host header */ @Test public void sendsToDefaultHttpPort() { NettyConnectionFactory factory = mockConnectionFactory(); ArgumentCaptor<Origin> originCaptor = ArgumentCaptor.forClass(Origin.class); StyxHttpClient.sendRequestInternal(factory, get("/") .header(HOST, "localhost") .build() .stream(), new StyxHttpClient.Builder()); verify(factory).createConnection(originCaptor.capture(), any(ConnectionSettings.class), nullable(SslContext.class)); assertThat(originCaptor.getValue().port(), is(80)); } /* * StyxHttpClient.sendRequestInternal * - Uses default HTTPS port 443 when not specified in Host header */ @Test public void sendsToDefaultHttpsPort() { NettyConnectionFactory factory = mockConnectionFactory(); ArgumentCaptor<Origin> originCaptor = ArgumentCaptor.forClass(Origin.class); StyxHttpClient.sendRequestInternal(factory, get("/") .header(HOST, "localhost") .build() .stream(), new StyxHttpClient.Builder().secure(true)); verify(factory).createConnection(originCaptor.capture(), any(ConnectionSettings.class), any(SslContext.class)); assertThat(originCaptor.getValue().port(), is(443)); } private static NettyConnectionFactory mockConnectionFactory() { NettyConnectionFactory factory = mock(NettyConnectionFactory.class); when(factory.createConnection(any(Origin.class), any(ConnectionSettings.class), nullable(SslContext.class))) .thenReturn(Mono.just(mock(Connection.class))); return factory; } }