Skip to content

[S3] Add support for more user-friendly CopyObject source parameters #2612

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 21, 2021
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