Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changes/next-release/feature-AmazonS3-9b198d0.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"category": "Amazon S3",
"contributor": "",
"type": "feature",
"description": "Add support for more user-friendly CopyObject source parameters"
}
Original file line number Diff line number Diff line change
@@ -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<String> 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.
* <p>
* Motivated by: https://github.com/aws/aws-sdk-js/issues/727
*/
@Test
public void copyObject_WithVersion_AcceptsSameKeyAsPut() throws Exception {
Map<String, String> 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));
});
}
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* <a href="https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html#API_CopyObject_RequestParameters">API_CopyObject_RequestParameters</a>
* <p>
* <a href="https://docs.aws.amazon.com/AmazonS3/latest/API/API_UploadPartCopy.html#API_UploadPartCopy_RequestParameters">API_UploadPartCopy_RequestParameters</a>
*/
@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> T requireSet(T value, String paramName) {
Validate.isTrue(value != null, "Parameter '%s' must not be null",
paramName);
return value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,4 +73,15 @@ public static IntermediateOutpostResource parseOutpostArn(Arn arn) {
.outpostSubresource(ArnResource.fromString(subresource))
.build();
}

public static Optional<S3ResourceType> 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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,5 +115,14 @@
"parameterTypes": []
}
]
},
{
"name": "software.amazon.awssdk.services.s3.internal.handlers.CopySourceInterceptor",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
software.amazon.awssdk.services.s3.internal.handlers.GetObjectInterceptor
software.amazon.awssdk.services.s3.internal.handlers.CopySourceInterceptor
Loading