diff --git a/.changes/next-release/feature-AmazonS3-9b198d0.json b/.changes/next-release/feature-AmazonS3-9b198d0.json new file mode 100644 index 000000000000..7bc4aa7703b0 --- /dev/null +++ b/.changes/next-release/feature-AmazonS3-9b198d0.json @@ -0,0 +1,6 @@ +{ + "category": "Amazon S3", + "contributor": "", + "type": "feature", + "description": "Add support for more user-friendly CopyObject source parameters" +} diff --git a/services/s3/src/it/java/software/amazon/awssdk/services/s3/CopySourceIntegrationTest.java b/services/s3/src/it/java/software/amazon/awssdk/services/s3/CopySourceIntegrationTest.java new file mode 100644 index 000000000000..1b47754040d4 --- /dev/null +++ b/services/s3/src/it/java/software/amazon/awssdk/services/s3/CopySourceIntegrationTest.java @@ -0,0 +1,150 @@ +/* + * 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.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static software.amazon.awssdk.testutils.service.S3BucketUtils.temporaryBucketName; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.internal.handlers.CopySourceInterceptor; +import software.amazon.awssdk.services.s3.model.BucketVersioningStatus; +import software.amazon.awssdk.services.s3.model.CopyObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; + +/** + * Integration tests for the {@code sourceBucket}, {@code sourceKey}, and {@code sourceVersionId} parameters for + * {@link CopyObjectRequest}. Specifically, we ensure that users are able to seamlessly use the same input for both the + * {@link PutObjectRequest} key and the {@link CopyObjectRequest} source key (and not be required to manually URL encode the + * COPY source key). This also effectively tests for parity with the SDK v1 behavior. + * + * @see CopySourceInterceptor + */ +@RunWith(Parameterized.class) +public class CopySourceIntegrationTest extends S3IntegrationTestBase { + + private static final String SOURCE_UNVERSIONED_BUCKET_NAME = temporaryBucketName("copy-source-integ-test-src"); + private static final String SOURCE_VERSIONED_BUCKET_NAME = temporaryBucketName("copy-source-integ-test-versioned-src"); + private static final String DESTINATION_BUCKET_NAME = temporaryBucketName("copy-source-integ-test-dest"); + + @BeforeClass + public static void initializeTestData() throws Exception { + createBucket(SOURCE_UNVERSIONED_BUCKET_NAME); + createBucket(SOURCE_VERSIONED_BUCKET_NAME); + s3.putBucketVersioning(r -> r + .bucket(SOURCE_VERSIONED_BUCKET_NAME) + .versioningConfiguration(v -> v.status(BucketVersioningStatus.ENABLED))); + createBucket(DESTINATION_BUCKET_NAME); + } + + @AfterClass + public static void tearDown() { + deleteBucketAndAllContents(SOURCE_UNVERSIONED_BUCKET_NAME); + deleteBucketAndAllContents(SOURCE_VERSIONED_BUCKET_NAME); + deleteBucketAndAllContents(DESTINATION_BUCKET_NAME); + } + + @Parameters + public static Collection parameters() throws Exception { + return Arrays.asList( + "simpleKey", + "key/with/slashes", + "\uD83E\uDEA3", + "specialChars/ +!#$&'()*,:;=?@\"", + "%20" + ); + } + + private final String key; + + public CopySourceIntegrationTest(String key) { + this.key = key; + } + + @Test + public void copyObject_WithoutVersion_AcceptsSameKeyAsPut() throws Exception { + String originalContent = UUID.randomUUID().toString(); + + s3.putObject(PutObjectRequest.builder() + .bucket(SOURCE_UNVERSIONED_BUCKET_NAME) + .key(key) + .build(), RequestBody.fromString(originalContent, StandardCharsets.UTF_8)); + + s3.copyObject(CopyObjectRequest.builder() + .sourceBucket(SOURCE_UNVERSIONED_BUCKET_NAME) + .sourceKey(key) + .destinationBucket(DESTINATION_BUCKET_NAME) + .destinationKey(key) + .build()); + + String copiedContent = s3.getObjectAsBytes(GetObjectRequest.builder() + .bucket(DESTINATION_BUCKET_NAME) + .key(key) + .build()).asUtf8String(); + + assertThat(copiedContent, is(originalContent)); + } + + /** + * Test that we can correctly copy versioned source objects. + *

+ * Motivated by: https://github.com/aws/aws-sdk-js/issues/727 + */ + @Test + public void copyObject_WithVersion_AcceptsSameKeyAsPut() throws Exception { + Map versionToContentMap = new HashMap<>(); + int numVersionsToCreate = 3; + for (int i = 0; i < numVersionsToCreate; i++) { + String originalContent = UUID.randomUUID().toString(); + PutObjectResponse response = s3.putObject(PutObjectRequest.builder() + .bucket(SOURCE_VERSIONED_BUCKET_NAME) + .key(key) + .build(), + RequestBody.fromString(originalContent, StandardCharsets.UTF_8)); + versionToContentMap.put(response.versionId(), originalContent); + } + + versionToContentMap.forEach((versionId, originalContent) -> { + s3.copyObject(CopyObjectRequest.builder() + .sourceBucket(SOURCE_VERSIONED_BUCKET_NAME) + .sourceKey(key) + .sourceVersionId(versionId) + .destinationBucket(DESTINATION_BUCKET_NAME) + .destinationKey(key) + .build()); + + String copiedContent = s3.getObjectAsBytes(GetObjectRequest.builder() + .bucket(DESTINATION_BUCKET_NAME) + .key(key) + .build()).asUtf8String(); + assertThat(copiedContent, is(originalContent)); + }); + } +} diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/CopySourceInterceptor.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/CopySourceInterceptor.java new file mode 100644 index 000000000000..81816bfd01af --- /dev/null +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/CopySourceInterceptor.java @@ -0,0 +1,126 @@ +/* + * 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 software.amazon.awssdk.utils.http.SdkHttpUtils.urlEncodeIgnoreSlashes; + +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.core.SdkRequest; +import software.amazon.awssdk.core.interceptor.Context.ModifyRequest; +import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.interceptor.ExecutionInterceptor; +import software.amazon.awssdk.services.s3.internal.resource.S3ArnUtils; +import software.amazon.awssdk.services.s3.internal.resource.S3ResourceType; +import software.amazon.awssdk.services.s3.model.CopyObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.UploadPartCopyRequest; +import software.amazon.awssdk.utils.Validate; + +/** + * This interceptor transforms the {@code sourceBucket}, {@code sourceKey}, and {@code sourceVersionId} parameters for + * {@link CopyObjectRequest} and {@link UploadPartCopyRequest} into a {@code copySource} parameter. The logic needed to + * construct a {@code copySource} can be considered non-trivial, so this interceptor facilitates allowing users to + * use higher-level constructs that more closely match other APIs, like {@link PutObjectRequest}. Additionally, this + * interceptor is responsible for URL encoding the relevant portions of the {@code copySource} value. + *

+ * API_CopyObject_RequestParameters + *

+ * API_UploadPartCopy_RequestParameters + */ +@SdkInternalApi +public final class CopySourceInterceptor implements ExecutionInterceptor { + + @Override + public SdkRequest modifyRequest(ModifyRequest context, ExecutionAttributes executionAttributes) { + SdkRequest request = context.request(); + if (request instanceof CopyObjectRequest) { + return modifyCopyObjectRequest((CopyObjectRequest) request); + } + if (request instanceof UploadPartCopyRequest) { + return modifyUploadPartCopyRequest((UploadPartCopyRequest) request); + } + return request; + } + + private static SdkRequest modifyCopyObjectRequest(CopyObjectRequest request) { + if (request.copySource() != null) { + requireNotSet(request.sourceBucket(), "sourceBucket"); + requireNotSet(request.sourceKey(), "sourceKey"); + requireNotSet(request.sourceVersionId(), "sourceVersionId"); + return request; + } + String copySource = constructCopySource( + requireSet(request.sourceBucket(), "sourceBucket"), + requireSet(request.sourceKey(), "sourceKey"), + request.sourceVersionId() + ); + return request.toBuilder() + .sourceBucket(null) + .sourceKey(null) + .sourceVersionId(null) + .copySource(copySource) + .build(); + } + + private static SdkRequest modifyUploadPartCopyRequest(UploadPartCopyRequest request) { + if (request.copySource() != null) { + requireNotSet(request.sourceBucket(), "sourceBucket"); + requireNotSet(request.sourceKey(), "sourceKey"); + requireNotSet(request.sourceVersionId(), "sourceVersionId"); + return request; + } + String copySource = constructCopySource( + requireSet(request.sourceBucket(), "sourceBucket"), + requireSet(request.sourceKey(), "sourceKey"), + request.sourceVersionId() + ); + return request.toBuilder() + .sourceBucket(null) + .sourceKey(null) + .sourceVersionId(null) + .copySource(copySource) + .build(); + } + + private static String constructCopySource(String sourceBucket, String sourceKey, String sourceVersionId) { + StringBuilder copySource = new StringBuilder(); + copySource.append("/"); + copySource.append(urlEncodeIgnoreSlashes(sourceBucket)); + S3ArnUtils.getArnType(sourceBucket).ifPresent(arnType -> { + if (arnType == S3ResourceType.ACCESS_POINT || arnType == S3ResourceType.OUTPOST) { + copySource.append("/object"); + } + }); + copySource.append("/"); + copySource.append(urlEncodeIgnoreSlashes(sourceKey)); + if (sourceVersionId != null) { + copySource.append("?versionId="); + copySource.append(urlEncodeIgnoreSlashes(sourceVersionId)); + } + return copySource.toString(); + } + + private static void requireNotSet(Object value, String paramName) { + Validate.isTrue(value == null, "Parameter 'copySource' must not be used in conjunction with '%s'", + paramName); + } + + private static T requireSet(T value, String paramName) { + Validate.isTrue(value != null, "Parameter '%s' must not be null", + paramName); + return value; + } +} diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/resource/S3ArnUtils.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/resource/S3ArnUtils.java index ec5262419c5a..4aa5bbeb10e5 100644 --- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/resource/S3ArnUtils.java +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/resource/S3ArnUtils.java @@ -16,6 +16,7 @@ package software.amazon.awssdk.services.s3.internal.resource; +import java.util.Optional; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.arns.Arn; import software.amazon.awssdk.arns.ArnResource; @@ -72,4 +73,15 @@ public static IntermediateOutpostResource parseOutpostArn(Arn arn) { .outpostSubresource(ArnResource.fromString(subresource)) .build(); } + + public static Optional getArnType(String arnString) { + try { + Arn arn = Arn.fromString(arnString); + String resourceType = arn.resource().resourceType().get(); + S3ResourceType s3ResourceType = S3ResourceType.fromValue(resourceType); + return Optional.of(s3ResourceType); + } catch (Exception ignored) { + return Optional.empty(); + } + } } diff --git a/services/s3/src/main/resources/META-INF/native-image/software.amazon.awssdk/s3/reflect-config.json b/services/s3/src/main/resources/META-INF/native-image/software.amazon.awssdk/s3/reflect-config.json index 27a7bbe640b9..a8cb0f7a641a 100644 --- a/services/s3/src/main/resources/META-INF/native-image/software.amazon.awssdk/s3/reflect-config.json +++ b/services/s3/src/main/resources/META-INF/native-image/software.amazon.awssdk/s3/reflect-config.json @@ -115,5 +115,14 @@ "parameterTypes": [] } ] + }, + { + "name": "software.amazon.awssdk.services.s3.internal.handlers.CopySourceInterceptor", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] } ] \ No newline at end of file diff --git a/services/s3/src/main/resources/codegen-resources/customization.config b/services/s3/src/main/resources/codegen-resources/customization.config index e284b3aa21cf..1f2e50aa08b4 100644 --- a/services/s3/src/main/resources/codegen-resources/customization.config +++ b/services/s3/src/main/resources/codegen-resources/customization.config @@ -17,6 +17,52 @@ ] }, "CopyObjectRequest": { + "inject": [ + { + "SourceBucket": { + "shape": "BucketName", + "documentation": "The name of the bucket containing the object to copy. The provided input will be URL encoded. The {@code sourceBucket}, {@code sourceKey}, and {@code sourceVersionId} parameters must not be used in conjunction with the {@code copySource} parameter." + }, + "SourceKey": { + "shape": "ObjectKey", + "documentation": "The key of the object to copy. The provided input will be URL encoded. The {@code sourceBucket}, {@code sourceKey}, and {@code sourceVersionId} parameters must not be used in conjunction with the {@code copySource} parameter." + }, + "SourceVersionId": { + "shape": "ObjectVersionId", + "documentation": "Specifies a particular version of the source object to copy. By default the latest version is copied. The {@code sourceBucket}, {@code sourceKey}, and {@code sourceVersionId} parameters must not be used in conjunction with the {@code copySource} parameter." + } + } + ], + "modify": [ + { + "Bucket": { + "emitPropertyName": "DestinationBucket", + "existingNameDeprecated": true + }, + "Key": { + "emitPropertyName": "DestinationKey", + "existingNameDeprecated": true + } + } + ] + }, + "UploadPartCopyRequest": { + "inject": [ + { + "SourceBucket": { + "shape": "BucketName", + "documentation": "The name of the bucket containing the object to copy. The provided input will be URL encoded. The {@code sourceBucket}, {@code sourceKey}, and {@code sourceVersionId} parameters must not be used in conjunction with the {@code copySource} parameter." + }, + "SourceKey": { + "shape": "ObjectKey", + "documentation": "The key of the object to copy. The provided input will be URL encoded. The {@code sourceBucket}, {@code sourceKey}, and {@code sourceVersionId} parameters must not be used in conjunction with the {@code copySource} parameter." + }, + "SourceVersionId": { + "shape": "ObjectVersionId", + "documentation": "Specifies a particular version of the source object to copy. By default the latest version is copied. The {@code sourceBucket}, {@code sourceKey}, and {@code sourceVersionId} parameters must not be used in conjunction with the {@code copySource} parameter." + } + } + ], "modify": [ { "Bucket": { 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 index d9d447cb955d..a0cad21e1180 100644 --- 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 @@ -10,4 +10,5 @@ software.amazon.awssdk.services.s3.internal.handlers.AsyncChecksumValidationInte software.amazon.awssdk.services.s3.internal.handlers.SyncChecksumValidationInterceptor software.amazon.awssdk.services.s3.internal.handlers.EnableTrailingChecksumInterceptor software.amazon.awssdk.services.s3.internal.handlers.ExceptionTranslationInterceptor -software.amazon.awssdk.services.s3.internal.handlers.GetObjectInterceptor \ No newline at end of file +software.amazon.awssdk.services.s3.internal.handlers.GetObjectInterceptor +software.amazon.awssdk.services.s3.internal.handlers.CopySourceInterceptor \ No newline at end of file diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/handlers/CopySourceInterceptorTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/handlers/CopySourceInterceptorTest.java new file mode 100644 index 000000000000..5bbe9b025adf --- /dev/null +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/handlers/CopySourceInterceptorTest.java @@ -0,0 +1,173 @@ +/* + * 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.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import java.util.Arrays; +import java.util.Collection; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.services.s3.model.CopyObjectRequest; +import software.amazon.awssdk.services.s3.model.UploadPartCopyRequest; + +@RunWith(Parameterized.class) +public class CopySourceInterceptorTest { + private final CopySourceInterceptor interceptor = new CopySourceInterceptor(); + + @Parameters + public static Collection parameters() throws Exception { + return Arrays.asList(new String[][] { + {"bucket", "simpleKey", null, + "/bucket/simpleKey"}, + + {"bucket", "key/with/slashes", null, + "/bucket/key/with/slashes"}, + + {"bucket", "\uD83E\uDEA3", null, + "/bucket/%F0%9F%AA%A3"}, + + {"bucket", "specialChars._ +!#$&'()*,:;=?@\"", null, + "/bucket/specialChars._%20%2B%21%23%24%26%27%28%29%2A%2C%3A%3B%3D%3F%40%22"}, + + {"bucket", "%20", null, + "/bucket/%2520"}, + + {"bucket", "key/with/version", "ZJlqdTGGfnWjRWjm.WtQc5XRTNJn3sz_", + "/bucket/key/with/version?versionId=ZJlqdTGGfnWjRWjm.WtQc5XRTNJn3sz_"} + }); + } + + private final String sourceBucket; + private final String sourceKey; + private final String sourceVersionId; + private final String expectedCopySource; + + public CopySourceInterceptorTest(String sourceBucket, String sourceKey, String sourceVersionId, String expectedCopySource) { + this.sourceBucket = sourceBucket; + this.sourceKey = sourceKey; + this.sourceVersionId = sourceVersionId; + this.expectedCopySource = expectedCopySource; + } + + @Test + public void modifyRequest_ConstructsUrlEncodedCopySource_whenCopyObjectRequest() { + CopyObjectRequest originalRequest = CopyObjectRequest.builder() + .sourceBucket(sourceBucket) + .sourceKey(sourceKey) + .sourceVersionId(sourceVersionId) + .build(); + CopyObjectRequest modifiedRequest = (CopyObjectRequest) interceptor + .modifyRequest(() -> originalRequest, new ExecutionAttributes()); + + assertThat(modifiedRequest.copySource()).isEqualTo(expectedCopySource); + } + + @Test + public void modifyRequest_ConstructsUrlEncodedCopySource_whenUploadPartCopyRequest() { + UploadPartCopyRequest originalRequest = UploadPartCopyRequest.builder() + .sourceBucket(sourceBucket) + .sourceKey(sourceKey) + .sourceVersionId(sourceVersionId) + .build(); + UploadPartCopyRequest modifiedRequest = (UploadPartCopyRequest) interceptor + .modifyRequest(() -> originalRequest, new ExecutionAttributes()); + + assertThat(modifiedRequest.copySource()).isEqualTo(expectedCopySource); + } + + @Test + public void modifyRequest_Throws_whenCopySourceUsedWithSourceBucket_withCopyObjectRequest() { + CopyObjectRequest originalRequest = CopyObjectRequest.builder() + .sourceBucket(sourceBucket) + .sourceKey(sourceKey) + .sourceVersionId(sourceVersionId) + .copySource("copySource") + .build(); + + assertThatThrownBy(() -> { + interceptor.modifyRequest(() -> originalRequest, new ExecutionAttributes()); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Parameter 'copySource' must not be used in conjunction with 'sourceBucket'"); + } + + @Test + public void modifyRequest_Throws_whenCopySourceUsedWithSourceBucket_withUploadPartCopyRequest() { + UploadPartCopyRequest originalRequest = UploadPartCopyRequest.builder() + .sourceBucket(sourceBucket) + .sourceKey(sourceKey) + .sourceVersionId(sourceVersionId) + .copySource("copySource") + .build(); + + assertThatThrownBy(() -> { + interceptor.modifyRequest(() -> originalRequest, new ExecutionAttributes()); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Parameter 'copySource' must not be used in conjunction with 'sourceBucket'"); + } + + @Test + public void modifyRequest_Throws_whenSourceBucketNotSpecified_withCopyObjectRequest() { + CopyObjectRequest originalRequest = CopyObjectRequest.builder().build(); + + assertThatThrownBy(() -> { + interceptor.modifyRequest(() -> originalRequest, new ExecutionAttributes()); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Parameter 'sourceBucket' must not be null"); + } + + @Test + public void modifyRequest_Throws_whenSourceBucketNotSpecified_withUploadPartCopyRequest() { + UploadPartCopyRequest originalRequest = UploadPartCopyRequest.builder().build(); + + assertThatThrownBy(() -> { + interceptor.modifyRequest(() -> originalRequest, new ExecutionAttributes()); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Parameter 'sourceBucket' must not be null"); + } + + @Test + public void modifyRequest_insertsSlashObject_whenAccessPointArn() { + String accessPointArn = "arn:aws:s3:us-west-2:123456789012:accesspoint/my-access-point"; + CopyObjectRequest originalRequest = CopyObjectRequest.builder() + .sourceBucket(accessPointArn) + .sourceKey("my-key") + .build(); + CopyObjectRequest modifiedRequest = (CopyObjectRequest) interceptor + .modifyRequest(() -> originalRequest, new ExecutionAttributes()); + + assertThat(modifiedRequest.copySource()).isEqualTo( + "/arn%3Aaws%3As3%3Aus-west-2%3A123456789012%3Aaccesspoint/my-access-point/object/my-key"); + } + + @Test + public void modifyRequest_insertsSlashObject_whenOutpostArn() { + String outpostBucketArn = "arn:aws:s3-outposts:us-west-2:123456789012:outpost/my-outpost/bucket/my-bucket"; + CopyObjectRequest originalRequest = CopyObjectRequest.builder() + .sourceBucket(outpostBucketArn) + .sourceKey("my-key") + .build(); + CopyObjectRequest modifiedRequest = (CopyObjectRequest) interceptor + .modifyRequest(() -> originalRequest, new ExecutionAttributes()); + + assertThat(modifiedRequest.copySource()).isEqualTo( + "/arn%3Aaws%3As3-outposts%3Aus-west-2%3A123456789012%3Aoutpost/my-outpost/bucket/my-bucket/object/my-key"); + } +} diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/resource/S3ArnUtilsTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/resource/S3ArnUtilsTest.java index 1bd47ba02294..b5159aa8bd35 100644 --- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/resource/S3ArnUtilsTest.java +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/resource/S3ArnUtilsTest.java @@ -20,6 +20,7 @@ import static org.hamcrest.Matchers.is; import java.util.Optional; +import java.util.UUID; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -131,4 +132,22 @@ public void parseOutpostArn_malformedArnEmptyOutpostId_shouldThrowException() { .resource("outpost::accesspoint:name") .build()); } + + @Test + public void getArnType_shouldRecognizeAccessPointArn() { + String arnString = "arn:aws:s3:us-west-2:123456789012:accesspoint/my-access-point"; + assertThat(S3ArnUtils.getArnType(arnString), is(Optional.of(S3ResourceType.ACCESS_POINT))); + } + + @Test + public void getArnType_shouldRecognizeOutpostArn() { + String arnString = "arn:aws:s3-outposts:us-west-2:123456789012:outpost/my-outpost/bucket/my-bucket"; + assertThat(S3ArnUtils.getArnType(arnString), is(Optional.of(S3ResourceType.OUTPOST))); + } + + @Test + public void getArnType_shouldNotThrow_onRandomInput() { + String arnString = UUID.randomUUID().toString(); + assertThat(S3ArnUtils.getArnType(arnString), is(Optional.empty())); + } }