diff --git a/.brazil.json b/.brazil.json index 9e383583e0f0..3b29aff0123a 100644 --- a/.brazil.json +++ b/.brazil.json @@ -4,6 +4,7 @@ "modules": { "annotations": { "packageName": "AwsJavaSdk-Core-Annotations" }, "apache-client": { "packageName": "AwsJavaSdk-HttpClient-ApacheClient" }, + "apache5-client": { "packageName": "AwsJavaSdk-HttpClient-Apache5Client" }, "arns": { "packageName": "AwsJavaSdk-Core-Arns" }, "auth": { "packageName": "AwsJavaSdk-Core-Auth" }, "auth-crt": { "packageName": "AwsJavaSdk-Core-AuthCrt" }, @@ -140,7 +141,9 @@ "io.netty:netty-transport-classes-epoll": { "packageName": "Netty4", "packageVersion": "4.1" }, "io.netty:netty-transport-native-unix-common": { "packageName": "Netty4", "packageVersion": "4.1" }, "org.apache.httpcomponents:httpclient": { "packageName": "Apache-HttpComponents-HttpClient", "packageVersion": "4.5.x" }, + "org.apache.httpcomponents.client5:httpclient5": { "packageName": "Apache-HttpComponents-HttpClient5", "packageVersion": "5.0.x" }, "org.apache.httpcomponents:httpcore": { "packageName": "Apache-HttpComponents-HttpCore", "packageVersion": "4.4.x" }, + "org.apache.httpcomponents.core5:httpcore5": { "packageName": "Apache-HttpComponents-HttpCore5", "packageVersion": "5.0.x" }, "org.eclipse.jdt:org.eclipse.jdt.core": { "packageName": "AwsJavaSdk-Codegen-EclipseJdtDependencies", "packageVersion": "2.0" }, "org.eclipse.text:org.eclipse.text": { "packageName": "AwsJavaSdk-Codegen-EclipseJdtDependencies", "packageVersion": "2.0" }, "org.reactivestreams:reactive-streams": { "packageName": "Maven-org-reactivestreams_reactive-streams", "packageVersion": "1.x" }, diff --git a/.changes/next-release/feature-AWSSDKforJavav2-65d4de2.json b/.changes/next-release/feature-AWSSDKforJavav2-65d4de2.json new file mode 100644 index 000000000000..14631328252f --- /dev/null +++ b/.changes/next-release/feature-AWSSDKforJavav2-65d4de2.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "Apache HTTP Client 5", + "contributor": "", + "description": "Preview Release of AWS SDK Apache5 HttpClient with Apache HttpClient 5.5.x" +} diff --git a/bom-internal/pom.xml b/bom-internal/pom.xml index 86b8c4a9ca21..677055198298 100644 --- a/bom-internal/pom.xml +++ b/bom-internal/pom.xml @@ -94,6 +94,16 @@ httpcore ${httpcomponents.httpcore.version} + + org.apache.httpcomponents.client5 + httpclient5 + ${httpcomponents.client5.version} + + + org.apache.httpcomponents.core5 + httpcore5 + ${httpcomponents.core5.version} + commons-codec commons-codec diff --git a/bom/pom.xml b/bom/pom.xml index 9502b70c053c..38d228d86567 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -192,6 +192,11 @@ apache-client ${awsjavasdk.version} + + software.amazon.awssdk + apache5-client + ${awsjavasdk.version}-PREVIEW + software.amazon.awssdk netty-nio-client diff --git a/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml b/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml index f822308b6ced..58f3fff2db94 100644 --- a/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml +++ b/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml @@ -285,6 +285,7 @@ + @@ -337,6 +338,7 @@ + diff --git a/http-clients/apache-client/pom.xml b/http-clients/apache-client/pom.xml index 6e5bee6b2f66..6309a2397d21 100644 --- a/http-clients/apache-client/pom.xml +++ b/http-clients/apache-client/pom.xml @@ -92,6 +92,42 @@ wiremock-jre8 test + + + + org.apache.logging.log4j + log4j-api + test + + + org.apache.logging.log4j + log4j-core + test + + + org.apache.logging.log4j + log4j-slf4j-impl + test + + + org.slf4j + jcl-over-slf4j + test + ${slf4j.version} + + + org.mockito + mockito-junit-jupiter + test + + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheClientTlsAuthTest.java b/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheClientTlsAuthTest.java index 113de34d8ca5..ec93b85714d7 100644 --- a/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheClientTlsAuthTest.java +++ b/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheClientTlsAuthTest.java @@ -83,6 +83,7 @@ public static void setUp() throws IOException { wireMockServer = new WireMockServer(wireMockConfig() .dynamicHttpsPort() + .dynamicPort() .needClientAuth(true) .keystorePath(serverKeyStore.toAbsolutePath().toString()) .keystorePassword(STORE_PASSWORD) diff --git a/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheHttpClientLocalAddressFunctionalTest.java b/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheHttpClientLocalAddressFunctionalTest.java new file mode 100644 index 000000000000..839929cc8e4e --- /dev/null +++ b/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheHttpClientLocalAddressFunctionalTest.java @@ -0,0 +1,33 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache; + +import java.net.InetAddress; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpClientLocalAddressFunctionalTestSuite; + +@DisplayName("Apache HTTP Client - Local Address Functional Tests") +class ApacheHttpClientLocalAddressFunctionalTest extends SdkHttpClientLocalAddressFunctionalTestSuite { + + @Override + protected SdkHttpClient createHttpClient(InetAddress localAddress, Duration connectionTimeout) { + return ApacheHttpClient.builder() + .localAddress(localAddress) + .connectionTimeout(connectionTimeout) + .build(); + } +} \ No newline at end of file diff --git a/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheHttpClientUriNormalizationTest.java b/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheHttpClientUriNormalizationTest.java new file mode 100644 index 000000000000..fc6f754b410d --- /dev/null +++ b/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheHttpClientUriNormalizationTest.java @@ -0,0 +1,29 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache; + +import software.amazon.awssdk.http.HttpClientUriNormalizationTestSuite; +import software.amazon.awssdk.http.SdkHttpClient; + +public class ApacheHttpClientUriNormalizationTest extends HttpClientUriNormalizationTestSuite { + + + @Override + protected SdkHttpClient createSdkHttpClient() { + return ApacheHttpClient.create(); + } +} + diff --git a/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheHttpClientUriSanitizationTest.java b/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheHttpClientUriSanitizationTest.java new file mode 100644 index 000000000000..d22e0be807a7 --- /dev/null +++ b/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheHttpClientUriSanitizationTest.java @@ -0,0 +1,30 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache; + + +import org.junit.jupiter.api.DisplayName; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpClientUriSanitizationTestSuite; + +@DisplayName("Apache HTTP Client - URI Sanitization Tests") +class ApacheHttpClientUriSanitizationTest extends SdkHttpClientUriSanitizationTestSuite { + + @Override + protected SdkHttpClient createHttpClient() { + return ApacheHttpClient.create(); + } +} diff --git a/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheHttpClientWireMockTest.java b/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheHttpClientWireMockTest.java index d9eb7bdd80df..8fa66492a2bb 100644 --- a/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheHttpClientWireMockTest.java +++ b/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheHttpClientWireMockTest.java @@ -19,6 +19,8 @@ import static com.github.tomakehurst.wiremock.client.WireMock.any; import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static software.amazon.awssdk.http.SdkHttpConfigurationOption.TRUST_ALL_CERTIFICATES; @@ -46,6 +48,7 @@ import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.HttpExecuteResponse; import software.amazon.awssdk.http.SdkHttpClient; import software.amazon.awssdk.http.SdkHttpClientTestSuite; import software.amazon.awssdk.http.SdkHttpFullRequest; @@ -53,6 +56,7 @@ import software.amazon.awssdk.http.apache.internal.ApacheHttpRequestConfig; import software.amazon.awssdk.http.apache.internal.impl.ConnectionManagerAwareHttpClient; import software.amazon.awssdk.utils.AttributeMap; +import software.amazon.awssdk.utils.IoUtils; @RunWith(MockitoJUnitRunner.class) public class ApacheHttpClientWireMockTest extends SdkHttpClientTestSuite { @@ -179,6 +183,45 @@ public void explicitNullDnsResolver_WithLocalhost_successful() throws Exception overrideDnsResolver("localhost", true); } + @Test + public void handlesVariousContentLengths() throws Exception { + SdkHttpClient client = createSdkHttpClient(); + int[] contentLengths = {0, 1, 100, 1024, 65536}; + + for (int length : contentLengths) { + String path = "/content-length-" + length; + byte[] body = new byte[length]; + for (int i = 0; i < length; i++) { + body[i] = (byte) ('A' + (i % 26)); + } + + mockServer.stubFor(any(urlPathEqualTo(path)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Length", String.valueOf(length)) + .withBody(body))); + + SdkHttpFullRequest req = mockSdkRequest("http://localhost:" + mockServer.port() + path, SdkHttpMethod.GET); + HttpExecuteResponse rsp = client.prepareRequest(HttpExecuteRequest.builder() + .request(req) + .build()) + .call(); + + assertThat(rsp.httpResponse().statusCode()).isEqualTo(200); + + if (length == 0) { + // Empty body should still have a response body present, but EOF immediately + if (rsp.responseBody().isPresent()) { + assertThat(rsp.responseBody().get().read()).isEqualTo(-1); + } + } else { + assertThat(rsp.responseBody()).isPresent(); + byte[] readBody = IoUtils.toByteArray(rsp.responseBody().get()); + assertThat(readBody).isEqualTo(body); + } + } + } + private void overrideDnsResolver(String hostName) throws IOException { overrideDnsResolver(hostName, false); } @@ -223,4 +266,23 @@ public InetAddress[] resolve(final String host) throws UnknownHostException { mockProxyServer.verify(1, RequestPatternBuilder.allRequests()); } + + @Test + public void closeReleasesResources() throws Exception { + SdkHttpClient client = createSdkHttpClient(); + // Make a successful request first + stubForMockRequest(200); + SdkHttpFullRequest request = mockSdkRequest("http://localhost:" + mockServer.port(), SdkHttpMethod.POST); + HttpExecuteResponse response = client.prepareRequest( + HttpExecuteRequest.builder().request(request).build()).call(); + response.responseBody().ifPresent(IoUtils::drainInputStream); + // Close the client + client.close(); + // Verify subsequent requests fail + assertThatThrownBy(() -> { + client.prepareRequest(HttpExecuteRequest.builder().request(request).build()).call(); + }).isInstanceOfAny( + IllegalStateException.class + ).hasMessageContaining("Connection pool shut down"); + } } diff --git a/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/internal/RepeatableInputStreamRequestEntityTest.java b/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/internal/RepeatableInputStreamRequestEntityTest.java new file mode 100644 index 000000000000..104204f1767c --- /dev/null +++ b/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/internal/RepeatableInputStreamRequestEntityTest.java @@ -0,0 +1,903 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache.internal; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.net.URI; +import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import software.amazon.awssdk.http.ContentStreamProvider; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.SdkHttpRequest; + +class RepeatableInputStreamRequestEntityTest { + + private static final String TRANSFER_ENCODING = "Transfer-Encoding"; + private static final String CHUNKED = "chunked"; + + private RepeatableInputStreamRequestEntity entity; + private SdkHttpRequest.Builder httpRequestBuilder; + + @BeforeEach + void setUp() { + httpRequestBuilder = SdkHttpRequest.builder() + .uri(URI.create("https://example.com")) + .method(SdkHttpMethod.POST); + } + + @Test + @DisplayName("Constructor should initialize with chunked transfer encoding") + void constructor_WithChunkedTransferEncoding_SetsChunkedTrue() { + // Given + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader(TRANSFER_ENCODING, CHUNKED) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + assertTrue(entity.isChunked()); + } + + @Test + @DisplayName("Constructor should handle content length header correctly") + void constructor_WithContentLength_SetsContentLengthCorrectly() { + long expectedLength = 1024L; + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader("Content-Length", String.valueOf(expectedLength)) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + assertEquals(expectedLength, entity.getContentLength()); + } + + @Test + @DisplayName("Constructor should handle invalid content length gracefully") + void constructor_WithInvalidContentLength_DefaultsToMinusOne() { + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader("Content-Length", "not-a-number") + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + assertEquals(-1L, entity.getContentLength()); + } + + @Test + @DisplayName("Constructor should set content type when provided") + void constructor_WithContentType_SetsContentTypeCorrectly() { + String contentType = "application/json"; + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader("Content-Type", contentType) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + assertEquals(contentType, entity.getContentType().getValue()); + } + + @Test + @DisplayName("Constructor should use provided content stream") + void constructor_WithContentStreamProvider_UsesProvidedStream() { + String content = "test content"; + ByteArrayInputStream inputStream = new ByteArrayInputStream(content.getBytes()); + ContentStreamProvider provider = () -> inputStream; + + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + assertSame(inputStream, entity.getContent()); + } + + @Test + @DisplayName("Constructor should create empty stream when no content provider") + void constructor_WithoutContentStreamProvider_CreatesEmptyStream() throws IOException { + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + InputStream content = entity.getContent(); + assertNotNull(content); + assertEquals(0, content.available()); + } + + @Test + @DisplayName("isRepeatable should return true for mark-supported streams") + void isRepeatable_WithMarkSupportedStream_ReturnsTrue() { + ByteArrayInputStream markableStream = new ByteArrayInputStream("content".getBytes()); + ContentStreamProvider provider = () -> markableStream; + + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + assertTrue(entity.isRepeatable()); + assertTrue(markableStream.markSupported()); + } + + @Test + @DisplayName("isRepeatable should return false for non-mark-supported streams") + void isRepeatable_WithNonMarkSupportedStream_ReturnsFalse() { + // Given + InputStream nonMarkableStream = new InputStream() { + @Override + public int read() { + return -1; + } + + @Override + public boolean markSupported() { + return false; + } + }; + ContentStreamProvider provider = () -> nonMarkableStream; + + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + assertFalse(entity.isRepeatable()); + } + + @Test + @DisplayName("writeTo should not reset stream on first attempt") + void writeTo_FirstAttempt_DoesNotResetStream() throws IOException { + // Given + String content = "test content"; + + // Create a custom stream that tracks reset calls + AtomicInteger resetCallCount = new AtomicInteger(0); + ByteArrayInputStream trackingStream = new ByteArrayInputStream(content.getBytes()) { + @Override + public synchronized void reset() { + resetCallCount.incrementAndGet(); + super.reset(); + } + }; + + ContentStreamProvider provider = () -> trackingStream; + + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + entity.writeTo(output); + assertEquals(content, output.toString()); + assertEquals(0, resetCallCount.get(), "Reset should not be called on first attempt"); + } + + @Test + @DisplayName("writeTo should reset stream on subsequent attempts if repeatable") + void writeTo_SubsequentAttemptWithRepeatableStream_ResetsStream() throws IOException { + // Given + String content = "test content"; + + // Create a custom stream that tracks reset calls + AtomicInteger resetCallCount = new AtomicInteger(0); + ByteArrayInputStream trackingStream = new ByteArrayInputStream(content.getBytes()) { + @Override + public synchronized void reset() { + resetCallCount.incrementAndGet(); + super.reset(); + } + }; + + ContentStreamProvider provider = () -> trackingStream; + + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + // First write + ByteArrayOutputStream firstOutput = new ByteArrayOutputStream(); + entity.writeTo(firstOutput); + + //Second write + ByteArrayOutputStream secondOutput = new ByteArrayOutputStream(); + entity.writeTo(secondOutput); + + // Then + assertEquals(content, firstOutput.toString()); + assertEquals(content, secondOutput.toString()); + assertEquals(1, resetCallCount.get(), "Reset should be called exactly once for second attempt"); + } + + @Test + @DisplayName("writeTo should preserve original exception on first failure") + void writeTo_FirstAttemptThrowsException_PreservesOriginalException() throws IOException { + // Given + IOException originalException = new IOException("Original error"); + InputStream faultyStream = mock(InputStream.class); + when(faultyStream.read(any(byte[].class))).thenThrow(originalException); + when(faultyStream.markSupported()).thenReturn(true); + + ContentStreamProvider provider = () -> faultyStream; + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + IOException thrown = assertThrows(IOException.class, + () -> entity.writeTo(new ByteArrayOutputStream())); + assertSame(originalException, thrown); + } + + @Test + @DisplayName("writeTo should throw original exception on subsequent failures") + void writeTo_SubsequentFailures_ThrowsOriginalException() throws IOException { + // Given + IOException originalException = new IOException("Original error"); + IOException secondException = new IOException("Second error"); + + InputStream faultyStream = mock(InputStream.class); + when(faultyStream.read(any(byte[].class))) + .thenThrow(originalException) + .thenThrow(secondException); + when(faultyStream.markSupported()).thenReturn(true); + + ContentStreamProvider provider = () -> faultyStream; + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + // First attempt + IOException firstThrown = assertThrows(IOException.class, + () -> entity.writeTo(new ByteArrayOutputStream())); + assertEquals("Original error", firstThrown.getMessage()); + assertSame(originalException, firstThrown); + // Second attempt + IOException secondThrown = assertThrows(IOException.class, + () -> entity.writeTo(new ByteArrayOutputStream())); + + // Should still throw original exception (not the second one) + assertSame(originalException, secondThrown); + assertEquals("Original error", secondThrown.getMessage()); + assertNotSame(secondException, secondThrown); + } + + @Test + @DisplayName("writeTo should handle reset failures gracefully") + void writeTo_ResetThrowsException_PropagatesResetException() throws IOException { + // Given + String content = "test content"; + + // Create a custom stream that throws on reset after first successful read + InputStream problematicStream = new InputStream() { + private final byte[] data = content.getBytes(); + private int position = 0; + private boolean hasBeenRead = false; + + @Override + public int read() throws IOException { + if (position >= data.length) { + hasBeenRead = true; + return -1; + } + hasBeenRead = true; + int i = data[position] & 0xFF; + position++; + return i; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (position >= data.length) { + hasBeenRead = true; + return -1; + } + int bytesToRead = Math.min(len, data.length - position); + System.arraycopy(data, position, b, off, bytesToRead); + position += bytesToRead; + hasBeenRead = true; + return bytesToRead; + } + + @Override + public boolean markSupported() { + return true; + } + + @Override + public synchronized void mark(int readlimit) { + // Mark at current position + } + + @Override + public synchronized void reset() throws IOException { + if (hasBeenRead) { + throw new IOException("Reset failed"); + } + position = 0; + } + }; + + ContentStreamProvider provider = () -> problematicStream; + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + // First successful write + ByteArrayOutputStream firstOutput = new ByteArrayOutputStream(); + entity.writeTo(firstOutput); + assertEquals(content, firstOutput.toString()); + + // Second write where reset should fail + IOException thrown = assertThrows(IOException.class, + () -> entity.writeTo(new ByteArrayOutputStream())); + + + assertEquals("Reset failed", thrown.getMessage()); + } + + @ParameterizedTest + @ValueSource(strings = {"100", "0", "9223372036854775807"}) // Long.MAX_VALUE + @DisplayName("parseContentLength should handle valid numeric values") + void parseContentLength_ValidNumbers_ParsesCorrectly(String contentLength) { + // Given + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader("Content-Length", contentLength) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + assertEquals(Long.parseLong(contentLength), entity.getContentLength()); + } + + @Test + @DisplayName("Multiple writes should work correctly with repeatable stream") + void writeTo_MultipleWrites_AllSucceed() throws IOException { + // Given + String content = "repeatable content"; + ByteArrayInputStream inputStream = new ByteArrayInputStream(content.getBytes()); + ContentStreamProvider provider = () -> inputStream; + + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + // Multiple writes + ByteArrayOutputStream output1 = new ByteArrayOutputStream(); + ByteArrayOutputStream output2 = new ByteArrayOutputStream(); + ByteArrayOutputStream output3 = new ByteArrayOutputStream(); + + entity.writeTo(output1); + entity.writeTo(output2); + entity.writeTo(output3); + + // All outputs should contain the same content + assertEquals(content, output1.toString()); + assertEquals(content, output2.toString()); + assertEquals(content, output3.toString()); + } + + @Test + @DisplayName("Entity should handle multiple headers correctly") + void constructor_WithMultiHeaders_HandlesAllCorrectly() { + // Given + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader("Content-Length", "2048") + .putHeader("Content-Type", "application/xml") + .putHeader(TRANSFER_ENCODING, CHUNKED) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + assertEquals(2048L, entity.getContentLength()); + assertEquals("application/xml", entity.getContentType().getValue()); + assertTrue(entity.isChunked()); + } + + @Test + @DisplayName("Entity should handle empty content correctly") + void writeTo_EmptyContent_WritesNothing() throws IOException { + // Given + ContentStreamProvider provider = () -> new ByteArrayInputStream(new byte[0]); + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + entity.writeTo(output); + assertEquals(0, output.size()); + } + + @Test + @DisplayName("Entity should handle large content streams") + void writeTo_LargeContent_HandlesCorrectly() throws IOException { + // Given - 10MB of data + int size = 10 * 1024 * 1024; + byte[] largeContent = new byte[size]; + new Random().nextBytes(largeContent); + + ContentStreamProvider provider = () -> new ByteArrayInputStream(largeContent); + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader("Content-Length", String.valueOf(size)) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + entity.writeTo(output); + assertArrayEquals(largeContent, output.toByteArray()); + assertEquals(size, entity.getContentLength()); + } + + @Test + @DisplayName("Entity should handle non-repeatable stream on multiple writes") + void writeTo_NonRepeatableStreamMultipleWrites_FailsGracefully() throws IOException { + InputStream nonRepeatableStream = new InputStream() { + private boolean hasBeenRead = false; + + @Override + public int read() throws IOException { + if (hasBeenRead) { + throw new IOException("Stream already consumed"); + } + hasBeenRead = true; + return -1; + } + + @Override + public boolean markSupported() { + return false; + } + }; + + ContentStreamProvider provider = () -> nonRepeatableStream; + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + // First write should succeed + entity.writeTo(new ByteArrayOutputStream()); + + // Second write should fail + assertThrows(IOException.class, () -> entity.writeTo(new ByteArrayOutputStream())); + } + + @Test + @DisplayName("Entity should handle non repeatable data arriving in chunks") + void writeTo_withChunkedReads_CompletesSuccessfully() throws IOException { + // Given - Stream that returns data in small chunks + String content = "This is a test content that will be read in chunks"; + InputStream chunkingStream = new InputStream() { + private final byte[] data = content.getBytes(); + private int position = 0; + + @Override + public int read() { + if (position >= data.length) { + return -1; + } + int i = data[position] & 0xFF; + position++; + return i; + } + + @Override + public int read(byte[] b, int off, int len) { + if (position >= data.length) { + return -1; + } + // Return only 5 bytes at a time to simulate chunked reading + int bytesToRead = Math.min(5, Math.min(len, data.length - position)); + System.arraycopy(data, position, b, off, bytesToRead); + position += bytesToRead; + return bytesToRead; + } + + @Override + public boolean markSupported() { + return false; + } + }; + + ContentStreamProvider provider = () -> chunkingStream; + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + entity.writeTo(output); + + + assertEquals(content, output.toString()); + } + + @Test + @DisplayName("Entity should handle mark/reset with limited buffer") + void writeTo_MarkResetWithLimitedBuffer_HandlesCorrectly() throws IOException { + // Given - Stream with limited mark buffer + String content = "Short content"; + InputStream limitedMarkStream = new InputStream() { + private final ByteArrayInputStream delegate = new ByteArrayInputStream(content.getBytes()); + + @Override + public int read() throws IOException { + return delegate.read(); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + return delegate.read(b, off, len); + } + + @Override + public boolean markSupported() { + return true; + } + + @Override + public synchronized void mark(int readlimit) { + delegate.mark(5); // Very small buffer + } + + @Override + public synchronized void reset() throws IOException { + delegate.reset(); + } + }; + + ContentStreamProvider provider = () -> limitedMarkStream; + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + //Multiple writes + ByteArrayOutputStream output1 = new ByteArrayOutputStream(); + ByteArrayOutputStream output2 = new ByteArrayOutputStream(); + + entity.writeTo(output1); + entity.writeTo(output2); + + assertEquals(content, output1.toString()); + assertEquals(content, output2.toString()); + } + + @Test + @DisplayName("Entity should handle null content type gracefully") + void constructor_WithoutContentType_HandlesGracefully() { + // Given + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader("Content-Length", "100") + // No Content-Type header + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + + + entity = new RepeatableInputStreamRequestEntity(request); + + + assertNull(entity.getContentType()); + assertEquals(100L, entity.getContentLength()); + } + + @Test + @DisplayName("Entity should handle interrupted IO operations") + void writeTo_InterruptedStream_ThrowsIOException() throws IOException { + // Given + InputStream interruptibleStream = new InputStream() { + @Override + public int read() throws IOException { + throw new InterruptedIOException("Stream interrupted"); + } + + @Override + public boolean markSupported() { + return true; + } + }; + + ContentStreamProvider provider = () -> interruptibleStream; + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + IOException thrown = assertThrows(IOException.class, + () -> entity.writeTo(new ByteArrayOutputStream())); + assertInstanceOf(InterruptedIOException.class, thrown); + assertEquals("Stream interrupted", thrown.getMessage()); + } + + @Test + @DisplayName("Entity should preserve state across multiple operations") + void multipleOperations_StatePreservation_WorksCorrectly() throws IOException { + // Given + String content = "State preservation test"; + ContentStreamProvider provider = () -> new ByteArrayInputStream(content.getBytes()); + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader("Content-Length", String.valueOf(content.length())) + .putHeader("Content-Type", "text/plain") + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + // Perform multiple operations + boolean isRepeatable1 = entity.isRepeatable(); + boolean isChunked1 = entity.isChunked(); + long contentLength1 = entity.getContentLength(); + + // Write once + entity.writeTo(new ByteArrayOutputStream()); + + boolean isRepeatable2 = entity.isRepeatable(); + boolean isChunked2 = entity.isChunked(); + long contentLength2 = entity.getContentLength(); + + // Write again + entity.writeTo(new ByteArrayOutputStream()); + + boolean isRepeatable3 = entity.isRepeatable(); + boolean isChunked3 = entity.isChunked(); + long contentLength3 = entity.getContentLength(); + + //State should remain consistent + assertEquals(isRepeatable1, isRepeatable2); + assertEquals(isRepeatable2, isRepeatable3); + assertEquals(isChunked1, isChunked2); + assertEquals(isChunked2, isChunked3); + assertEquals(contentLength1, contentLength2); + assertEquals(contentLength2, contentLength3); + } + + @Test + @DisplayName("markSupported should be be called everytime") + void markSupported_NotCachedDuringConstruction() { + // Given + AtomicInteger markSupportedCalls = new AtomicInteger(0); + InputStream trackingStream = new ByteArrayInputStream("test".getBytes()) { + @Override + public boolean markSupported() { + markSupportedCalls.incrementAndGet(); + return true; + } + }; + + entity = createEntity(trackingStream); + assertEquals(0, markSupportedCalls.get()); + + // Multiple isRepeatable calls trigger new markSupported calls + assertTrue(entity.isRepeatable()); + assertTrue(entity.isRepeatable()); + assertEquals(2, markSupportedCalls.get()); + } + + @Test + @DisplayName("ContentStreamProvider.newStream() should only be called once") + void contentStreamProvider_NewStreamCalledOnce() { + AtomicInteger newStreamCalls = new AtomicInteger(0); + ContentStreamProvider provider = () -> { + if (newStreamCalls.incrementAndGet() > 1) { + throw new RuntimeException("Could not create new stream: Already created"); + } + return new ByteArrayInputStream("test".getBytes()); + }; + + entity = createEntity(provider); + + assertEquals(1, newStreamCalls.get()); + assertTrue(entity.isRepeatable()); + assertFalse(entity.isChunked()); + } + + @Test + @DisplayName("writeTo should use cached markSupported for reset decision") + void writeTo_UsesCachedMarkSupported() throws IOException { + // Given - Stream that changes markSupported behavior + AtomicInteger markSupportedCalls = new AtomicInteger(0); + ByteArrayInputStream baseStream = new ByteArrayInputStream("test".getBytes()); + InputStream stream = new InputStream() { + @Override + public int read() throws IOException { + return baseStream.read(); + } + + @Override + public boolean markSupported() { + return markSupportedCalls.incrementAndGet() == 1; // Only first call returns true + } + + @Override + public synchronized void reset() throws IOException { + baseStream.reset(); + } + }; + + entity = createEntity(stream); + + // Write twice + ByteArrayOutputStream output1 = new ByteArrayOutputStream(); + entity.writeTo(output1); + + ByteArrayOutputStream output2 = new ByteArrayOutputStream(); + entity.writeTo(output2); + + // Then - Both writes succeed using cached markSupported value + assertEquals("test", output1.toString()); + assertEquals("test", output2.toString()); + assertEquals(1, markSupportedCalls.get()); + } + + @Test + @DisplayName("Non-repeatable stream should not attempt reset") + void nonRepeatableStream_NoResetAttempt() throws IOException { + // Given + AtomicInteger resetCalls = new AtomicInteger(0); + InputStream nonRepeatableStream = new ByteArrayInputStream("test".getBytes()) { + @Override + public boolean markSupported() { + return false; + } + + @Override + public synchronized void reset() { + resetCalls.incrementAndGet(); + throw new RuntimeException("Reset not supported"); + } + }; + + entity = createEntity(nonRepeatableStream); + assertFalse(entity.isRepeatable()); + entity.writeTo(new ByteArrayOutputStream()); + entity.writeTo(new ByteArrayOutputStream()); + assertEquals(0, resetCalls.get()); + } + + @Test + @DisplayName("Stream should not be read during construction") + void constructor_DoesNotReadStream() { + // Given + InputStream nonReadableStream = new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("Stream should not be read during construction"); + } + + @Override + public boolean markSupported() { + return true; + } + }; + assertDoesNotThrow(() -> entity = createEntity(nonReadableStream)); + assertTrue(entity.isRepeatable()); + } + + @Test + @DisplayName("getContent should reuse existing stream") + void getContent_ReusesExistingStream() throws IOException { + InputStream originalStream = new ByteArrayInputStream("content".getBytes()); + entity = createEntity(originalStream); + InputStream content1 = entity.getContent(); + InputStream content2 = entity.getContent(); + assertSame(content1, content2); + } + + @Test + @DisplayName("Empty stream should be repeatable") + void emptyStream_IsRepeatable() { + // Given - No content provider + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequestBuilder.build()) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + assertTrue(entity.isRepeatable()); + } + + // Helper methods + private RepeatableInputStreamRequestEntity createEntity(InputStream stream) { + return createEntity(() -> stream); + } + + private RepeatableInputStreamRequestEntity createEntity(ContentStreamProvider provider) { + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequestBuilder.build()) + .contentStreamProvider(provider) + .build(); + return new RepeatableInputStreamRequestEntity(request); + } +} + diff --git a/http-clients/apache5-client/pom.xml b/http-clients/apache5-client/pom.xml new file mode 100644 index 000000000000..671f59da0e6f --- /dev/null +++ b/http-clients/apache5-client/pom.xml @@ -0,0 +1,134 @@ + + + + + 4.0.0 + + http-clients + software.amazon.awssdk + 2.31.79-SNAPSHOT + + + apache5-client + AWS Java SDK :: HTTP Clients :: Apache5 + ${awsjavasdk.version}-PREVIEW + + + ${project.parent.version} + 1.8 + + + + + + software.amazon.awssdk + http-client-spi + ${awsjavasdk.version} + + + org.apache.httpcomponents.client5 + httpclient5 + + + org.apache.httpcomponents.core5 + httpcore5 + + + software.amazon.awssdk + utils + ${awsjavasdk.version} + + + software.amazon.awssdk + metrics-spi + ${awsjavasdk.version} + + + software.amazon.awssdk + annotations + ${awsjavasdk.version} + + + + software.amazon.awssdk + http-client-tests + ${awsjavasdk.version} + test + + + + org.apache.logging.log4j + log4j-api + test + + + org.apache.logging.log4j + log4j-core + test + + + org.apache.logging.log4j + log4j-slf4j-impl + test + + + org.slf4j + jcl-over-slf4j + test + ${slf4j.version} + + + + org.junit.jupiter + junit-jupiter + test + + + org.junit.vintage + junit-vintage-engine + test + + + org.assertj + assertj-core + test + + + com.github.tomakehurst + wiremock-jre8 + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + software.amazon.awssdk.http.apache5 + + + + + + + + diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/Apache5HttpClient.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/Apache5HttpClient.java new file mode 100644 index 000000000000..135b76e05a0d --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/Apache5HttpClient.java @@ -0,0 +1,833 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import static software.amazon.awssdk.http.HttpMetric.AVAILABLE_CONCURRENCY; +import static software.amazon.awssdk.http.HttpMetric.HTTP_CLIENT_NAME; +import static software.amazon.awssdk.http.HttpMetric.LEASED_CONCURRENCY; +import static software.amazon.awssdk.http.HttpMetric.MAX_CONCURRENCY; +import static software.amazon.awssdk.http.HttpMetric.PENDING_CONCURRENCY_ACQUIRES; +import static software.amazon.awssdk.http.apache5.internal.conn.ClientConnectionRequestFactory.THREAD_LOCAL_REQUEST_METRIC_COLLECTOR; +import static software.amazon.awssdk.utils.NumericUtils.saturatedCast; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Iterator; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import org.apache.hc.client5.http.ConnectionKeepAliveStrategy; +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.auth.AuthSchemeFactory; +import org.apache.hc.client5.http.auth.CredentialsProvider; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.client5.http.impl.DefaultSchemePortResolver; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.impl.routing.DefaultRoutePlanner; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.client5.http.routing.HttpRoutePlanner; +import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.config.Registry; +import org.apache.hc.core5.http.impl.io.HttpRequestExecutor; +import org.apache.hc.core5.http.io.SocketConfig; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.pool.PoolStats; +import org.apache.hc.core5.ssl.SSLInitializationException; +import org.apache.hc.core5.util.TimeValue; +import software.amazon.awssdk.annotations.SdkPreviewApi; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.annotations.SdkTestInternalApi; +import software.amazon.awssdk.http.AbortableInputStream; +import software.amazon.awssdk.http.ExecutableHttpRequest; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpConfigurationOption; +import software.amazon.awssdk.http.SdkHttpResponse; +import software.amazon.awssdk.http.SystemPropertyTlsKeyManagersProvider; +import software.amazon.awssdk.http.TlsKeyManagersProvider; +import software.amazon.awssdk.http.TlsTrustManagersProvider; +import software.amazon.awssdk.http.apache5.internal.Apache5HttpRequestConfig; +import software.amazon.awssdk.http.apache5.internal.DefaultConfiguration; +import software.amazon.awssdk.http.apache5.internal.SdkProxyRoutePlanner; +import software.amazon.awssdk.http.apache5.internal.conn.ClientConnectionManagerFactory; +import software.amazon.awssdk.http.apache5.internal.conn.IdleConnectionReaper; +import software.amazon.awssdk.http.apache5.internal.conn.SdkConnectionKeepAliveStrategy; +import software.amazon.awssdk.http.apache5.internal.conn.SdkTlsSocketFactory; +import software.amazon.awssdk.http.apache5.internal.impl.Apache5HttpRequestFactory; +import software.amazon.awssdk.http.apache5.internal.impl.Apache5SdkHttpClient; +import software.amazon.awssdk.http.apache5.internal.impl.ConnectionManagerAwareHttpClient; +import software.amazon.awssdk.http.apache5.internal.utils.Apache5Utils; +import software.amazon.awssdk.metrics.MetricCollector; +import software.amazon.awssdk.metrics.NoOpMetricCollector; +import software.amazon.awssdk.utils.AttributeMap; +import software.amazon.awssdk.utils.Logger; +import software.amazon.awssdk.utils.Validate; + +/** + * An implementation of {@link SdkHttpClient} that uses Apache5 HTTP client to communicate with the service. This is the most + * powerful synchronous client that adds an extra dependency and additional startup latency in exchange for more functionality, + * like support for HTTP proxies. + * + *

See software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient for an alternative implementation.

+ * + *

This can be created via {@link #builder()}

+ */ +@SdkPreviewApi +@SdkPublicApi +public final class Apache5HttpClient implements SdkHttpClient { + + private static final String CLIENT_NAME = "Apache5Preview"; + + private static final Logger log = Logger.loggerFor(Apache5HttpClient.class); + private static final HostnameVerifier DEFAULT_HOSTNAME_VERIFIER = new DefaultHostnameVerifier(); + private final Apache5HttpRequestFactory apacheHttpRequestFactory = new Apache5HttpRequestFactory(); + private final ConnectionManagerAwareHttpClient httpClient; + private final Apache5HttpRequestConfig requestConfig; + private final AttributeMap resolvedOptions; + + @SdkTestInternalApi + Apache5HttpClient(ConnectionManagerAwareHttpClient httpClient, + Apache5HttpRequestConfig requestConfig, + AttributeMap resolvedOptions) { + this.httpClient = httpClient; + this.requestConfig = requestConfig; + this.resolvedOptions = resolvedOptions; + } + + private Apache5HttpClient(DefaultBuilder builder, AttributeMap resolvedOptions) { + this.httpClient = createClient(builder, resolvedOptions); + this.requestConfig = createRequestConfig(builder, resolvedOptions); + this.resolvedOptions = resolvedOptions; + } + + public static Builder builder() { + return new DefaultBuilder(); + } + + /** + * Create a {@link Apache5HttpClient} with the default properties + * + * @return an {@link Apache5HttpClient} + */ + public static SdkHttpClient create() { + return new DefaultBuilder().build(); + } + + private ConnectionManagerAwareHttpClient createClient(Apache5HttpClient.DefaultBuilder configuration, + AttributeMap standardOptions) { + ApacheConnectionManagerFactory cmFactory = new ApacheConnectionManagerFactory(); + + HttpClientBuilder builder = HttpClients.custom(); + + // Note that it is important we register the original connection manager with the + // IdleConnectionReaper as it's required for the successful deregistration of managers + // from the reaper. See https://github.com/aws/aws-sdk-java/issues/722. + PoolingHttpClientConnectionManager cm = cmFactory.create(configuration, standardOptions); + + Registry authSchemeRegistry = configuration.authSchemeRegistry ; + if (authSchemeRegistry != null) { + builder.setDefaultAuthSchemeRegistry(authSchemeRegistry); + } + + builder.setRequestExecutor(new HttpRequestExecutor()) + // SDK handles decompression + .disableContentCompression() + .setKeepAliveStrategy(buildKeepAliveStrategy(standardOptions)) + .setUserAgent("") // SDK will set the user agent header in the pipeline. Don't let Apache waste time + .setConnectionManager(ClientConnectionManagerFactory.wrap(cm)) + //This is done to keep backward compatibility with Apache 4.x + .disableRedirectHandling() + // SDK handles retries , we do not need additional retries on Http clients. + .disableAutomaticRetries(); + + addProxyConfig(builder, configuration); + + if (useIdleConnectionReaper(standardOptions)) { + IdleConnectionReaper.getInstance().registerConnectionManager( + cm, standardOptions.get(SdkHttpConfigurationOption.CONNECTION_MAX_IDLE_TIMEOUT).toMillis()); + } + + return new Apache5SdkHttpClient(builder.build(), cm); + } + + private void addProxyConfig(HttpClientBuilder builder, + DefaultBuilder configuration) { + ProxyConfiguration proxyConfiguration = configuration.proxyConfiguration; + + Validate.isTrue(configuration.httpRoutePlanner == null || !isProxyEnabled(proxyConfiguration), + "The httpRoutePlanner and proxyConfiguration can't both be configured."); + Validate.isTrue(configuration.credentialsProvider == null || !isAuthenticatedProxy(proxyConfiguration), + "The credentialsProvider and proxyConfiguration username/password can't both be configured."); + + HttpRoutePlanner routePlanner = configuration.httpRoutePlanner; + if (isProxyEnabled(proxyConfiguration)) { + log.debug(() -> "Configuring Proxy. Proxy Host: " + proxyConfiguration.host()); + routePlanner = new SdkProxyRoutePlanner(proxyConfiguration.host(), + proxyConfiguration.port(), + proxyConfiguration.scheme(), + proxyConfiguration.nonProxyHosts()); + } + + CredentialsProvider credentialsProvider = configuration.credentialsProvider; + if (isAuthenticatedProxy(proxyConfiguration)) { + credentialsProvider = Apache5Utils.newProxyCredentialsProvider(proxyConfiguration); + } + + if (routePlanner != null) { + if (configuration.localAddress != null) { + log.debug(() -> "localAddress configuration was ignored since Route planner was explicitly provided"); + } + builder.setRoutePlanner(routePlanner); + } else if (configuration.localAddress != null) { + builder.setRoutePlanner(new LocalAddressRoutePlanner(configuration.localAddress)); + } + + if (credentialsProvider != null) { + builder.setDefaultCredentialsProvider(credentialsProvider); + } + } + + private ConnectionKeepAliveStrategy buildKeepAliveStrategy(AttributeMap standardOptions) { + long maxIdle = standardOptions.get(SdkHttpConfigurationOption.CONNECTION_MAX_IDLE_TIMEOUT).toMillis(); + return maxIdle > 0 ? new SdkConnectionKeepAliveStrategy(maxIdle) : null; + } + + private boolean useIdleConnectionReaper(AttributeMap standardOptions) { + return Boolean.TRUE.equals(standardOptions.get(SdkHttpConfigurationOption.REAP_IDLE_CONNECTIONS)); + } + + private boolean isAuthenticatedProxy(ProxyConfiguration proxyConfiguration) { + return proxyConfiguration.username() != null && proxyConfiguration.password() != null; + } + + private boolean isProxyEnabled(ProxyConfiguration proxyConfiguration) { + return proxyConfiguration.host() != null + && proxyConfiguration.port() > 0; + } + + @Override + public ExecutableHttpRequest prepareRequest(HttpExecuteRequest request) { + MetricCollector metricCollector = request.metricCollector().orElseGet(NoOpMetricCollector::create); + metricCollector.reportMetric(HTTP_CLIENT_NAME, clientName()); + HttpUriRequestBase apacheRequest = toApacheRequest(request); + return new ExecutableHttpRequest() { + @Override + public HttpExecuteResponse call() throws IOException { + HttpExecuteResponse executeResponse = execute(apacheRequest, metricCollector); + collectPoolMetric(metricCollector); + return executeResponse; + } + + @Override + public void abort() { + apacheRequest.abort(); + } + }; + } + + @Override + public void close() { + HttpClientConnectionManager cm = httpClient.getHttpClientConnectionManager(); + IdleConnectionReaper.getInstance().deregisterConnectionManager(cm); + cm.close(CloseMode.IMMEDIATE); + } + + private HttpExecuteResponse execute(HttpUriRequestBase apacheRequest, MetricCollector metricCollector) throws IOException { + HttpClientContext localRequestContext = Apache5Utils.newClientContext(requestConfig.proxyConfiguration()); + THREAD_LOCAL_REQUEST_METRIC_COLLECTOR.set(metricCollector); + try { + HttpResponse httpResponse = httpClient.execute(apacheRequest, localRequestContext); + // Create a connection-aware input stream that closes the response when closed + return createResponse(httpResponse, apacheRequest); + } finally { + THREAD_LOCAL_REQUEST_METRIC_COLLECTOR.remove(); + } + } + + private HttpUriRequestBase toApacheRequest(HttpExecuteRequest request) { + return apacheHttpRequestFactory.create(request, requestConfig); + } + + /** + * Creates and initializes an HttpResponse object suitable to be passed to an HTTP response + * handler object. + * + * @return The new, initialized HttpResponse object ready to be passed to an HTTP response handler object. + * @throws IOException If there were any problems getting any response information from the + * HttpClient method object. + */ + private HttpExecuteResponse createResponse(HttpResponse apacheHttpResponse, + HttpUriRequestBase apacheRequest) throws IOException { + SdkHttpResponse.Builder responseBuilder = + SdkHttpResponse.builder() + .statusCode(apacheHttpResponse.getCode()) + .statusText(apacheHttpResponse.getReasonPhrase()); + + + Iterator
headerIterator = apacheHttpResponse.headerIterator(); + while (headerIterator.hasNext()) { + Header header = headerIterator.next(); + responseBuilder.appendHeader(header.getName(), header.getValue()); + + } + AbortableInputStream responseBody = getResponseBody(apacheHttpResponse, apacheRequest); + return HttpExecuteResponse.builder().response(responseBuilder.build()).responseBody(responseBody).build(); + + } + + private AbortableInputStream getResponseBody(HttpResponse apacheHttpResponse, + HttpUriRequestBase apacheRequest) throws IOException { + AbortableInputStream responseBody = null; + if (apacheHttpResponse instanceof ClassicHttpResponse) { + ClassicHttpResponse classicResponse = (ClassicHttpResponse) apacheHttpResponse; + HttpEntity entity = classicResponse.getEntity(); + if (entity != null) { + if (entity.getContentLength() == 0) { + // Close immediately for empty responses + classicResponse.close(); + responseBody = AbortableInputStream.create(new ByteArrayInputStream(new byte[0])); + } else { + responseBody = toAbortableInputStream(classicResponse, apacheRequest); + } + } else { + // No entity, close the response immediately + classicResponse.close(); + } + } + return responseBody; + } + + private AbortableInputStream toAbortableInputStream(ClassicHttpResponse apacheResponse, + HttpUriRequestBase apacheRequest) throws IOException { + return AbortableInputStream.create(apacheResponse.getEntity().getContent(), apacheRequest::abort); + } + + private Apache5HttpRequestConfig createRequestConfig(DefaultBuilder builder, + AttributeMap resolvedOptions) { + return Apache5HttpRequestConfig.builder() + .socketTimeout(resolvedOptions.get(SdkHttpConfigurationOption.READ_TIMEOUT)) + .connectionTimeout(resolvedOptions.get(SdkHttpConfigurationOption.CONNECTION_TIMEOUT)) + .connectionAcquireTimeout( + resolvedOptions.get(SdkHttpConfigurationOption.CONNECTION_ACQUIRE_TIMEOUT)) + .proxyConfiguration(builder.proxyConfiguration) + .expectContinueEnabled(Optional.ofNullable(builder.expectContinueEnabled) + .orElse(DefaultConfiguration.EXPECT_CONTINUE_ENABLED)) + .build(); + } + + private void collectPoolMetric(MetricCollector metricCollector) { + HttpClientConnectionManager cm = httpClient.getHttpClientConnectionManager(); + if (cm instanceof PoolingHttpClientConnectionManager && !(metricCollector instanceof NoOpMetricCollector)) { + PoolingHttpClientConnectionManager poolingCm = (PoolingHttpClientConnectionManager) cm; + PoolStats totalStats = poolingCm.getTotalStats(); + metricCollector.reportMetric(MAX_CONCURRENCY, totalStats.getMax()); + metricCollector.reportMetric(AVAILABLE_CONCURRENCY, totalStats.getAvailable()); + metricCollector.reportMetric(LEASED_CONCURRENCY, totalStats.getLeased()); + metricCollector.reportMetric(PENDING_CONCURRENCY_ACQUIRES, totalStats.getPending()); + } + } + + @Override + public String clientName() { + return CLIENT_NAME; + } + + /** + * Builder for creating an instance of {@link SdkHttpClient}. The factory can be configured through the builder {@link + * #builder()}, once built it can create a {@link SdkHttpClient} via {@link #build()} or can be passed to the SDK + * client builders directly to have the SDK create and manage the HTTP client. See documentation on the service's respective + * client builder for more information on configuring the HTTP layer. + * + *
+     * SdkHttpClient httpClient =
+     *     Apache5HttpClient.builder()
+     *                     .socketTimeout(Duration.ofSeconds(10))
+     *                     .build();
+     * 
+ */ + public interface Builder extends SdkHttpClient.Builder { + + /** + * The amount of time to wait for data to be transferred over an established, open connection before the connection is + * timed out. A duration of 0 means infinity, and is not recommended. + */ + Builder socketTimeout(Duration socketTimeout); + + /** + * The amount of time to wait when initially establishing a connection before giving up and timing out. A duration of 0 + * means infinity, and is not recommended. + */ + Builder connectionTimeout(Duration connectionTimeout); + + /** + * The amount of time to wait when acquiring a connection from the pool before giving up and timing out. + * @param connectionAcquisitionTimeout the timeout duration + * @return this builder for method chaining. + */ + Builder connectionAcquisitionTimeout(Duration connectionAcquisitionTimeout); + + /** + * The maximum number of connections allowed in the connection pool. Each built HTTP client has its own private + * connection pool. + */ + Builder maxConnections(Integer maxConnections); + + /** + * Configuration that defines how to communicate via an HTTP proxy. + */ + Builder proxyConfiguration(ProxyConfiguration proxyConfiguration); + + /** + * Configure the local address that the HTTP client should use for communication. + */ + Builder localAddress(InetAddress localAddress); + + /** + * Configure whether the client should send an HTTP expect-continue handshake before each request. + */ + Builder expectContinueEnabled(Boolean expectContinueEnabled); + + + /** + * The maximum amount of time that a connection should be allowed to remain open, regardless of usage frequency. + * + *

Note: A duration of 0 is treated as infinite to maintain backward compatibility with Apache 4.x behavior. + * The SDK handles this internally by not setting the TTL when the value is 0.

+ */ + Builder connectionTimeToLive(Duration connectionTimeToLive); + + /** + * Configure the maximum amount of time that a connection should be allowed to remain open while idle. + */ + Builder connectionMaxIdleTime(Duration maxIdleConnectionTimeout); + + /** + * Configure whether the idle connections in the connection pool should be closed asynchronously. + *

+ * When enabled, connections left idling for longer than {@link #connectionMaxIdleTime(Duration)} will be + * closed. This will not close connections currently in use. By default, this is enabled. + */ + Builder useIdleConnectionReaper(Boolean useConnectionReaper); + + /** + * Configuration that defines a DNS resolver. If no matches are found, the default resolver is used. + */ + Builder dnsResolver(DnsResolver dnsResolver); + + /** + * Configuration that defines a custom Socket factory. If set to a null value, a default factory is used. + *

+ * When set to a non-null value, the use of a custom factory implies the configuration options TRUST_ALL_CERTIFICATES, + * TLS_TRUST_MANAGERS_PROVIDER, and TLS_KEY_MANAGERS_PROVIDER are ignored. + */ + Builder socketFactory(SSLConnectionSocketFactory socketFactory); + + /** + * Configuration that defines an HTTP route planner that computes the route an HTTP request should take. + * May not be used in conjunction with {@link #proxyConfiguration(ProxyConfiguration)}. + */ + Builder httpRoutePlanner(HttpRoutePlanner proxyConfiguration); + + /** + * Configuration that defines a custom credential provider for HTTP requests. + * May not be used in conjunction with {@link ProxyConfiguration#username()} and {@link ProxyConfiguration#password()}. + */ + Builder credentialsProvider(CredentialsProvider credentialsProvider); + + /** + * Configure whether to enable or disable TCP KeepAlive. + * The configuration will be passed to the socket option {@link java.net.SocketOptions#SO_KEEPALIVE}. + *

+ * By default, this is disabled. + *

+ * When enabled, the actual KeepAlive mechanism is dependent on the Operating System and therefore additional TCP + * KeepAlive values (like timeout, number of packets, etc) must be configured via the Operating System (sysctl on + * Linux/Mac, and Registry values on Windows). + */ + Builder tcpKeepAlive(Boolean keepConnectionAlive); + + /** + * Configure the {@link TlsKeyManagersProvider} that will provide the {@link javax.net.ssl.KeyManager}s to use + * when constructing the SSL context. + *

+ * The default used by the client will be {@link SystemPropertyTlsKeyManagersProvider}. Configure an instance of + * {@link software.amazon.awssdk.internal.http.NoneTlsKeyManagersProvider} or another implementation of + * {@link TlsKeyManagersProvider} to override it. + */ + Builder tlsKeyManagersProvider(TlsKeyManagersProvider tlsKeyManagersProvider); + + /** + * Configure the {@link TlsTrustManagersProvider} that will provide the {@link javax.net.ssl.TrustManager}s to use + * when constructing the SSL context. + */ + Builder tlsTrustManagersProvider(TlsTrustManagersProvider tlsTrustManagersProvider); + + /** + * Configure the authentication scheme registry that can be used to obtain the corresponding authentication scheme + * implementation for a given type of authorization challenge. + */ + Builder authSchemeRegistry(Registry authSchemeRegistry) ; + } + + private static final class DefaultBuilder implements Builder { + private final AttributeMap.Builder standardOptions = AttributeMap.builder(); + private Registry authSchemeRegistry; + private ProxyConfiguration proxyConfiguration = ProxyConfiguration.builder().build(); + private InetAddress localAddress; + private Boolean expectContinueEnabled; + private HttpRoutePlanner httpRoutePlanner; + private CredentialsProvider credentialsProvider; + private DnsResolver dnsResolver; + private SSLConnectionSocketFactory socketFactory; + + private DefaultBuilder() { + } + + @Override + public Builder socketTimeout(Duration socketTimeout) { + standardOptions.put(SdkHttpConfigurationOption.READ_TIMEOUT, socketTimeout); + return this; + } + + public void setSocketTimeout(Duration socketTimeout) { + socketTimeout(socketTimeout); + } + + @Override + public Builder connectionTimeout(Duration connectionTimeout) { + standardOptions.put(SdkHttpConfigurationOption.CONNECTION_TIMEOUT, connectionTimeout); + return this; + } + + public void setConnectionTimeout(Duration connectionTimeout) { + connectionTimeout(connectionTimeout); + } + + /** + * The amount of time to wait when acquiring a connection from the pool before giving up and timing out. + * @param connectionAcquisitionTimeout the timeout duration + * @return this builder for method chaining. + */ + @Override + public Builder connectionAcquisitionTimeout(Duration connectionAcquisitionTimeout) { + Validate.isPositive(connectionAcquisitionTimeout, "connectionAcquisitionTimeout"); + standardOptions.put(SdkHttpConfigurationOption.CONNECTION_ACQUIRE_TIMEOUT, connectionAcquisitionTimeout); + return this; + } + + public void setConnectionAcquisitionTimeout(Duration connectionAcquisitionTimeout) { + connectionAcquisitionTimeout(connectionAcquisitionTimeout); + } + + @Override + public Builder maxConnections(Integer maxConnections) { + standardOptions.put(SdkHttpConfigurationOption.MAX_CONNECTIONS, maxConnections); + return this; + } + + public void setMaxConnections(Integer maxConnections) { + maxConnections(maxConnections); + } + + @Override + public Builder proxyConfiguration(ProxyConfiguration proxyConfiguration) { + this.proxyConfiguration = proxyConfiguration; + return this; + } + + public void setProxyConfiguration(ProxyConfiguration proxyConfiguration) { + proxyConfiguration(proxyConfiguration); + } + + @Override + public Builder localAddress(InetAddress localAddress) { + this.localAddress = localAddress; + return this; + } + + public void setLocalAddress(InetAddress localAddress) { + localAddress(localAddress); + } + + @Override + public Builder expectContinueEnabled(Boolean expectContinueEnabled) { + this.expectContinueEnabled = expectContinueEnabled; + return this; + } + + public void setExpectContinueEnabled(Boolean useExpectContinue) { + this.expectContinueEnabled = useExpectContinue; + } + + @Override + public Builder connectionTimeToLive(Duration connectionTimeToLive) { + standardOptions.put(SdkHttpConfigurationOption.CONNECTION_TIME_TO_LIVE, connectionTimeToLive); + return this; + } + + public void setConnectionTimeToLive(Duration connectionTimeToLive) { + connectionTimeToLive(connectionTimeToLive); + } + + @Override + public Builder connectionMaxIdleTime(Duration maxIdleConnectionTimeout) { + standardOptions.put(SdkHttpConfigurationOption.CONNECTION_MAX_IDLE_TIMEOUT, maxIdleConnectionTimeout); + return this; + } + + public void setConnectionMaxIdleTime(Duration connectionMaxIdleTime) { + connectionMaxIdleTime(connectionMaxIdleTime); + } + + @Override + public Builder useIdleConnectionReaper(Boolean useIdleConnectionReaper) { + standardOptions.put(SdkHttpConfigurationOption.REAP_IDLE_CONNECTIONS, useIdleConnectionReaper); + return this; + } + + public void setUseIdleConnectionReaper(Boolean useIdleConnectionReaper) { + useIdleConnectionReaper(useIdleConnectionReaper); + } + + @Override + public Builder dnsResolver(DnsResolver dnsResolver) { + this.dnsResolver = dnsResolver; + return this; + } + + public void setDnsResolver(DnsResolver dnsResolver) { + dnsResolver(dnsResolver); + } + + @Override + public Builder socketFactory(SSLConnectionSocketFactory socketFactory) { + this.socketFactory = socketFactory; + return this; + } + + public void setSocketFactory(SSLConnectionSocketFactory socketFactory) { + socketFactory(socketFactory); + } + + @Override + public Builder httpRoutePlanner(HttpRoutePlanner httpRoutePlanner) { + this.httpRoutePlanner = httpRoutePlanner; + return this; + } + + public void setHttpRoutePlanner(HttpRoutePlanner httpRoutePlanner) { + httpRoutePlanner(httpRoutePlanner); + } + + @Override + public Builder credentialsProvider(CredentialsProvider credentialsProvider) { + this.credentialsProvider = credentialsProvider; + return this; + } + + public void setCredentialsProvider(CredentialsProvider credentialsProvider) { + credentialsProvider(credentialsProvider); + } + + @Override + public Builder tcpKeepAlive(Boolean keepConnectionAlive) { + standardOptions.put(SdkHttpConfigurationOption.TCP_KEEPALIVE, keepConnectionAlive); + return this; + } + + public void setTcpKeepAlive(Boolean keepConnectionAlive) { + tcpKeepAlive(keepConnectionAlive); + } + + @Override + public Builder tlsKeyManagersProvider(TlsKeyManagersProvider tlsKeyManagersProvider) { + standardOptions.put(SdkHttpConfigurationOption.TLS_KEY_MANAGERS_PROVIDER, tlsKeyManagersProvider); + return this; + } + + public void setTlsKeyManagersProvider(TlsKeyManagersProvider tlsKeyManagersProvider) { + tlsKeyManagersProvider(tlsKeyManagersProvider); + } + + @Override + public Builder tlsTrustManagersProvider(TlsTrustManagersProvider tlsTrustManagersProvider) { + standardOptions.put(SdkHttpConfigurationOption.TLS_TRUST_MANAGERS_PROVIDER, tlsTrustManagersProvider); + return this; + } + + public void setTlsTrustManagersProvider(TlsTrustManagersProvider tlsTrustManagersProvider) { + tlsTrustManagersProvider(tlsTrustManagersProvider); + } + + + @Override + public Builder authSchemeRegistry(Registry authSchemeRegistry) { + this.authSchemeRegistry = authSchemeRegistry; + return this; + } + + public void setAuthSchemeProviderRegistry(Registry authSchemeRegistry) { + authSchemeRegistry(authSchemeRegistry); + } + + + @Override + public SdkHttpClient buildWithDefaults(AttributeMap serviceDefaults) { + AttributeMap resolvedOptions = standardOptions.build().merge(serviceDefaults).merge( + SdkHttpConfigurationOption.GLOBAL_HTTP_DEFAULTS); + return new Apache5HttpClient(this, resolvedOptions); + } + } + + private static class ApacheConnectionManagerFactory { + + public PoolingHttpClientConnectionManager create(Apache5HttpClient.DefaultBuilder configuration, + AttributeMap standardOptions) { + // TODO : Deprecated method needs to be removed with new replacements + SSLConnectionSocketFactory sslsf = getPreferredSocketFactory(configuration, standardOptions); + + PoolingHttpClientConnectionManagerBuilder builder = + PoolingHttpClientConnectionManagerBuilder.create() + .setSSLSocketFactory(sslsf) + .setSchemePortResolver(DefaultSchemePortResolver.INSTANCE) + .setDnsResolver(configuration.dnsResolver); + Duration connectionTtl = standardOptions.get(SdkHttpConfigurationOption.CONNECTION_TIME_TO_LIVE); + if (!connectionTtl.isZero()) { + // Skip TTL=0 to maintain backward compatibility (infinite in 4.x vs immediate expiration in 5.x) + builder.setConnectionTimeToLive(TimeValue.of(connectionTtl.toMillis(), TimeUnit.MILLISECONDS)); + } + builder.setMaxConnPerRoute(standardOptions.get(SdkHttpConfigurationOption.MAX_CONNECTIONS)); + builder.setMaxConnTotal(standardOptions.get(SdkHttpConfigurationOption.MAX_CONNECTIONS)); + builder.setDefaultSocketConfig(buildSocketConfig(standardOptions)); + return builder.build(); + } + + private SSLConnectionSocketFactory getPreferredSocketFactory(Apache5HttpClient.DefaultBuilder configuration, + AttributeMap standardOptions) { + return Optional.ofNullable(configuration.socketFactory) + .orElseGet(() -> new SdkTlsSocketFactory(getSslContext(standardOptions), + getHostNameVerifier(standardOptions))); + } + + + private HostnameVerifier getHostNameVerifier(AttributeMap standardOptions) { + return standardOptions.get(SdkHttpConfigurationOption.TRUST_ALL_CERTIFICATES) + ? NoopHostnameVerifier.INSTANCE + : DEFAULT_HOSTNAME_VERIFIER; + } + + private SSLContext getSslContext(AttributeMap standardOptions) { + Validate.isTrue(standardOptions.get(SdkHttpConfigurationOption.TLS_TRUST_MANAGERS_PROVIDER) == null || + !standardOptions.get(SdkHttpConfigurationOption.TRUST_ALL_CERTIFICATES), + "A TlsTrustManagerProvider can't be provided if TrustAllCertificates is also set"); + + TrustManager[] trustManagers = null; + if (standardOptions.get(SdkHttpConfigurationOption.TLS_TRUST_MANAGERS_PROVIDER) != null) { + trustManagers = standardOptions.get(SdkHttpConfigurationOption.TLS_TRUST_MANAGERS_PROVIDER).trustManagers(); + } + + if (standardOptions.get(SdkHttpConfigurationOption.TRUST_ALL_CERTIFICATES)) { + log.warn(() -> "SSL Certificate verification is disabled. This is not a safe setting and should only be " + + "used for testing."); + trustManagers = trustAllTrustManager(); + } + + TlsKeyManagersProvider provider = standardOptions.get(SdkHttpConfigurationOption.TLS_KEY_MANAGERS_PROVIDER); + KeyManager[] keyManagers = provider.keyManagers(); + + try { + SSLContext sslcontext = SSLContext.getInstance("TLS"); + // http://download.java.net/jdk9/docs/technotes/guides/security/jsse/JSSERefGuide.html + sslcontext.init(keyManagers, trustManagers, null); + return sslcontext; + } catch (final NoSuchAlgorithmException | KeyManagementException ex) { + throw new SSLInitializationException(ex.getMessage(), ex); + } + } + + /** + * Insecure trust manager to trust all certs. Should only be used for testing. + */ + private static TrustManager[] trustAllTrustManager() { + return new TrustManager[] { + new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException { + log.debug(() -> "Accepting a client certificate: " + x509Certificates[0].getSubjectDN()); + } + + @Override + public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException { + log.debug(() -> "Accepting a client certificate: " + x509Certificates[0].getSubjectDN()); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } + }; + } + + private SocketConfig buildSocketConfig(AttributeMap standardOptions) { + return SocketConfig.custom() + .setSoKeepAlive(standardOptions.get(SdkHttpConfigurationOption.TCP_KEEPALIVE)) + .setSoTimeout(saturatedCast(standardOptions.get(SdkHttpConfigurationOption.READ_TIMEOUT) + .toMillis()), TimeUnit.MILLISECONDS) + .setTcpNoDelay(true) + .build(); + } + + } + + private static class LocalAddressRoutePlanner extends DefaultRoutePlanner { + private final InetAddress localAddress; + + LocalAddressRoutePlanner(InetAddress localAddress) { + super(DefaultSchemePortResolver.INSTANCE); + this.localAddress = localAddress; + } + + @Override + protected InetAddress determineLocalAddress(HttpHost firstHop, HttpContext context) throws HttpException { + return localAddress; + } + } +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/Apache5SdkHttpService.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/Apache5SdkHttpService.java new file mode 100644 index 000000000000..c724c0bc9483 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/Apache5SdkHttpService.java @@ -0,0 +1,33 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import software.amazon.awssdk.annotations.SdkPreviewApi; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpService; + +/** + * Service binding for the Apache5 implementation. + */ +@SdkPreviewApi +@SdkPublicApi +public class Apache5SdkHttpService implements SdkHttpService { + @Override + public SdkHttpClient.Builder createHttpClientBuilder() { + return Apache5HttpClient.builder(); + } +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/ProxyConfiguration.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/ProxyConfiguration.java new file mode 100644 index 000000000000..712b2c42e149 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/ProxyConfiguration.java @@ -0,0 +1,452 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import static software.amazon.awssdk.utils.ProxyConfigProvider.fromSystemEnvironmentSettings; +import static software.amazon.awssdk.utils.StringUtils.isEmpty; + +import java.net.URI; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import software.amazon.awssdk.annotations.SdkPreviewApi; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.utils.ProxyConfigProvider; +import software.amazon.awssdk.utils.ProxySystemSetting; +import software.amazon.awssdk.utils.ToString; +import software.amazon.awssdk.utils.Validate; +import software.amazon.awssdk.utils.builder.CopyableBuilder; +import software.amazon.awssdk.utils.builder.ToCopyableBuilder; + +/** + * Configuration that defines how to communicate via an HTTP or HTTPS proxy. + */ +@SdkPreviewApi +@SdkPublicApi +public final class ProxyConfiguration implements ToCopyableBuilder { + private final URI endpoint; + private final String username; + private final String password; + private final String ntlmDomain; + private final String ntlmWorkstation; + private final Set nonProxyHosts; + private final Boolean preemptiveBasicAuthenticationEnabled; + private final Boolean useSystemPropertyValues; + private final String host; + private final int port; + private final String scheme; + private final Boolean useEnvironmentVariablesValues; + + /** + * Initialize this configuration. Private to require use of {@link #builder()}. + */ + private ProxyConfiguration(DefaultClientProxyConfigurationBuilder builder) { + this.endpoint = builder.endpoint; + String resolvedScheme = getResolvedScheme(builder); + this.scheme = resolvedScheme; + ProxyConfigProvider proxyConfiguration = fromSystemEnvironmentSettings(builder.useSystemPropertyValues, + builder.useEnvironmentVariableValues, + resolvedScheme); + this.username = resolveUsername(builder, proxyConfiguration); + this.password = resolvePassword(builder, proxyConfiguration); + this.ntlmDomain = builder.ntlmDomain; + this.ntlmWorkstation = builder.ntlmWorkstation; + this.nonProxyHosts = resolveNonProxyHosts(builder, proxyConfiguration); + this.preemptiveBasicAuthenticationEnabled = builder.preemptiveBasicAuthenticationEnabled == null ? Boolean.FALSE : + builder.preemptiveBasicAuthenticationEnabled; + this.useSystemPropertyValues = builder.useSystemPropertyValues; + this.useEnvironmentVariablesValues = builder.useEnvironmentVariableValues; + + if (this.endpoint != null) { + this.host = endpoint.getHost(); + this.port = endpoint.getPort(); + } else { + this.host = proxyConfiguration != null ? proxyConfiguration.host() : null; + this.port = proxyConfiguration != null ? proxyConfiguration.port() : 0; + } + } + + private static String resolvePassword(DefaultClientProxyConfigurationBuilder builder, + ProxyConfigProvider proxyConfiguration) { + return !isEmpty(builder.password) || proxyConfiguration == null ? builder.password : + proxyConfiguration.password().orElseGet(() -> builder.password); + } + + private static String resolveUsername(DefaultClientProxyConfigurationBuilder builder, + ProxyConfigProvider proxyConfiguration) { + return !isEmpty(builder.username) || proxyConfiguration == null ? builder.username : + proxyConfiguration.userName().orElseGet(() -> builder.username); + } + + + private static Set resolveNonProxyHosts(DefaultClientProxyConfigurationBuilder builder, + ProxyConfigProvider proxyConfiguration) { + if (builder.nonProxyHosts != null || proxyConfiguration == null) { + return builder.nonProxyHosts; + } + return proxyConfiguration.nonProxyHosts(); + } + + private String getResolvedScheme(DefaultClientProxyConfigurationBuilder builder) { + return endpoint != null ? endpoint.getScheme() : builder.scheme; + } + + /** + * Returns the proxy host name from the configured endpoint if set, else from the "https.proxyHost" or "http.proxyHost" system + * property, based on the scheme used, if {@link Builder#useSystemPropertyValues(Boolean)} is set to true. + */ + public String host() { + return host; + } + + /** + * Returns the proxy port from the configured endpoint if set, else from the "https.proxyPort" or "http.proxyPort" system + * property, based on the scheme used, if {@link Builder#useSystemPropertyValues(Boolean)} is set to true. + * If no value is found in none of the above options, the default value of 0 is returned. + */ + public int port() { + return port; + } + + /** + * Returns the {@link URI#scheme} from the configured endpoint. Otherwise return null. + */ + public String scheme() { + return scheme; + } + + /** + * The username to use when connecting through a proxy. + * + * @see Builder#password(String) + */ + public String username() { + return username; + } + + /** + * The password to use when connecting through a proxy. + * + * @see Builder#password(String) + */ + public String password() { + + return password; + } + + /** + * For NTLM proxies: The Windows domain name to use when authenticating with the proxy. + * + * @see Builder#ntlmDomain(String) + */ + public String ntlmDomain() { + return ntlmDomain; + } + + /** + * For NTLM proxies: The Windows workstation name to use when authenticating with the proxy. + * + * @see Builder#ntlmWorkstation(String) + */ + public String ntlmWorkstation() { + return ntlmWorkstation; + } + + /** + * The hosts that the client is allowed to access without going through the proxy. + * If the value is not set on the object, the value represent by "http.nonProxyHosts" system property is returned. + * If system property is also not set, an unmodifiable empty set is returned. + * + * @see Builder#nonProxyHosts(Set) + */ + public Set nonProxyHosts() { + return Collections.unmodifiableSet(nonProxyHosts != null ? nonProxyHosts : Collections.emptySet()); + } + + /** + * Whether to attempt to authenticate preemptively against the proxy server using basic authentication. + * + * @see Builder#preemptiveBasicAuthenticationEnabled(Boolean) + */ + public Boolean preemptiveBasicAuthenticationEnabled() { + return preemptiveBasicAuthenticationEnabled; + } + + @Override + public Builder toBuilder() { + return builder() + .endpoint(endpoint) + .username(username) + .password(password) + .ntlmDomain(ntlmDomain) + .ntlmWorkstation(ntlmWorkstation) + .nonProxyHosts(nonProxyHosts) + .preemptiveBasicAuthenticationEnabled(preemptiveBasicAuthenticationEnabled) + .useSystemPropertyValues(useSystemPropertyValues) + .scheme(scheme) + .useEnvironmentVariableValues(useEnvironmentVariablesValues); + } + + /** + * Create a {@link Builder}, used to create a {@link ProxyConfiguration}. + */ + public static Builder builder() { + return new DefaultClientProxyConfigurationBuilder(); + } + + @Override + public String toString() { + return ToString.builder("ProxyConfiguration") + .add("endpoint", endpoint) + .add("username", username) + .add("ntlmDomain", ntlmDomain) + .add("ntlmWorkstation", ntlmWorkstation) + .add("nonProxyHosts", nonProxyHosts) + .add("preemptiveBasicAuthenticationEnabled", preemptiveBasicAuthenticationEnabled) + .add("useSystemPropertyValues", useSystemPropertyValues) + .add("useEnvironmentVariablesValues", useEnvironmentVariablesValues) + .add("scheme", scheme) + .build(); + } + + public String resolveScheme() { + return endpoint != null ? endpoint.getScheme() : scheme; + } + + /** + * A builder for {@link ProxyConfiguration}. + * + *

All implementations of this interface are mutable and not thread safe.

+ */ + public interface Builder extends CopyableBuilder { + + /** + * Configure the endpoint of the proxy server that the SDK should connect through. Currently, the endpoint is limited to + * a host and port. Any other URI components will result in an exception being raised. + */ + Builder endpoint(URI endpoint); + + /** + * Configure the username to use when connecting through a proxy. + */ + Builder username(String username); + + /** + * Configure the password to use when connecting through a proxy. + */ + Builder password(String password); + + /** + * For NTLM proxies: Configure the Windows domain name to use when authenticating with the proxy. + */ + Builder ntlmDomain(String proxyDomain); + + /** + * For NTLM proxies: Configure the Windows workstation name to use when authenticating with the proxy. + */ + Builder ntlmWorkstation(String proxyWorkstation); + + /** + * Configure the hosts that the client is allowed to access without going through the proxy. + */ + Builder nonProxyHosts(Set nonProxyHosts); + + /** + * Add a host that the client is allowed to access without going through the proxy. + * + * @see ProxyConfiguration#nonProxyHosts() + */ + Builder addNonProxyHost(String nonProxyHost); + + /** + * Configure whether to attempt to authenticate pre-emptively against the proxy server using basic authentication. + */ + Builder preemptiveBasicAuthenticationEnabled(Boolean preemptiveBasicAuthenticationEnabled); + + /** + * Option whether to use system property values from {@link ProxySystemSetting} if any of the config options are missing. + *

+ * This value is set to "true" by default which means SDK will automatically use system property values for options that + * are not provided during building the {@link ProxyConfiguration} object. To disable this behavior, set this value to + * "false".It is important to note that when this property is set to "true," all proxy settings will exclusively originate + * from system properties, and no partial settings will be obtained from EnvironmentVariableValues. + */ + Builder useSystemPropertyValues(Boolean useSystemPropertyValues); + + /** + * Option whether to use environment variable values for proxy configuration if any of the config options are missing. + *

+ * This value is set to "true" by default, which means the SDK will automatically use environment variable values for + * proxy configuration options that are not provided during the building of the {@link ProxyConfiguration} object. To + * disable this behavior, set this value to "false". It is important to note that when this property is set to "true," all + * proxy settings will exclusively originate from environment variableValues, and no partial settings will be obtained + * from SystemPropertyValues. + *

Comma-separated host names in the NO_PROXY environment variable indicate multiple hosts to exclude from + * proxy settings. + * + * @param useEnvironmentVariableValues The option whether to use environment variable values. + * @return This object for method chaining. + */ + Builder useEnvironmentVariableValues(Boolean useEnvironmentVariableValues); + + /** + * The HTTP scheme to use for connecting to the proxy. Valid values are {@code http} and {@code https}. + *

+ * The client defaults to {@code http} if none is given. + * + * @param scheme The proxy scheme. + * @return This object for method chaining. + */ + Builder scheme(String scheme); + + } + + /** + * An SDK-internal implementation of {@link Builder}. + */ + private static final class DefaultClientProxyConfigurationBuilder implements Builder { + + private URI endpoint; + private String username; + private String password; + private String ntlmDomain; + private String ntlmWorkstation; + private Set nonProxyHosts; + private Boolean preemptiveBasicAuthenticationEnabled; + private Boolean useSystemPropertyValues = Boolean.TRUE; + private Boolean useEnvironmentVariableValues = Boolean.TRUE; + private String scheme = "http"; + + @Override + public Builder endpoint(URI endpoint) { + if (endpoint != null) { + Validate.isTrue(isEmpty(endpoint.getUserInfo()), "Proxy endpoint user info is not supported."); + Validate.isTrue(isEmpty(endpoint.getPath()), "Proxy endpoint path is not supported."); + Validate.isTrue(isEmpty(endpoint.getQuery()), "Proxy endpoint query is not supported."); + Validate.isTrue(isEmpty(endpoint.getFragment()), "Proxy endpoint fragment is not supported."); + } + + this.endpoint = endpoint; + return this; + } + + public void setEndpoint(URI endpoint) { + endpoint(endpoint); + } + + @Override + public Builder username(String username) { + this.username = username; + return this; + } + + public void setUsername(String username) { + username(username); + } + + @Override + public Builder password(String password) { + this.password = password; + return this; + } + + public void setPassword(String password) { + password(password); + } + + @Override + public Builder ntlmDomain(String proxyDomain) { + this.ntlmDomain = proxyDomain; + return this; + } + + public void setNtlmDomain(String ntlmDomain) { + ntlmDomain(ntlmDomain); + } + + @Override + public Builder ntlmWorkstation(String proxyWorkstation) { + this.ntlmWorkstation = proxyWorkstation; + return this; + } + + public void setNtlmWorkstation(String ntlmWorkstation) { + ntlmWorkstation(ntlmWorkstation); + } + + @Override + public Builder nonProxyHosts(Set nonProxyHosts) { + this.nonProxyHosts = nonProxyHosts != null ? new HashSet<>(nonProxyHosts) : null; + return this; + } + + @Override + public Builder addNonProxyHost(String nonProxyHost) { + if (this.nonProxyHosts == null) { + this.nonProxyHosts = new HashSet<>(); + } + this.nonProxyHosts.add(nonProxyHost); + return this; + } + + public void setNonProxyHosts(Set nonProxyHosts) { + nonProxyHosts(nonProxyHosts); + } + + @Override + public Builder preemptiveBasicAuthenticationEnabled(Boolean preemptiveBasicAuthenticationEnabled) { + this.preemptiveBasicAuthenticationEnabled = preemptiveBasicAuthenticationEnabled; + return this; + } + + public void setPreemptiveBasicAuthenticationEnabled(Boolean preemptiveBasicAuthenticationEnabled) { + preemptiveBasicAuthenticationEnabled(preemptiveBasicAuthenticationEnabled); + } + + @Override + public Builder useSystemPropertyValues(Boolean useSystemPropertyValues) { + this.useSystemPropertyValues = useSystemPropertyValues; + return this; + } + + public void setUseSystemPropertyValues(Boolean useSystemPropertyValues) { + useSystemPropertyValues(useSystemPropertyValues); + } + + + @Override + public Builder useEnvironmentVariableValues(Boolean useEnvironmentVariableValues) { + this.useEnvironmentVariableValues = useEnvironmentVariableValues; + return this; + } + + @Override + public Builder scheme(String scheme) { + this.scheme = scheme; + return this; + } + + public void setUseEnvironmentVariableValues(Boolean useEnvironmentVariableValues) { + useEnvironmentVariableValues(useEnvironmentVariableValues); + } + + @Override + public ProxyConfiguration build() { + return new ProxyConfiguration(this); + } + } + +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/Apache5HttpRequestConfig.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/Apache5HttpRequestConfig.java new file mode 100644 index 000000000000..92bf31c33bfa --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/Apache5HttpRequestConfig.java @@ -0,0 +1,117 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal; + +import java.time.Duration; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.http.apache5.ProxyConfiguration; + +/** + * Configuration needed when building an Apache request. Note that at this time, we only support client level configuration so + * all of these settings are supplied when creating the client. + */ +@SdkInternalApi +public final class Apache5HttpRequestConfig { + + private final Duration socketTimeout; + private final Duration connectionTimeout; + private final Duration connectionAcquireTimeout; + private final boolean expectContinueEnabled; + private final ProxyConfiguration proxyConfiguration; + + private Apache5HttpRequestConfig(Builder builder) { + this.socketTimeout = builder.socketTimeout; + this.connectionTimeout = builder.connectionTimeout; + this.connectionAcquireTimeout = builder.connectionAcquireTimeout; + this.expectContinueEnabled = builder.expectContinueEnabled; + this.proxyConfiguration = builder.proxyConfiguration; + } + + public Duration socketTimeout() { + return socketTimeout; + } + + public Duration connectionTimeout() { + return connectionTimeout; + } + + public Duration connectionAcquireTimeout() { + return connectionAcquireTimeout; + } + + public boolean expectContinueEnabled() { + return expectContinueEnabled; + } + + public ProxyConfiguration proxyConfiguration() { + return proxyConfiguration; + } + + /** + * @return Builder instance to construct a {@link Apache5HttpRequestConfig}. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for a {@link Apache5HttpRequestConfig}. + */ + public static final class Builder { + + private Duration socketTimeout; + private Duration connectionTimeout; + private Duration connectionAcquireTimeout; + private boolean expectContinueEnabled; + private ProxyConfiguration proxyConfiguration; + + private Builder() { + } + + public Builder socketTimeout(Duration socketTimeout) { + this.socketTimeout = socketTimeout; + return this; + } + + public Builder connectionTimeout(Duration connectionTimeout) { + this.connectionTimeout = connectionTimeout; + return this; + } + + public Builder connectionAcquireTimeout(Duration connectionAcquireTimeout) { + this.connectionAcquireTimeout = connectionAcquireTimeout; + return this; + } + + public Builder expectContinueEnabled(boolean expectContinueEnabled) { + this.expectContinueEnabled = expectContinueEnabled; + return this; + } + + public Builder proxyConfiguration(ProxyConfiguration proxyConfiguration) { + this.proxyConfiguration = proxyConfiguration; + return this; + } + + /** + * @return An immutable {@link Apache5HttpRequestConfig} object. + */ + public Apache5HttpRequestConfig build() { + return new Apache5HttpRequestConfig(this); + } + } + +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/DefaultConfiguration.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/DefaultConfiguration.java new file mode 100644 index 000000000000..0128d6f18911 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/DefaultConfiguration.java @@ -0,0 +1,29 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal; + +import software.amazon.awssdk.annotations.SdkInternalApi; + +/** + * Default configuration values. + */ +@SdkInternalApi +public final class DefaultConfiguration { + public static final Boolean EXPECT_CONTINUE_ENABLED = Boolean.TRUE; + + private DefaultConfiguration() { + } +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/RepeatableInputStreamRequestEntity.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/RepeatableInputStreamRequestEntity.java new file mode 100644 index 000000000000..7239fa0c1f36 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/RepeatableInputStreamRequestEntity.java @@ -0,0 +1,193 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal; + +import static software.amazon.awssdk.http.Header.CHUNKED; +import static software.amazon.awssdk.http.Header.TRANSFER_ENCODING; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Optional; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.HttpEntityWrapper; +import org.apache.hc.core5.http.io.entity.InputStreamEntity; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.http.ContentStreamProvider; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.utils.Logger; + +/** + * Custom implementation of HttpEntity that delegates to an InputStreamEntity, with the one notable difference, that if the + * underlying InputStream supports being reset, this RequestEntity will report that it is repeatable and will reset the stream on + * all subsequent attempts to write out the request. + */ +@SdkInternalApi +public class RepeatableInputStreamRequestEntity extends HttpEntityWrapper { + + private static final Logger log = Logger.loggerFor(RepeatableInputStreamRequestEntity.class); + + /** + * True if the request entity hasn't been written out yet + */ + private boolean firstAttempt = true; + + /** + * True if the "Transfer-Encoding:chunked" header is present + */ + private final boolean isChunked; + /** + * The underlying reference of content + */ + private final InputStream content; + /** + * Record the original exception if we do attempt a retry, so that if the + * retry fails, we can report the original exception. Otherwise, we're most + * likely masking the real exception with an error about not being able to + * reset far enough back in the input stream. + */ + private IOException originalException; + + /** + * Helper class to capture both the created entity and the original content stream reference. + *

+ * We store the content stream reference to avoid calling {@code getContent()} on the wrapped + * entity multiple times, which could potentially create new stream instances or perform + * unnecessary operations. This ensures we consistently use the same stream instance for + * {@code markSupported()} checks and {@code reset()} operations throughout the entity's lifecycle. + */ + + private static class EntityCreationResult { + final InputStreamEntity entity; + final InputStream content; + + EntityCreationResult(InputStreamEntity entity, InputStream content) { + this.entity = entity; + this.content = content; + } + } + + public RepeatableInputStreamRequestEntity(HttpExecuteRequest request) { + this(createInputStreamEntityWithMetadata(request), request); + } + + private RepeatableInputStreamRequestEntity(EntityCreationResult result, HttpExecuteRequest request) { + super(result.entity); + this.content = result.content; + this.isChunked = request.httpRequest().matchingHeaders(TRANSFER_ENCODING).contains(CHUNKED); + } + + private static EntityCreationResult createInputStreamEntityWithMetadata(HttpExecuteRequest request) { + InputStream content = getContent(request.contentStreamProvider()); + + /* + * If we don't specify a content length when we instantiate our + * InputStreamRequestEntity, then HttpClient will attempt to + * buffer the entire stream contents into memory to determine + * the content length. + */ + long contentLength = request.httpRequest().firstMatchingHeader("Content-Length") + .map(RepeatableInputStreamRequestEntity::parseContentLength) + .orElse(-1L); + + ContentType contentType = request.httpRequest().firstMatchingHeader("Content-Type") + .map(RepeatableInputStreamRequestEntity::parseContentType) + .orElse(null); + + InputStreamEntity entity = contentLength >= 0 + ? new InputStreamEntity(content, contentLength, contentType) + : new InputStreamEntity(content, contentType); + return new EntityCreationResult(entity, content); + } + + private static long parseContentLength(String contentLength) { + try { + return Long.parseLong(contentLength); + } catch (NumberFormatException nfe) { + log.warn(() -> "Unable to parse content length from request. Buffering contents in memory."); + return -1; + } + } + + private static ContentType parseContentType(String contentTypeValue) { + if (contentTypeValue == null) { + return null; + } + try { + return ContentType.parse(contentTypeValue); + } catch (Exception e) { + log.warn(() -> "Unable to parse content type: " + contentTypeValue); + return null; + } + } + + /** + * @return The request content input stream or an empty input stream if there is no content. + */ + private static InputStream getContent(Optional contentStreamProvider) { + return contentStreamProvider.map(ContentStreamProvider::newStream) + .orElseGet(() -> new ByteArrayInputStream(new byte[0])); + } + + @Override + public boolean isChunked() { + return isChunked; + } + + /** + * Returns true if the underlying InputStream supports marking/resetting or + * if the underlying InputStreamRequestEntity is repeatable. + */ + @Override + public boolean isRepeatable() { + return content.markSupported() || super.isRepeatable(); + } + + /** + * Resets the underlying InputStream if this isn't the first attempt to + * write out the request, otherwise simply delegates to + * InputStreamRequestEntity to write out the data. + *

+ * If an error is encountered the first time we try to write the request + * entity, we remember the original exception, and report that as the root + * cause if we continue to encounter errors, rather than masking the + * original error. + */ + @Override + public void writeTo(OutputStream output) throws IOException { + try { + if (!firstAttempt && isRepeatable()) { + content.reset(); + } + + firstAttempt = false; + super.writeTo(output); + } catch (IOException ioe) { + if (originalException == null) { + originalException = ioe; + } + throw originalException; + } + } + + @Override + public void close() throws IOException { + // The InputStreamEntity handles closing the stream when it's closed + // We don't need to close our reference separately to avoid double-closing + super.close(); + } +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/SdkProxyRoutePlanner.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/SdkProxyRoutePlanner.java new file mode 100644 index 000000000000..b05c1d7e7900 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/SdkProxyRoutePlanner.java @@ -0,0 +1,63 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal; + +import static software.amazon.awssdk.utils.StringUtils.lowerCase; + +import java.util.Set; +import org.apache.hc.client5.http.impl.DefaultSchemePortResolver; +import org.apache.hc.client5.http.impl.routing.DefaultRoutePlanner; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.protocol.HttpContext; +import software.amazon.awssdk.annotations.SdkInternalApi; + +/** + * SdkProxyRoutePlanner delegates a Proxy Route Planner from the settings instead of the + * system properties. It will use the proxy created from proxyHost and proxyPort and + * filter the hosts who matches nonProxyHosts pattern. + */ +@SdkInternalApi +public class SdkProxyRoutePlanner extends DefaultRoutePlanner { + + private HttpHost proxy; + private Set hostPatterns; + + public SdkProxyRoutePlanner(String proxyHost, int proxyPort, String proxyProtocol, Set nonProxyHosts) { + super(DefaultSchemePortResolver.INSTANCE); + proxy = new HttpHost(proxyProtocol, proxyHost, proxyPort); + this.hostPatterns = nonProxyHosts; + } + + private boolean doesTargetMatchNonProxyHosts(HttpHost target) { + if (hostPatterns == null) { + return false; + } + String targetHost = lowerCase(target.getHostName()); + for (String pattern : hostPatterns) { + if (targetHost.matches(pattern)) { + return true; + } + } + return false; + } + + @Override + protected HttpHost determineProxy(HttpHost target, HttpContext context) throws HttpException { + return doesTargetMatchNonProxyHosts(target) ? null : proxy; + } + +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/ClientConnectionManagerFactory.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/ClientConnectionManagerFactory.java new file mode 100644 index 000000000000..d3e30aec3532 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/ClientConnectionManagerFactory.java @@ -0,0 +1,112 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.conn; + +import java.io.IOException; +import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.io.ConnectionEndpoint; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.client5.http.io.LeaseRequest; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; +import software.amazon.awssdk.annotations.SdkInternalApi; + +@SdkInternalApi +public final class ClientConnectionManagerFactory { + + + private ClientConnectionManagerFactory() { + } + + /** + * Returns a wrapped instance of {@link HttpClientConnectionManager} + * to capture the necessary performance metrics. + * + * @param orig the target instance to be wrapped + */ + public static HttpClientConnectionManager wrap(HttpClientConnectionManager orig) { + if (orig instanceof DelegatingHttpClientConnectionManager) { + throw new IllegalArgumentException(); + } + return new InstrumentedHttpClientConnectionManager(orig); + } + + /** + * Further wraps {@link LeaseRequest} to capture performance metrics. + */ + private static class InstrumentedHttpClientConnectionManager extends DelegatingHttpClientConnectionManager { + + private InstrumentedHttpClientConnectionManager(HttpClientConnectionManager delegate) { + super(delegate); + } + + @Override + public LeaseRequest lease(String id, HttpRoute route, Timeout requestTimeout, Object state) { + LeaseRequest connectionRequest = super.lease(id, route, requestTimeout, state); + return ClientConnectionRequestFactory.wrap(connectionRequest); + } + + + } + + /** + * Delegates all methods to {@link HttpClientConnectionManager}. Subclasses can override select methods to change behavior. + */ + private static class DelegatingHttpClientConnectionManager implements HttpClientConnectionManager { + + private final HttpClientConnectionManager delegate; + + protected DelegatingHttpClientConnectionManager(HttpClientConnectionManager delegate) { + this.delegate = delegate; + } + + @Override + public LeaseRequest lease(String id, HttpRoute route, Timeout requestTimeout, Object state) { + return delegate.lease(id, route, requestTimeout, state); + } + + @Override + public void release(ConnectionEndpoint endpoint, Object newState, TimeValue validDuration) { + delegate.release(endpoint, newState, validDuration); + + } + + @Override + public void connect(ConnectionEndpoint endpoint, TimeValue connectTimeout, HttpContext context) throws IOException { + delegate.connect(endpoint, connectTimeout, context); + + } + + @Override + public void upgrade(ConnectionEndpoint endpoint, HttpContext context) throws IOException { + delegate.upgrade(endpoint, context); + } + + @Override + public void close(CloseMode closeMode) { + delegate.close(closeMode); + + } + + @Override + public void close() throws IOException { + delegate.close(); + + } + } +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/ClientConnectionRequestFactory.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/ClientConnectionRequestFactory.java new file mode 100644 index 000000000000..b107b7c59df7 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/ClientConnectionRequestFactory.java @@ -0,0 +1,100 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.conn; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import org.apache.hc.client5.http.io.ConnectionEndpoint; +import org.apache.hc.client5.http.io.LeaseRequest; +import org.apache.hc.core5.util.Timeout; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.http.HttpMetric; +import software.amazon.awssdk.http.apache5.Apache5HttpClient; +import software.amazon.awssdk.metrics.MetricCollector; + +@SdkInternalApi +public final class ClientConnectionRequestFactory { + + /** + * {@link ThreadLocal}, request-level {@link MetricCollector}, set and removed by {@link Apache5HttpClient}. + */ + public static final ThreadLocal THREAD_LOCAL_REQUEST_METRIC_COLLECTOR = new ThreadLocal<>(); + + private ClientConnectionRequestFactory() { + } + + /** + * Returns a wrapped instance of {@link LeaseRequest} + * to capture the necessary performance metrics. + * + * @param orig the target instance to be wrapped + */ + static LeaseRequest wrap(LeaseRequest orig) { + if (orig instanceof DelegatingConnectionRequest) { + throw new IllegalArgumentException(); + } + return new InstrumentedConnectionRequest(orig); + } + + /** + * Measures the latency of {@link LeaseRequest#get(Timeout)}. + */ + private static class InstrumentedConnectionRequest extends DelegatingConnectionRequest { + + private InstrumentedConnectionRequest(LeaseRequest delegate) { + super(delegate); + } + + + @Override + public ConnectionEndpoint get(Timeout timeout) throws InterruptedException, ExecutionException, TimeoutException { + Instant startTime = Instant.now(); + try { + return super.get(timeout); + } finally { + Duration elapsed = Duration.between(startTime, Instant.now()); + MetricCollector metricCollector = THREAD_LOCAL_REQUEST_METRIC_COLLECTOR.get(); + metricCollector.reportMetric(HttpMetric.CONCURRENCY_ACQUIRE_DURATION, elapsed); + } + } + + } + + /** + * Delegates all methods to {@link LeaseRequest}. Subclasses can override select methods to change behavior. + */ + private static class DelegatingConnectionRequest implements LeaseRequest { + + private final LeaseRequest delegate; + + private DelegatingConnectionRequest(LeaseRequest delegate) { + this.delegate = delegate; + } + + @Override + public boolean cancel() { + return delegate.cancel(); + } + + @Override + public ConnectionEndpoint get(Timeout timeout) throws InterruptedException, ExecutionException, TimeoutException { + return delegate.get(timeout); + } + } +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/IdleConnectionReaper.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/IdleConnectionReaper.java new file mode 100644 index 000000000000..0edf47201d21 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/IdleConnectionReaper.java @@ -0,0 +1,172 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.conn; + +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Supplier; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.core5.util.TimeValue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.annotations.SdkTestInternalApi; + +/** + * Manages the reaping of idle connections. + */ +@SdkInternalApi +public final class IdleConnectionReaper { + private static final Logger log = LoggerFactory.getLogger(IdleConnectionReaper.class); + + private static final IdleConnectionReaper INSTANCE = new IdleConnectionReaper(); + + private final Map connectionManagers; + + private final Supplier executorServiceSupplier; + + private final long sleepPeriod; + + private volatile ExecutorService exec; + + private volatile ReaperTask reaperTask; + + private IdleConnectionReaper() { + this.connectionManagers = Collections.synchronizedMap(new WeakHashMap<>()); + + this.executorServiceSupplier = () -> { + ExecutorService e = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "idle-connection-reaper"); + t.setDaemon(true); + return t; + }); + return e; + }; + + this.sleepPeriod = Duration.ofMinutes(1).toMillis(); + } + + @SdkTestInternalApi + IdleConnectionReaper(Map connectionManagers, + Supplier executorServiceSupplier, + long sleepPeriod) { + + this.connectionManagers = connectionManagers; + this.executorServiceSupplier = executorServiceSupplier; + this.sleepPeriod = sleepPeriod; + } + + /** + * Register the connection manager with this reaper. + * + * @param manager The connection manager. + * @param maxIdleTime The maximum time connections in the connection manager are to remain idle before being reaped. + * @return {@code true} If the connection manager was not previously registered with this reaper, {@code false} + * otherwise. + */ + public synchronized boolean registerConnectionManager(PoolingHttpClientConnectionManager manager, long maxIdleTime) { + boolean notPreviouslyRegistered = connectionManagers.put(manager, maxIdleTime) == null; + setupExecutorIfNecessary(); + return notPreviouslyRegistered; + } + + /** + * Deregister this connection manager with this reaper. + * + * @param manager The connection manager. + * @return {@code true} If this connection manager was previously registered with this reaper and it was removed, {@code + * false} otherwise. + */ + public synchronized boolean deregisterConnectionManager(HttpClientConnectionManager manager) { + boolean wasRemoved = connectionManagers.remove(manager) != null; + cleanupExecutorIfNecessary(); + return wasRemoved; + } + + /** + * @return The singleton instance of this class. + */ + public static IdleConnectionReaper getInstance() { + return INSTANCE; + } + + private void setupExecutorIfNecessary() { + if (exec != null) { + return; + } + + ExecutorService e = executorServiceSupplier.get(); + + this.reaperTask = new ReaperTask(connectionManagers, sleepPeriod); + + e.execute(this.reaperTask); + + exec = e; + } + + private void cleanupExecutorIfNecessary() { + if (exec == null || !connectionManagers.isEmpty()) { + return; + } + + reaperTask.stop(); + reaperTask = null; + exec.shutdownNow(); + exec = null; + } + + private static final class ReaperTask implements Runnable { + private final Map connectionManagers; + private final long sleepPeriod; + + private volatile boolean stopping = false; + + private ReaperTask(Map connectionManagers, + long sleepPeriod) { + this.connectionManagers = connectionManagers; + this.sleepPeriod = sleepPeriod; + } + + @Override + public void run() { + while (!stopping) { + try { + Thread.sleep(sleepPeriod); + + for (Map.Entry entry : connectionManagers.entrySet()) { + try { + entry.getKey().closeIdle(TimeValue.ofMilliseconds(entry.getValue())); + } catch (Exception t) { + log.warn("Unable to close idle connections", t); + } + } + } catch (Throwable t) { + log.debug("Reaper thread: ", t); + } + } + log.debug("Shutting down reaper thread."); + } + + private void stop() { + stopping = true; + } + } +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/SdkConnectionKeepAliveStrategy.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/SdkConnectionKeepAliveStrategy.java new file mode 100644 index 000000000000..9ac76caac62d --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/SdkConnectionKeepAliveStrategy.java @@ -0,0 +1,62 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.conn; + +import java.util.concurrent.TimeUnit; +import org.apache.hc.client5.http.ConnectionKeepAliveStrategy; +import org.apache.hc.client5.http.impl.DefaultConnectionKeepAliveStrategy; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.TimeValue; +import software.amazon.awssdk.annotations.SdkInternalApi; + +/** + * The AWS SDK for Java's implementation of the + * {@code ConnectionKeepAliveStrategy} interface. Allows a user-configurable + * maximum idle time for connections. + */ +@SdkInternalApi +public class SdkConnectionKeepAliveStrategy implements ConnectionKeepAliveStrategy { + + private final TimeValue maxIdleTime; + + /** + * @param maxIdleTime the maximum time a connection may be idle + */ + public SdkConnectionKeepAliveStrategy(long maxIdleTime) { + this.maxIdleTime = TimeValue.of(maxIdleTime, TimeUnit.MILLISECONDS); + } + + @Override + public TimeValue getKeepAliveDuration( + HttpResponse response, + HttpContext context) { + + // If there's a Keep-Alive timeout directive in the response and it's + // shorter than our configured max, honor that. Otherwise go with the + // configured maximum. + + TimeValue duration = DefaultConnectionKeepAliveStrategy.INSTANCE + .getKeepAliveDuration(response, context); + + // Check if duration is positive and less than maxIdleTime + if (TimeValue.isPositive(duration) && duration.compareTo(maxIdleTime) < 0) { + return duration; + } + + return maxIdleTime; + } +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/SdkTlsSocketFactory.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/SdkTlsSocketFactory.java new file mode 100644 index 000000000000..8f2a0ef44406 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/SdkTlsSocketFactory.java @@ -0,0 +1,65 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.conn; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.Arrays; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.TimeValue; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.http.apache5.internal.net.SdkSocket; +import software.amazon.awssdk.utils.Logger; + +@SdkInternalApi +public class SdkTlsSocketFactory extends SSLConnectionSocketFactory { + + private static final Logger log = Logger.loggerFor(SdkTlsSocketFactory.class); + + public SdkTlsSocketFactory(SSLContext sslContext, HostnameVerifier hostnameVerifier) { + super(sslContext, hostnameVerifier); + if (sslContext == null) { + throw new IllegalArgumentException( + "sslContext must not be null. " + "Use SSLContext.getDefault() if you are unsure."); + } + } + + @Override + protected final void prepareSocket(SSLSocket socket) { + log.debug(() -> String.format("socket.getSupportedProtocols(): %s, socket.getEnabledProtocols(): %s", + Arrays.toString(socket.getSupportedProtocols()), + Arrays.toString(socket.getEnabledProtocols()))); + } + + @Override + public Socket connectSocket(TimeValue connectTimeout, + Socket socket, + HttpHost host, + InetSocketAddress remoteAddress, + InetSocketAddress localAddress, + HttpContext context) throws IOException { + log.trace(() -> String.format("Connecting to %s:%s", remoteAddress.getAddress(), remoteAddress.getPort())); + + Socket connectSocket = super.connectSocket(connectTimeout, socket, host, remoteAddress, localAddress, context); + return new SdkSocket(connectSocket); + } +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/Wrapped.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/Wrapped.java new file mode 100644 index 000000000000..28ad6edce113 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/conn/Wrapped.java @@ -0,0 +1,25 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.conn; + +import software.amazon.awssdk.annotations.SdkInternalApi; + +/** + * An internal marker interface to defend against accidental recursive wrappings. + */ +@SdkInternalApi +public interface Wrapped { +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/impl/Apache5HttpRequestFactory.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/impl/Apache5HttpRequestFactory.java new file mode 100644 index 000000000000..fc3ecf5c1cd6 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/impl/Apache5HttpRequestFactory.java @@ -0,0 +1,193 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.impl; + +import static software.amazon.awssdk.utils.NumericUtils.saturatedCast; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import org.apache.hc.client5.http.classic.methods.HttpDelete; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpHead; +import org.apache.hc.client5.http.classic.methods.HttpOptions; +import org.apache.hc.client5.http.classic.methods.HttpPatch; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.classic.methods.HttpPut; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpHeaders; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.http.apache5.internal.Apache5HttpRequestConfig; +import software.amazon.awssdk.http.apache5.internal.RepeatableInputStreamRequestEntity; +import software.amazon.awssdk.http.apache5.internal.utils.Apache5Utils; +import software.amazon.awssdk.utils.StringUtils; +import software.amazon.awssdk.utils.http.SdkHttpUtils; + +/** + * Responsible for creating Apache HttpClient 5 request objects. + */ +@SdkInternalApi +public class Apache5HttpRequestFactory { + + private static final List IGNORE_HEADERS = Arrays.asList(HttpHeaders.CONTENT_LENGTH, HttpHeaders.HOST, + HttpHeaders.TRANSFER_ENCODING); + + public HttpUriRequestBase create(final HttpExecuteRequest request, final Apache5HttpRequestConfig requestConfig) { + HttpUriRequestBase base = createApacheRequest(request, sanitizeUri(request.httpRequest())); + addHeadersToRequest(base, request.httpRequest()); + addRequestConfig(base, request.httpRequest(), requestConfig); + return base; + } + + /** + * + * The Apache HTTP client doesn't allow consecutive slashes in the URI. For S3 + * and other AWS services, this is allowed and required. This methods replaces + * any occurrence of "//" in the URI path with "/%2F". + * + * @see SdkHttpRequest#getUri() + * @param request The existing request + * @return a new String containing the modified URI + */ + private URI sanitizeUri(SdkHttpRequest request) { + String path = request.encodedPath(); + if (path.contains("//")) { + int port = request.port(); + String protocol = request.protocol(); + String newPath = StringUtils.replace(path, "//", "/%2F"); + String encodedQueryString = request.encodedQueryParameters().map(value -> "?" + value).orElse(""); + + // Do not include the port in the URI when using the default port for the protocol. + String portString = SdkHttpUtils.isUsingStandardPort(protocol, port) ? + "" : ":" + port; + + return URI.create(protocol + "://" + request.host() + portString + newPath + encodedQueryString); + } + + return request.getUri(); + } + + private void addRequestConfig(HttpUriRequestBase base, + SdkHttpRequest request, + Apache5HttpRequestConfig requestConfig) { + int connectTimeout = saturatedCast(requestConfig.connectionTimeout().toMillis()); + int connectAcquireTimeout = saturatedCast(requestConfig.connectionAcquireTimeout().toMillis()); + RequestConfig.Builder requestConfigBuilder = RequestConfig + .custom() + .setConnectionRequestTimeout(connectAcquireTimeout, TimeUnit.MILLISECONDS) + .setConnectTimeout(connectTimeout, TimeUnit.MILLISECONDS) + .setResponseTimeout(saturatedCast(requestConfig.socketTimeout().toMillis()), TimeUnit.MILLISECONDS); + + /* + * Enable 100-continue support for PUT operations, since this is + * where we're potentially uploading large amounts of data and want + * to find out as early as possible if an operation will fail. We + * don't want to do this for all operations since it will cause + * extra latency in the network interaction. + */ + if (SdkHttpMethod.PUT == request.method() && requestConfig.expectContinueEnabled()) { + requestConfigBuilder.setExpectContinueEnabled(true); + } + + base.setConfig(requestConfigBuilder.build()); + } + + + private HttpUriRequestBase createApacheRequest(HttpExecuteRequest request, URI uri) { + switch (request.httpRequest().method()) { + case HEAD: + return new HttpHead(uri); + case GET: + return new HttpGet(uri); + case DELETE: + return new HttpDelete(uri); + case OPTIONS: + return new HttpOptions(uri); + case PATCH: + return wrapEntity(request, new HttpPatch(uri)); + case POST: + return wrapEntity(request, new HttpPost(uri)); + case PUT: + return wrapEntity(request, new HttpPut(uri)); + default: + throw new RuntimeException("Unknown HTTP method name: " + request.httpRequest().method()); + } + } + + private HttpUriRequestBase wrapEntity(HttpExecuteRequest request, + HttpUriRequestBase entityEnclosingRequest) { + + /* + * We should never reuse the entity of the previous request, since + * reading from the buffered entity will bypass reading from the + * original request content. And if the content contains InputStream + * wrappers that were added for validation-purpose (e.g. + * Md5DigestCalculationInputStream), these wrappers would never be + * read and updated again after AmazonHttpClient resets it in + * preparation for the retry. Eventually, these wrappers would + * return incorrect validation result. + */ + if (request.contentStreamProvider().isPresent()) { + HttpEntity entity = new RepeatableInputStreamRequestEntity(request); + if (!request.httpRequest().firstMatchingHeader(HttpHeaders.CONTENT_LENGTH).isPresent() && !entity.isChunked()) { + entity = Apache5Utils.newBufferedHttpEntity(entity); + } + entityEnclosingRequest.setEntity(entity); + } + + return entityEnclosingRequest; + } + + /** + * Configures the headers in the specified Apache5 HTTP request. + */ + private void addHeadersToRequest(HttpUriRequestBase httpRequest, SdkHttpRequest request) { + httpRequest.addHeader(HttpHeaders.HOST, getHostHeaderValue(request)); + + // Copy over any other headers already in our request + request.forEachHeader((name, value) -> { + // HttpClient4 fills in the Content-Length header and complains if + // it's already present, so we skip it here. We also skip the Host + // header to avoid sending it twice, which will interfere with some + // signing schemes. + if (IGNORE_HEADERS.stream().noneMatch(name::equalsIgnoreCase)) { + for (String headerValue : value) { + httpRequest.addHeader(name, headerValue); + } + } + }); + } + + private String getHostHeaderValue(SdkHttpRequest request) { + // Respect any user-specified Host header when present + Optional existingHostHeader = request.firstMatchingHeader(HttpHeaders.HOST); + if (existingHostHeader.isPresent()) { + return existingHostHeader.get(); + } + // Apache doesn't allow us to include the port in the host header if it's a standard port for that protocol. For that + // reason, we don't include the port when we sign the message. See {@link SdkHttpRequest#port()}. + return !SdkHttpUtils.isUsingStandardPort(request.protocol(), request.port()) + ? request.host() + ":" + request.port() + : request.host(); + } +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/impl/Apache5SdkHttpClient.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/impl/Apache5SdkHttpClient.java new file mode 100644 index 000000000000..06ed5efad6a6 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/impl/Apache5SdkHttpClient.java @@ -0,0 +1,101 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.impl; + +import java.io.IOException; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.io.HttpClientResponseHandler; +import org.apache.hc.core5.http.protocol.HttpContext; +import software.amazon.awssdk.annotations.SdkInternalApi; + +/** + * An instance of {@link ConnectionManagerAwareHttpClient} that delegates all the requests to the given http client. + */ +@SdkInternalApi +public class Apache5SdkHttpClient implements ConnectionManagerAwareHttpClient { + + private final HttpClient delegate; + + private final HttpClientConnectionManager cm; + + public Apache5SdkHttpClient(final HttpClient delegate, + final HttpClientConnectionManager cm) { + if (delegate == null) { + throw new IllegalArgumentException("delegate " + + "cannot be null"); + } + if (cm == null) { + throw new IllegalArgumentException("connection manager " + + "cannot be null"); + } + this.delegate = delegate; + this.cm = cm; + } + + @Override + public HttpResponse execute(ClassicHttpRequest request) throws IOException { + return delegate.execute(request); + } + + @Override + public HttpResponse execute(ClassicHttpRequest request, HttpContext context) throws IOException { + return delegate.execute(request, context); + } + + @Override + public ClassicHttpResponse execute(HttpHost target, ClassicHttpRequest request) throws IOException { + return delegate.execute(target, request); + } + + @Override + public HttpResponse execute(HttpHost target, ClassicHttpRequest request, HttpContext context) throws IOException { + return delegate.execute(target, request, context); + } + + @Override + public T execute(ClassicHttpRequest request, HttpClientResponseHandler responseHandler) throws IOException { + return delegate.execute(request, responseHandler); + } + + @Override + public T execute(ClassicHttpRequest request, HttpContext context, + HttpClientResponseHandler responseHandler) throws IOException { + return delegate.execute(request, context, responseHandler); + } + + @Override + public T execute(HttpHost target, ClassicHttpRequest request, + HttpClientResponseHandler responseHandler) throws IOException { + return delegate.execute(target, request, responseHandler); + } + + @Override + public T execute(HttpHost target, ClassicHttpRequest request, + HttpContext context, + HttpClientResponseHandler responseHandler) throws IOException { + return delegate.execute(target, request, context, responseHandler); + } + + @Override + public HttpClientConnectionManager getHttpClientConnectionManager() { + return cm; + } +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/impl/ConnectionManagerAwareHttpClient.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/impl/ConnectionManagerAwareHttpClient.java new file mode 100644 index 000000000000..5231f170021b --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/impl/ConnectionManagerAwareHttpClient.java @@ -0,0 +1,34 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.impl; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import software.amazon.awssdk.annotations.SdkInternalApi; + +/** + * An extension of Apache's HttpClient that expose the connection manager + * associated with the client. + */ +@SdkInternalApi +public interface ConnectionManagerAwareHttpClient extends HttpClient { + + /** + * Returns the {@link HttpClientConnectionManager} associated with the + * http client. + */ + HttpClientConnectionManager getHttpClientConnectionManager(); +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/net/DelegateSocket.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/net/DelegateSocket.java new file mode 100644 index 000000000000..cc203f059630 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/net/DelegateSocket.java @@ -0,0 +1,246 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.net; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.SocketException; +import java.nio.channels.SocketChannel; +import software.amazon.awssdk.annotations.SdkInternalApi; + +/** + * Socket delegate class. Subclasses could extend this class, so that + * they only need to override methods they are interested in enhancing. + */ +@SdkInternalApi +public class DelegateSocket extends Socket { + + protected final Socket sock; + + public DelegateSocket(Socket sock) { + this.sock = sock; + } + + @Override + public void connect(SocketAddress endpoint) throws IOException { + sock.connect(endpoint); + } + + @Override + public void connect(SocketAddress endpoint, int timeout) throws IOException { + sock.connect(endpoint, timeout); + } + + @Override + public void bind(SocketAddress bindpoint) throws IOException { + sock.bind(bindpoint); + } + + @Override + public InetAddress getInetAddress() { + return sock.getInetAddress(); + } + + @Override + public InetAddress getLocalAddress() { + return sock.getLocalAddress(); + } + + @Override + public int getPort() { + return sock.getPort(); + } + + @Override + public int getLocalPort() { + return sock.getLocalPort(); + } + + @Override + public SocketAddress getRemoteSocketAddress() { + return sock.getRemoteSocketAddress(); + } + + @Override + public SocketAddress getLocalSocketAddress() { + return sock.getLocalSocketAddress(); + } + + @Override + public SocketChannel getChannel() { + return sock.getChannel(); + } + + @Override + public InputStream getInputStream() throws IOException { + return sock.getInputStream(); + } + + @Override + public OutputStream getOutputStream() throws IOException { + return sock.getOutputStream(); + } + + @Override + public void setTcpNoDelay(boolean on) throws SocketException { + sock.setTcpNoDelay(on); + } + + @Override + public boolean getTcpNoDelay() throws SocketException { + return sock.getTcpNoDelay(); + } + + @Override + public void setSoLinger(boolean on, int linger) throws SocketException { + sock.setSoLinger(on, linger); + } + + @Override + public int getSoLinger() throws SocketException { + return sock.getSoLinger(); + } + + @Override + public void sendUrgentData(int data) throws IOException { + sock.sendUrgentData(data); + } + + @Override + public void setOOBInline(boolean on) throws SocketException { + sock.setOOBInline(on); + } + + @Override + public boolean getOOBInline() throws SocketException { + return sock.getOOBInline(); + } + + @Override + public void setSoTimeout(int timeout) throws SocketException { + sock.setSoTimeout(timeout); + } + + @Override + public int getSoTimeout() throws SocketException { + return sock.getSoTimeout(); + } + + @Override + public void setSendBufferSize(int size) throws SocketException { + sock.setSendBufferSize(size); + } + + @Override + public int getSendBufferSize() throws SocketException { + return sock.getSendBufferSize(); + } + + @Override + public void setReceiveBufferSize(int size) throws SocketException { + sock.setReceiveBufferSize(size); + } + + @Override + public int getReceiveBufferSize() throws SocketException { + return sock.getReceiveBufferSize(); + } + + @Override + public void setKeepAlive(boolean on) throws SocketException { + sock.setKeepAlive(on); + } + + @Override + public boolean getKeepAlive() throws SocketException { + return sock.getKeepAlive(); + } + + @Override + public void setTrafficClass(int tc) throws SocketException { + sock.setTrafficClass(tc); + } + + @Override + public int getTrafficClass() throws SocketException { + return sock.getTrafficClass(); + } + + @Override + public void setReuseAddress(boolean on) throws SocketException { + sock.setReuseAddress(on); + } + + @Override + public boolean getReuseAddress() throws SocketException { + return sock.getReuseAddress(); + } + + @Override + public void close() throws IOException { + sock.close(); + } + + @Override + public void shutdownInput() throws IOException { + sock.shutdownInput(); + } + + @Override + public void shutdownOutput() throws IOException { + sock.shutdownOutput(); + } + + @Override + public String toString() { + return sock.toString(); + } + + @Override + public boolean isConnected() { + return sock.isConnected(); + } + + @Override + public boolean isBound() { + return sock.isBound(); + } + + @Override + public boolean isClosed() { + return sock.isClosed(); + } + + @Override + public boolean isInputShutdown() { + return sock.isInputShutdown(); + } + + @Override + public boolean isOutputShutdown() { + return sock.isOutputShutdown(); + } + + @Override + public void setPerformancePreferences(int connectionTime, int latency, + int bandwidth) { + sock.setPerformancePreferences(connectionTime, latency, bandwidth); + } +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/net/DelegateSslSocket.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/net/DelegateSslSocket.java new file mode 100644 index 000000000000..d3d590725277 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/net/DelegateSslSocket.java @@ -0,0 +1,335 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.net; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.SocketAddress; +import java.net.SocketException; +import java.nio.channels.SocketChannel; +import javax.net.ssl.HandshakeCompletedListener; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; +import software.amazon.awssdk.annotations.SdkInternalApi; + +@SdkInternalApi +public class DelegateSslSocket extends SSLSocket { + protected final SSLSocket sock; + + public DelegateSslSocket(SSLSocket sock) { + this.sock = sock; + } + + @Override + public void connect(SocketAddress endpoint) throws IOException { + sock.connect(endpoint); + } + + @Override + public void connect(SocketAddress endpoint, int timeout) throws IOException { + sock.connect(endpoint, timeout); + } + + @Override + public void bind(SocketAddress bindpoint) throws IOException { + sock.bind(bindpoint); + } + + @Override + public InetAddress getInetAddress() { + return sock.getInetAddress(); + } + + @Override + public InetAddress getLocalAddress() { + return sock.getLocalAddress(); + } + + @Override + public int getPort() { + return sock.getPort(); + } + + @Override + public int getLocalPort() { + return sock.getLocalPort(); + } + + @Override + public SocketAddress getRemoteSocketAddress() { + return sock.getRemoteSocketAddress(); + } + + @Override + public SocketAddress getLocalSocketAddress() { + return sock.getLocalSocketAddress(); + } + + @Override + public SocketChannel getChannel() { + return sock.getChannel(); + } + + @Override + public InputStream getInputStream() throws IOException { + return sock.getInputStream(); + } + + @Override + public OutputStream getOutputStream() throws IOException { + return sock.getOutputStream(); + } + + @Override + public void setTcpNoDelay(boolean on) throws SocketException { + sock.setTcpNoDelay(on); + } + + @Override + public boolean getTcpNoDelay() throws SocketException { + return sock.getTcpNoDelay(); + } + + @Override + public void setSoLinger(boolean on, int linger) throws SocketException { + sock.setSoLinger(on, linger); + } + + @Override + public int getSoLinger() throws SocketException { + return sock.getSoLinger(); + } + + @Override + public void sendUrgentData(int data) throws IOException { + sock.sendUrgentData(data); + } + + @Override + public void setOOBInline(boolean on) throws SocketException { + sock.setOOBInline(on); + } + + @Override + public boolean getOOBInline() throws SocketException { + return sock.getOOBInline(); + } + + @Override + public void setSoTimeout(int timeout) throws SocketException { + sock.setSoTimeout(timeout); + } + + @Override + public int getSoTimeout() throws SocketException { + return sock.getSoTimeout(); + } + + @Override + public void setSendBufferSize(int size) throws SocketException { + sock.setSendBufferSize(size); + } + + @Override + public int getSendBufferSize() throws SocketException { + return sock.getSendBufferSize(); + } + + @Override + public void setReceiveBufferSize(int size) throws SocketException { + sock.setReceiveBufferSize(size); + } + + @Override + public int getReceiveBufferSize() throws SocketException { + return sock.getReceiveBufferSize(); + } + + @Override + public void setKeepAlive(boolean on) throws SocketException { + sock.setKeepAlive(on); + } + + @Override + public boolean getKeepAlive() throws SocketException { + return sock.getKeepAlive(); + } + + @Override + public void setTrafficClass(int tc) throws SocketException { + sock.setTrafficClass(tc); + } + + @Override + public int getTrafficClass() throws SocketException { + return sock.getTrafficClass(); + } + + @Override + public void setReuseAddress(boolean on) throws SocketException { + sock.setReuseAddress(on); + } + + @Override + public boolean getReuseAddress() throws SocketException { + return sock.getReuseAddress(); + } + + @Override + public void close() throws IOException { + sock.close(); + } + + @Override + public void shutdownInput() throws IOException { + sock.shutdownInput(); + } + + @Override + public void shutdownOutput() throws IOException { + sock.shutdownOutput(); + } + + @Override + public String toString() { + return sock.toString(); + } + + @Override + public boolean isConnected() { + return sock.isConnected(); + } + + @Override + public boolean isBound() { + return sock.isBound(); + } + + @Override + public boolean isClosed() { + return sock.isClosed(); + } + + @Override + public boolean isInputShutdown() { + return sock.isInputShutdown(); + } + + @Override + public boolean isOutputShutdown() { + return sock.isOutputShutdown(); + } + + @Override + public void setPerformancePreferences(int connectionTime, int latency, + int bandwidth) { + sock.setPerformancePreferences(connectionTime, latency, bandwidth); + } + + @Override + public String[] getSupportedCipherSuites() { + return sock.getSupportedCipherSuites(); + } + + @Override + public String[] getEnabledCipherSuites() { + return sock.getEnabledCipherSuites(); + } + + @Override + public void setEnabledCipherSuites(String[] suites) { + sock.setEnabledCipherSuites(suites); + } + + @Override + public String[] getSupportedProtocols() { + return sock.getSupportedProtocols(); + } + + @Override + public String[] getEnabledProtocols() { + return sock.getEnabledProtocols(); + } + + @Override + public void setEnabledProtocols(String[] protocols) { + sock.setEnabledProtocols(protocols); + } + + @Override + public SSLSession getSession() { + return sock.getSession(); + } + + @Override + public void addHandshakeCompletedListener( + HandshakeCompletedListener listener) { + sock.addHandshakeCompletedListener(listener); + } + + @Override + public void removeHandshakeCompletedListener( + HandshakeCompletedListener listener) { + sock.removeHandshakeCompletedListener(listener); + } + + @Override + public void startHandshake() throws IOException { + sock.startHandshake(); + } + + @Override + public void setUseClientMode(boolean mode) { + sock.setUseClientMode(mode); + } + + @Override + public boolean getUseClientMode() { + return sock.getUseClientMode(); + } + + @Override + public void setNeedClientAuth(boolean need) { + sock.setNeedClientAuth(need); + } + + @Override + public boolean getNeedClientAuth() { + return sock.getNeedClientAuth(); + } + + @Override + public void setWantClientAuth(boolean want) { + sock.setWantClientAuth(want); + } + + @Override + public boolean getWantClientAuth() { + return sock.getWantClientAuth(); + } + + @Override + public void setEnableSessionCreation(boolean flag) { + sock.setEnableSessionCreation(flag); + } + + @Override + public boolean getEnableSessionCreation() { + return sock.getEnableSessionCreation(); + } +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/net/SdkSocket.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/net/SdkSocket.java new file mode 100644 index 000000000000..286ce1b87d65 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/net/SdkSocket.java @@ -0,0 +1,71 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.net; + +import java.io.IOException; +import java.net.Socket; +import java.net.SocketAddress; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.utils.Logger; + +@SdkInternalApi +public class SdkSocket extends DelegateSocket { + private static final Logger log = Logger.loggerFor(SdkSocket.class); + + public SdkSocket(Socket sock) { + super(sock); + log.debug(() -> "created: " + endpoint()); + } + + /** + * Returns the endpoint in the format of "address:port" + */ + private String endpoint() { + return sock.getInetAddress() + ":" + sock.getPort(); + } + + @Override + public void connect(SocketAddress endpoint) throws IOException { + log.trace(() -> "connecting to: " + endpoint); + sock.connect(endpoint); + log.debug(() -> "connected to: " + endpoint); + } + + @Override + public void connect(SocketAddress endpoint, int timeout) throws IOException { + log.trace(() -> "connecting to: " + endpoint); + sock.connect(endpoint, timeout); + log.debug(() -> "connected to: " + endpoint); + } + + @Override + public void close() throws IOException { + log.debug(() -> "closing " + endpoint()); + sock.close(); + } + + @Override + public void shutdownInput() throws IOException { + log.debug(() -> "shutting down input of " + endpoint()); + sock.shutdownInput(); + } + + @Override + public void shutdownOutput() throws IOException { + log.debug(() -> "shutting down output of " + endpoint()); + sock.shutdownOutput(); + } +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/net/SdkSslSocket.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/net/SdkSslSocket.java new file mode 100644 index 000000000000..9848e7786249 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/net/SdkSslSocket.java @@ -0,0 +1,71 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.net; + +import java.io.IOException; +import java.net.SocketAddress; +import javax.net.ssl.SSLSocket; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.utils.Logger; + +@SdkInternalApi +public class SdkSslSocket extends DelegateSslSocket { + private static final Logger log = Logger.loggerFor(SdkSslSocket.class); + + public SdkSslSocket(SSLSocket sock) { + super(sock); + log.debug(() -> "created: " + endpoint()); + } + + /** + * Returns the endpoint in the format of "address:port" + */ + private String endpoint() { + return sock.getInetAddress() + ":" + sock.getPort(); + } + + @Override + public void connect(SocketAddress endpoint) throws IOException { + log.trace(() -> "connecting to: " + endpoint); + sock.connect(endpoint); + log.debug(() -> "connected to: " + endpoint); + } + + @Override + public void connect(SocketAddress endpoint, int timeout) throws IOException { + log.trace(() -> "connecting to: " + endpoint); + sock.connect(endpoint, timeout); + log.debug(() -> "connected to: " + endpoint); + } + + @Override + public void close() throws IOException { + log.debug(() -> "closing " + endpoint()); + sock.close(); + } + + @Override + public void shutdownInput() throws IOException { + log.debug(() -> "shutting down input of " + endpoint()); + sock.shutdownInput(); + } + + @Override + public void shutdownOutput() throws IOException { + log.debug(() -> "shutting down output of " + endpoint()); + sock.shutdownOutput(); + } +} diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/utils/Apache5Utils.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/utils/Apache5Utils.java new file mode 100644 index 000000000000..5bd726463206 --- /dev/null +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/internal/utils/Apache5Utils.java @@ -0,0 +1,114 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.utils; + +import java.io.IOException; +import java.io.UncheckedIOException; +import org.apache.hc.client5.http.auth.AuthCache; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.Credentials; +import org.apache.hc.client5.http.auth.CredentialsProvider; +import org.apache.hc.client5.http.auth.NTCredentials; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.auth.BasicAuthCache; +import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; +import org.apache.hc.client5.http.impl.auth.BasicScheme; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.io.entity.BufferedHttpEntity; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.http.apache5.ProxyConfiguration; + +@SdkInternalApi +public final class Apache5Utils { + + private Apache5Utils() { + } + + /** + * Utility function for creating a new BufferedEntity and wrapping any errors as a SdkClientException. + * + * @param entity The HTTP entity to wrap with a buffered HTTP entity. + * @return A new BufferedHttpEntity wrapping the specified entity. + */ + public static HttpEntity newBufferedHttpEntity(HttpEntity entity) { + try { + return new BufferedHttpEntity(entity); + } catch (IOException e) { + throw new UncheckedIOException("Unable to create HTTP entity: " + e.getMessage(), e); + } + } + + /** + * Returns a new HttpClientContext used for request execution. + */ + public static HttpClientContext newClientContext(ProxyConfiguration proxyConfiguration) { + HttpClientContext clientContext = new HttpClientContext(); + addPreemptiveAuthenticationProxy(clientContext, proxyConfiguration); + + RequestConfig.Builder builder = RequestConfig.custom(); + clientContext.setRequestConfig(builder.build()); + return clientContext; + + } + + /** + * Returns a new Credentials Provider for use with proxy authentication. + */ + public static CredentialsProvider newProxyCredentialsProvider(ProxyConfiguration proxyConfiguration) { + BasicCredentialsProvider provider = new BasicCredentialsProvider(); + provider.setCredentials(newAuthScope(proxyConfiguration), newNtCredentials(proxyConfiguration)); + return provider; + } + + /** + * Returns a new instance of NTCredentials used for proxy authentication. + */ + private static Credentials newNtCredentials(ProxyConfiguration proxyConfiguration) { + return new NTCredentials( + proxyConfiguration.username(), + proxyConfiguration.password().toCharArray(), + proxyConfiguration.ntlmWorkstation(), + proxyConfiguration.ntlmDomain() + ); + } + + /** + * Returns a new instance of AuthScope used for proxy authentication. + */ + private static AuthScope newAuthScope(ProxyConfiguration proxyConfiguration) { + return new AuthScope(proxyConfiguration.host(), proxyConfiguration.port()); + } + + + private static void addPreemptiveAuthenticationProxy(HttpClientContext clientContext, + ProxyConfiguration proxyConfiguration) { + + if (proxyConfiguration.preemptiveBasicAuthenticationEnabled()) { + HttpHost targetHost = new HttpHost(proxyConfiguration.host(), proxyConfiguration.port()); + CredentialsProvider credsProvider = newProxyCredentialsProvider(proxyConfiguration); + // Create AuthCache instance + AuthCache authCache = new BasicAuthCache(); + // Generate BASIC scheme object and add it to the local auth cache + BasicScheme basicAuth = new BasicScheme(); + authCache.put(targetHost, basicAuth); + + clientContext.setCredentialsProvider(credsProvider); + clientContext.setAuthCache(authCache); + } + } +} diff --git a/http-clients/apache5-client/src/main/resources/META-INF/native-image/software.amazon.awssdk/apache-client/proxy-config.json b/http-clients/apache5-client/src/main/resources/META-INF/native-image/software.amazon.awssdk/apache-client/proxy-config.json new file mode 100644 index 000000000000..014f866bc2be --- /dev/null +++ b/http-clients/apache5-client/src/main/resources/META-INF/native-image/software.amazon.awssdk/apache-client/proxy-config.json @@ -0,0 +1,15 @@ +[ + [ + "org.apache.http.conn.HttpClientConnectionManager", + "org.apache.http.pool.ConnPoolControl", + "import software.amazon.awssdk.http.apache5.internal.conn.Wrapped" + ], + [ + "org.apache.http.conn.HttpClientConnectionManager", + "import software.amazon.awssdk.http.apache5.internal.conn.Wrapped" + ], + [ + "org.apache.http.conn.ConnectionRequest", + "import software.amazon.awssdk.http.apache5.internal.conn.Wrapped" + ] +] \ No newline at end of file diff --git a/http-clients/apache5-client/src/main/resources/META-INF/native-image/software.amazon.awssdk/apache-client/reflect-config.json b/http-clients/apache5-client/src/main/resources/META-INF/native-image/software.amazon.awssdk/apache-client/reflect-config.json new file mode 100644 index 000000000000..cae8832dd42d --- /dev/null +++ b/http-clients/apache5-client/src/main/resources/META-INF/native-image/software.amazon.awssdk/apache-client/reflect-config.json @@ -0,0 +1,35 @@ +[ + { + "name": "import software.amazon.awssdk.http.apache5.ApacheSdkHttpService", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "name": "org.apache.commons.logging.LogFactory", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + }, + { + "name":"org.apache.commons.logging.impl.Jdk14Logger", + "methods":[{"name":"","parameterTypes":["java.lang.String"] }] + }, + { + "name":"org.apache.commons.logging.impl.Log4JLogger" + }, + { + "name":"org.apache.commons.logging.impl.LogFactoryImpl", + "methods":[{"name":"","parameterTypes":[] }] + }, + { + "name":"org.apache.commons.logging.impl.WeakHashtable", + "methods":[{"name":"","parameterTypes":[] }] + } +] diff --git a/http-clients/apache5-client/src/main/resources/META-INF/native-image/software.amazon.awssdk/apache-client/resource-config.json b/http-clients/apache5-client/src/main/resources/META-INF/native-image/software.amazon.awssdk/apache-client/resource-config.json new file mode 100644 index 000000000000..3a0b7729fff7 --- /dev/null +++ b/http-clients/apache5-client/src/main/resources/META-INF/native-image/software.amazon.awssdk/apache-client/resource-config.json @@ -0,0 +1,7 @@ +{ + "resources": [ + { + "pattern": "\\Qsoftware.amazon.awssdk.http.SdkHttpService\\E" + } + ] +} \ No newline at end of file diff --git a/http-clients/apache5-client/src/main/resources/META-INF/services/software.amazon.awssdk.http.SdkHttpService b/http-clients/apache5-client/src/main/resources/META-INF/services/software.amazon.awssdk.http.SdkHttpService new file mode 100644 index 000000000000..b66b620f82b1 --- /dev/null +++ b/http-clients/apache5-client/src/main/resources/META-INF/services/software.amazon.awssdk.http.SdkHttpService @@ -0,0 +1,16 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0 +# +# or in the "license" file accompanying this file. This file 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. +# + +software.amazon.awssdk.http.apache5.Apache5SdkHttpService diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5ClientTlsAuthTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5ClientTlsAuthTest.java new file mode 100644 index 000000000000..45e4b44a6d59 --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5ClientTlsAuthTest.java @@ -0,0 +1,255 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.any; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static software.amazon.awssdk.utils.JavaSystemSetting.SSL_KEY_STORE; +import static software.amazon.awssdk.utils.JavaSystemSetting.SSL_KEY_STORE_PASSWORD; +import static software.amazon.awssdk.utils.JavaSystemSetting.SSL_KEY_STORE_TYPE; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.io.IOException; +import java.net.SocketException; +import java.net.URI; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import org.apache.hc.client5.http.socket.ConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.Mockito; +import software.amazon.awssdk.http.FileStoreTlsKeyManagersProvider; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.http.TlsKeyManagersProvider; +import software.amazon.awssdk.http.apache5.internal.conn.SdkTlsSocketFactory; +import software.amazon.awssdk.internal.http.NoneTlsKeyManagersProvider; + +/** + * Tests to ensure that {@link Apache5HttpClient} can properly support TLS + * client authentication. + */ +public class Apache5ClientTlsAuthTest extends ClientTlsAuthTestBase { + private static WireMockServer wireMockServer; + private static TlsKeyManagersProvider keyManagersProvider; + private SdkHttpClient client; + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @BeforeClass + public static void setUp() throws IOException { + ClientTlsAuthTestBase.setUp(); + + // Will be used by both client and server to trust the self-signed + // cert they present to each other + System.setProperty("javax.net.ssl.trustStore", serverKeyStore.toAbsolutePath().toString()); + System.setProperty("javax.net.ssl.trustStorePassword", STORE_PASSWORD); + System.setProperty("javax.net.ssl.trustStoreType", "jks"); + + wireMockServer = new WireMockServer(wireMockConfig() + .dynamicHttpsPort() + .dynamicPort() + .needClientAuth(true) + .keystorePath(serverKeyStore.toAbsolutePath().toString()) + .keystorePassword(STORE_PASSWORD) + ); + + wireMockServer.start(); + + keyManagersProvider = FileStoreTlsKeyManagersProvider.create(clientKeyStore, CLIENT_STORE_TYPE, STORE_PASSWORD); + } + + @Before + public void methodSetup() { + wireMockServer.stubFor(any(urlMatching(".*")).willReturn(aResponse().withStatus(200).withBody("{}"))); + } + + @AfterClass + public static void teardown() throws IOException { + wireMockServer.stop(); + System.clearProperty("javax.net.ssl.trustStore"); + System.clearProperty("javax.net.ssl.trustStorePassword"); + System.clearProperty("javax.net.ssl.trustStoreType"); + ClientTlsAuthTestBase.teardown(); + } + + @After + public void methodTeardown() { + if (client != null) { + client.close(); + } + client = null; + } + + @Test + public void canMakeHttpsRequestWhenKeyProviderConfigured() throws IOException { + client = Apache5HttpClient.builder() + .tlsKeyManagersProvider(keyManagersProvider) + .build(); + HttpExecuteResponse httpExecuteResponse = makeRequestWithHttpClient(client); + assertThat(httpExecuteResponse.httpResponse().isSuccessful()).isTrue(); + } + + @Test + public void requestFailsWhenKeyProviderNotConfigured() throws IOException { + client = Apache5HttpClient.builder().tlsKeyManagersProvider(NoneTlsKeyManagersProvider.getInstance()).build(); + assertThatThrownBy(() -> makeRequestWithHttpClient(client)).isInstanceOfAny(SSLException.class, SocketException.class); + } + + @Test + public void authenticatesWithTlsProxy() throws IOException { + ProxyConfiguration proxyConfig = ProxyConfiguration.builder() + .endpoint(URI.create("https://localhost:" + wireMockServer.httpsPort())) + .build(); + + client = Apache5HttpClient.builder() + .proxyConfiguration(proxyConfig) + .tlsKeyManagersProvider(keyManagersProvider) + .build(); + + HttpExecuteResponse httpExecuteResponse = makeRequestWithHttpClient(client); + + // WireMock doesn't mock 'CONNECT' methods and will return a 404 for this + assertThat(httpExecuteResponse.httpResponse().statusCode()).isEqualTo(404); + } + + @Test + public void defaultTlsKeyManagersProviderIsSystemPropertyProvider() throws IOException { + System.setProperty(SSL_KEY_STORE.property(), clientKeyStore.toAbsolutePath().toString()); + System.setProperty(SSL_KEY_STORE_TYPE.property(), CLIENT_STORE_TYPE); + System.setProperty(SSL_KEY_STORE_PASSWORD.property(), STORE_PASSWORD); + + client = Apache5HttpClient.builder().build(); + try { + makeRequestWithHttpClient(client); + } finally { + System.clearProperty(SSL_KEY_STORE.property()); + System.clearProperty(SSL_KEY_STORE_TYPE.property()); + System.clearProperty(SSL_KEY_STORE_PASSWORD.property()); + } + } + + @Test + public void defaultTlsKeyManagersProviderIsSystemPropertyProvider_explicitlySetToNull() throws IOException { + System.setProperty(SSL_KEY_STORE.property(), clientKeyStore.toAbsolutePath().toString()); + System.setProperty(SSL_KEY_STORE_TYPE.property(), CLIENT_STORE_TYPE); + System.setProperty(SSL_KEY_STORE_PASSWORD.property(), STORE_PASSWORD); + + client = Apache5HttpClient.builder().tlsKeyManagersProvider(null).build(); + try { + makeRequestWithHttpClient(client); + } finally { + System.clearProperty(SSL_KEY_STORE.property()); + System.clearProperty(SSL_KEY_STORE_TYPE.property()); + System.clearProperty(SSL_KEY_STORE_PASSWORD.property()); + } + } + + @Test + public void build_notSettingSocketFactory_configuresClientWithDefaultSocketFactory() throws IOException, + NoSuchAlgorithmException, + KeyManagementException { + System.setProperty(SSL_KEY_STORE.property(), clientKeyStore.toAbsolutePath().toString()); + System.setProperty(SSL_KEY_STORE_TYPE.property(), CLIENT_STORE_TYPE); + System.setProperty(SSL_KEY_STORE_PASSWORD.property(), STORE_PASSWORD); + + TlsKeyManagersProvider provider = FileStoreTlsKeyManagersProvider.create(clientKeyStore, + CLIENT_STORE_TYPE, + STORE_PASSWORD); + KeyManager[] keyManagers = provider.keyManagers(); + + SSLContext sslcontext = SSLContext.getInstance("TLS"); + sslcontext.init(keyManagers, null, null); + + ConnectionSocketFactory socketFactory = new SdkTlsSocketFactory(sslcontext, NoopHostnameVerifier.INSTANCE); + ConnectionSocketFactory socketFactoryMock = Mockito.spy(socketFactory); + + client = Apache5HttpClient.builder().build(); + + try { + HttpExecuteResponse httpExecuteResponse = makeRequestWithHttpClient(client); + assertThat(httpExecuteResponse.httpResponse().statusCode()).isEqualTo(200); + } finally { + System.clearProperty(SSL_KEY_STORE.property()); + System.clearProperty(SSL_KEY_STORE_TYPE.property()); + System.clearProperty(SSL_KEY_STORE_PASSWORD.property()); + } + + Mockito.verifyNoInteractions(socketFactoryMock); + } + + @Test + public void build_settingCustomSocketFactory_configuresClientWithGivenSocketFactory() throws IOException, + NoSuchAlgorithmException, + KeyManagementException { + TlsKeyManagersProvider provider = FileStoreTlsKeyManagersProvider.create(clientKeyStore, + CLIENT_STORE_TYPE, + STORE_PASSWORD); + KeyManager[] keyManagers = provider.keyManagers(); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagers, null, null); + + SdkTlsSocketFactory socketFactory = new SdkTlsSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE); + SdkTlsSocketFactory socketFactorySpy = Mockito.spy(socketFactory); + + + client = Apache5HttpClient.builder() + .socketFactory(socketFactorySpy) + .build(); + makeRequestWithHttpClient(client); + + Mockito.verify(socketFactorySpy).createLayeredSocket( + Mockito.any(), // Socket + Mockito.anyString(), // Target host + Mockito.anyInt(), // Port + Mockito.any() // HttpContext + ); + } + + private HttpExecuteResponse makeRequestWithHttpClient(SdkHttpClient httpClient) throws IOException { + SdkHttpRequest httpRequest = SdkHttpFullRequest.builder() + .method(SdkHttpMethod.GET) + .protocol("https") + .host("localhost:" + wireMockServer.httpsPort()) + .build(); + + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + + return httpClient.prepareRequest(request).call(); + } + +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5ClientTlsHalfCloseTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5ClientTlsHalfCloseTest.java new file mode 100644 index 000000000000..334eb2ac6b5b --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5ClientTlsHalfCloseTest.java @@ -0,0 +1,146 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; +import software.amazon.awssdk.http.ContentStreamProvider; +import software.amazon.awssdk.http.FileStoreTlsKeyManagersProvider; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.http.TlsKeyManagersProvider; +import software.amazon.awssdk.http.server.MockServer; + +public class Apache5ClientTlsHalfCloseTest extends ClientTlsAuthTestBase { + + private static TlsKeyManagersProvider tlsKeyManagersProvider; + private static MockServer mockServer; + private SdkHttpClient httpClient; + + private static final int TWO_MB = 2 * 1024 * 1024; + private static final byte[] CONTENT = new byte[TWO_MB]; + + @Test + @EnabledIf("halfCloseSupported") + public void errorWhenServerHalfClosesSocketWhileStreamIsOpened() { + + mockServer = MockServer.createMockServer(MockServer.ServerBehavior.HALF_CLOSE); + mockServer.startServer(tlsKeyManagersProvider); + + httpClient = Apache5HttpClient.builder() + .tlsKeyManagersProvider(tlsKeyManagersProvider) + .build(); + IOException exception = assertThrows(IOException.class, () -> { + executeHttpRequest(httpClient); + }); + assertThat(exception.getMessage()) + .containsIgnoringCase("broken pipe"); + } + + + @Test + public void errorWhenServerFullClosesSocketWhileStreamIsOpened() throws IOException { + mockServer = MockServer.createMockServer(MockServer.ServerBehavior.FULL_CLOSE_IN_BETWEEN); + mockServer.startServer(tlsKeyManagersProvider); + + httpClient = Apache5HttpClient.builder() + .tlsKeyManagersProvider(tlsKeyManagersProvider) + .build(); + + IOException exception = assertThrows(IOException.class, () -> { + executeHttpRequest(httpClient); + }); + + if(halfCloseSupported()){ + assertEquals("Connection or outbound has closed", exception.getMessage()); + + }else { + assertEquals("Socket is closed", exception.getMessage()); + + } + } + + @Test + public void successfulRequestForFullCloseSocketAtTheEnd() throws IOException { + mockServer = MockServer.createMockServer(MockServer.ServerBehavior.FULL_CLOSE_AT_THE_END); + mockServer.startServer(tlsKeyManagersProvider); + + httpClient = Apache5HttpClient.builder() + .tlsKeyManagersProvider(tlsKeyManagersProvider) + .build(); + + HttpExecuteResponse response = executeHttpRequest(httpClient); + + assertThat(response.httpResponse().isSuccessful()).isTrue(); + } + + @AfterEach + void tearDown() { + if (mockServer != null) { + mockServer.stopServer(); + } + } + + @BeforeAll + public static void setUp() throws IOException { + ClientTlsAuthTestBase.setUp(); + System.setProperty("javax.net.ssl.trustStore", serverKeyStore.toAbsolutePath().toString()); + System.setProperty("javax.net.ssl.trustStorePassword", STORE_PASSWORD); + System.setProperty("javax.net.ssl.trustStoreType", "jks"); + tlsKeyManagersProvider = FileStoreTlsKeyManagersProvider.create(clientKeyStore, CLIENT_STORE_TYPE, STORE_PASSWORD); + } + + @AfterAll + public static void clear() throws IOException { + System.clearProperty("javax.net.ssl.trustStore"); + System.clearProperty("javax.net.ssl.trustStorePassword"); + System.clearProperty("javax.net.ssl.trustStoreType"); + ClientTlsAuthTestBase.teardown(); + + } + + private static HttpExecuteResponse executeHttpRequest(SdkHttpClient client) throws IOException { + ContentStreamProvider contentStreamProvider = () -> new ByteArrayInputStream(CONTENT); + SdkHttpRequest httpRequest = SdkHttpFullRequest.builder() + .method(SdkHttpMethod.PUT) + .protocol("https") + .host("localhost:" + mockServer.getPort()) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(contentStreamProvider) + .build(); + + return client.prepareRequest(request).call(); + } + + public static boolean halfCloseSupported(){ + return MockServer.isTlsHalfCloseSupported(); + } +} \ No newline at end of file diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientAuthRegistryTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientAuthRegistryTest.java new file mode 100644 index 000000000000..0293424ec6e6 --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientAuthRegistryTest.java @@ -0,0 +1,160 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED; + +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import java.net.URI; +import org.apache.hc.client5.http.auth.AuthSchemeFactory; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; +import org.apache.hc.client5.http.impl.auth.BasicSchemeFactory; +import org.apache.hc.client5.http.impl.auth.KerberosSchemeFactory; +import org.apache.hc.core5.http.config.Registry; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import software.amazon.awssdk.http.ExecutableHttpRequest; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.SdkHttpRequest; + + +class Apache5HttpClientAuthRegistryTest { + + @RegisterExtension + static WireMockExtension proxyWireMock = WireMockExtension.newInstance() + .options(wireMockConfig().dynamicPort()) + .build(); + @RegisterExtension + static WireMockExtension serverWireMock = WireMockExtension.newInstance() + .options(wireMockConfig().dynamicPort()) + .build(); + + private Apache5HttpClient httpClient; + private static final String PROXY_AUTH_SCENARIO = "Proxy Auth"; + private static final String SERVER_AUTH_SCENARIO = "Server Auth"; + private static final String CHALLENGED_STATE = "Challenged"; + private static final String BASIC_AUTH = "Basic"; + private static final String KERBEROS_AUTH = "Kerberos"; + + + private Registry createAuthSchemeRegistry(String scheme, AuthSchemeFactory factory) { + return RegistryBuilder.create() + .register(scheme, factory) + .build(); + } + + + private Apache5HttpClient createHttpClient(Registry authSchemeRegistry) { + BasicCredentialsProvider credsProvider = new BasicCredentialsProvider(); + credsProvider.setCredentials( + new AuthScope("localhost", -1), + new UsernamePasswordCredentials("u1", "p1".toCharArray())); + + return (Apache5HttpClient) Apache5HttpClient.builder() + .proxyConfiguration(ProxyConfiguration.builder().endpoint(URI.create("http://localhost:" + proxyWireMock.getPort())) + .build()) + .authSchemeRegistry(authSchemeRegistry) + .credentialsProvider(credsProvider) + .build(); + } + + + private SdkHttpRequest createHttpRequest() { + return SdkHttpRequest.builder() + .uri(URI.create("http://localhost:" + serverWireMock.getPort())) + .method(SdkHttpMethod.GET) + .build(); + } + private void setupProxyWireMockStub() { + proxyWireMock.stubFor(get(urlMatching(".*")) + .inScenario(PROXY_AUTH_SCENARIO) + .whenScenarioStateIs(STARTED) + .willReturn(aResponse() + .withStatus(401) + .withHeader("WWW-Authenticate", "Basic")) + .willSetStateTo(CHALLENGED_STATE)); + + proxyWireMock.stubFor(get(urlMatching(".*")) + .inScenario(PROXY_AUTH_SCENARIO) + .whenScenarioStateIs(CHALLENGED_STATE) + //.withHeader("WWW-Authenticate", matching(".*")) + .willReturn(aResponse() + .withStatus(200)) + .willSetStateTo("success")); + } + + private void setupWireMockStub() { + serverWireMock.stubFor(get(urlMatching(".*")) + .inScenario(SERVER_AUTH_SCENARIO) + .whenScenarioStateIs(STARTED) + .withHeader("Authorization", matching(".*")) + .willReturn(aResponse().withStatus(200))); + } + + private HttpExecuteResponse executeRequest(SdkHttpRequest request) throws Exception { + HttpExecuteRequest executeRequest = HttpExecuteRequest.builder() + .request(request) + .build(); + ExecutableHttpRequest executableRequest = httpClient.prepareRequest(executeRequest); + return executableRequest.call(); + } + + @Test + void authSchemeRegistryConfigured_registeredAuthShouldPass() throws Exception { + Registry authSchemeRegistry = createAuthSchemeRegistry( + BASIC_AUTH, + new BasicSchemeFactory() + ); + + + + httpClient = createHttpClient(authSchemeRegistry); + setupProxyWireMockStub(); + setupWireMockStub(); + + HttpExecuteResponse response = executeRequest(createHttpRequest()); + + proxyWireMock.verify(1, getRequestedFor(urlMatching(".*")) + .withHeader("Authorization", matching(".*")) + ); + } + + @Test + void authSchemeRegistryConfigured_unRegisteredAuthShouldWarn() throws Exception { + Registry authSchemeRegistry = createAuthSchemeRegistry( + KERBEROS_AUTH, + KerberosSchemeFactory.DEFAULT + ); + + + httpClient = createHttpClient(authSchemeRegistry); + setupProxyWireMockStub(); + setupWireMockStub(); + + HttpExecuteResponse response = executeRequest(createHttpRequest()); + proxyWireMock.verify(0, getRequestedFor(urlMatching(".*")) + .withHeader("Authorization", matching(".*")) + ); + } +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientDefaultWireMockTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientDefaultWireMockTest.java new file mode 100644 index 000000000000..d949796ac61c --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientDefaultWireMockTest.java @@ -0,0 +1,28 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpClientDefaultTestSuite; + +public class Apache5HttpClientDefaultWireMockTest extends SdkHttpClientDefaultTestSuite { + + @Override + protected SdkHttpClient createSdkHttpClient() { + return Apache5HttpClient.create(); + } + +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientLocalAddressFunctionalTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientLocalAddressFunctionalTest.java new file mode 100644 index 000000000000..505c53219a9e --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientLocalAddressFunctionalTest.java @@ -0,0 +1,119 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.InetAddress; +import java.time.Duration; +import org.apache.hc.client5.http.impl.DefaultSchemePortResolver; +import org.apache.hc.client5.http.impl.routing.DefaultRoutePlanner; +import org.apache.hc.client5.http.routing.HttpRoutePlanner; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.logging.log4j.Level; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpClientLocalAddressFunctionalTestSuite; +import software.amazon.awssdk.testutils.LogCaptor; + +/** + * Functional tests for Apache5 HTTP Client's local address binding capabilities. + * Tests three scenarios: + * 1. Local address configured via builder + * 2. Local address configured via custom route planner + * 3. Both methods used together (route planner takes precedence) + */ +@DisplayName("Apache5 HTTP Client - Local Address Functional Tests") +class Apache5HttpClientLocalAddressFunctionalTest { + + @Nested + @DisplayName("When local address is configured via builder") + class LocalAddressViaBuilderTest extends SdkHttpClientLocalAddressFunctionalTestSuite { + @Override + protected SdkHttpClient createHttpClient(InetAddress localAddress, Duration connectionTimeout) { + return Apache5HttpClient.builder() + .localAddress(localAddress) + .connectionTimeout(connectionTimeout) + .build(); + } + } + + @Nested + @DisplayName("When local address is configured via custom route planner") + class LocalAddressViaRoutePlannerTest extends SdkHttpClientLocalAddressFunctionalTestSuite { + + @Override + protected SdkHttpClient createHttpClient(InetAddress localAddress, Duration connectionTimeout) { + HttpRoutePlanner routePlanner = createLocalAddressRoutePlanner(localAddress); + return Apache5HttpClient.builder() + .httpRoutePlanner(routePlanner) + .connectionTimeout(connectionTimeout) + .build(); + } + + private HttpRoutePlanner createLocalAddressRoutePlanner(InetAddress localAddress) { + return new DefaultRoutePlanner(DefaultSchemePortResolver.INSTANCE) { + @Override + protected InetAddress determineLocalAddress(HttpHost firstHop, HttpContext context) throws HttpException { + return localAddress != null ? localAddress : super.determineLocalAddress(firstHop, context); + } + }; + } + } + + @Nested + @DisplayName("When both route planner and builder local address are configured (route planner takes precedence)") + class RoutePlannerPrecedenceTest extends SdkHttpClientLocalAddressFunctionalTestSuite { + + private final InetAddress BUILDER_LOCAL_ADDRESS = InetAddress.getLoopbackAddress(); + + @Override + protected SdkHttpClient createHttpClient(InetAddress localAddress, Duration connectionTimeout) { + // The localAddress parameter will be used by the route planner + // The builder's localAddress will be overridden + HttpRoutePlanner routePlanner = createLocalAddressRoutePlanner(localAddress); + SdkHttpClient httpClient; + + try (LogCaptor logCaptor = LogCaptor.create(Level.DEBUG)) { + httpClient = Apache5HttpClient.builder() + .httpRoutePlanner(routePlanner) + .localAddress(BUILDER_LOCAL_ADDRESS) // This will be overridden by route planner + .connectionTimeout(connectionTimeout) + .build(); + + assertThat(logCaptor.loggedEvents()).anySatisfy(logEvent -> { + assertThat(logEvent.getLevel()).isEqualTo(Level.DEBUG); + assertThat(logEvent.getMessage().getFormattedMessage()) + .contains("localAddress configuration was ignored since Route planner was explicitly provided"); + }); + } + return httpClient; + } + + private HttpRoutePlanner createLocalAddressRoutePlanner(InetAddress routePlannerAddress) { + return new DefaultRoutePlanner(DefaultSchemePortResolver.INSTANCE) { + @Override + protected InetAddress determineLocalAddress(HttpHost firstHop, HttpContext context) throws HttpException { + // Route planner's address takes precedence over builder's address + return routePlannerAddress != null ? routePlannerAddress : super.determineLocalAddress(firstHop, context); + } + }; + } + } +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientTest.java new file mode 100644 index 000000000000..891722c49a59 --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientTest.java @@ -0,0 +1,148 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.auth.CredentialsProvider; +import org.apache.hc.client5.http.routing.HttpRoutePlanner; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * @see Apache5HttpClientWireMockTest + */ +public class Apache5HttpClientTest { + @AfterEach + public void cleanup() { + System.clearProperty("http.proxyHost"); + System.clearProperty("http.proxyPort"); + System.clearProperty("http.proxyUser"); + System.clearProperty("http.proxyPassword"); + } + + @Test + public void connectionReaperCanBeManuallyEnabled() { + Apache5HttpClient.builder() + .useIdleConnectionReaper(true) + .build() + .close(); + } + + @Test + public void httpRoutePlannerCantBeUsedWithProxy() { + ProxyConfiguration proxyConfig = ProxyConfiguration.builder() + .endpoint(URI.create("http://localhost:1234")) + .useSystemPropertyValues(Boolean.FALSE) + .build(); + assertThatThrownBy(() -> { + Apache5HttpClient.builder() + .proxyConfiguration(proxyConfig) + .httpRoutePlanner(Mockito.mock(HttpRoutePlanner.class)) + .build(); + }).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void httpRoutePlannerCantBeUsedWithProxy_SystemPropertiesEnabled() { + System.setProperty("http.proxyHost", "localhost"); + System.setProperty("http.proxyPort", "1234"); + + assertThatThrownBy(() -> { + Apache5HttpClient.builder() + .httpRoutePlanner(Mockito.mock(HttpRoutePlanner.class)) + .build(); + }).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void httpRoutePlannerCantBeUsedWithProxy_SystemPropertiesDisabled() { + System.setProperty("http.proxyHost", "localhost"); + System.setProperty("http.proxyPort", "1234"); + + ProxyConfiguration proxyConfig = ProxyConfiguration.builder() + .useSystemPropertyValues(Boolean.FALSE) + .build(); + + Apache5HttpClient.builder() + .proxyConfiguration(proxyConfig) + .httpRoutePlanner(Mockito.mock(HttpRoutePlanner.class)) + .build(); + } + + @Test + public void credentialProviderCantBeUsedWithProxyCredentials() { + ProxyConfiguration proxyConfig = ProxyConfiguration.builder() + .endpoint(URI.create("http://localhost:1234")) + .username("foo") + .password("bar") + .build(); + assertThatThrownBy(() -> { + Apache5HttpClient.builder() + .proxyConfiguration(proxyConfig) + .credentialsProvider(Mockito.mock(CredentialsProvider.class)) + .build(); + }).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void credentialProviderCantBeUsedWithProxyCredentials_SystemProperties() { + System.setProperty("http.proxyUser", "foo"); + System.setProperty("http.proxyPassword", "bar"); + + assertThatThrownBy(() -> { + Apache5HttpClient.builder() + .credentialsProvider(Mockito.mock(CredentialsProvider.class)) + .build(); + }).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void credentialProviderCanBeUsedWithProxy() { + ProxyConfiguration proxyConfig = ProxyConfiguration.builder() + .endpoint(URI.create("http://localhost:1234")) + .build(); + Apache5HttpClient.builder() + .proxyConfiguration(proxyConfig) + .credentialsProvider(Mockito.mock(CredentialsProvider.class)) + .build(); + } + + @Test + public void dnsResolverCanBeUsed() { + DnsResolver dnsResolver = new SystemDefaultDnsResolver() { + @Override + public InetAddress[] resolve(final String host) throws UnknownHostException { + if (host.equalsIgnoreCase("my.host.com")) { + return new InetAddress[] { InetAddress.getByName("127.0.0.1") }; + } else { + return super.resolve(host); + } + } + }; + + Apache5HttpClient.builder() + .dnsResolver(dnsResolver) + .build() + .close(); + } +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientUriNormalizationTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientUriNormalizationTest.java new file mode 100644 index 000000000000..592508ab1057 --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientUriNormalizationTest.java @@ -0,0 +1,29 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import software.amazon.awssdk.http.HttpClientUriNormalizationTestSuite; +import software.amazon.awssdk.http.SdkHttpClient; + +public class Apache5HttpClientUriNormalizationTest extends HttpClientUriNormalizationTestSuite { + + + @Override + protected SdkHttpClient createSdkHttpClient() { + return Apache5HttpClient.create(); + } +} + diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientUriSanitizationTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientUriSanitizationTest.java new file mode 100644 index 000000000000..60b101d07763 --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientUriSanitizationTest.java @@ -0,0 +1,30 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + + +import org.junit.jupiter.api.DisplayName; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpClientUriSanitizationTestSuite; + +@DisplayName("Apache5 HTTP Client - URI Sanitization Tests") +class Apache5HttpClientUriSanitizationTest extends SdkHttpClientUriSanitizationTestSuite { + + @Override + protected SdkHttpClient createHttpClient() { + return Apache5HttpClient.create(); + } +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientWireMockTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientWireMockTest.java new file mode 100644 index 000000000000..613280251d49 --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpClientWireMockTest.java @@ -0,0 +1,282 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.any; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static software.amazon.awssdk.http.SdkHttpConfigurationOption.TRUST_ALL_CERTIFICATES; + +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import com.github.tomakehurst.wiremock.matching.RequestPatternBuilder; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.CredentialsProvider; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +import org.apache.hc.client5.http.impl.auth.CredentialsProviderBuilder; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.io.CloseMode; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpClientTestSuite; +import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.apache5.internal.Apache5HttpRequestConfig; +import software.amazon.awssdk.http.apache5.internal.impl.ConnectionManagerAwareHttpClient; +import software.amazon.awssdk.utils.AttributeMap; +import software.amazon.awssdk.utils.IoUtils; + +@RunWith(MockitoJUnitRunner.class) +public class Apache5HttpClientWireMockTest extends SdkHttpClientTestSuite { + @Rule + public WireMockRule mockProxyServer = new WireMockRule(wireMockConfig().dynamicPort().dynamicHttpsPort()); + + @Mock + private ConnectionManagerAwareHttpClient httpClient; + + @Mock + private HttpClientConnectionManager connectionManager; + + @Override + protected SdkHttpClient createSdkHttpClient(SdkHttpClientOptions options) { + Apache5HttpClient.Builder builder = Apache5HttpClient.builder(); + + AttributeMap.Builder attributeMap = AttributeMap.builder(); + + if (options.tlsTrustManagersProvider() != null) { + builder.tlsTrustManagersProvider(options.tlsTrustManagersProvider()); + } + + if (options.trustAll()) { + attributeMap.put(TRUST_ALL_CERTIFICATES, options.trustAll()); + } + + return builder.buildWithDefaults(attributeMap.build()); + } + + @Test + public void closeClient_shouldCloseUnderlyingResources() { + Apache5HttpClient client = new Apache5HttpClient(httpClient, Apache5HttpRequestConfig.builder().build(), + AttributeMap.empty()); + when(httpClient.getHttpClientConnectionManager()).thenReturn(connectionManager); + + client.close(); + verify(connectionManager).close(CloseMode.IMMEDIATE); + } + + @Test + public void routePlannerIsInvoked() throws Exception { + mockProxyServer.resetToDefaultMappings(); + mockProxyServer.addStubMapping(any(urlPathEqualTo("/")) + .willReturn(aResponse().proxiedFrom("http://localhost:" + mockServer.port())) + .build()); + + SdkHttpClient client = Apache5HttpClient.builder() + .httpRoutePlanner( + (request, context) -> + new HttpRoute( + new HttpHost("https", "localhost", mockProxyServer.httpsPort()) + )) + .buildWithDefaults(AttributeMap.builder() + .put(TRUST_ALL_CERTIFICATES, Boolean.TRUE) + .build()); + + testForResponseCodeUsingHttps(client, HttpURLConnection.HTTP_OK); + + mockProxyServer.verify(1, RequestPatternBuilder.allRequests()); + } + + @Test + public void credentialPlannerIsInvoked() throws Exception { + + mockProxyServer.addStubMapping(any(urlPathEqualTo("/")) + .willReturn(aResponse() + .withHeader("WWW-Authenticate", "Basic realm=\"proxy server\"") + .withStatus(401)) + .build()); + + mockProxyServer.addStubMapping(any(urlPathEqualTo("/")) + .withBasicAuth("foo", "bar") + .willReturn(aResponse() + .proxiedFrom("http://localhost:" + mockServer.port())) + .build()); + + CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create() + .add(new AuthScope("localhost", -1), + new UsernamePasswordCredentials("foo", "bar".toCharArray())) + .build(); + + + + SdkHttpClient client = Apache5HttpClient.builder() + .credentialsProvider(credentialsProvider) + .httpRoutePlanner( + (request, context) -> + new HttpRoute( + new HttpHost("https", "localhost", mockProxyServer.httpsPort()) + + )) + .buildWithDefaults(AttributeMap.builder() + .put(TRUST_ALL_CERTIFICATES, Boolean.TRUE) + .build()); + testForResponseCodeUsingHttps(client, HttpURLConnection.HTTP_OK); + + mockProxyServer.verify(2, RequestPatternBuilder.allRequests()); + } + + @Test + public void overrideDnsResolver_WithDnsMatchingResolver_successful() throws Exception { + overrideDnsResolver("magic.local.host"); + } + + @Test(expected = UnknownHostException.class) + public void overrideDnsResolver_WithUnknownHost_throwsException() throws Exception { + overrideDnsResolver("sad.local.host"); + } + + @Test + public void overrideDnsResolver_WithLocalhost_successful() throws Exception { + overrideDnsResolver("localhost"); + } + + @Test + public void explicitNullDnsResolver_WithLocalhost_successful() throws Exception { + overrideDnsResolver("localhost", true); + } + + + + @Test + public void handlesVariousContentLengths() throws Exception { + SdkHttpClient client = createSdkHttpClient(); + int[] contentLengths = {0, 1, 100, 1024, 65536}; + + for (int length : contentLengths) { + String path = "/content-length-" + length; + byte[] body = new byte[length]; + for (int i = 0; i < length; i++) { + body[i] = (byte) ('A' + (i % 26)); + } + + mockServer.stubFor(any(urlPathEqualTo(path)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Length", String.valueOf(length)) + .withBody(body))); + + SdkHttpFullRequest req = mockSdkRequest("http://localhost:" + mockServer.port() + path, SdkHttpMethod.GET); + HttpExecuteResponse rsp = client.prepareRequest(HttpExecuteRequest.builder() + .request(req) + .build()) + .call(); + + assertThat(rsp.httpResponse().statusCode()).isEqualTo(200); + + if (length == 0) { + // Empty body should still have a response body present, but EOF immediately + if (rsp.responseBody().isPresent()) { + assertThat(rsp.responseBody().get().read()).isEqualTo(-1); + } + } else { + assertThat(rsp.responseBody()).isPresent(); + byte[] readBody = IoUtils.toByteArray(rsp.responseBody().get()); + assertThat(readBody).isEqualTo(body); + } + } + } + + private void overrideDnsResolver(String hostName) throws IOException { + overrideDnsResolver(hostName, false); + } + + private void overrideDnsResolver(String hostName, boolean nullifyResolver) throws IOException { + + DnsResolver dnsResolver = new SystemDefaultDnsResolver() { + @Override + public InetAddress[] resolve(String host) throws UnknownHostException { + if ("magic.local.host".equalsIgnoreCase(host)) { + return new InetAddress[] {InetAddress.getByName("127.0.0.1")}; + } + return super.resolve(host); + } + }; + if (nullifyResolver) { + dnsResolver = null; + } + + SdkHttpClient client = Apache5HttpClient.builder() + .dnsResolver(dnsResolver) + .buildWithDefaults(AttributeMap.builder() + .put(TRUST_ALL_CERTIFICATES, Boolean.TRUE) + .build()); + + mockProxyServer.resetToDefaultMappings(); + mockProxyServer.stubFor(any(urlPathEqualTo("/")).willReturn(aResponse().withStatus(HttpURLConnection.HTTP_OK))); + + URI uri = URI.create("https://" + hostName + ":" + mockProxyServer.httpsPort()); + SdkHttpFullRequest req = SdkHttpFullRequest.builder() + .uri(uri) + .method(SdkHttpMethod.POST) + .putHeader("Host", uri.getHost()) + .build(); + + client.prepareRequest(HttpExecuteRequest.builder() + .request(req) + .contentStreamProvider(req.contentStreamProvider().orElse(null)) + .build()) + .call(); + + mockProxyServer.verify(1, RequestPatternBuilder.allRequests()); + } + + @Test + public void closeReleasesResources() throws Exception { + SdkHttpClient client = createSdkHttpClient(); + // Make a successful request first + stubForMockRequest(200); + SdkHttpFullRequest request = mockSdkRequest("http://localhost:" + mockServer.port(), SdkHttpMethod.POST); + HttpExecuteResponse response = client.prepareRequest( + HttpExecuteRequest.builder().request(request).build()).call(); + response.responseBody().ifPresent(IoUtils::drainInputStream); + // Close the client + client.close(); + // Verify subsequent requests fail + assertThatThrownBy(() -> { + client.prepareRequest(HttpExecuteRequest.builder().request(request).build()).call(); + }).isInstanceOfAny( + IllegalStateException.class + ).hasMessageContaining("Connection pool shut down"); + } +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpProxyTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpProxyTest.java new file mode 100644 index 000000000000..bfeb1ef70a1a --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5HttpProxyTest.java @@ -0,0 +1,71 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.util.Set; +import software.amazon.awssdk.http.HttpProxyTestSuite; +import software.amazon.awssdk.http.proxy.TestProxySetting; + +public class Apache5HttpProxyTest extends HttpProxyTestSuite { + @Override + protected void assertProxyConfiguration(TestProxySetting userSetProxySettings, + TestProxySetting expectedProxySettings, + Boolean useSystemProperty, + Boolean useEnvironmentVariable, + String protocol) { + + ProxyConfiguration.Builder builder = ProxyConfiguration.builder(); + + if (userSetProxySettings != null) { + String hostName = userSetProxySettings.getHost(); + Integer portNumber = userSetProxySettings.getPort(); + String userName = userSetProxySettings.getUserName(); + String password = userSetProxySettings.getPassword(); + Set nonProxyHosts = userSetProxySettings.getNonProxyHosts(); + + if (hostName != null && portNumber != null) { + builder.endpoint(URI.create(String.format("%s://%s:%d", protocol, hostName, portNumber))); + } + if (userName != null) { + builder.username(userName); + } + if (password != null) { + builder.password(password); + } + if (nonProxyHosts != null && !nonProxyHosts.isEmpty()) { + builder.nonProxyHosts(nonProxyHosts); + } + } + if (!"http".equals(protocol)) { + builder.scheme(protocol); + } + if (useSystemProperty != null) { + builder.useSystemPropertyValues(useSystemProperty); + } + if (useEnvironmentVariable != null) { + builder.useEnvironmentVariableValues(useEnvironmentVariable); + } + ProxyConfiguration proxyConfiguration = builder.build(); + assertThat(proxyConfiguration.host()).isEqualTo(expectedProxySettings.getHost()); + assertThat(proxyConfiguration.port()).isEqualTo(expectedProxySettings.getPort()); + assertThat(proxyConfiguration.username()).isEqualTo(expectedProxySettings.getUserName()); + assertThat(proxyConfiguration.password()).isEqualTo(expectedProxySettings.getPassword()); + assertThat(proxyConfiguration.nonProxyHosts()).isEqualTo(expectedProxySettings.getNonProxyHosts()); + } +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5MetricsTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5MetricsTest.java new file mode 100644 index 000000000000..71f0221412e7 --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5MetricsTest.java @@ -0,0 +1,99 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.any; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; +import static org.assertj.core.api.Assertions.assertThat; +import static software.amazon.awssdk.http.HttpMetric.CONCURRENCY_ACQUIRE_DURATION; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.io.IOException; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.metrics.MetricCollection; +import software.amazon.awssdk.metrics.MetricCollector; + + +public class Apache5MetricsTest { + private static WireMockServer wireMockServer; + private SdkHttpClient client; + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @BeforeClass + public static void setUp() throws IOException { + wireMockServer = new WireMockServer(); + wireMockServer.start(); + } + + @Before + public void methodSetup() { + wireMockServer.stubFor(any(urlMatching(".*")).willReturn(aResponse().withStatus(200).withBody("{}"))); + } + + @AfterClass + public static void teardown() throws IOException { + wireMockServer.stop(); + } + + @After + public void methodTeardown() { + if (client != null) { + client.close(); + } + client = null; + } + + @Test + public void concurrencyAcquireDurationIsRecorded() throws IOException { + client = Apache5HttpClient.create(); + MetricCollector collector = MetricCollector.create("test"); + makeRequestWithMetrics(client, collector); + + MetricCollection collection = collector.collect(); + + assertThat(collection.metricValues(CONCURRENCY_ACQUIRE_DURATION)).isNotEmpty(); + } + + private HttpExecuteResponse makeRequestWithMetrics(SdkHttpClient httpClient, MetricCollector metricCollector) throws IOException { + SdkHttpRequest httpRequest = SdkHttpFullRequest.builder() + .method(SdkHttpMethod.GET) + .protocol("http") + .host("localhost:" + wireMockServer.port()) + .build(); + + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .metricCollector(metricCollector) + .build(); + + return httpClient.prepareRequest(request).call(); + } +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/ClientTlsAuthTestBase.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/ClientTlsAuthTestBase.java new file mode 100644 index 000000000000..ea2ecc9431d7 --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/ClientTlsAuthTestBase.java @@ -0,0 +1,64 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; + +abstract class ClientTlsAuthTestBase { + protected static final String STORE_PASSWORD = "password"; + protected static final String CLIENT_STORE_TYPE = "pkcs12"; + protected static final String TEST_KEY_STORE = "/software/amazon/awssdk/http/apache5/server-keystore"; + protected static final String CLIENT_KEY_STORE = "/software/amazon/awssdk/http/apache5/client1.p12"; + + protected static Path tempDir; + protected static Path serverKeyStore; + protected static Path clientKeyStore; + + @BeforeAll + public static void setUp() throws IOException { + tempDir = Files.createTempDirectory(ClientTlsAuthTestBase.class.getSimpleName()); + copyCertsToTmpDir(); + } + + @AfterAll + public static void teardown() throws IOException { + Files.deleteIfExists(serverKeyStore); + Files.deleteIfExists(clientKeyStore); + Files.deleteIfExists(tempDir); + } + + private static void copyCertsToTmpDir() throws IOException { + InputStream sksStream = ClientTlsAuthTestBase.class.getResourceAsStream(TEST_KEY_STORE); + Path sks = copyToTmpDir(sksStream, "server-keystore"); + + InputStream cksStream = ClientTlsAuthTestBase.class.getResourceAsStream(CLIENT_KEY_STORE); + Path cks = copyToTmpDir(cksStream, "client1.p12"); + + serverKeyStore = sks; + clientKeyStore = cks; + } + + private static Path copyToTmpDir(InputStream srcStream, String name) throws IOException { + Path dst = tempDir.resolve(name); + Files.copy(srcStream, dst); + return dst; + } +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/MetricReportingTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/MetricReportingTest.java new file mode 100644 index 000000000000..3c2529d697f7 --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/MetricReportingTest.java @@ -0,0 +1,131 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static software.amazon.awssdk.http.HttpMetric.AVAILABLE_CONCURRENCY; +import static software.amazon.awssdk.http.HttpMetric.HTTP_CLIENT_NAME; +import static software.amazon.awssdk.http.HttpMetric.LEASED_CONCURRENCY; +import static software.amazon.awssdk.http.HttpMetric.MAX_CONCURRENCY; +import static software.amazon.awssdk.http.HttpMetric.PENDING_CONCURRENCY_ACQUIRES; + +import java.io.IOException; +import java.time.Duration; +import org.apache.hc.client5.http.classic.methods.HttpUriRequest; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpVersion; +import org.apache.hc.core5.http.io.HttpClientResponseHandler; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.apache.hc.core5.http.message.BasicHttpResponse; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.pool.PoolStats; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.apache5.internal.Apache5HttpRequestConfig; +import software.amazon.awssdk.http.apache5.internal.impl.ConnectionManagerAwareHttpClient; +import software.amazon.awssdk.metrics.MetricCollection; +import software.amazon.awssdk.metrics.MetricCollector; +import software.amazon.awssdk.utils.AttributeMap; + +@RunWith(MockitoJUnitRunner.class) +public class MetricReportingTest { + + @Mock + public ConnectionManagerAwareHttpClient mockHttpClient; + + @Mock + public PoolingHttpClientConnectionManager cm; + + @Before + public void methodSetup() throws IOException { + + when(mockHttpClient.execute(any(HttpUriRequest.class), any(HttpContext.class))) + .thenReturn(new BasicClassicHttpResponse(200, "OK")); + + when(mockHttpClient.getHttpClientConnectionManager()).thenReturn(cm); + + PoolStats stats = new PoolStats(1, 2, 3, 4); + when(cm.getTotalStats()).thenReturn(stats); + } + + @Test + public void prepareRequest_callableCalled_metricsReported() throws IOException { + Apache5HttpClient client = newClient(); + MetricCollector collector = MetricCollector.create("test"); + HttpExecuteRequest executeRequest = newRequest(collector); + + client.prepareRequest(executeRequest).call(); + MetricCollection collected = collector.collect(); + assertThat(collected.metricValues(HTTP_CLIENT_NAME)).containsExactly("Apache5Preview"); + assertThat(collected.metricValues(LEASED_CONCURRENCY)).containsExactly(1); + assertThat(collected.metricValues(PENDING_CONCURRENCY_ACQUIRES)).containsExactly(2); + assertThat(collected.metricValues(AVAILABLE_CONCURRENCY)).containsExactly(3); + assertThat(collected.metricValues(MAX_CONCURRENCY)).containsExactly(4); + } + + @Test + public void prepareRequest_connectionManagerNotPooling_callableCalled_metricsReported() throws IOException { + Apache5HttpClient client = newClient(); + when(mockHttpClient.getHttpClientConnectionManager()).thenReturn(mock(HttpClientConnectionManager.class)); + MetricCollector collector = MetricCollector.create("test"); + HttpExecuteRequest executeRequest = newRequest(collector); + + client.prepareRequest(executeRequest).call(); + + MetricCollection collected = collector.collect(); + + assertThat(collected.metricValues(HTTP_CLIENT_NAME)).containsExactly("Apache5Preview"); + assertThat(collected.metricValues(LEASED_CONCURRENCY)).isEmpty(); + assertThat(collected.metricValues(PENDING_CONCURRENCY_ACQUIRES)).isEmpty(); + assertThat(collected.metricValues(AVAILABLE_CONCURRENCY)).isEmpty(); + assertThat(collected.metricValues(MAX_CONCURRENCY)).isEmpty(); + } + + private Apache5HttpClient newClient() { + Apache5HttpRequestConfig config = Apache5HttpRequestConfig.builder() + .connectionAcquireTimeout(Duration.ofDays(1)) + .connectionTimeout(Duration.ofDays(1)) + .socketTimeout(Duration.ofDays(1)) + .proxyConfiguration(ProxyConfiguration.builder().build()) + .build(); + + return new Apache5HttpClient(mockHttpClient, config, AttributeMap.empty()); + } + + private HttpExecuteRequest newRequest(MetricCollector collector) { + final SdkHttpFullRequest sdkRequest = SdkHttpFullRequest.builder() + .method(SdkHttpMethod.HEAD) + .host("amazonaws.com") + .protocol("https") + .build(); + return HttpExecuteRequest.builder() + .request(sdkRequest) + .metricCollector(collector) + .build(); + } +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/ProxyConfigurationTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/ProxyConfigurationTest.java new file mode 100644 index 000000000000..85368704bd56 --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/ProxyConfigurationTest.java @@ -0,0 +1,216 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.testutils.EnvironmentVariableHelper; + +public class ProxyConfigurationTest { + + private static final EnvironmentVariableHelper ENVIRONMENT_VARIABLE_HELPER = new EnvironmentVariableHelper(); + + @BeforeEach + public void setup() { + clearProxyProperties(); + ENVIRONMENT_VARIABLE_HELPER.reset(); + } + + @AfterAll + public static void cleanup() { + clearProxyProperties(); + ENVIRONMENT_VARIABLE_HELPER.reset(); + } + + @Test + void testEndpointValues_Http_SystemPropertyEnabled() { + String host = "foo.com"; + int port = 7777; + System.setProperty("http.proxyHost", host); + System.setProperty("http.proxyPort", Integer.toString(port)); + ENVIRONMENT_VARIABLE_HELPER.set("http_proxy", "http://UserOne:passwordSecret@bar.com:555/"); + ProxyConfiguration config = ProxyConfiguration.builder().useSystemPropertyValues(true).build(); + + assertThat(config.host()).isEqualTo(host); + assertThat(config.port()).isEqualTo(port); + assertThat(config.scheme()).isEqualTo("http"); + + } + + @Test + void testEndpointValues_Http_EnvironmentVariableEnabled() { + String host = "bar.com"; + int port = 7777; + System.setProperty("http.proxyHost", "foo.com"); + System.setProperty("http.proxyPort", Integer.toString(8888)); + + ENVIRONMENT_VARIABLE_HELPER.set("http_proxy", String.format("http://%s:%d/", host, port)); + + ProxyConfiguration config = + ProxyConfiguration.builder().useSystemPropertyValues(false).useEnvironmentVariableValues(true).build(); + + assertThat(config.host()).isEqualTo(host); + assertThat(config.port()).isEqualTo(port); + assertThat(config.scheme()).isEqualTo("http"); + } + + @Test + void testEndpointValues_Https_SystemPropertyEnabled() { + String host = "foo.com"; + int port = 7777; + System.setProperty("https.proxyHost", host); + System.setProperty("https.proxyPort", Integer.toString(port)); + + ProxyConfiguration config = ProxyConfiguration.builder() + .endpoint(URI.create("https://foo.com:7777")) + .useSystemPropertyValues(true).build(); + + assertThat(config.host()).isEqualTo(host); + assertThat(config.port()).isEqualTo(port); + assertThat(config.scheme()).isEqualTo("https"); + } + + + @Test + void testEndpointValues_Https_EnvironmentVariableEnabled() { + String host = "bar.com"; + int port = 7777; + System.setProperty("https.proxyHost", "foo.com"); + System.setProperty("https.proxyPort", Integer.toString(8888)); + + ENVIRONMENT_VARIABLE_HELPER.set("http_proxy", String.format("http://%s:%d/", "foo.com", 8888)); + ENVIRONMENT_VARIABLE_HELPER.set("https_proxy", String.format("http://%s:%d/", host, port)); + + ProxyConfiguration config = + ProxyConfiguration.builder() + .scheme("https") + .useSystemPropertyValues(false) + .useEnvironmentVariableValues(true) + .build(); + + assertThat(config.host()).isEqualTo(host); + assertThat(config.port()).isEqualTo(port); + assertThat(config.scheme()).isEqualTo("https"); + } + + + @Test + void testEndpointValues_SystemPropertyDisabled() { + ProxyConfiguration config = ProxyConfiguration.builder() + .endpoint(URI.create("http://localhost:1234")) + .useSystemPropertyValues(Boolean.FALSE) + .build(); + + assertThat(config.host()).isEqualTo("localhost"); + assertThat(config.port()).isEqualTo(1234); + assertThat(config.scheme()).isEqualTo("http"); + } + + @Test + void testProxyConfigurationWithSystemPropertyDisabled() throws Exception { + Set nonProxyHosts = new HashSet<>(); + nonProxyHosts.add("foo.com"); + + // system property should not be used + System.setProperty("http.proxyHost", "foo.com"); + System.setProperty("http.proxyPort", "5555"); + System.setProperty("http.nonProxyHosts", "bar.com"); + System.setProperty("http.proxyUser", "user"); + + ProxyConfiguration config = ProxyConfiguration.builder() + .endpoint(URI.create("http://localhost:1234")) + .nonProxyHosts(nonProxyHosts) + .useSystemPropertyValues(Boolean.FALSE) + .build(); + + assertThat(config.host()).isEqualTo("localhost"); + assertThat(config.port()).isEqualTo(1234); + assertThat(config.nonProxyHosts()).isEqualTo(nonProxyHosts); + assertThat(config.username()).isNull(); + } + + @Test + void testProxyConfigurationWithSystemPropertyEnabled_Http() throws Exception { + Set nonProxyHosts = new HashSet<>(); + nonProxyHosts.add("foo.com"); + + // system property should not be used + System.setProperty("http.proxyHost", "foo.com"); + System.setProperty("http.proxyPort", "5555"); + System.setProperty("http.nonProxyHosts", "bar.com"); + System.setProperty("http.proxyUser", "user"); + + ProxyConfiguration config = ProxyConfiguration.builder() + .nonProxyHosts(nonProxyHosts) + .build(); + + assertThat(config.nonProxyHosts()).isEqualTo(nonProxyHosts); + assertThat(config.host()).isEqualTo("foo.com"); + assertThat(config.username()).isEqualTo("user"); + } + + @Test + void testProxyConfigurationWithSystemPropertyEnabled_Https() throws Exception { + Set nonProxyHosts = new HashSet<>(); + nonProxyHosts.add("foo.com"); + + // system property should not be used + System.setProperty("https.proxyHost", "foo.com"); + System.setProperty("https.proxyPort", "5555"); + System.setProperty("http.nonProxyHosts", "bar.com"); + System.setProperty("https.proxyUser", "user"); + + ProxyConfiguration config = ProxyConfiguration.builder() + .endpoint(URI.create("https://foo.com:1234")) + .nonProxyHosts(nonProxyHosts) + .build(); + + assertThat(config.nonProxyHosts()).isEqualTo(nonProxyHosts); + assertThat(config.host()).isEqualTo("foo.com"); + assertThat(config.username()).isEqualTo("user"); + } + + @Test + void testProxyConfigurationWithoutNonProxyHosts_toBuilder_shouldNotThrowNPE() { + ProxyConfiguration proxyConfiguration = + ProxyConfiguration.builder() + .endpoint(URI.create("http://localhost:4321")) + .username("username") + .password("password") + .build(); + + assertThat(proxyConfiguration.toBuilder()).isNotNull(); + } + + private static void clearProxyProperties() { + System.clearProperty("http.proxyHost"); + System.clearProperty("http.proxyPort"); + System.clearProperty("http.nonProxyHosts"); + System.clearProperty("http.proxyUser"); + System.clearProperty("http.proxyPassword"); + + System.clearProperty("https.proxyHost"); + System.clearProperty("https.proxyPort"); + System.clearProperty("https.proxyUser"); + System.clearProperty("https.proxyPassword"); + } +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/RepeatableInputStreamRequestEntityTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/RepeatableInputStreamRequestEntityTest.java new file mode 100644 index 000000000000..d69eb64bff13 --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/RepeatableInputStreamRequestEntityTest.java @@ -0,0 +1,906 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.net.URI; +import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import software.amazon.awssdk.http.ContentStreamProvider; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.SdkHttpRequest; + +class RepeatableInputStreamRequestEntityTest { + + private static final String TRANSFER_ENCODING = "Transfer-Encoding"; + private static final String CHUNKED = "chunked"; + + private RepeatableInputStreamRequestEntity entity; + private SdkHttpRequest.Builder httpRequestBuilder; + + @BeforeEach + void setUp() { + httpRequestBuilder = SdkHttpRequest.builder() + .uri(URI.create("https://example.com")) + .method(SdkHttpMethod.POST); + } + + @Test + @DisplayName("Constructor should initialize with chunked transfer encoding") + void constructor_WithChunkedTransferEncoding_SetsChunkedTrue() { + // Given + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader(TRANSFER_ENCODING, CHUNKED) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + assertTrue(entity.isChunked()); + } + + @Test + @DisplayName("Constructor should handle content length header correctly") + void constructor_WithContentLength_SetsContentLengthCorrectly() { + long expectedLength = 1024L; + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader("Content-Length", String.valueOf(expectedLength)) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + assertEquals(expectedLength, entity.getContentLength()); + } + + @Test + @DisplayName("Constructor should handle invalid content length gracefully") + void constructor_WithInvalidContentLength_DefaultsToMinusOne() { + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader("Content-Length", "not-a-number") + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + assertEquals(-1L, entity.getContentLength()); + } + + @Test + @DisplayName("Constructor should set content type when provided") + void constructor_WithContentType_SetsContentTypeCorrectly() { + String contentType = "application/json"; + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader("Content-Type", contentType) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + assertEquals(contentType, entity.getContentType()); + } + + @Test + @DisplayName("Constructor should use provided content stream") + void constructor_WithContentStreamProvider_UsesProvidedStream() throws IOException { + String content = "test content"; + ByteArrayInputStream inputStream = new ByteArrayInputStream(content.getBytes()); + ContentStreamProvider provider = () -> inputStream; + + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + assertSame(inputStream, entity.getContent()); + } + + @Test + @DisplayName("Constructor should create empty stream when no content provider") + void constructor_WithoutContentStreamProvider_CreatesEmptyStream() throws IOException { + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + InputStream content = entity.getContent(); + assertNotNull(content); + assertEquals(0, content.available()); + } + + @Test + @DisplayName("isRepeatable should return true for mark-supported streams") + void isRepeatable_WithMarkSupportedStream_ReturnsTrue() { + ByteArrayInputStream markableStream = new ByteArrayInputStream("content".getBytes()); + ContentStreamProvider provider = () -> markableStream; + + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + assertTrue(entity.isRepeatable()); + assertTrue(markableStream.markSupported()); + } + + @Test + @DisplayName("isRepeatable should return false for non-mark-supported streams") + void isRepeatable_WithNonMarkSupportedStream_ReturnsFalse() { + // Given + InputStream nonMarkableStream = new InputStream() { + @Override + public int read() { + return -1; + } + + @Override + public boolean markSupported() { + return false; + } + }; + ContentStreamProvider provider = () -> nonMarkableStream; + + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + assertFalse(entity.isRepeatable()); + } + + @Test + @DisplayName("writeTo should not reset stream on first attempt") + void writeTo_FirstAttempt_DoesNotResetStream() throws IOException { + // Given + String content = "test content"; + // Create a custom stream that tracks reset calls + AtomicInteger resetCallCount = new AtomicInteger(0); + ByteArrayInputStream trackingStream = new ByteArrayInputStream(content.getBytes()) { + @Override + public synchronized void reset() { + resetCallCount.incrementAndGet(); + super.reset(); + } + }; + ContentStreamProvider provider = () -> trackingStream; + + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + entity.writeTo(output); + assertEquals(content, output.toString()); + assertEquals(0, resetCallCount.get(), "Reset should not be called on first attempt"); + } + + @Test + @DisplayName("writeTo should reset stream on subsequent attempts if repeatable") + void writeTo_SubsequentAttemptWithRepeatableStream_ResetsStream() throws IOException { + // Given + String content = "test content"; + + // Create a custom stream that tracks reset calls + AtomicInteger resetCallCount = new AtomicInteger(0); + ByteArrayInputStream trackingStream = new ByteArrayInputStream(content.getBytes()) { + @Override + public synchronized void reset() { + resetCallCount.incrementAndGet(); + super.reset(); + } + }; + ContentStreamProvider provider = () -> trackingStream; + + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + // First write + ByteArrayOutputStream firstOutput = new ByteArrayOutputStream(); + entity.writeTo(firstOutput); + + //Second write + ByteArrayOutputStream secondOutput = new ByteArrayOutputStream(); + entity.writeTo(secondOutput); + + assertEquals(content, firstOutput.toString()); + assertEquals(content, secondOutput.toString()); + assertEquals(1, resetCallCount.get(), "Reset should be called exactly once for second attempt"); + } + + @Test + @DisplayName("writeTo should preserve original exception on first failure") + void writeTo_FirstAttemptThrowsException_PreservesOriginalException() throws IOException { + // Given + IOException originalException = new IOException("Original error"); + InputStream faultyStream = mock(InputStream.class); + when(faultyStream.read(any(byte[].class))).thenThrow(originalException); + when(faultyStream.markSupported()).thenReturn(true); + + ContentStreamProvider provider = () -> faultyStream; + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + IOException thrown = assertThrows(IOException.class, + () -> entity.writeTo(new ByteArrayOutputStream())); + assertSame(originalException, thrown); + } + + @Test + @DisplayName("writeTo should throw original exception on subsequent failures") + void writeTo_SubsequentFailures_ThrowsOriginalException() throws IOException { + // Given + IOException originalException = new IOException("Original error"); + IOException secondException = new IOException("Second error"); + + InputStream faultyStream = mock(InputStream.class); + when(faultyStream.read(any(byte[].class))) + .thenThrow(originalException) + .thenThrow(secondException); + when(faultyStream.markSupported()).thenReturn(true); + + ContentStreamProvider provider = () -> faultyStream; + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + // First attempt + IOException firstThrown = assertThrows(IOException.class, + () -> entity.writeTo(new ByteArrayOutputStream())); + assertEquals("Original error", firstThrown.getMessage()); + assertSame(originalException, firstThrown); + + //Second attempt + IOException secondThrown = assertThrows(IOException.class, + () -> entity.writeTo(new ByteArrayOutputStream())); + + // Should still throw original exception (not the second one) + assertSame(originalException, secondThrown); + assertEquals("Original error", secondThrown.getMessage()); + assertNotSame(secondException, secondThrown); + } + + @Test + @DisplayName("writeTo should handle reset failures gracefully") + void writeTo_ResetThrowsException_PropagatesResetException() throws IOException { + // Given + String content = "test content"; + + // Create a custom stream that throws on reset after first successful read + InputStream problematicStream = new InputStream() { + private final byte[] data = content.getBytes(); + private int position = 0; + private boolean hasBeenRead = false; + + @Override + public int read() throws IOException { + if (position >= data.length) { + hasBeenRead = true; + return -1; + } + hasBeenRead = true; + int i = data[position] & 0xFF; + position++; + return i; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (position >= data.length) { + hasBeenRead = true; + return -1; + } + int bytesToRead = Math.min(len, data.length - position); + System.arraycopy(data, position, b, off, bytesToRead); + position += bytesToRead; + hasBeenRead = true; + return bytesToRead; + } + + @Override + public boolean markSupported() { + return true; + } + + @Override + public synchronized void mark(int readlimit) { + // Mark at current position + } + + @Override + public synchronized void reset() throws IOException { + if (hasBeenRead) { + throw new IOException("Reset failed"); + } + position = 0; + } + }; + + ContentStreamProvider provider = () -> problematicStream; + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + // First successful write + ByteArrayOutputStream firstOutput = new ByteArrayOutputStream(); + entity.writeTo(firstOutput); + assertEquals(content, firstOutput.toString()); + + //Second write where reset should fail + IOException thrown = assertThrows(IOException.class, + () -> entity.writeTo(new ByteArrayOutputStream())); + + + assertEquals("Reset failed", thrown.getMessage()); + } + + @ParameterizedTest + @ValueSource(strings = {"100", "0", "9223372036854775807"}) // Long.MAX_VALUE + @DisplayName("parseContentLength should handle valid numeric values") + void parseContentLength_ValidNumbers_ParsesCorrectly(String contentLength) { + // Given + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader("Content-Length", contentLength) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + assertEquals(Long.parseLong(contentLength), entity.getContentLength()); + } + + @Test + @DisplayName("Multiple writes should work correctly with repeatable stream") + void writeTo_MultipleWrites_AllSucceed() throws IOException { + // Given + String content = "repeatable content"; + ByteArrayInputStream inputStream = new ByteArrayInputStream(content.getBytes()); + ContentStreamProvider provider = () -> inputStream; + + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + // Multiple writes + ByteArrayOutputStream output1 = new ByteArrayOutputStream(); + ByteArrayOutputStream output2 = new ByteArrayOutputStream(); + ByteArrayOutputStream output3 = new ByteArrayOutputStream(); + + entity.writeTo(output1); + entity.writeTo(output2); + entity.writeTo(output3); + + // All outputs should contain the same content + assertEquals(content, output1.toString()); + assertEquals(content, output2.toString()); + assertEquals(content, output3.toString()); + } + + @Test + @DisplayName("Entity should handle multiple headers correctly") + void constructor_WithMultiHeaders_HandlesAllCorrectly() { + // Given + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader("Content-Length", "2048") + .putHeader("Content-Type", "application/xml") + .putHeader(TRANSFER_ENCODING, CHUNKED) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + assertEquals(2048L, entity.getContentLength()); + assertEquals("application/xml", entity.getContentType()); + assertTrue(entity.isChunked()); + } + + @Test + @DisplayName("Entity should handle empty content correctly") + void writeTo_EmptyContent_WritesNothing() throws IOException { + // Given + ContentStreamProvider provider = () -> new ByteArrayInputStream(new byte[0]); + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + entity.writeTo(output); + + assertEquals(0, output.size()); + } + + @Test + @DisplayName("Entity should handle large content streams") + void writeTo_LargeContent_HandlesCorrectly() throws IOException { + // Given - 10MB of data + int size = 10 * 1024 * 1024; + byte[] largeContent = new byte[size]; + new Random().nextBytes(largeContent); + + ContentStreamProvider provider = () -> new ByteArrayInputStream(largeContent); + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader("Content-Length", String.valueOf(size)) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + entity.writeTo(output); + assertArrayEquals(largeContent, output.toByteArray()); + assertEquals(size, entity.getContentLength()); + } + + @Test + @DisplayName("Entity should handle non-repeatable stream on multiple writes") + void writeTo_NonRepeatableStreamMultipleWrites_FailsGracefully() throws IOException { + // Given + InputStream nonRepeatableStream = new InputStream() { + private boolean hasBeenRead = false; + + @Override + public int read() throws IOException { + if (hasBeenRead) { + throw new IOException("Stream already consumed"); + } + hasBeenRead = true; + return -1; + } + + @Override + public boolean markSupported() { + return false; + } + }; + + ContentStreamProvider provider = () -> nonRepeatableStream; + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + // First write should succeed + entity.writeTo(new ByteArrayOutputStream()); + + assertThrows(IOException.class, () -> entity.writeTo(new ByteArrayOutputStream())); + } + + @Test + @DisplayName("Entity should handle non repeatable data arriving in chunks") + void writeTo_withChunkedReads_CompletesSuccessfully() throws IOException { + // Given - Stream that returns data in small chunks + String content = "This is a test content that will be read in chunks"; + InputStream chunkingStream = new InputStream() { + private final byte[] data = content.getBytes(); + private int position = 0; + + @Override + public int read() { + if (position >= data.length) { + return -1; + } + int i = data[position] & 0xFF; + position++; + return i; + } + + @Override + public int read(byte[] b, int off, int len) { + if (position >= data.length) { + return -1; + } + // Return only 5 bytes at a time to simulate chunked reading + int bytesToRead = Math.min(5, Math.min(len, data.length - position)); + System.arraycopy(data, position, b, off, bytesToRead); + position += bytesToRead; + return bytesToRead; + } + + @Override + public boolean markSupported() { + return false; + } + }; + + ContentStreamProvider provider = () -> chunkingStream; + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + entity.writeTo(output); + assertEquals(content, output.toString()); + } + + @Test + @DisplayName("Entity should handle mark/reset with limited buffer") + void writeTo_MarkResetWithLimitedBuffer_HandlesCorrectly() throws IOException { + // Given - Stream with limited mark buffer + String content = "Short content"; + InputStream limitedMarkStream = new InputStream() { + private final ByteArrayInputStream delegate = new ByteArrayInputStream(content.getBytes()); + + @Override + public int read() throws IOException { + return delegate.read(); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + return delegate.read(b, off, len); + } + + @Override + public boolean markSupported() { + return true; + } + + @Override + public synchronized void mark(int readlimit) { + delegate.mark(5); // Very small buffer + } + + @Override + public synchronized void reset() throws IOException { + delegate.reset(); + } + }; + + ContentStreamProvider provider = () -> limitedMarkStream; + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + ByteArrayOutputStream output1 = new ByteArrayOutputStream(); + ByteArrayOutputStream output2 = new ByteArrayOutputStream(); + + entity.writeTo(output1); + entity.writeTo(output2); + + + assertEquals(content, output1.toString()); + assertEquals(content, output2.toString()); + } + + @Test + @DisplayName("Entity should handle null content type gracefully") + void constructor_WithoutContentType_HandlesGracefully() { + // Given + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader("Content-Length", "100") + // No Content-Type header + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + assertNull(entity.getContentType()); + assertEquals(100L, entity.getContentLength()); + } + + @Test + @DisplayName("Entity should handle interrupted IO operations") + void writeTo_InterruptedStream_ThrowsIOException() throws IOException { + // Given + InputStream interruptibleStream = new InputStream() { + @Override + public int read() throws IOException { + throw new InterruptedIOException("Stream interrupted"); + } + + @Override + public boolean markSupported() { + return true; + } + }; + + ContentStreamProvider provider = () -> interruptibleStream; + SdkHttpRequest httpRequest = httpRequestBuilder.build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + IOException thrown = assertThrows(IOException.class, + () -> entity.writeTo(new ByteArrayOutputStream())); + assertInstanceOf(InterruptedIOException.class, thrown); + assertEquals("Stream interrupted", thrown.getMessage()); + } + + @Test + @DisplayName("Entity should preserve state across multiple operations") + void multipleOperations_StatePreservation_WorksCorrectly() throws IOException { + // Given + String content = "State preservation test"; + ContentStreamProvider provider = () -> new ByteArrayInputStream(content.getBytes()); + SdkHttpRequest httpRequest = httpRequestBuilder + .putHeader("Content-Length", String.valueOf(content.length())) + .putHeader("Content-Type", "text/plain") + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequest) + .contentStreamProvider(provider) + .build(); + + entity = new RepeatableInputStreamRequestEntity(request); + + //Perform multiple operations + boolean isRepeatable1 = entity.isRepeatable(); + boolean isChunked1 = entity.isChunked(); + long contentLength1 = entity.getContentLength(); + + // Write once + entity.writeTo(new ByteArrayOutputStream()); + + boolean isRepeatable2 = entity.isRepeatable(); + boolean isChunked2 = entity.isChunked(); + long contentLength2 = entity.getContentLength(); + + // Write again + entity.writeTo(new ByteArrayOutputStream()); + + boolean isRepeatable3 = entity.isRepeatable(); + boolean isChunked3 = entity.isChunked(); + long contentLength3 = entity.getContentLength(); + + // State should remain consistent + assertEquals(isRepeatable1, isRepeatable2); + assertEquals(isRepeatable2, isRepeatable3); + assertEquals(isChunked1, isChunked2); + assertEquals(isChunked2, isChunked3); + assertEquals(contentLength1, contentLength2); + assertEquals(contentLength2, contentLength3); + } + + @Test + @DisplayName("markSupported should be be called everytime") + void markSupported_NotCachedDuringConstruction() { + // Given + AtomicInteger markSupportedCalls = new AtomicInteger(0); + InputStream trackingStream = new ByteArrayInputStream("test".getBytes()) { + @Override + public boolean markSupported() { + markSupportedCalls.incrementAndGet(); + return true; + } + }; + + entity = createEntity(trackingStream); + assertEquals(0, markSupportedCalls.get()); + // Multiple isRepeatable calls trigger new markSupported calls + assertTrue(entity.isRepeatable()); + assertTrue(entity.isRepeatable()); + assertEquals(2, markSupportedCalls.get()); + } + + @Test + @DisplayName("ContentStreamProvider.newStream() should only be called once") + void contentStreamProvider_NewStreamCalledOnce() { + // Given + AtomicInteger newStreamCalls = new AtomicInteger(0); + ContentStreamProvider provider = () -> { + if (newStreamCalls.incrementAndGet() > 1) { + throw new RuntimeException("Could not create new stream: Already created"); + } + return new ByteArrayInputStream("test".getBytes()); + }; + entity = createEntity(provider); + assertEquals(1, newStreamCalls.get()); + assertTrue(entity.isRepeatable()); + assertFalse(entity.isChunked()); + } + + @Test + @DisplayName("writeTo should use cached markSupported for reset decision") + void writeTo_UsesCachedMarkSupported() throws IOException { + // Given - Stream that changes markSupported behavior + AtomicInteger markSupportedCalls = new AtomicInteger(0); + ByteArrayInputStream baseStream = new ByteArrayInputStream("test".getBytes()); + InputStream stream = new InputStream() { + @Override + public int read() throws IOException { + return baseStream.read(); + } + + @Override + public boolean markSupported() { + return markSupportedCalls.incrementAndGet() == 1; // Only first call returns true + } + + @Override + public synchronized void reset() throws IOException { + baseStream.reset(); + } + }; + + entity = createEntity(stream); + + // When - Write twice + ByteArrayOutputStream output1 = new ByteArrayOutputStream(); + entity.writeTo(output1); + + ByteArrayOutputStream output2 = new ByteArrayOutputStream(); + entity.writeTo(output2); + + // Then - Both writes succeed using cached markSupported value + assertEquals("test", output1.toString()); + assertEquals("test", output2.toString()); + assertEquals(1, markSupportedCalls.get()); + } + + @Test + @DisplayName("Non-repeatable stream should not attempt reset") + void nonRepeatableStream_NoResetAttempt() throws IOException { + // Given + AtomicInteger resetCalls = new AtomicInteger(0); + InputStream nonRepeatableStream = new ByteArrayInputStream("test".getBytes()) { + @Override + public boolean markSupported() { + return false; + } + + @Override + public synchronized void reset() { + resetCalls.incrementAndGet(); + throw new RuntimeException("Reset not supported"); + } + }; + + entity = createEntity(nonRepeatableStream); + assertFalse(entity.isRepeatable()); + + // Write twice + entity.writeTo(new ByteArrayOutputStream()); + entity.writeTo(new ByteArrayOutputStream()); + + // Reset never called + assertEquals(0, resetCalls.get()); + } + + @Test + @DisplayName("Stream should not be read during construction") + void constructor_DoesNotReadStream() { + + InputStream nonReadableStream = new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("Stream should not be read during construction"); + } + + @Override + public boolean markSupported() { + return true; + } + }; + + // Should not throw exception + assertDoesNotThrow(() -> entity = createEntity(nonReadableStream)); + assertTrue(entity.isRepeatable()); + } + + @Test + @DisplayName("getContent should reuse existing stream") + void getContent_ReusesExistingStream() throws IOException { + InputStream originalStream = new ByteArrayInputStream("content".getBytes()); + entity = createEntity(originalStream); + + InputStream content1 = entity.getContent(); + InputStream content2 = entity.getContent(); + + assertSame(content1, content2); + } + + @Test + @DisplayName("Empty stream should be repeatable") + void emptyStream_IsRepeatable() { + // Given - No content provider + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequestBuilder.build()) + .build(); + entity = new RepeatableInputStreamRequestEntity(request); + assertTrue(entity.isRepeatable()); + } + + // Helper methods + private RepeatableInputStreamRequestEntity createEntity(InputStream stream) { + return createEntity(() -> stream); + } + + private RepeatableInputStreamRequestEntity createEntity(ContentStreamProvider provider) { + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(httpRequestBuilder.build()) + .contentStreamProvider(provider) + .build(); + return new RepeatableInputStreamRequestEntity(request); + } +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/SdkProxyRoutePlannerTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/SdkProxyRoutePlannerTest.java new file mode 100644 index 000000000000..d410f6fccaf3 --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/SdkProxyRoutePlannerTest.java @@ -0,0 +1,53 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal; + + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Collections; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHost; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link SdkProxyRoutePlanner}. + */ +public class SdkProxyRoutePlannerTest { + private static final HttpHost S3_HOST = new HttpHost("https", "s3.us-west-2.amazonaws.com", 443); + private static final HttpGet S3_REQUEST = new HttpGet("/my-bucket/my-object"); + private static final HttpClientContext CONTEXT = new HttpClientContext(); + + @Test + public void testSetsCorrectSchemeBasedOnProcotol_HTTPS() throws HttpException { + SdkProxyRoutePlanner planner = new SdkProxyRoutePlanner("localhost", 1234, "https", Collections.emptySet()); + + HttpHost proxyHost = planner.determineRoute(S3_HOST, S3_REQUEST, CONTEXT).getProxyHost(); + assertEquals("localhost", proxyHost.getHostName()); + assertEquals("https", proxyHost.getSchemeName()); + } + + @Test + public void testSetsCorrectSchemeBasedOnProcotol_HTTP() throws HttpException { + SdkProxyRoutePlanner planner = new SdkProxyRoutePlanner("localhost", 1234, "http", Collections.emptySet()); + + HttpHost proxyHost = planner.determineRoute(S3_HOST, S3_REQUEST, CONTEXT).getProxyHost(); + assertEquals("localhost", proxyHost.getHostName()); + assertEquals("http", proxyHost.getSchemeName()); + } +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/conn/ClientConnectionManagerFactoryTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/conn/ClientConnectionManagerFactoryTest.java new file mode 100644 index 000000000000..c00fcdbb6845 --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/conn/ClientConnectionManagerFactoryTest.java @@ -0,0 +1,74 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.conn; + +import org.apache.hc.client5.http.io.ConnectionEndpoint; +import org.apache.hc.client5.http.io.LeaseRequest; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; + +import java.io.IOException; +import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.junit.Test; + +public class ClientConnectionManagerFactoryTest { + HttpClientConnectionManager noop = new HttpClientConnectionManager() { + + @Override + public void close() throws IOException { + + } + + @Override + public void close(CloseMode closeMode) { + + } + + @Override + public LeaseRequest lease(String id, HttpRoute route, Timeout requestTimeout, Object state) { + return null; + } + + @Override + public void release(ConnectionEndpoint endpoint, Object newState, TimeValue validDuration) { + + } + + @Override + public void connect(ConnectionEndpoint endpoint, TimeValue connectTimeout, HttpContext context) throws IOException { + + } + + @Override + public void upgrade(ConnectionEndpoint endpoint, HttpContext context) throws IOException { + + } + }; + + @Test + public void wrapOnce() { + ClientConnectionManagerFactory.wrap(noop); + } + + @Test(expected = IllegalArgumentException.class) + public void wrapTwice() { + HttpClientConnectionManager wrapped = ClientConnectionManagerFactory.wrap(noop); + ClientConnectionManagerFactory.wrap(wrapped); + } +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/conn/IdleConnectionReaperTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/conn/IdleConnectionReaperTest.java new file mode 100644 index 000000000000..9f0ef8d8667a --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/conn/IdleConnectionReaperTest.java @@ -0,0 +1,98 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.conn; + +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.core5.io.CloseMode; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.core5.util.TimeValue; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +/** + * Tests for {@link IdleConnectionReaper}. + */ +@RunWith(MockitoJUnitRunner.class) +public class IdleConnectionReaperTest { + private static final long SLEEP_PERIOD = 250; + + private final Map connectionManagers = new HashMap<>(); + + @Mock + public ExecutorService executorService; + + @Mock + public PoolingHttpClientConnectionManager connectionManager; + + private IdleConnectionReaper idleConnectionReaper; + + @Before + public void methodSetup() { + this.connectionManagers.clear(); + idleConnectionReaper = new IdleConnectionReaper(connectionManagers, () -> executorService, SLEEP_PERIOD); + } + + @Test + public void setsUpExecutorIfManagerNotPreviouslyRegistered() { + idleConnectionReaper.registerConnectionManager(connectionManager, 1L); + verify(executorService).execute(any(Runnable.class)); + } + + @Test + public void shutsDownExecutorIfMapEmptied() { + // use register method so it sets up the executor + idleConnectionReaper.registerConnectionManager(connectionManager, 1L); + idleConnectionReaper.deregisterConnectionManager(connectionManager); + verify(executorService).shutdownNow(); + } + + @Test + public void doesNotShutDownExecutorIfNoManagerRemoved() { + idleConnectionReaper.registerConnectionManager(connectionManager, 1L); + HttpClientConnectionManager someOtherConnectionManager = mock(HttpClientConnectionManager.class); + idleConnectionReaper.deregisterConnectionManager(someOtherConnectionManager); + verify(executorService, times(0)).shutdownNow(); + } + + @Test(timeout = 1000L) + public void testReapsConnections() throws InterruptedException { + IdleConnectionReaper reaper = new IdleConnectionReaper(new HashMap<>(), + Executors::newSingleThreadExecutor, + SLEEP_PERIOD); + final long idleTime = 1L; + reaper.registerConnectionManager(connectionManager, idleTime); + try { + Thread.sleep(SLEEP_PERIOD * 2); + verify(connectionManager, atLeastOnce()).closeIdle(any(TimeValue.class)); + } finally { + reaper.deregisterConnectionManager(connectionManager); + } + } +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/conn/SdkTlsSocketFactoryTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/conn/SdkTlsSocketFactoryTest.java new file mode 100644 index 000000000000..ed8e89686eb9 --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/conn/SdkTlsSocketFactoryTest.java @@ -0,0 +1,91 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.conn; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class SdkTlsSocketFactoryTest { + + SdkTlsSocketFactory factory; + SSLSocket socket; + + @BeforeEach + public void before() throws Exception { + factory = new SdkTlsSocketFactory(SSLContext.getDefault(), null); + socket = Mockito.mock(SSLSocket.class); + } + + @Test + void nullProtocols() { + when(socket.getSupportedProtocols()).thenReturn(null); + when(socket.getEnabledProtocols()).thenReturn(null); + + factory.prepareSocket(socket); + + verify(socket, never()).setEnabledProtocols(any()); + } + + @Test + void amazonCorretto_8_0_292_defaultEnabledProtocols() { + when(socket.getSupportedProtocols()).thenReturn(new String[] { + "TLSv1.3", "TLSv1.2", "TLSv1.1", "TLSv1", "SSLv3", "SSLv2Hello" + }); + when(socket.getEnabledProtocols()).thenReturn(new String[] { + "TLSv1.2", "TLSv1.1", "TLSv1" + }); + + factory.prepareSocket(socket); + + verify(socket, never()).setEnabledProtocols(any()); + } + + @Test + void amazonCorretto_11_0_08_defaultEnabledProtocols() { + when(socket.getSupportedProtocols()).thenReturn(new String[] { + "TLSv1.3", "TLSv1.2", "TLSv1.1", "TLSv1", "SSLv3", "SSLv2Hello" + }); + when(socket.getEnabledProtocols()).thenReturn(new String[] { + "TLSv1.3", "TLSv1.2", "TLSv1.1", "TLSv1" + }); + + factory.prepareSocket(socket); + + verify(socket, never()).setEnabledProtocols(any()); + } + + @Test + void amazonCorretto_17_0_1_defaultEnabledProtocols() { + when(socket.getSupportedProtocols()).thenReturn(new String[] { + "TLSv1.3", "TLSv1.2", "TLSv1.1", "TLSv1", "SSLv3", "SSLv2Hello" + }); + when(socket.getEnabledProtocols()).thenReturn(new String[] { + "TLSv1.3", "TLSv1.2" + }); + + factory.prepareSocket(socket); + + verify(socket, never()).setEnabledProtocols(any()); + } +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/impl/ApacheHttpRequestFactoryTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/impl/ApacheHttpRequestFactoryTest.java new file mode 100644 index 000000000000..dbc2cc79db7f --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/internal/impl/ApacheHttpRequestFactoryTest.java @@ -0,0 +1,203 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5.internal.impl; + +import java.net.URISyntaxException; +import org.apache.hc.core5.http.HttpEntityContainer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.io.entity.BufferedHttpEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.http.apache5.internal.Apache5HttpRequestConfig; +import software.amazon.awssdk.http.apache5.internal.RepeatableInputStreamRequestEntity; + +class ApacheHttpRequestFactoryTest { + + private Apache5HttpRequestConfig requestConfig; + private Apache5HttpRequestFactory instance; + + @BeforeEach + public void setup() { + instance = new Apache5HttpRequestFactory(); + requestConfig = Apache5HttpRequestConfig.builder() + .connectionAcquireTimeout(Duration.ZERO) + .connectionTimeout(Duration.ZERO) + .socketTimeout(Duration.ZERO) + .build(); + } + + @Test + public void createSetsHostHeaderByDefault() { + SdkHttpRequest sdkRequest = SdkHttpRequest.builder() + .uri(URI.create("http://localhost:12345/")) + .method(SdkHttpMethod.HEAD) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(sdkRequest) + .build(); + HttpUriRequestBase result = instance.create(request, requestConfig); + Header[] hostHeaders = result.getHeaders(HttpHeaders.HOST); + assertNotNull(hostHeaders); + assertEquals(1, hostHeaders.length); + assertEquals("localhost:12345", hostHeaders[0].getValue()); + } + + @Test + public void createRespectsUserHostHeader() { + String hostOverride = "virtual.host:123"; + SdkHttpRequest sdkRequest = SdkHttpRequest.builder() + .uri(URI.create("http://localhost:12345/")) + .method(SdkHttpMethod.HEAD) + .putHeader("Host", hostOverride) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(sdkRequest) + .build(); + + HttpUriRequestBase result = instance.create(request, requestConfig); + + Header[] hostHeaders = result.getHeaders(HttpHeaders.HOST); + assertNotNull(hostHeaders); + assertEquals(1, hostHeaders.length); + assertEquals(hostOverride, hostHeaders[0].getValue()); + } + + @Test + public void createRespectsLowercaseUserHostHeader() { + String hostOverride = "virtual.host:123"; + SdkHttpRequest sdkRequest = SdkHttpRequest.builder() + .uri(URI.create("http://localhost:12345/")) + .method(SdkHttpMethod.HEAD) + .putHeader("host", hostOverride) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(sdkRequest) + .build(); + + HttpUriRequestBase result = instance.create(request, requestConfig); + + Header[] hostHeaders = result.getHeaders(HttpHeaders.HOST); + assertNotNull(hostHeaders); + assertEquals(1, hostHeaders.length); + assertEquals(hostOverride, hostHeaders[0].getValue()); + } + + @Test + public void putRequest_withTransferEncodingChunked_isChunkedAndDoesNotIncludeHeader() { + SdkHttpRequest sdkRequest = SdkHttpRequest.builder() + .uri(URI.create("http://localhost:12345/")) + .method(SdkHttpMethod.PUT) + .putHeader("Transfer-Encoding", "chunked") + .build(); + InputStream inputStream = new ByteArrayInputStream("TestStream".getBytes(StandardCharsets.UTF_8)); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(sdkRequest) + .contentStreamProvider(() -> inputStream) + .build(); + HttpUriRequestBase result = instance.create(request, requestConfig); + Header[] transferEncodingHeaders = result.getHeaders("Transfer-Encoding"); + assertThat(transferEncodingHeaders).isEmpty(); + + assertThat(result).isInstanceOf(HttpEntityContainer.class); + HttpEntity httpEntity = ((HttpEntityContainer) result).getEntity(); + + assertThat(httpEntity.isChunked()).isTrue(); + assertThat(httpEntity).isNotInstanceOf(BufferedHttpEntity.class); + assertThat(httpEntity).isInstanceOf(RepeatableInputStreamRequestEntity.class); + } + + @Test + public void defaultHttpPortsAreNotInDefaultHostHeader() { + SdkHttpRequest sdkRequest = SdkHttpRequest.builder() + .uri(URI.create("http://localhost:80/")) + .method(SdkHttpMethod.HEAD) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(sdkRequest) + .build(); + HttpUriRequestBase result = instance.create(request, requestConfig); + Header[] hostHeaders = result.getHeaders(HttpHeaders.HOST); + assertNotNull(hostHeaders); + assertEquals(1, hostHeaders.length); + assertEquals("localhost", hostHeaders[0].getValue()); + + sdkRequest = SdkHttpRequest.builder() + .uri(URI.create("https://localhost:443/")) + .method(SdkHttpMethod.HEAD) + .build(); + request = HttpExecuteRequest.builder() + .request(sdkRequest) + .build(); + result = instance.create(request, requestConfig); + hostHeaders = result.getHeaders(HttpHeaders.HOST); + assertNotNull(hostHeaders); + assertEquals(1, hostHeaders.length); + assertEquals("localhost", hostHeaders[0].getValue()); + } + + @Test + public void pathWithLeadingSlash_shouldEncode() { + assertThat(sanitizedUri("/foobar")).isEqualTo("http://localhost/%2Ffoobar"); + } + + @Test + public void pathWithOnlySlash_shouldEncode() { + assertThat(sanitizedUri("/")).isEqualTo("http://localhost/%2F"); + } + + @Test + public void pathWithoutSlash_shouldReturnSameUri() { + assertThat(sanitizedUri("path")).isEqualTo("http://localhost/path"); + } + + @Test + public void pathWithSpecialChars_shouldPreserveEncoding() { + assertThat(sanitizedUri("/special-chars-%40%24%25")).isEqualTo("http://localhost/%2Fspecial-chars-%40%24%25"); + } + + private String sanitizedUri(String path) { + SdkHttpRequest sdkRequest = SdkHttpRequest.builder() + .uri(URI.create("http://localhost:80")) + .encodedPath("/" + path) + .method(SdkHttpMethod.HEAD) + .build(); + HttpExecuteRequest request = HttpExecuteRequest.builder() + .request(sdkRequest) + .build(); + + try { + return instance.create(request, requestConfig).getUri().toString(); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } +} diff --git a/http-clients/apache5-client/src/test/resources/log4j2.properties b/http-clients/apache5-client/src/test/resources/log4j2.properties new file mode 100644 index 000000000000..acd42c123ee2 --- /dev/null +++ b/http-clients/apache5-client/src/test/resources/log4j2.properties @@ -0,0 +1,61 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# A copy of the License is located at +# +# http://aws.amazon.com/apache2.0 +# +# or in the "license" file accompanying this file. This file 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. +# + +status = warn + +appender.console.type = Console +appender.console.name = ConsoleAppender +appender.console.layout.type = PatternLayout +appender.console.layout.pattern = %d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n%throwable + +rootLogger.level = info +rootLogger.appenderRef.stdout.ref = ConsoleAppender + +# Uncomment below to enable more specific logging +# +#logger.sdk.name = software.amazon.awssdk +#logger.sdk.level = debug +# +#logger.request.name = software.amazon.awssdk.request +#logger.request.level = debug +## Apache HttpClient 5.x wire logging (most detailed) +#logger.apache.name = org.apache.hc.client5.http.wire +#logger.apache.level = debug +#logger.apache.additivity = false +#logger.apache.appenderRef.console.ref = ConsoleAppender +# +## Apache HttpClient 5.x headers logging +#logger.apacheheaders.name = org.apache.hc.client5.http.headers +#logger.apacheheaders.level = debug +#logger.apacheheaders.additivity = false +#logger.apacheheaders.appenderRef.console.ref = ConsoleAppender +# +## Apache HttpClient 5.x general logging +#logger.apacheclient.name = org.apache.hc.client5.http +#logger.apacheclient.level = debug +#logger.apacheclient.additivity = false +#logger.apacheclient.appenderRef.console.ref = ConsoleAppender +# +## Apache HttpClient 5.x impl logging +#logger.apacheimpl.name = org.apache.hc.client5.http.impl +#logger.apacheimpl.level = debug +#logger.apacheimpl.additivity = false +#logger.apacheimpl.appenderRef.console.ref = ConsoleAppender +# +## Apache HttpClient 5.x auth logging +#logger.apacheauth.name = org.apache.hc.client5.http.auth +#logger.apacheauth.level = debug +#logger.apacheauth.additivity = false +#logger.apacheauth.appenderRef.console.ref = ConsoleAppender diff --git a/http-clients/apache5-client/src/test/resources/software/amazon/awssdk/http/apache5/client1.p12 b/http-clients/apache5-client/src/test/resources/software/amazon/awssdk/http/apache5/client1.p12 new file mode 100644 index 000000000000..a56e38c196b5 Binary files /dev/null and b/http-clients/apache5-client/src/test/resources/software/amazon/awssdk/http/apache5/client1.p12 differ diff --git a/http-clients/apache5-client/src/test/resources/software/amazon/awssdk/http/apache5/server-keystore b/http-clients/apache5-client/src/test/resources/software/amazon/awssdk/http/apache5/server-keystore new file mode 100644 index 000000000000..55e8a7998c2d Binary files /dev/null and b/http-clients/apache5-client/src/test/resources/software/amazon/awssdk/http/apache5/server-keystore differ diff --git a/http-clients/pom.xml b/http-clients/pom.xml index a907f41c50b9..8a6fa48a84d8 100644 --- a/http-clients/pom.xml +++ b/http-clients/pom.xml @@ -34,6 +34,7 @@ aws-crt-client netty-nio-client url-connection-client + apache5-client @@ -41,7 +42,7 @@ software.amazon.awssdk bom-internal - ${project.version} + ${awsjavasdk.version} pom import diff --git a/pom.xml b/pom.xml index 1a240319ccfa..b95582f40da4 100644 --- a/pom.xml +++ b/pom.xml @@ -183,7 +183,8 @@ 1.8 4.5.13 4.4.16 - + 5.5 + 5.3.4 1.0.4 diff --git a/test/architecture-tests/archunit_store/4195d6e3-8849-4e5a-848d-04f810577cd3 b/test/architecture-tests/archunit_store/4195d6e3-8849-4e5a-848d-04f810577cd3 index 8c3b1f284548..dd81179f8903 100644 --- a/test/architecture-tests/archunit_store/4195d6e3-8849-4e5a-848d-04f810577cd3 +++ b/test/architecture-tests/archunit_store/4195d6e3-8849-4e5a-848d-04f810577cd3 @@ -11,8 +11,11 @@ Method calls method in (FileStoreTlsKeyManagersProvider.java:52) Method calls method in (SystemPropertyTlsKeyManagersProvider.java:61) Method calls method in (ApacheHttpClient.java:699) +Method calls method in (Apache5HttpClient.java:741) Method calls method in (RepeatableInputStreamRequestEntity.java:113) Method calls method in (ApacheUtils.java:162) +Method calls method in (RepeatableInputStreamRequestEntity.java:131) +Method calls method in (RepeatableInputStreamRequestEntity.java:143) Method calls method in (NettyUtils.java:289) Method calls method in (UrlConnectionHttpClient.java:263) Method calls method in (CloudWatchMetricPublisher.java:293) diff --git a/test/architecture-tests/pom.xml b/test/architecture-tests/pom.xml index 3c6b8774a98e..5a1a7c89be41 100644 --- a/test/architecture-tests/pom.xml +++ b/test/architecture-tests/pom.xml @@ -151,6 +151,11 @@ url-connection-client ${awsjavasdk.version} + + software.amazon.awssdk + apache5-client + ${awsjavasdk.version}-PREVIEW + org.junit.jupiter junit-jupiter diff --git a/test/http-client-tests/src/main/java/software/amazon/awssdk/http/HttpClientUriNormalizationTestSuite.java b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/HttpClientUriNormalizationTestSuite.java new file mode 100644 index 000000000000..e09eaf3cb8f9 --- /dev/null +++ b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/HttpClientUriNormalizationTestSuite.java @@ -0,0 +1,169 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.any; +import static com.github.tomakehurst.wiremock.client.WireMock.anyRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.verification.LoggedRequest; +import java.net.URI; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public abstract class HttpClientUriNormalizationTestSuite { + + protected static SdkHttpClient httpClient; + private static WireMockServer wireMockServer; + + @BeforeAll + static void setUp() { + wireMockServer = new WireMockServer(options().dynamicPort()); + wireMockServer.start(); + WireMock.configureFor("localhost", wireMockServer.port()); + } + + @BeforeEach + void prepare() { + wireMockServer.stubFor(any(urlMatching(".*")) + .willReturn(aResponse() + .withStatus(200) + .withBody("success"))); + } + + @AfterEach + void reset() { + wireMockServer.resetAll(); + } + + @AfterAll + static void tearDown() { + if (httpClient != null) { + httpClient.close(); + } + if (wireMockServer != null) { + wireMockServer.stop(); + } + } + + private static Stream uriTestCases() { + return Stream.of( + Arguments.of( + "Encoded spaces", + "/path/with%20spaces/file.txt", + "%20" + ), + Arguments.of( + "Encoded slashes", + "/path/with%2Fslash/file.txt", + "%2F" + ), + Arguments.of( + "Encoded plus", + "/path/with%2Bplus/file.txt", + "%2B" + ), + Arguments.of( + "Plus sign", + "/path/with+plus/file.txt", + "+" + ), + Arguments.of( + "Encoded question mark", + "/path/with%3Fquery/file.txt", + "%3F" + ), + Arguments.of( + "Encoded ampersand", + "/path/with%26ampersand/file.txt", + "%26" + ), + Arguments.of( + "Encoded equals", + "/path/with%3Dequals/file.txt", + "%3D" + ), + Arguments.of( + "AWS S3 style path", + "/my-bucket/folder%2Fsubfolder/file%20name.txt", + "%2F" + ) + ); + } + + @ParameterizedTest + @MethodSource("uriTestCases") + @DisplayName("Verify URI normalization is disabled (encoded characters are preserved)") + void testUriNormalizationDisabled(String testName, String path, String encodedChar) throws Exception { + httpClient = createSdkHttpClient(); + + // Create and execute request + HttpExecuteRequest request = createTestRequest(path); + ExecutableHttpRequest executableRequest = httpClient.prepareRequest(request); + HttpExecuteResponse response = executableRequest.call(); + + // Verify response was successful + assertThat(response.httpResponse().statusCode()).isEqualTo(200); + + // Capture the actual request sent to server + List requests = wireMockServer.findAll(anyRequestedFor(anyUrl())); + assertThat(requests).hasSize(1); + + String actualPathSent = requests.get(0).getUrl(); + assertThat(actualPathSent).contains(encodedChar); + } + + private HttpExecuteRequest createTestRequest(String path) { + String baseUrl = "http://localhost:" + wireMockServer.port(); + return HttpExecuteRequest.builder() + .request(SdkHttpRequest.builder() + .method(SdkHttpMethod.GET) + .uri(URI.create(baseUrl + path)) + .build()) + .build(); + } + + @ParameterizedTest + @MethodSource("uriTestCases") + @DisplayName("Test end-to-end execution flow with client context") + void testExecuteFlowWithClientContext(String testName, String path, String encodedChar) throws Exception { + httpClient = createSdkHttpClient(); + HttpExecuteRequest request = createTestRequest(path); + HttpExecuteResponse response = httpClient.prepareRequest(request).call(); + assertThat(response.httpResponse().statusCode()).isEqualTo(200); + List requests = wireMockServer.findAll(anyRequestedFor(anyUrl())); + assertThat(requests).hasSize(1); + + String actualUrl = requests.get(0).getUrl(); + assertThat(actualUrl).contains(encodedChar); + } + + protected abstract SdkHttpClient createSdkHttpClient(); +} \ No newline at end of file diff --git a/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkAsyncHttpClientH1TestSuite.java b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkAsyncHttpClientH1TestSuite.java index fc76f370ccf4..3345ee0d4153 100644 --- a/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkAsyncHttpClientH1TestSuite.java +++ b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkAsyncHttpClientH1TestSuite.java @@ -172,6 +172,26 @@ public void connectionsArePooledByHostAndPort() throws InterruptedException { } + @Test + public void doesNotRetryOn429StatusCode() throws InterruptedException { + server.return429OnFirstRequest = true; + server.closeConnection = false; + + HttpTestUtils.sendGetRequest(server.port(), client).join(); + // Wait to ensure no retries happen + Thread.sleep(100); + + // Verify only one request was made (no retries) + assertThat(server.requestCount).isEqualTo(1); + + // Send second request to verify connection reuse works after 429 + HttpTestUtils.sendGetRequest(server.port(), client).join(); + + // Verify connection was reused and total of 2 requests + assertThat(server.channels.size()).isEqualTo(1); + assertThat(server.requestCount).isEqualTo(2); + } + private static class Server extends ChannelInitializer { private static final byte[] CONTENT = "helloworld".getBytes(StandardCharsets.UTF_8); private ServerBootstrap bootstrap; @@ -181,6 +201,9 @@ private static class Server extends ChannelInitializer { private SslContext sslCtx; private boolean return500OnFirstRequest; private boolean closeConnection; + private boolean return429OnFirstRequest; + private volatile int requestCount = 0; + public void init() throws Exception { SelfSignedCertificate ssc = new SelfSignedCertificate(); @@ -218,10 +241,14 @@ private class BehaviorTestChannelHandler extends ChannelDuplexHandler { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { if (msg instanceof HttpRequest) { + requestCount++; HttpResponseStatus status; if (ctx.channel().equals(channels.get(0)) && return500OnFirstRequest) { status = INTERNAL_SERVER_ERROR; + } else if (ctx.channel().equals(channels.get(0)) && return429OnFirstRequest) { + status = HttpResponseStatus.TOO_MANY_REQUESTS; + return429OnFirstRequest = false; // Reset after first use } else { status = OK; } diff --git a/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientLocalAddressFunctionalTestSuite.java b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientLocalAddressFunctionalTestSuite.java new file mode 100644 index 000000000000..658036750012 --- /dev/null +++ b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientLocalAddressFunctionalTestSuite.java @@ -0,0 +1,163 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.any; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.BindException; +import java.net.InetAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import software.amazon.awssdk.utils.IoUtils; + +/** + * Abstract test suite for testing local address functionality across different HTTP client implementations. + * Subclasses must implement the {@link #createHttpClient(InetAddress, Duration)} method to provide + * their specific client implementation. + */ +@WireMockTest +public abstract class SdkHttpClientLocalAddressFunctionalTestSuite { + + @RegisterExtension + static WireMockExtension wireMock = WireMockExtension.newInstance() + .options(wireMockConfig().dynamicPort()) + .build(); + + private static final Duration CONNECTION_TIMEOUT = Duration.ofSeconds(2); + private static final String TEST_BODY = "test body"; + private static final String SUCCESS_RESPONSE = "success"; + + private SdkHttpClient client; + + /** + * Creates an HTTP client with the specified local address configuration. + * + * @param localAddress the local address to bind to, or null for default behavior + * @param connectionTimeout the connection timeout + * @return the configured HTTP client + */ + protected abstract SdkHttpClient createHttpClient(InetAddress localAddress, Duration connectionTimeout); + + @BeforeEach + void setUp() { + wireMock.stubFor(any(urlPathEqualTo("/")) + .willReturn(aResponse() + .withStatus(200) + .withBody(SUCCESS_RESPONSE))); + } + + @AfterEach + void tearDown() { + if (client != null) { + client.close(); + } + } + + @ParameterizedTest(name = "Invalid local address {0} should fail with BindException") + @ValueSource(strings = { + "192.0.2.1", // TEST-NET-1 reserved range + "198.51.100.1", // TEST-NET-2 + "203.0.113.1" // TEST-NET-3 + }) + @DisplayName("Invalid local addresses should fail with BindException") + void invalidLocalAddressesShouldFailWithBindexception(String invalidIpAddress) throws Exception { + InetAddress invalidAddress = InetAddress.getByName(invalidIpAddress); + client = createHttpClient(invalidAddress, CONNECTION_TIMEOUT); + assertThatExceptionOfType(BindException.class) + .isThrownBy(this::executeRequest); + } + + @ParameterizedTest(name = "Valid local address: {1}") + @MethodSource("provideValidLocalAddresses") + @DisplayName("Valid local addresses should succeed") + void validLocalAddressesShouldSucceed(InetAddress address, String description) throws Exception { + client = createHttpClient(address, CONNECTION_TIMEOUT); + HttpExecuteResponse response = executeRequest(); + assertThat(response.httpResponse().statusCode()) + .as("Request with %s should succeed", description) + .isEqualTo(200); + assertThat(readResponseBody(response)) + .isEqualTo(SUCCESS_RESPONSE); + } + + @Test + @DisplayName("Client without local address configuration should use system default") + void withoutLocalAddressConfigurationShouldSucceed() throws Exception { + client = createHttpClient(null, CONNECTION_TIMEOUT); + HttpExecuteResponse response = executeRequest(); + assertThat(response.httpResponse().statusCode()).isEqualTo(200); + assertThat(readResponseBody(response)).isEqualTo(SUCCESS_RESPONSE); + } + + private static Stream provideValidLocalAddresses() throws Exception { + return Stream.of( + Arguments.of(InetAddress.getLoopbackAddress(), "loopback address"), + Arguments.of(InetAddress.getByName("127.0.0.1"), "explicit localhost") + ); + } + + private HttpExecuteResponse executeRequest() throws Exception { + SdkHttpFullRequest request = createTestRequest(); + + return client.prepareRequest( + HttpExecuteRequest.builder() + .request(request) + .contentStreamProvider(request.contentStreamProvider().orElse(null)) + .build()) + .call(); + } + + private SdkHttpFullRequest createTestRequest() { + URI uri = URI.create("http://localhost:" + wireMock.getPort()); + byte[] content = TEST_BODY.getBytes(StandardCharsets.UTF_8); + + return SdkHttpFullRequest.builder() + .uri(uri) + .method(SdkHttpMethod.POST) + .putHeader("Host", uri.getHost()) + .putHeader("User-Agent", "test-client") + .putHeader("Content-Length", Integer.toString(content.length)) + .contentStreamProvider(() -> new ByteArrayInputStream(content)) + .build(); + } + + private String readResponseBody(HttpExecuteResponse response) throws IOException { + if (!response.responseBody().isPresent()) { + return ""; + } + return IoUtils.toUtf8String(response.responseBody().get()); + } +} diff --git a/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientTestSuite.java b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientTestSuite.java index 71a8f5cc18ab..b2706354d335 100644 --- a/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientTestSuite.java +++ b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientTestSuite.java @@ -20,6 +20,8 @@ import static com.github.tomakehurst.wiremock.client.WireMock.any; import static com.github.tomakehurst.wiremock.client.WireMock.containing; import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; @@ -59,6 +61,7 @@ public abstract class SdkHttpClientTestSuite { private static final Logger LOG = Logger.loggerFor(SdkHttpClientTestSuite.class); private static final ConnectionCountingTrafficListener CONNECTION_COUNTER = new ConnectionCountingTrafficListener(); + private static final int HTTP_TOO_MANY_REQUESTS = 429; @Rule public WireMockRule mockServer = createWireMockRule(); @@ -218,6 +221,48 @@ public void testCustomTlsTrustManagerAndTrustAllFails() throws Exception { assertThatThrownBy(() -> createSdkHttpClient(httpClientOptions)).isInstanceOf(IllegalArgumentException.class); } + @Test + public void doesNotRetryOn429StatusCode() throws Exception { + SdkHttpClientOptions httpClientOptions = new SdkHttpClientOptions(); + httpClientOptions.trustAll(true); + + try (SdkHttpClient client = createSdkHttpClient(httpClientOptions)) { + // Test 429 with no retry + validateStatusCodeWithRetryCheck(client, HTTP_TOO_MANY_REQUESTS, 1); + + // Reset and test normal request works + mockServer.resetAll(); + validateStatusCodeWithRetryCheck(client, HttpURLConnection.HTTP_OK, 1); + } + } + + private void validateStatusCodeWithRetryCheck(SdkHttpClient client, + int expectedStatusCode, + int expectedRequestCount) throws IOException { + stubForMockRequest(expectedStatusCode); + SdkHttpFullRequest request = mockSdkRequest("http://localhost:" + mockServer.port(), SdkHttpMethod.POST); + HttpExecuteResponse response = client.prepareRequest( + HttpExecuteRequest.builder() + .request(request) + .contentStreamProvider(request.contentStreamProvider() + .orElse(null)) + .build()) + .call(); + validateResponseStatusCode(response, expectedStatusCode); + verifyRequestCount(expectedRequestCount); + } + + private void verifyRequestCount(int expectedCount) { + mockServer.verify(expectedCount, + postRequestedFor(urlEqualTo("/")) + .withHeader("Host", containing("localhost"))); + } + + private void validateResponseStatusCode(HttpExecuteResponse response, int expectedStatusCode) throws IOException { + response.responseBody().ifPresent(IoUtils::drainInputStream); + assertThat(response.httpResponse().statusCode()).isEqualTo(expectedStatusCode); + } + protected void testForResponseCode(int returnCode) throws Exception { testForResponseCode(returnCode, SdkHttpMethod.POST); } diff --git a/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientUriSanitizationTestSuite.java b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientUriSanitizationTestSuite.java new file mode 100644 index 000000000000..36d34a328840 --- /dev/null +++ b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientUriSanitizationTestSuite.java @@ -0,0 +1,121 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.any; +import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import java.net.URI; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Abstract test suite for testing URI sanitization functionality across different HTTP client implementations. + * Verifies that consecutive slashes in URIs are properly encoded. + */ +@WireMockTest +public abstract class SdkHttpClientUriSanitizationTestSuite { + + @RegisterExtension + static WireMockExtension wireMock = WireMockExtension.newInstance() + .options(wireMockConfig().dynamicPort()) + .build(); + + private SdkHttpClient client; + + protected abstract SdkHttpClient createHttpClient(); + + @BeforeEach + void setUp() { + client = createHttpClient(); + // Generic stub for all requests + wireMock.stubFor(any(anyUrl()) + .willReturn(aResponse() + .withStatus(200) + .withBody("success"))); + } + + @AfterEach + void tearDown() { + if (client != null) { + client.close(); + } + } + + @ParameterizedTest(name = "URI path: ''{0}'' should become ''{1}''") + @MethodSource("provideUriSanitizationTestCases") + @DisplayName("URI paths should be properly sanitized") + void uriPathsShouldBeProperlySanitized(String inputPath, String expectedPath) throws Exception { + SdkHttpFullRequest request = createRequestWithPath(inputPath); + HttpExecuteResponse response = executeRequest(request); + + assertThat(response.httpResponse().statusCode()).isEqualTo(200); + wireMock.verify(getRequestedFor(urlPathEqualTo(expectedPath))); + } + + private static Stream provideUriSanitizationTestCases() { + return Stream.of( + // Normal paths should remain unchanged + Arguments.of("/normal/path", "/normal/path"), + Arguments.of("/api/v1/users/123", "/api/v1/users/123"), + Arguments.of("/single/slash/only", "/single/slash/only"), + + // Consecutive slashes should be encoded + Arguments.of("/path//to//resource", "/path/%2Fto/%2Fresource"), + Arguments.of("/folder//file.txt", "/folder/%2Ffile.txt"), + Arguments.of("//leading//double", "/%2Fleading/%2Fdouble"), + Arguments.of("/trailing//", "/trailing/%2F"), + Arguments.of("/multiple///slashes", "/multiple/%2F/slashes"), + Arguments.of("/four////slashes", "/four/%2F/%2Fslashes"), + + // Edge cases + Arguments.of("//", "/%2F"), + Arguments.of("///", "/%2F/"), + Arguments.of("////", "/%2F/%2F") + ); + } + + private SdkHttpFullRequest createRequestWithPath(String path) { + URI uri = URI.create("http://localhost:" + wireMock.getPort() + path); + + return SdkHttpFullRequest.builder() + .uri(uri) + .method(SdkHttpMethod.GET) + .putHeader("Host", uri.getHost() + ":" + uri.getPort()) + .build(); + } + + private HttpExecuteResponse executeRequest(SdkHttpFullRequest request) throws Exception { + return client.prepareRequest( + HttpExecuteRequest.builder() + .request(request) + .build()) + .call(); + } +} \ No newline at end of file diff --git a/test/s3-benchmarks/pom.xml b/test/s3-benchmarks/pom.xml index 42fa029c6313..7604499eeecd 100644 --- a/test/s3-benchmarks/pom.xml +++ b/test/s3-benchmarks/pom.xml @@ -102,6 +102,17 @@ netty-nio-client ${awsjavasdk.version} + + apache-client + software.amazon.awssdk + ${awsjavasdk.version} + + + apache5-client + software.amazon.awssdk + ${awsjavasdk.version}-PREVIEW + + software.amazon.awssdk aws-crt-client diff --git a/test/sdk-benchmarks/pom.xml b/test/sdk-benchmarks/pom.xml index 7bf06cdbdedd..a3da44bb83f5 100644 --- a/test/sdk-benchmarks/pom.xml +++ b/test/sdk-benchmarks/pom.xml @@ -158,6 +158,11 @@ apache-client ${awsjavasdk.version} + + software.amazon.awssdk + apache5-client + ${awsjavasdk.version}-PREVIEW + software.amazon.awssdk protocol-tests diff --git a/test/sdk-benchmarks/src/main/java/software/amazon/awssdk/benchmark/apicall/httpclient/SdkHttpClientBenchmark.java b/test/sdk-benchmarks/src/main/java/software/amazon/awssdk/benchmark/apicall/httpclient/SdkHttpClientBenchmark.java index c8448dfb37bc..8a130e2c83e9 100644 --- a/test/sdk-benchmarks/src/main/java/software/amazon/awssdk/benchmark/apicall/httpclient/SdkHttpClientBenchmark.java +++ b/test/sdk-benchmarks/src/main/java/software/amazon/awssdk/benchmark/apicall/httpclient/SdkHttpClientBenchmark.java @@ -40,4 +40,39 @@ public interface SdkHttpClientBenchmark { */ default void concurrentApiCall(Blackhole blackhole) { } + + + /** + * Benchmark for PUT operations with streaming + * + * @param blackhole the blackhole + */ + default void streamingPutOperation(Blackhole blackhole) { + } + + /** + * Benchmark for concurrent PUT operations + * + * @param blackhole the blackhole + */ + default void concurrentStreamingPutOperation(Blackhole blackhole) { + } + + /** + * Benchmark for GET operations with streaming response + * + * @param blackhole the blackhole + */ + default void streamingOutputOperation(Blackhole blackhole) { + } + + /** + * Benchmark for concurrent GET operations with streaming response + * + * @param blackhole the blackhole + */ + default void concurrentStreamingOutputOperation(Blackhole blackhole) { + } + + } diff --git a/test/sdk-benchmarks/src/main/java/software/amazon/awssdk/benchmark/apicall/httpclient/sync/ApacheHttpClientBenchmark.java b/test/sdk-benchmarks/src/main/java/software/amazon/awssdk/benchmark/apicall/httpclient/sync/ApacheHttpClientBenchmark.java index 232a892c23d5..2171c4467ba7 100644 --- a/test/sdk-benchmarks/src/main/java/software/amazon/awssdk/benchmark/apicall/httpclient/sync/ApacheHttpClientBenchmark.java +++ b/test/sdk-benchmarks/src/main/java/software/amazon/awssdk/benchmark/apicall/httpclient/sync/ApacheHttpClientBenchmark.java @@ -33,12 +33,14 @@ import org.openjdk.jmh.annotations.Measurement; import org.openjdk.jmh.annotations.Mode; import org.openjdk.jmh.annotations.OperationsPerInvocation; +import org.openjdk.jmh.annotations.Param; import org.openjdk.jmh.annotations.Scope; import org.openjdk.jmh.annotations.Setup; import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.annotations.TearDown; import org.openjdk.jmh.annotations.Warmup; import org.openjdk.jmh.infra.Blackhole; +import org.openjdk.jmh.profile.GCProfiler; import org.openjdk.jmh.profile.StackProfiler; import org.openjdk.jmh.results.RunResult; import org.openjdk.jmh.runner.Runner; @@ -46,13 +48,20 @@ import org.openjdk.jmh.runner.options.OptionsBuilder; import software.amazon.awssdk.benchmark.apicall.httpclient.SdkHttpClientBenchmark; import software.amazon.awssdk.benchmark.utils.MockServer; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.core.sync.ResponseTransformer; import software.amazon.awssdk.http.SdkHttpClient; import software.amazon.awssdk.http.apache.ApacheHttpClient; +import software.amazon.awssdk.http.apache5.Apache5HttpClient; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.protocolrestjson.ProtocolRestJsonClient; +import software.amazon.awssdk.services.protocolrestjson.model.StreamingInputOperationRequest; +import software.amazon.awssdk.services.protocolrestjson.model.StreamingOutputOperationRequest; +import software.amazon.awssdk.services.protocolrestjson.model.StreamingOutputOperationResponse; /** - * Benchmarking for running with different http clients. + * Benchmarking for running with different Apache HTTP clients. */ @State(Scope.Benchmark) @Warmup(iterations = 3, time = 15, timeUnit = TimeUnit.SECONDS) @@ -61,6 +70,19 @@ @BenchmarkMode(Mode.Throughput) public class ApacheHttpClientBenchmark implements SdkHttpClientBenchmark { + private static final int STREAM_SIZE = 1024 * 1024; // 1MB + private static final byte[] STREAM_DATA = new byte[STREAM_SIZE]; + + static { + // Initialize stream data + for (int i = 0; i < STREAM_SIZE; i++) { + STREAM_DATA[i] = (byte) (i % 256); + } + } + + @Param({"apache4", "apache5"}) + private String clientType; + private MockServer mockServer; private SdkHttpClient sdkHttpClient; private ProtocolRestJsonClient client; @@ -70,8 +92,21 @@ public class ApacheHttpClientBenchmark implements SdkHttpClientBenchmark { public void setup() throws Exception { mockServer = new MockServer(); mockServer.start(); - sdkHttpClient = ApacheHttpClient.builder() - .buildWithDefaults(trustAllTlsAttributeMapBuilder().build()); + + // Create HTTP client based on parameter + switch (clientType) { + case "apache4": + sdkHttpClient = ApacheHttpClient.builder() + .buildWithDefaults(trustAllTlsAttributeMapBuilder().build()); + break; + case "apache5": + sdkHttpClient = Apache5HttpClient.builder() + .buildWithDefaults(trustAllTlsAttributeMapBuilder().build()); + break; + default: + throw new IllegalArgumentException("Unknown client type: " + clientType); + } + client = ProtocolRestJsonClient.builder() .endpointOverride(mockServer.getHttpsUri()) .httpClient(sdkHttpClient) @@ -109,12 +144,78 @@ public void concurrentApiCall(Blackhole blackhole) { awaitCountdownLatchUninterruptibly(countDownLatch, 10, TimeUnit.SECONDS); } - public static void main(String... args) throws Exception { + @Benchmark + @Override + public void streamingPutOperation(Blackhole blackhole) { + StreamingInputOperationRequest request = StreamingInputOperationRequest.builder() + .build(); + RequestBody requestBody = RequestBody.fromBytes(STREAM_DATA); + + blackhole.consume(client.streamingInputOperation(request, requestBody)); + } + + @Benchmark + @Override + @OperationsPerInvocation(CONCURRENT_CALLS) + public void concurrentStreamingPutOperation(Blackhole blackhole) { + CountDownLatch countDownLatch = new CountDownLatch(CONCURRENT_CALLS); + for (int i = 0; i < CONCURRENT_CALLS; i++) { + CompletableFuture future = CompletableFuture.runAsync(() -> { + StreamingInputOperationRequest request = StreamingInputOperationRequest.builder() + .build(); + RequestBody requestBody = RequestBody.fromBytes(STREAM_DATA); + client.streamingInputOperation(request, requestBody); + }, executorService); + + countDownUponCompletion(blackhole, future, countDownLatch); + } + + awaitCountdownLatchUninterruptibly(countDownLatch, 10, TimeUnit.SECONDS); + } + + @Benchmark + @Override + public void streamingOutputOperation(Blackhole blackhole) { + StreamingOutputOperationRequest request = StreamingOutputOperationRequest.builder() + .build(); + + ResponseBytes responseBytes = + client.streamingOutputOperation(request, ResponseTransformer.toBytes()); + + blackhole.consume(responseBytes.asByteArray()); + } + + @Benchmark + @Override + @OperationsPerInvocation(CONCURRENT_CALLS) + public void concurrentStreamingOutputOperation(Blackhole blackhole) { + CountDownLatch countDownLatch = new CountDownLatch(CONCURRENT_CALLS); + + for (int i = 0; i < CONCURRENT_CALLS; i++) { + CompletableFuture future = CompletableFuture.runAsync(() -> { + StreamingOutputOperationRequest request = StreamingOutputOperationRequest.builder() + .build(); + + ResponseBytes responseBytes = + client.streamingOutputOperation(request, ResponseTransformer.toBytes()); + + blackhole.consume(responseBytes.asByteArray()); + }, executorService); + + countDownUponCompletion(blackhole, future, countDownLatch); + } + + awaitCountdownLatchUninterruptibly(countDownLatch, 10, TimeUnit.SECONDS); + } + + public static void main(String... args) throws Exception { Options opt = new OptionsBuilder() - .include(ApacheHttpClientBenchmark.class.getSimpleName() + ".concurrentApiCall") + .include(ApacheHttpClientBenchmark.class.getSimpleName()) .addProfiler(StackProfiler.class) + .addProfiler(GCProfiler.class) .build(); + Collection run = new Runner(opt).run(); } } diff --git a/test/sdk-benchmarks/src/main/java/software/amazon/awssdk/benchmark/utils/MockServer.java b/test/sdk-benchmarks/src/main/java/software/amazon/awssdk/benchmark/utils/MockServer.java index 15cca300789c..7a4e9a13df6a 100644 --- a/test/sdk-benchmarks/src/main/java/software/amazon/awssdk/benchmark/utils/MockServer.java +++ b/test/sdk-benchmarks/src/main/java/software/amazon/awssdk/benchmark/utils/MockServer.java @@ -59,7 +59,7 @@ public MockServer() throws IOException { server.setConnectors(new Connector[] {connector, sslConnector}); ServletContextHandler context = new ServletContextHandler(server, "/", ServletContextHandler.SESSIONS); - context.addServlet(new ServletHolder(new AlwaysSuccessServlet()), "/*"); + context.addServlet(new ServletHolder(new StreamingMockServlet()), "/*"); server.setHandler(context); } diff --git a/test/sdk-benchmarks/src/main/java/software/amazon/awssdk/benchmark/utils/StreamingMockServlet.java b/test/sdk-benchmarks/src/main/java/software/amazon/awssdk/benchmark/utils/StreamingMockServlet.java new file mode 100644 index 000000000000..f47573356918 --- /dev/null +++ b/test/sdk-benchmarks/src/main/java/software/amazon/awssdk/benchmark/utils/StreamingMockServlet.java @@ -0,0 +1,112 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.benchmark.utils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintWriter; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +public class StreamingMockServlet extends HttpServlet { + private static final byte[] STREAMING_RESPONSE_DATA = new byte[1024 * 1024]; // 1MB response + + static { + // Initialize response data + for (int i = 0; i < STREAMING_RESPONSE_DATA.length; i++) { + STREAMING_RESPONSE_DATA[i] = (byte) (i % 256); + } + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { + handleRequest(request, response); + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { + handleRequest(request, response); + } + + @Override + protected void doPut(HttpServletRequest request, HttpServletResponse response) throws IOException { + handleRequest(request, response); + } + + private void handleRequest(HttpServletRequest request, HttpServletResponse response) throws IOException { + // Check if this should be a streaming response + if (isStreamingOperation(request)) { + handleStreamingRequest(request, response); + } else { + handleJsonRequest(request, response); + } + } + + private boolean isStreamingOperation(HttpServletRequest request) { + String uri = request.getRequestURI(); + String contentType = request.getContentType(); + + return uri.contains("streaming") || + uri.contains("StreamingInput") || + uri.contains("StreamingOutput") || + "application/octet-stream".equals(contentType); + } + + private void handleStreamingRequest(HttpServletRequest request, HttpServletResponse response) throws IOException { + + // Consume input stream if present + try (InputStream inputStream = request.getInputStream()) { + byte[] buffer = new byte[8192]; + while (inputStream.read(buffer) != -1) { + // Just consume the data + } + } + + // Send streaming response + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/octet-stream"); + response.setContentLength(STREAMING_RESPONSE_DATA.length); + response.setHeader("x-amz-request-id", "streaming-" + System.currentTimeMillis()); + + try (OutputStream outputStream = response.getOutputStream()) { + outputStream.write(STREAMING_RESPONSE_DATA); + outputStream.flush(); + } + } + + private void handleJsonRequest(HttpServletRequest request, HttpServletResponse response) throws IOException { + + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.setHeader("x-amz-request-id", "json-" + System.currentTimeMillis()); + + String jsonResponse = "{" + + "\"status\":\"success\"," + + "\"message\":\"Mock operation completed\"," + + "\"ResponseMetadata\":{" + + "\"RequestId\":\"mock-request-id\"" + + "}" + + "}"; + + try (PrintWriter writer = response.getWriter()) { + writer.write(jsonResponse); + writer.flush(); + } + } +} \ No newline at end of file diff --git a/test/tests-coverage-reporting/pom.xml b/test/tests-coverage-reporting/pom.xml index 7084aa9f7097..94556f27211a 100644 --- a/test/tests-coverage-reporting/pom.xml +++ b/test/tests-coverage-reporting/pom.xml @@ -167,6 +167,11 @@ software.amazon.awssdk ${awsjavasdk.version} + + apache5-client + software.amazon.awssdk + ${awsjavasdk.version}-PREVIEW + aws-sdk-java software.amazon.awssdk