diff --git a/.changes/next-release/feature-AmazonS3-138feae.json b/.changes/next-release/feature-AmazonS3-138feae.json new file mode 100644 index 000000000000..ea09ff13a952 --- /dev/null +++ b/.changes/next-release/feature-AmazonS3-138feae.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "Amazon S3", + "contributor": "", + "description": "Automatically trim object metadata keys of whitespace for `PutObject` and `CreateMultipartUpload`." +} diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/ObjectMetadataInterceptor.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/ObjectMetadataInterceptor.java new file mode 100644 index 000000000000..b5043a990374 --- /dev/null +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/ObjectMetadataInterceptor.java @@ -0,0 +1,74 @@ +/* + * 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.Map; +import java.util.stream.Collectors; +import software.amazon.awssdk.annotations.SdkInternalApi; +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.SdkExecutionAttribute; +import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.utils.StringUtils; + +/** + * Interceptor that trims object metadata keys of any leading or trailing whitespace for {@code PutObject} and {@code + * CreateMultipartUpload}. This behavior is intended to provide the same functionality as in 1.x. + */ +@SdkInternalApi +public final class ObjectMetadataInterceptor implements ExecutionInterceptor { + @Override + public SdkRequest modifyRequest(Context.ModifyRequest context, ExecutionAttributes executionAttributes) { + SdkRequest request = context.request(); + + switch (executionAttributes.getAttribute(SdkExecutionAttribute.OPERATION_NAME)) { + case "PutObject": + return trimMetadataNames((PutObjectRequest) request); + case "CreateMultipartUpload": + return trimMetadataNames((CreateMultipartUploadRequest) request); + default: + return request; + } + } + + private PutObjectRequest trimMetadataNames(PutObjectRequest putObjectRequest) { + if (!putObjectRequest.hasMetadata()) { + return putObjectRequest; + } + + return putObjectRequest.toBuilder() + .metadata(trimKeys(putObjectRequest.metadata())) + .build(); + } + + private CreateMultipartUploadRequest trimMetadataNames(CreateMultipartUploadRequest createMultipartUploadRequest) { + if (!createMultipartUploadRequest.hasMetadata()) { + return createMultipartUploadRequest; + } + + return createMultipartUploadRequest.toBuilder() + .metadata(trimKeys(createMultipartUploadRequest.metadata())) + .build(); + } + + private Map trimKeys(Map map) { + return map.entrySet().stream() + .collect(Collectors.toMap(e -> StringUtils.trim(e.getKey()), Map.Entry::getValue)); + } +} diff --git a/services/s3/src/main/resources/codegen-resources/customization.config b/services/s3/src/main/resources/codegen-resources/customization.config index 1baddf167c98..0f06cc755691 100644 --- a/services/s3/src/main/resources/codegen-resources/customization.config +++ b/services/s3/src/main/resources/codegen-resources/customization.config @@ -256,7 +256,8 @@ "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.CopySourceInterceptor" + "software.amazon.awssdk.services.s3.internal.handlers.CopySourceInterceptor", + "software.amazon.awssdk.services.s3.internal.handlers.ObjectMetadataInterceptor" ], "internalPlugins": [ "software.amazon.awssdk.services.s3.internal.plugins.S3DisableChunkEncodingIfConfiguredPlugin(config)", diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/handlers/ObjectMetadataInterceptorTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/handlers/ObjectMetadataInterceptorTest.java new file mode 100644 index 000000000000..a29fff164c8e --- /dev/null +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/handlers/ObjectMetadataInterceptorTest.java @@ -0,0 +1,124 @@ +/* + * 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 java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +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.SdkExecutionAttribute; +import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +public class ObjectMetadataInterceptorTest { + private static final ObjectMetadataInterceptor INTERCEPTOR = new ObjectMetadataInterceptor(); + + + + public static List testCases() { + return asList( + tc(asList("a", "b", "c"), asList("a", "b", "c")), + tc(asList(" a ", "b", "c"), asList("a", "b", "c")), + tc(asList(" a", "\tb", "\tc"), asList("a", "b", "c")), + tc(asList("a\n", "\tb", "\tc\r\n"), asList("a", "b", "c")) + + ); + } + + @ParameterizedTest + @MethodSource("testCases") + public void modifyRequest_putObject_metadataKeysAreTrimmed(TestCase tc) { + Map metadata = tc.inputKeys.stream() + .collect(Collectors.toMap(k -> k, k -> "value")); + + Context.ModifyHttpRequest ctx = mock(Context.ModifyHttpRequest.class); + + PutObjectRequest put = PutObjectRequest.builder() + .metadata(metadata) + .build(); + + when(ctx.request()).thenReturn(put); + + ExecutionAttributes attrs = new ExecutionAttributes(); + attrs.putAttribute(SdkExecutionAttribute.OPERATION_NAME, "PutObject"); + + PutObjectRequest modified = (PutObjectRequest) INTERCEPTOR.modifyRequest(ctx, attrs); + + assertThat(modified.metadata().keySet()).containsExactlyElementsOf(tc.expectedKeys); + } + + @ParameterizedTest + @MethodSource("testCases") + public void modifyRequest_creatMultipartUpload_metadataKeysAreTrimmed(TestCase tc) { + Map metadata = tc.inputKeys.stream() + .collect(Collectors.toMap(k -> k, k -> "value")); + + Context.ModifyHttpRequest ctx = mock(Context.ModifyHttpRequest.class); + + CreateMultipartUploadRequest mpu = CreateMultipartUploadRequest.builder() + .metadata(metadata) + .build(); + + when(ctx.request()).thenReturn(mpu); + + ExecutionAttributes attrs = new ExecutionAttributes(); + attrs.putAttribute(SdkExecutionAttribute.OPERATION_NAME, "CreateMultipartUpload"); + + CreateMultipartUploadRequest modified = (CreateMultipartUploadRequest) INTERCEPTOR.modifyRequest(ctx, attrs); + + assertThat(modified.metadata().keySet()).containsExactlyElementsOf(tc.expectedKeys); + } + + @Test + public void modifyRequest_unknownOperation_ignores() { + Context.ModifyHttpRequest ctx = mock(Context.ModifyHttpRequest.class); + + GetObjectRequest get = GetObjectRequest.builder().build(); + + when(ctx.request()).thenReturn(get); + + ExecutionAttributes attrs = new ExecutionAttributes(); + attrs.putAttribute(SdkExecutionAttribute.OPERATION_NAME, "GetObject"); + + SdkRequest sdkRequest = INTERCEPTOR.modifyRequest(ctx, attrs); + + assertThat(sdkRequest).isEqualTo(get); + } + + private static TestCase tc(List input, List expected) { + return new TestCase(input, expected); + } + private static class TestCase { + private List inputKeys; + private List expectedKeys; + + public TestCase(List inputKeys, List expectedKeys) { + this.inputKeys = inputKeys; + this.expectedKeys = expectedKeys; + } + } +}