diff --git a/.changes/next-release/feature-AWSSDKforJavav2-2dc1b10.json b/.changes/next-release/feature-AWSSDKforJavav2-2dc1b10.json new file mode 100644 index 000000000000..e7ee3c0fadd5 --- /dev/null +++ b/.changes/next-release/feature-AWSSDKforJavav2-2dc1b10.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "AWS SDK for Java v2", + "contributor": "", + "description": "Implemented business metrics tracking for S3_Express_Bucket (feature ID \"J\") and S3_Access_Grants plugin (feature ID \"K\") through User-Agent header." +} diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/useragent/BusinessMetricFeatureId.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/useragent/BusinessMetricFeatureId.java index 7f1483d56895..ea9d17359d5e 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/useragent/BusinessMetricFeatureId.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/useragent/BusinessMetricFeatureId.java @@ -34,6 +34,8 @@ public enum BusinessMetricFeatureId { RETRY_MODE_STANDARD("E"), RETRY_MODE_ADAPTIVE("F"), S3_TRANSFER("G"), + S3_EXPRESS_BUCKET("J"), + S3_ACCESS_GRANTS("K"), GZIP_REQUEST_COMPRESSION("L"), //TODO(metrics): Not working, compression happens after header ENDPOINT_OVERRIDE("N"), ACCOUNT_ID_MODE_PREFERRED("P"), diff --git a/services/s3/src/it/java/software/amazon/awssdk/services/s3/S3AccessGrantsUserAgentIntegrationTest.java b/services/s3/src/it/java/software/amazon/awssdk/services/s3/S3AccessGrantsUserAgentIntegrationTest.java new file mode 100644 index 000000000000..bb067d470fd3 --- /dev/null +++ b/services/s3/src/it/java/software/amazon/awssdk/services/s3/S3AccessGrantsUserAgentIntegrationTest.java @@ -0,0 +1,240 @@ +/* + * 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.services.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static software.amazon.awssdk.testutils.service.S3BucketUtils.temporaryBucketName; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +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.core.sync.ResponseTransformer; +import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; +import software.amazon.awssdk.identity.spi.IdentityProvider; +import software.amazon.awssdk.identity.spi.IdentityProviders; +import software.amazon.awssdk.identity.spi.ResolveIdentityRequest; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +/** + * Integration test to verify that S3 Access Grants operations include the correct business metric feature ID + * in the User-Agent header for tracking purposes. + */ +public class S3AccessGrantsUserAgentIntegrationTest extends S3IntegrationTestBase { + private static final String KEY = "test-s3-access-grants-feature-id.txt"; + private static final String CONTENTS = "test content for S3 Access Grants feature id validation"; + private static final UserAgentCapturingInterceptor userAgentInterceptor = new UserAgentCapturingInterceptor(); + + private static S3Client s3WithAccessGrants; + private static S3Client regularS3; + private static String testBucket; + + @BeforeAll + static void setup() { + testBucket = temporaryBucketName(S3AccessGrantsUserAgentIntegrationTest.class); + + s3WithAccessGrants = S3Client.builder() + .region(Region.US_EAST_1) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create("test", "test"))) + .overrideConfiguration(o -> o.addExecutionInterceptor(userAgentInterceptor) + .addExecutionInterceptor(new S3AccessGrantsSimulatorInterceptor())) + .build(); + + regularS3 = S3Client.builder() + .region(Region.US_EAST_1) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create("test", "test"))) + .overrideConfiguration(o -> o.addExecutionInterceptor(userAgentInterceptor)) + .build(); + + regularS3.createBucket(b -> b.bucket(testBucket)); + regularS3.waiter().waitUntilBucketExists(r -> r.bucket(testBucket)); + } + + @AfterAll + static void teardown() { + try { + deleteBucketAndAllContents(testBucket); + } catch (Exception e) { } + + if (s3WithAccessGrants != null) { + s3WithAccessGrants.close(); + } + if (regularS3 != null) { + regularS3.close(); + } + } + + @BeforeEach + void reset() { + userAgentInterceptor.reset(); + } + + @Test + void putObject_whenS3AccessGrantsPluginActive_shouldIncludeS3AccessGrantsFeatureIdInUserAgent() { + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(testBucket) + .key(KEY) + .build(); + + s3WithAccessGrants.putObject(putRequest, RequestBody.fromString(CONTENTS)); + + List capturedUserAgents = userAgentInterceptor.getCapturedUserAgents(); + assertThat(capturedUserAgents).hasSize(1); + + String userAgent = capturedUserAgents.get(0); + assertThat(userAgent).isNotNull(); + + // Verify the User-Agent contains the S3 Access Grants business metric feature ID + String expectedFeatureId = BusinessMetricFeatureId.S3_ACCESS_GRANTS.value(); + assertThat(userAgent).containsPattern("m/([A-Za-z0-9+\\-]+,)*" + expectedFeatureId + "(,[A-Za-z0-9+\\-]+)*"); + + userAgentInterceptor.reset(); + + GetObjectRequest getRequest = GetObjectRequest.builder() + .bucket(testBucket) + .key(KEY) + .build(); + + s3WithAccessGrants.getObject(getRequest, ResponseTransformer.toBytes()); + + capturedUserAgents = userAgentInterceptor.getCapturedUserAgents(); + assertThat(capturedUserAgents).hasSize(1); + + userAgent = capturedUserAgents.get(0); + assertThat(userAgent).isNotNull(); + assertThat(userAgent).containsPattern("m/([A-Za-z0-9+\\-]+,)*" + expectedFeatureId + "(,[A-Za-z0-9+\\-]+)*"); + } + + @Test + void putObject_whenS3AccessGrantsPluginNotActive_shouldNotIncludeS3AccessGrantsFeatureIdInUserAgent() { + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(testBucket) + .key(KEY + "-regular") + .build(); + + regularS3.putObject(putRequest, RequestBody.fromString(CONTENTS)); + + List capturedUserAgents = userAgentInterceptor.getCapturedUserAgents(); + assertThat(capturedUserAgents).hasSize(1); + + String userAgent = capturedUserAgents.get(0); + assertThat(userAgent).isNotNull(); + + // Verify the User-Agent does not contain the S3 Access Grants business metric feature ID + String s3AccessGrantsFeatureId = BusinessMetricFeatureId.S3_ACCESS_GRANTS.value(); + assertThat(userAgent).doesNotMatch(".*m/([A-Za-z0-9+\\-]+,)*" + s3AccessGrantsFeatureId + "(,[A-Za-z0-9+\\-]+)*.*"); + + userAgentInterceptor.reset(); + + GetObjectRequest getRequest = GetObjectRequest.builder() + .bucket(testBucket) + .key(KEY + "-regular") + .build(); + + regularS3.getObject(getRequest, ResponseTransformer.toBytes()); + + capturedUserAgents = userAgentInterceptor.getCapturedUserAgents(); + assertThat(capturedUserAgents).hasSize(1); + + userAgent = capturedUserAgents.get(0); + assertThat(userAgent).isNotNull(); + assertThat(userAgent).doesNotMatch(".*m/([A-Za-z0-9+\\-]+,)*" + s3AccessGrantsFeatureId + "(,[A-Za-z0-9+\\-]+)*.*"); + } + + /** + * Interceptor to capture User-Agent headers from HTTP requests + */ + private static class UserAgentCapturingInterceptor implements ExecutionInterceptor { + private final List capturedUserAgents = new ArrayList<>(); + private final AtomicReference lastUserAgent = new AtomicReference<>(); + + @Override + public void beforeTransmission(Context.BeforeTransmission context, ExecutionAttributes executionAttributes) { + SdkHttpRequest httpRequest = context.httpRequest(); + List userAgentHeaders = httpRequest.headers().get("User-Agent"); + + if (userAgentHeaders != null && !userAgentHeaders.isEmpty()) { + String userAgent = userAgentHeaders.get(0); + capturedUserAgents.add(userAgent); + lastUserAgent.set(userAgent); + } + } + + public List getCapturedUserAgents() { + return new ArrayList<>(capturedUserAgents); + } + + public String getLastUserAgent() { + return lastUserAgent.get(); + } + + public void reset() { + capturedUserAgents.clear(); + lastUserAgent.set(null); + } + } + + /** + * Interceptor that simulates the presence of S3 Access Grants plugin by injecting + * a mock identity provider with the expected class name pattern. + */ + private static class S3AccessGrantsSimulatorInterceptor implements ExecutionInterceptor { + @Override + public void beforeExecution(Context.BeforeExecution context, ExecutionAttributes executionAttributes) { + IdentityProvider mockS3AccessGrantsProvider = new MockS3AccessGrantsIdentityProvider(); + + IdentityProviders identityProviders = IdentityProviders.builder() + .putIdentityProvider(mockS3AccessGrantsProvider) + .build(); + + executionAttributes.putAttribute( + software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute.IDENTITY_PROVIDERS, + identityProviders + ); + } + } + + private static class MockS3AccessGrantsIdentityProvider implements IdentityProvider { + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + + @Override + public CompletableFuture resolveIdentity(ResolveIdentityRequest request) { + // Return basic credentials for testing + return CompletableFuture.completedFuture( + AwsCredentialsIdentity.create("test-access-key", "test-secret-key") + ); + } + } +} diff --git a/services/s3/src/it/java/software/amazon/awssdk/services/s3/s3express/S3ExpressUserAgentIntegrationTest.java b/services/s3/src/it/java/software/amazon/awssdk/services/s3/s3express/S3ExpressUserAgentIntegrationTest.java new file mode 100644 index 000000000000..c87d530e4dfe --- /dev/null +++ b/services/s3/src/it/java/software/amazon/awssdk/services/s3/s3express/S3ExpressUserAgentIntegrationTest.java @@ -0,0 +1,208 @@ +/* + * 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.services.s3.s3express; + +import static org.assertj.core.api.Assertions.assertThat; +import static software.amazon.awssdk.testutils.service.S3BucketUtils.temporaryBucketName; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.core.sync.ResponseTransformer; +import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +/** + * Integration test to verify that S3 Express operations include the correct business metric feature ID + * in the User-Agent header for tracking purposes. + */ +public class S3ExpressUserAgentIntegrationTest extends S3ExpressIntegrationTestBase { + private static final String KEY = "test-feature-id.txt"; + private static final String CONTENTS = "test content for feature id validation"; + private static final Region TEST_REGION = Region.US_EAST_1; + private static final String AZ = "use1-az4"; + private static final UserAgentCapturingInterceptor userAgentInterceptor = new UserAgentCapturingInterceptor(); + + private static S3Client s3; + private static S3Client regularS3; + private static String s3ExpressBucket; + private static String regularBucket; + + private static final String S3EXPRESS_BUCKET_PATTERN = temporaryBucketName(S3ExpressUserAgentIntegrationTest.class) + "--%s--x-s3"; + private static final String REGULAR_BUCKET_PATTERN = temporaryBucketName(S3ExpressUserAgentIntegrationTest.class) + "-regular"; + + @BeforeAll + static void setup() { + s3 = s3ClientBuilder(TEST_REGION) + .overrideConfiguration(o -> o.addExecutionInterceptor(userAgentInterceptor)) + .build(); + + regularS3 = s3ClientBuilder(TEST_REGION) + .overrideConfiguration(o -> o.addExecutionInterceptor(userAgentInterceptor)) + .build(); + + s3ExpressBucket = String.format(S3EXPRESS_BUCKET_PATTERN, AZ); + regularBucket = REGULAR_BUCKET_PATTERN; + + // Create S3 Express bucket + createBucketS3Express(s3, s3ExpressBucket, AZ); + + // Create regular S3 bucket + regularS3.createBucket(b -> b.bucket(regularBucket)); + regularS3.waiter().waitUntilBucketExists(r -> r.bucket(regularBucket)); + } + + @AfterAll + static void teardown() { + try { + deleteBucketAndAllContents(s3, s3ExpressBucket); + } catch (Exception e) { + System.err.println("Failed to delete S3 Express bucket: " + e.getMessage()); + } + + try { + deleteBucketAndAllContents(regularS3, regularBucket); + } catch (Exception e) { + System.err.println("Failed to delete regular bucket: " + e.getMessage()); + } + + s3.close(); + regularS3.close(); + } + + @BeforeEach + void reset() { + userAgentInterceptor.reset(); + } + + @Test + void putObject_whenS3ExpressBucket_shouldIncludeS3ExpressFeatureIdInUserAgent() { + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(s3ExpressBucket) + .key(KEY) + .build(); + + s3.putObject(putRequest, RequestBody.fromString(CONTENTS)); + + List capturedUserAgents = userAgentInterceptor.getCapturedUserAgents(); + assertThat(capturedUserAgents).hasSize(1); + + String userAgent = capturedUserAgents.get(0); + assertThat(userAgent).isNotNull(); + + // Verify the User-Agent contains the S3 Express business metric feature ID + String expectedFeatureId = BusinessMetricFeatureId.S3_EXPRESS_BUCKET.value(); + assertThat(userAgent).containsPattern("m/([A-Za-z0-9+\\-]+,)*" + expectedFeatureId + "(,[A-Za-z0-9+\\-]+)*"); + + userAgentInterceptor.reset(); + + GetObjectRequest getRequest = GetObjectRequest.builder() + .bucket(s3ExpressBucket) + .key(KEY) + .build(); + + s3.getObject(getRequest, ResponseTransformer.toBytes()); + + capturedUserAgents = userAgentInterceptor.getCapturedUserAgents(); + assertThat(capturedUserAgents).hasSize(1); + + userAgent = capturedUserAgents.get(0); + assertThat(userAgent).isNotNull(); + assertThat(userAgent).containsPattern("m/([A-Za-z0-9+\\-]+,)*" + expectedFeatureId + "(,[A-Za-z0-9+\\-]+)*"); + } + + @Test + void putObject_whenRegularS3Bucket_shouldNotIncludeS3ExpressFeatureIdInUserAgent() { + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(regularBucket) + .key(KEY) + .build(); + + regularS3.putObject(putRequest, RequestBody.fromString(CONTENTS)); + + List capturedUserAgents = userAgentInterceptor.getCapturedUserAgents(); + assertThat(capturedUserAgents).hasSize(1); + + String userAgent = capturedUserAgents.get(0); + assertThat(userAgent).isNotNull(); + + // Verify the User-Agent does not contain the S3 Express business metric feature ID + String s3ExpressFeatureId = BusinessMetricFeatureId.S3_EXPRESS_BUCKET.value(); + assertThat(userAgent).doesNotMatch(".*m/([A-Za-z0-9+\\-]+,)*" + s3ExpressFeatureId + "(,[A-Za-z0-9+\\-]+)*.*"); + + + userAgentInterceptor.reset(); + + GetObjectRequest getRequest = GetObjectRequest.builder() + .bucket(regularBucket) + .key(KEY) + .build(); + + regularS3.getObject(getRequest, ResponseTransformer.toBytes()); + + capturedUserAgents = userAgentInterceptor.getCapturedUserAgents(); + assertThat(capturedUserAgents).hasSize(1); + + userAgent = capturedUserAgents.get(0); + assertThat(userAgent).isNotNull(); + assertThat(userAgent).doesNotMatch(".*m/([A-Za-z0-9+\\-]+,)*" + s3ExpressFeatureId + "(,[A-Za-z0-9+\\-]+)*.*"); + } + + /** + * Interceptor to capture User-Agent headers from HTTP requests + */ + private static class UserAgentCapturingInterceptor implements ExecutionInterceptor { + private final List capturedUserAgents = new ArrayList<>(); + private final AtomicReference lastUserAgent = new AtomicReference<>(); + + @Override + public void beforeTransmission(Context.BeforeTransmission context, ExecutionAttributes executionAttributes) { + SdkHttpRequest httpRequest = context.httpRequest(); + List userAgentHeaders = httpRequest.headers().get("User-Agent"); + + if (userAgentHeaders != null && !userAgentHeaders.isEmpty()) { + String userAgent = userAgentHeaders.get(0); + capturedUserAgents.add(userAgent); + lastUserAgent.set(userAgent); + } + } + + public List getCapturedUserAgents() { + return new ArrayList<>(capturedUserAgents); + } + + public String getLastUserAgent() { + return lastUserAgent.get(); + } + + public void reset() { + capturedUserAgents.clear(); + lastUserAgent.set(null); + } + } +} diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/S3AccessGrantsUserAgentInterceptor.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/S3AccessGrantsUserAgentInterceptor.java new file mode 100644 index 000000000000..bb3d21cace6a --- /dev/null +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/S3AccessGrantsUserAgentInterceptor.java @@ -0,0 +1,96 @@ +/* + * 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.services.s3.internal.handlers; + +import java.util.function.Consumer; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.awscore.AwsRequestOverrideConfiguration; +import software.amazon.awssdk.core.ApiName; +import software.amazon.awssdk.core.SdkRequest; +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.interceptor.SdkInternalExecutionAttribute; +import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; +import software.amazon.awssdk.identity.spi.IdentityProvider; +import software.amazon.awssdk.identity.spi.IdentityProviders; +import software.amazon.awssdk.services.s3.model.S3Request; + +/** + * Interceptor that adds business metrics for S3 Access Grants operations. + */ +@SdkInternalApi +public final class S3AccessGrantsUserAgentInterceptor implements ExecutionInterceptor { + + private static final ApiName S3_ACCESS_GRANTS_API_NAME = ApiName.builder() + .name("sdk-metrics") + .version(BusinessMetricFeatureId.S3_ACCESS_GRANTS.value()) + .build(); + + private static final Consumer S3_ACCESS_GRANTS_USER_AGENT_APPLIER = + b -> b.addApiName(S3_ACCESS_GRANTS_API_NAME); + + @Override + public SdkRequest modifyRequest(Context.ModifyRequest context, ExecutionAttributes executionAttributes) { + SdkRequest request = context.request(); + + if (request instanceof S3Request) { + S3Request s3Request = (S3Request) request; + + if (isS3AccessGrantsActive(executionAttributes)) { + AwsRequestOverrideConfiguration overrideConfiguration = + s3Request.overrideConfiguration() + .map(c -> c.toBuilder() + .applyMutation(S3_ACCESS_GRANTS_USER_AGENT_APPLIER) + .build()) + .orElseGet(() -> AwsRequestOverrideConfiguration.builder() + .applyMutation(S3_ACCESS_GRANTS_USER_AGENT_APPLIER) + .build()); + + return s3Request.toBuilder().overrideConfiguration(overrideConfiguration).build(); + } + } + + return request; + } + + /** + * Determines if S3 Access Grants plugin is active by checking if the identity provider + * is an instance of S3AccessGrantsIdentityProvider. + */ + private boolean isS3AccessGrantsActive(ExecutionAttributes executionAttributes) { + try { + IdentityProviders identityProviders = + executionAttributes.getAttribute(SdkInternalExecutionAttribute.IDENTITY_PROVIDERS); + if (identityProviders == null) { + return false; + } + + IdentityProvider credentialsProvider = + identityProviders.identityProvider(AwsCredentialsIdentity.class); + + if (credentialsProvider == null) { + return false; + } + + String providerClassName = credentialsProvider.getClass().getName(); + return providerClassName.contains("S3AccessGrantsIdentityProvider"); + } catch (Exception e) { + return false; + } + } +} diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/S3ExpressUserAgentInterceptor.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/S3ExpressUserAgentInterceptor.java new file mode 100644 index 000000000000..6a5447c9940c --- /dev/null +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/S3ExpressUserAgentInterceptor.java @@ -0,0 +1,72 @@ +/* + * 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.services.s3.internal.handlers; + +import java.util.function.Consumer; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.awscore.AwsRequestOverrideConfiguration; +import software.amazon.awssdk.core.ApiName; +import software.amazon.awssdk.core.SdkRequest; +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.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.services.s3.internal.s3express.S3ExpressUtils; +import software.amazon.awssdk.services.s3.model.S3Request; + +/** + * Interceptor that adds business metrics for S3 Express bucket operations. + * This interceptor detects when an operation is performed on an S3 Express bucket + * using S3 Express credentials and adds the appropriate business metric to track usage. + */ + +@SdkInternalApi +public final class S3ExpressUserAgentInterceptor implements ExecutionInterceptor { + + private static final ApiName S3_EXPRESS_API_NAME = ApiName.builder() + .name("sdk-metrics") + .version(BusinessMetricFeatureId.S3_EXPRESS_BUCKET.value()) + .build(); + + private static final Consumer S3_EXPRESS_USER_AGENT_APPLIER = + b -> b.addApiName(S3_EXPRESS_API_NAME); + + @Override + public SdkRequest modifyRequest(Context.ModifyRequest context, ExecutionAttributes executionAttributes) { + SdkRequest request = context.request(); + + if (request instanceof S3Request) { + S3Request s3Request = (S3Request) request; + + if (S3ExpressUtils.useS3Express(executionAttributes) && + S3ExpressUtils.useS3ExpressAuthScheme(executionAttributes)) { + + AwsRequestOverrideConfiguration overrideConfiguration = + s3Request.overrideConfiguration() + .map(c -> c.toBuilder() + .applyMutation(S3_EXPRESS_USER_AGENT_APPLIER) + .build()) + .orElseGet(() -> AwsRequestOverrideConfiguration.builder() + .applyMutation(S3_EXPRESS_USER_AGENT_APPLIER) + .build()); + + return s3Request.toBuilder().overrideConfiguration(overrideConfiguration).build(); + } + } + + return request; + } +} diff --git a/services/s3/src/main/resources/software/amazon/awssdk/services/s3/execution.interceptors b/services/s3/src/main/resources/software/amazon/awssdk/services/s3/execution.interceptors new file mode 100644 index 000000000000..6a41348bbd3b --- /dev/null +++ b/services/s3/src/main/resources/software/amazon/awssdk/services/s3/execution.interceptors @@ -0,0 +1,2 @@ +software.amazon.awssdk.services.s3.internal.handlers.S3ExpressUserAgentInterceptor +software.amazon.awssdk.services.s3.internal.handlers.S3AccessGrantsUserAgentInterceptor diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/handlers/S3AccessGrantsUserAgentInterceptorTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/handlers/S3AccessGrantsUserAgentInterceptorTest.java new file mode 100644 index 000000000000..bb521babac33 --- /dev/null +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/handlers/S3AccessGrantsUserAgentInterceptorTest.java @@ -0,0 +1,123 @@ +/* + * 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.services.s3.internal.handlers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.function.Predicate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.ApiName; +import software.amazon.awssdk.core.RequestOverrideConfiguration; +import software.amazon.awssdk.core.SdkRequest; +import software.amazon.awssdk.core.interceptor.Context; +import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute; +import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; +import software.amazon.awssdk.identity.spi.IdentityProvider; +import software.amazon.awssdk.identity.spi.IdentityProviders; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; + +class S3AccessGrantsUserAgentInterceptorTest { + + private S3AccessGrantsUserAgentInterceptor interceptor; + private ExecutionAttributes executionAttributes; + private Context.ModifyRequest context; + + @BeforeEach + void setUp() { + interceptor = new S3AccessGrantsUserAgentInterceptor(); + executionAttributes = new ExecutionAttributes(); + context = mock(Context.ModifyRequest.class); + } + + @Test + void modifyRequest_whenS3AccessGrantsPluginActive_shouldAddS3AccessGrantsApiName() { + GetObjectRequest s3Request = GetObjectRequest.builder().build(); + when(context.request()).thenReturn(s3Request); + + IdentityProvider mockProvider = createMockS3AccessGrantsProvider(); + IdentityProviders identityProviders = IdentityProviders.builder() + .putIdentityProvider(mockProvider) + .build(); + executionAttributes.putAttribute(SdkInternalExecutionAttribute.IDENTITY_PROVIDERS, identityProviders); + + SdkRequest modifiedRequest = interceptor.modifyRequest(context, executionAttributes); + + RequestOverrideConfiguration requestOverrideConfiguration = modifiedRequest.overrideConfiguration().get(); + Predicate apiNamePredicate = a -> a.name().equals("sdk-metrics") && + a.version().equals(BusinessMetricFeatureId.S3_ACCESS_GRANTS.value()); + assertThat(requestOverrideConfiguration.apiNames().stream().anyMatch(apiNamePredicate)).isTrue(); + } + + @Test + void modifyRequest_whenRegularS3Operation_shouldNotAddS3AccessGrantsApiName() { + GetObjectRequest s3Request = GetObjectRequest.builder().build(); + when(context.request()).thenReturn(s3Request); + + IdentityProvider mockProvider = createMockRegularProvider(); + IdentityProviders identityProviders = IdentityProviders.builder() + .putIdentityProvider(mockProvider) + .build(); + executionAttributes.putAttribute(SdkInternalExecutionAttribute.IDENTITY_PROVIDERS, identityProviders); + + SdkRequest modifiedRequest = interceptor.modifyRequest(context, executionAttributes); + + assertThat(modifiedRequest.overrideConfiguration()).isEmpty(); + } + + @Test + void modifyRequest_whenNoCredentialsProvider_shouldNotAddS3AccessGrantsApiName() { + GetObjectRequest s3Request = GetObjectRequest.builder().build(); + when(context.request()).thenReturn(s3Request); + + // Empty identity providers + IdentityProviders identityProviders = IdentityProviders.builder().build(); + executionAttributes.putAttribute(SdkInternalExecutionAttribute.IDENTITY_PROVIDERS, identityProviders); + + SdkRequest modifiedRequest = interceptor.modifyRequest(context, executionAttributes); + + assertThat(modifiedRequest.overrideConfiguration()).isEmpty(); + } + + private IdentityProvider createMockS3AccessGrantsProvider() { + return new MockS3AccessGrantsIdentityProvider(); + } + + private IdentityProvider createMockRegularProvider() { + return mock(IdentityProvider.class); + } + + + /** + * Mock implementation that simulates S3AccessGrantsIdentityProvider + */ + private static class MockS3AccessGrantsIdentityProvider implements IdentityProvider { + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + + @Override + public java.util.concurrent.CompletableFuture resolveIdentity( + software.amazon.awssdk.identity.spi.ResolveIdentityRequest request) { + return null; + } + } +} diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/handlers/S3ExpressUserAgentInterceptorTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/handlers/S3ExpressUserAgentInterceptorTest.java new file mode 100644 index 000000000000..122e32f6f15d --- /dev/null +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/handlers/S3ExpressUserAgentInterceptorTest.java @@ -0,0 +1,125 @@ +/* + * 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.services.s3.internal.handlers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import software.amazon.awssdk.core.ApiName; +import software.amazon.awssdk.core.RequestOverrideConfiguration; +import software.amazon.awssdk.core.SdkField; +import software.amazon.awssdk.core.SdkRequest; +import software.amazon.awssdk.core.interceptor.Context; +import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.services.s3.internal.s3express.S3ExpressUtils; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; + +class S3ExpressUserAgentInterceptorTest { + + private S3ExpressUserAgentInterceptor interceptor; + private ExecutionAttributes executionAttributes; + private Context.ModifyRequest context; + + @BeforeEach + void setUp() { + interceptor = new S3ExpressUserAgentInterceptor(); + executionAttributes = new ExecutionAttributes(); + context = mock(Context.ModifyRequest.class); + } + + @Test + void modifyRequest_whenS3ExpressOperationWithBothConditions_shouldAddS3ExpressApiName() { + GetObjectRequest s3Request = GetObjectRequest.builder().build(); + when(context.request()).thenReturn(s3Request); + + try (MockedStatic mockedS3ExpressUtils = mockStatic(S3ExpressUtils.class)) { + mockedS3ExpressUtils.when(() -> S3ExpressUtils.useS3Express(executionAttributes)).thenReturn(true); + mockedS3ExpressUtils.when(() -> S3ExpressUtils.useS3ExpressAuthScheme(executionAttributes)).thenReturn(true); + + SdkRequest modifiedRequest = interceptor.modifyRequest(context, executionAttributes); + + RequestOverrideConfiguration requestOverrideConfiguration = modifiedRequest.overrideConfiguration().get(); + Predicate apiNamePredicate = a -> a.name().equals("sdk-metrics") && + a.version().equals(BusinessMetricFeatureId.S3_EXPRESS_BUCKET.value()); + assertThat(requestOverrideConfiguration.apiNames().stream().anyMatch(apiNamePredicate)).isTrue(); + } + } + + @Test + void modifyRequest_whenRegularS3Operation_shouldNotAddS3ExpressApiName() { + GetObjectRequest s3Request = GetObjectRequest.builder().build(); + when(context.request()).thenReturn(s3Request); + + try (MockedStatic mockedS3ExpressUtils = mockStatic(S3ExpressUtils.class)) { + mockedS3ExpressUtils.when(() -> S3ExpressUtils.useS3Express(executionAttributes)).thenReturn(false); + mockedS3ExpressUtils.when(() -> S3ExpressUtils.useS3ExpressAuthScheme(executionAttributes)).thenReturn(false); + + SdkRequest modifiedRequest = interceptor.modifyRequest(context, executionAttributes); + + assertThat(modifiedRequest.overrideConfiguration()).isEmpty(); + } + } + + @Test + void modifyRequest_whenS3ExpressEndpointWithoutS3ExpressAuth_shouldNotAddS3ExpressApiName() { + GetObjectRequest s3Request = GetObjectRequest.builder().build(); + when(context.request()).thenReturn(s3Request); + + try (MockedStatic mockedS3ExpressUtils = mockStatic(S3ExpressUtils.class)) { + mockedS3ExpressUtils.when(() -> S3ExpressUtils.useS3Express(executionAttributes)).thenReturn(true); + mockedS3ExpressUtils.when(() -> S3ExpressUtils.useS3ExpressAuthScheme(executionAttributes)).thenReturn(false); + + SdkRequest modifiedRequest = interceptor.modifyRequest(context, executionAttributes); + + assertThat(modifiedRequest.overrideConfiguration()).isEmpty(); + } + } + + @Test + void modifyRequest_whenNonS3Request_shouldNotBeModified() { + SdkRequest nonS3Request = new SdkRequest() { + @Override + public List> sdkFields() { + return null; + } + + @Override + public Optional overrideConfiguration() { + return Optional.empty(); + } + + @Override + public Builder toBuilder() { + return null; + } + }; + when(context.request()).thenReturn(nonS3Request); + + SdkRequest modifiedRequest = interceptor.modifyRequest(context, executionAttributes); + + assertThat(modifiedRequest).isSameAs(nonS3Request); + } + +}