diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/ClientType.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/ClientType.java index fb38677d9558..0b6090184a11 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/ClientType.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/ClientType.java @@ -24,7 +24,8 @@ public enum ClientType { ASYNC("Async"), - SYNC("Sync"); + SYNC("Sync"), + UNKNOWN("Unknown"); private final String clientType; diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/client/builder/SdkDefaultClientBuilder.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/client/builder/SdkDefaultClientBuilder.java index 00081bc87bd9..9bfd9cf735be 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/client/builder/SdkDefaultClientBuilder.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/client/builder/SdkDefaultClientBuilder.java @@ -390,6 +390,11 @@ public ExecutableHttpRequest prepareRequest(HttpExecuteRequest request) { public void close() { // Do nothing, this client is managed by the customer. } + + @Override + public String clientName() { + return delegate.clientName(); + } } /** diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java index e91ab725b14d..5366be07de9b 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java @@ -19,15 +19,18 @@ import java.util.stream.Collectors; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.core.ApiName; +import software.amazon.awssdk.core.ClientType; import software.amazon.awssdk.core.SdkSystemSetting; import software.amazon.awssdk.core.client.config.SdkAdvancedClientOption; import software.amazon.awssdk.core.client.config.SdkClientConfiguration; +import software.amazon.awssdk.core.client.config.SdkClientOption; import software.amazon.awssdk.core.internal.http.HttpClientDependencies; import software.amazon.awssdk.core.internal.http.RequestExecutionContext; import software.amazon.awssdk.core.internal.http.pipeline.MutableRequestToRequestPipeline; import software.amazon.awssdk.core.internal.util.UserAgentUtils; import software.amazon.awssdk.http.SdkHttpFullRequest; import software.amazon.awssdk.utils.StringUtils; +import software.amazon.awssdk.utils.http.SdkHttpUtils; /** * Apply any custom user agent supplied, otherwise instrument the user agent with info about the SDK and environment. @@ -37,6 +40,9 @@ public class ApplyUserAgentStage implements MutableRequestToRequestPipeline { private static final String COMMA = ", "; private static final String SPACE = " "; + private static final String IO = "io"; + private static final String HTTP = "http"; + private static final String AWS_EXECUTION_ENV_PREFIX = "exec-env/"; private static final String HEADER_USER_AGENT = "User-Agent"; @@ -70,6 +76,24 @@ private StringBuilder getUserAgent(SdkClientConfiguration config, List userAgent.append(SPACE).append(AWS_EXECUTION_ENV_PREFIX).append(awsExecutionEnvironment.trim()); } + ClientType clientType = clientConfig.option(SdkClientOption.CLIENT_TYPE); + + if (clientType == null) { + clientType = ClientType.UNKNOWN; + } + + userAgent.append(SPACE) + .append(IO) + .append("/") + .append(StringUtils.lowerCase(clientType.name())); + + String clientName = clientName(clientType); + + userAgent.append(SPACE) + .append(HTTP) + .append("/") + .append(SdkHttpUtils.urlEncode(clientName)); + if (!requestApiNames.isEmpty()) { String requestUserAgent = requestApiNames.stream() .map(n -> n.name() + "/" + n.version()) @@ -94,4 +118,16 @@ private String addUserAgentSuffix(StringBuilder userAgent, SdkClientConfiguratio return userAgent.toString(); } + + private String clientName(ClientType clientType) { + if (clientType.equals(ClientType.SYNC)) { + return clientConfig.option(SdkClientOption.SYNC_HTTP_CLIENT).clientName(); + } + + if (clientType.equals(ClientType.ASYNC)) { + return clientConfig.option(SdkClientOption.ASYNC_HTTP_CLIENT).clientName(); + } + + return ClientType.UNKNOWN.name(); + } } diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/http/AmazonHttpClientTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/http/AmazonHttpClientTest.java index 4701943b11ad..3270fcd2b8fd 100644 --- a/core/sdk-core/src/test/java/software/amazon/awssdk/core/http/AmazonHttpClientTest.java +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/http/AmazonHttpClientTest.java @@ -32,6 +32,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.runners.MockitoJUnitRunner; +import software.amazon.awssdk.core.ClientType; import software.amazon.awssdk.core.client.config.SdkAdvancedAsyncClientOption; import software.amazon.awssdk.core.client.config.SdkAdvancedClientOption; import software.amazon.awssdk.core.client.config.SdkClientConfiguration; @@ -66,6 +67,7 @@ public void setUp() throws Exception { BasicConfigurator.configure(); client = HttpTestUtils.testClientBuilder().httpClient(sdkHttpClient).build(); when(sdkHttpClient.prepareRequest(any())).thenReturn(abortableCallable); + when(sdkHttpClient.clientName()).thenReturn("UNKNOWN"); stubSuccessfulResponse(); } @@ -151,6 +153,33 @@ public void testUserAgentPrefixAndSuffixAreAdded() { Assert.assertTrue(userAgent.endsWith(suffix)); } + @Test + public void testUserAgentContainsHttpClientInfo() { + HttpResponseHandler handler = mock(HttpResponseHandler.class); + + SdkClientConfiguration config = HttpTestUtils.testClientConfiguration().toBuilder() + .option(SdkClientOption.SYNC_HTTP_CLIENT, sdkHttpClient) + .option(SdkClientOption.CLIENT_TYPE, ClientType.SYNC) + .option(SdkClientOption.ENDPOINT, URI.create("http://example.com")) + .build(); + AmazonSyncHttpClient client = new AmazonSyncHttpClient(config); + + client.requestExecutionBuilder() + .request(ValidSdkObjects.sdkHttpFullRequest().build()) + .originalRequest(NoopTestRequest.builder().build()) + .executionContext(ClientExecutionAndRequestTimerTestUtils.executionContext(null)) + .execute(handler); + + ArgumentCaptor httpRequestCaptor = ArgumentCaptor.forClass(HttpExecuteRequest.class); + verify(sdkHttpClient).prepareRequest(httpRequestCaptor.capture()); + + final String userAgent = httpRequestCaptor.getValue().httpRequest().firstMatchingHeader("User-Agent") + .orElseThrow(() -> new AssertionError("User-Agent header was not found")); + + Assert.assertTrue(userAgent.contains("io/sync")); + Assert.assertTrue(userAgent.contains("http/UNKNOWN")); + } + @Test public void closeClient_shouldCloseDependencies() { SdkClientConfiguration config = HttpTestUtils.testClientConfiguration() diff --git a/http-client-spi/src/main/java/software/amazon/awssdk/http/SdkHttpClient.java b/http-client-spi/src/main/java/software/amazon/awssdk/http/SdkHttpClient.java index 4761dbec0ee9..bca1702e12fb 100644 --- a/http-client-spi/src/main/java/software/amazon/awssdk/http/SdkHttpClient.java +++ b/http-client-spi/src/main/java/software/amazon/awssdk/http/SdkHttpClient.java @@ -31,6 +31,7 @@ @ThreadSafe @SdkPublicApi public interface SdkHttpClient extends SdkAutoCloseable { + /** * Create a {@link ExecutableHttpRequest} that can be used to execute the HTTP request. * @@ -39,6 +40,21 @@ public interface SdkHttpClient extends SdkAutoCloseable { */ ExecutableHttpRequest prepareRequest(HttpExecuteRequest request); + /** + * Each HTTP client implementation should return a well-formed client name + * that allows requests to be identifiable back to the client that made the request. + * The client name should include the backing implementation as well as the Sync or Async + * to identify the transmission type of the request. Client names should only include + * alphanumeric characters. Examples of well formed client names include, ApacheSync, for + * requests using Apache's synchronous http client or NettyNioAsync for Netty's asynchronous + * http client. + * + * @return String containing the name of the client + */ + default String clientName() { + return "UNKNOWN"; + } + /** * Interface for creating an {@link SdkHttpClient} with service specific defaults applied. */ diff --git a/http-client-spi/src/main/java/software/amazon/awssdk/http/async/SdkAsyncHttpClient.java b/http-client-spi/src/main/java/software/amazon/awssdk/http/async/SdkAsyncHttpClient.java index 13060aee9dcb..2741e9f4c09b 100644 --- a/http-client-spi/src/main/java/software/amazon/awssdk/http/async/SdkAsyncHttpClient.java +++ b/http-client-spi/src/main/java/software/amazon/awssdk/http/async/SdkAsyncHttpClient.java @@ -44,6 +44,20 @@ public interface SdkAsyncHttpClient extends SdkAutoCloseable { */ CompletableFuture execute(AsyncExecuteRequest request); + /** + * Each HTTP client implementation should return a well-formed client name + * that allows requests to be identifiable back to the client that made the request. + * The client name should include the backing implementation as well as the Sync or Async + * to identify the transmission type of the request. Client names should only include + * alphanumeric characters. Examples of well formed client names include, Apache, for + * requests using Apache's http client or NettyNio for Netty's http client. + * + * @return String containing the name of the client + */ + default String clientName() { + return "UNKNOWN"; + } + @FunctionalInterface interface Builder> extends SdkBuilder { /** diff --git a/http-clients/apache-client/src/main/java/software/amazon/awssdk/http/apache/ApacheHttpClient.java b/http-clients/apache-client/src/main/java/software/amazon/awssdk/http/apache/ApacheHttpClient.java index 21e16cf5d462..75d02bca7dc0 100644 --- a/http-clients/apache-client/src/main/java/software/amazon/awssdk/http/apache/ApacheHttpClient.java +++ b/http-clients/apache-client/src/main/java/software/amazon/awssdk/http/apache/ApacheHttpClient.java @@ -100,6 +100,9 @@ */ @SdkPublicApi public final class ApacheHttpClient implements SdkHttpClient { + + public static final String CLIENT_NAME = "Apache"; + private static final Logger log = Logger.loggerFor(ApacheHttpClient.class); private final ApacheHttpRequestFactory apacheHttpRequestFactory = new ApacheHttpRequestFactory(); @@ -283,6 +286,11 @@ private ApacheHttpRequestConfig createRequestConfig(DefaultBuilder builder, .build(); } + @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 diff --git a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/NettyNioAsyncHttpClient.java b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/NettyNioAsyncHttpClient.java index 516574f1a270..3a9653484e85 100644 --- a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/NettyNioAsyncHttpClient.java +++ b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/NettyNioAsyncHttpClient.java @@ -69,6 +69,9 @@ */ @SdkPublicApi public final class NettyNioAsyncHttpClient implements SdkAsyncHttpClient { + + private static final String CLIENT_NAME = "NettyNio"; + private static final Logger log = LoggerFactory.getLogger(NettyNioAsyncHttpClient.class); private static final long MAX_STREAMS_ALLOWED = 4294967295L; // unsigned 32-bit, 2^32 -1 @@ -163,6 +166,11 @@ private void closeEventLoopUninterruptibly(EventLoopGroup eventLoopGroup) throws } } + @Override + public String clientName() { + return CLIENT_NAME; + } + /** * Builder that allows configuration of the Netty NIO HTTP implementation. Use {@link #builder()} to configure and construct * a Netty HTTP client. diff --git a/http-clients/url-connection-client/src/it/java/software/amazon/awssdk/http/urlconnection/S3WithUrlHttpClientIntegrationTest.java b/http-clients/url-connection-client/src/it/java/software/amazon/awssdk/http/urlconnection/S3WithUrlHttpClientIntegrationTest.java index 586af2d4a74f..07f8b1d5ae97 100644 --- a/http-clients/url-connection-client/src/it/java/software/amazon/awssdk/http/urlconnection/S3WithUrlHttpClientIntegrationTest.java +++ b/http-clients/url-connection-client/src/it/java/software/amazon/awssdk/http/urlconnection/S3WithUrlHttpClientIntegrationTest.java @@ -15,12 +15,16 @@ package software.amazon.awssdk.http.urlconnection; +import static org.assertj.core.api.Assertions.assertThat; import static software.amazon.awssdk.testutils.service.AwsTestBase.CREDENTIALS_PROVIDER_CHAIN; -import org.assertj.core.api.Assertions; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; +import software.amazon.awssdk.auth.credentials.ProfileCredentialsProvider; +import software.amazon.awssdk.core.interceptor.Context; +import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.interceptor.ExecutionInterceptor; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; @@ -53,6 +57,7 @@ public static void createResources() throws Exception { .region(REGION) .httpClient(UrlConnectionHttpClient.builder().build()) .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN) + .overrideConfiguration(o -> o.addExecutionInterceptor(new UserAgentVerifyingInterceptor())) .build(); createBucket(BUCKET_NAME, REGION); @@ -69,14 +74,14 @@ public static void tearDown() { @Test public void verifyPutObject() { - Assertions.assertThat(objectCount(BUCKET_NAME)).isEqualTo(0); + assertThat(objectCount(BUCKET_NAME)).isEqualTo(0); // Put Object s3.putObject(PutObjectRequest.builder().bucket(BUCKET_NAME).key(KEY).build(), RequestBody.fromString("foobar")); - Assertions.assertThat(objectCount(BUCKET_NAME)).isEqualTo(1); + assertThat(objectCount(BUCKET_NAME)).isEqualTo(1); } @@ -108,4 +113,13 @@ private int objectCount(String bucket) { return s3.listObjectsV2(listReq).keyCount(); } + + private static final class UserAgentVerifyingInterceptor implements ExecutionInterceptor { + + @Override + public void beforeTransmission(Context.BeforeTransmission context, ExecutionAttributes executionAttributes) { + assertThat(context.httpRequest().firstMatchingHeader("User-Agent").get()).containsIgnoringCase("io/sync"); + assertThat(context.httpRequest().firstMatchingHeader("User-Agent").get()).containsIgnoringCase("http/UrlConnection"); + } + } } diff --git a/http-clients/url-connection-client/src/main/java/software/amazon/awssdk/http/urlconnection/UrlConnectionHttpClient.java b/http-clients/url-connection-client/src/main/java/software/amazon/awssdk/http/urlconnection/UrlConnectionHttpClient.java index 32857c93662c..b6060d16fba0 100644 --- a/http-clients/url-connection-client/src/main/java/software/amazon/awssdk/http/urlconnection/UrlConnectionHttpClient.java +++ b/http-clients/url-connection-client/src/main/java/software/amazon/awssdk/http/urlconnection/UrlConnectionHttpClient.java @@ -64,6 +64,8 @@ @SdkPublicApi public final class UrlConnectionHttpClient implements SdkHttpClient { + private static final String CLIENT_NAME = "UrlConnection"; + private final AttributeMap options; private final UrlConnectionFactory connectionFactory; @@ -112,6 +114,11 @@ public void close() { // Nothing to close. The connections will be closed by closing the InputStreams. } + @Override + public String clientName() { + return CLIENT_NAME; + } + private HttpURLConnection createAndConfigureConnection(HttpExecuteRequest request) { HttpURLConnection connection = connectionFactory.createConnection(request.httpRequest().getUri()); request.httpRequest() diff --git a/services/s3/src/it/java/software/amazon/awssdk/services/s3/S3IntegrationTestBase.java b/services/s3/src/it/java/software/amazon/awssdk/services/s3/S3IntegrationTestBase.java index 8257def86d63..cc2700609c53 100644 --- a/services/s3/src/it/java/software/amazon/awssdk/services/s3/S3IntegrationTestBase.java +++ b/services/s3/src/it/java/software/amazon/awssdk/services/s3/S3IntegrationTestBase.java @@ -15,14 +15,20 @@ package software.amazon.awssdk.services.s3; +import static org.assertj.core.api.Assertions.assertThat; + import org.junit.BeforeClass; +import software.amazon.awssdk.core.ClientType; +import software.amazon.awssdk.core.interceptor.Context; +import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.interceptor.ExecutionInterceptor; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.model.BucketLocationConstraint; import software.amazon.awssdk.services.s3.model.CreateBucketConfiguration; import software.amazon.awssdk.services.s3.model.CreateBucketRequest; import software.amazon.awssdk.services.s3.model.S3Exception; -import software.amazon.awssdk.testutils.service.AwsTestBase; import software.amazon.awssdk.services.s3.utils.S3TestUtils; +import software.amazon.awssdk.testutils.service.AwsTestBase; /** * Base class for S3 integration tests. Loads AWS credentials from a properties @@ -51,13 +57,17 @@ public static void setUp() throws Exception { protected static S3ClientBuilder s3ClientBuilder() { return S3Client.builder() .region(DEFAULT_REGION) - .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN); + .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN) + .overrideConfiguration(o -> o.addExecutionInterceptor( + new UserAgentVerifyingExecutionInterceptor("Apache", ClientType.SYNC))); } protected static S3AsyncClientBuilder s3AsyncClientBuilder() { return S3AsyncClient.builder() .region(DEFAULT_REGION) - .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN); + .credentialsProvider(CREDENTIALS_PROVIDER_CHAIN) + .overrideConfiguration(o -> o.addExecutionInterceptor( + new UserAgentVerifyingExecutionInterceptor("NettyNio", ClientType.ASYNC))); } protected static void createBucket(String bucketName) { @@ -67,13 +77,13 @@ protected static void createBucket(String bucketName) { private static void createBucket(String bucketName, int retryCount) { try { s3.createBucket( - CreateBucketRequest.builder() - .bucket(bucketName) - .createBucketConfiguration( - CreateBucketConfiguration.builder() - .locationConstraint(BucketLocationConstraint.US_WEST_2) - .build()) - .build()); + CreateBucketRequest.builder() + .bucket(bucketName) + .createBucketConfiguration( + CreateBucketConfiguration.builder() + .locationConstraint(BucketLocationConstraint.US_WEST_2) + .build()) + .build()); } catch (S3Exception e) { System.err.println("Error attempting to create bucket: " + bucketName); if (e.awsErrorDetails().errorCode().equals("BucketAlreadyOwnedByYou")) { @@ -96,4 +106,21 @@ private static void createBucket(String bucketName, int retryCount) { protected static void deleteBucketAndAllContents(String bucketName) { S3TestUtils.deleteBucketAndAllContents(s3, bucketName); } + + private static class UserAgentVerifyingExecutionInterceptor implements ExecutionInterceptor { + + private final String clientName; + private final ClientType clientType; + + public UserAgentVerifyingExecutionInterceptor(String clientName, ClientType clientType) { + this.clientName = clientName; + this.clientType = clientType; + } + + @Override + public void beforeTransmission(Context.BeforeTransmission context, ExecutionAttributes executionAttributes) { + assertThat(context.httpRequest().firstMatchingHeader("User-Agent").get()).containsIgnoringCase("io/" + clientType.name()); + assertThat(context.httpRequest().firstMatchingHeader("User-Agent").get()).containsIgnoringCase("http/" + clientName); + } + } }