Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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,148 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

package software.amazon.awssdk.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.After;
import org.junit.Before;
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_BUCKET_NAME = temporaryBucketName("copy-source-integ-test-src");
private static final String DESTINATION_BUCKET_NAME = temporaryBucketName("copy-source-integ-test-dest");

@Before
public void initializeTestData() throws Exception {
createBucket(SOURCE_BUCKET_NAME);
createBucket(DESTINATION_BUCKET_NAME);
}

@After
public void tearDown() {
deleteBucketAndAllContents(SOURCE_BUCKET_NAME);
deleteBucketAndAllContents(DESTINATION_BUCKET_NAME);
}

@Parameters
public static Collection parameters() throws Exception {
Comment thread
Bennett-Lynch marked this conversation as resolved.
Outdated
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_WithoutExplicitVersion_AcceptsSameKeyAsPut() throws Exception {
Comment thread
Bennett-Lynch marked this conversation as resolved.
Outdated
String originalContent = UUID.randomUUID().toString();

s3.putObject(PutObjectRequest.builder()
.bucket(SOURCE_BUCKET_NAME)
.key(key)
.build(), RequestBody.fromString(originalContent, StandardCharsets.UTF_8));

s3.copyObject(CopyObjectRequest.builder()
.sourceBucket(SOURCE_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_WithVersioning_AcceptsSameKeyAsPut() throws Exception {
s3.putBucketVersioning(r -> r
.bucket(SOURCE_BUCKET_NAME)
.versioningConfiguration(v -> v.status(BucketVersioningStatus.ENABLED)));

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_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_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,127 @@
/*
* 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.services.s3.internal.resource.S3ArnUtils.isArnFor;
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.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;

/**
* 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));
if (isArnFor(S3ResourceType.ACCESS_POINT, sourceBucket) || isArnFor(S3ResourceType.OUTPOST, sourceBucket)) {
copySource.append("/object");
}
Comment thread
Bennett-Lynch marked this conversation as resolved.
Outdated
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) {
if (value != null) {
throw new IllegalArgumentException(String.format("Parameter 'copySource' must not be used in conjunction with '%s'",
paramName));
}
}

private static <T> T requireSet(T value, String paramName) {
if (value == null) {
throw new IllegalArgumentException(String.format("Parameter '%s' must not be null",
paramName));
}
return value;
Comment thread
Bennett-Lynch marked this conversation as resolved.
Outdated
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,14 @@ public static IntermediateOutpostResource parseOutpostArn(Arn arn) {
.outpostSubresource(ArnResource.fromString(subresource))
.build();
}

public static boolean isArnFor(S3ResourceType s3ResourceType, String arnString) {
Comment thread
Bennett-Lynch marked this conversation as resolved.
Outdated
try {
Arn arn = Arn.fromString(arnString);
String parsedResourceType = arn.resource().resourceType().get();
return S3ResourceType.fromValue(parsedResourceType) == s3ResourceType;
} catch (Exception ignored) {
return false;
}
}
}
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