diff --git a/spring-cloud-aws-s3/src/main/java/io/awspring/cloud/s3/ObjectMetadata.java b/spring-cloud-aws-s3/src/main/java/io/awspring/cloud/s3/ObjectMetadata.java index 10131a86e..34c5a5db2 100644 --- a/spring-cloud-aws-s3/src/main/java/io/awspring/cloud/s3/ObjectMetadata.java +++ b/spring-cloud-aws-s3/src/main/java/io/awspring/cloud/s3/ObjectMetadata.java @@ -120,6 +120,9 @@ public class ObjectMetadata { @Nullable private final String contentMD5; + @Nullable + private final MetadataDirective metadataDirective; + public static Builder builder() { return new Builder(); } @@ -128,8 +131,8 @@ public static Builder builder() { @Nullable String contentEncoding, @Nullable String contentLanguage, @Nullable String contentType, @Nullable Long contentLength, @Nullable Instant expires, @Nullable String grantFullControl, @Nullable String grantRead, @Nullable String grantReadACP, @Nullable String grantWriteACP, - @Nullable Map metadata, @Nullable String serverSideEncryption, - @Nullable String storageClass, @Nullable String websiteRedirectLocation, + @Nullable Map metadata, @Nullable MetadataDirective metadataDirective, + @Nullable String serverSideEncryption, @Nullable String storageClass, @Nullable String websiteRedirectLocation, @Nullable String sseCustomerAlgorithm, @Nullable String sseCustomerKey, @Nullable String sseCustomerKeyMD5, @Nullable String ssekmsKeyId, @Nullable String ssekmsEncryptionContext, @Nullable Boolean bucketKeyEnabled, @Nullable String requestPayer, @Nullable String tagging, @Nullable String objectLockMode, @@ -148,6 +151,7 @@ public static Builder builder() { this.grantReadACP = grantReadACP; this.grantWriteACP = grantWriteACP; this.metadata = metadata; + this.metadataDirective = metadataDirective; this.serverSideEncryption = serverSideEncryption; this.storageClass = storageClass; this.websiteRedirectLocation = websiteRedirectLocation; @@ -260,6 +264,95 @@ void apply(PutObjectRequest.Builder builder) { } } + void apply(CopyObjectRequest.Builder builder) { + builder.metadataDirective(metadataDirective == null ? MetadataDirective.REPLACE : metadataDirective); + if (acl != null) { + builder.acl(acl); + } + if (cacheControl != null) { + builder.cacheControl(cacheControl); + } + if (contentDisposition != null) { + builder.contentDisposition(contentDisposition); + } + if (contentEncoding != null) { + builder.contentEncoding(contentEncoding); + } + if (contentLanguage != null) { + builder.contentLanguage(contentLanguage); + } + if (contentType != null) { + builder.contentType(contentType); + } + if (expires != null) { + builder.expires(expires); + } + if (grantFullControl != null) { + builder.grantFullControl(grantFullControl); + } + if (grantRead != null) { + builder.grantRead(grantRead); + } + if (grantReadACP != null) { + builder.grantReadACP(grantReadACP); + } + if (grantWriteACP != null) { + builder.grantWriteACP(grantWriteACP); + } + if (metadata != null) { + builder.metadata(metadata); + } + if (serverSideEncryption != null) { + builder.serverSideEncryption(serverSideEncryption); + } + if (storageClass != null) { + builder.storageClass(storageClass); + } + if (websiteRedirectLocation != null) { + builder.websiteRedirectLocation(websiteRedirectLocation); + } + if (sseCustomerAlgorithm != null) { + builder.sseCustomerAlgorithm(sseCustomerAlgorithm); + } + if (sseCustomerKey != null) { + builder.sseCustomerKey(sseCustomerKey); + } + if (sseCustomerKeyMD5 != null) { + builder.sseCustomerKeyMD5(sseCustomerKeyMD5); + } + if (ssekmsKeyId != null) { + builder.ssekmsKeyId(ssekmsKeyId); + } + if (ssekmsEncryptionContext != null) { + builder.ssekmsEncryptionContext(ssekmsEncryptionContext); + } + if (bucketKeyEnabled != null) { + builder.bucketKeyEnabled(bucketKeyEnabled); + } + if (requestPayer != null) { + builder.requestPayer(requestPayer); + } + if (tagging != null) { + builder.taggingDirective(TaggingDirective.REPLACE); + builder.tagging(tagging); + } + if (objectLockMode != null) { + builder.objectLockMode(objectLockMode); + } + if (objectLockRetainUntilDate != null) { + builder.objectLockRetainUntilDate(objectLockRetainUntilDate); + } + if (objectLockLegalHoldStatus != null) { + builder.objectLockLegalHoldStatus(objectLockLegalHoldStatus); + } + if (expectedBucketOwner != null) { + builder.expectedBucketOwner(expectedBucketOwner); + } + if (checksumAlgorithm != null) { + builder.checksumAlgorithm(checksumAlgorithm); + } + } + void apply(CreateMultipartUploadRequest.Builder builder) { if (acl != null) { builder.acl(acl); @@ -627,6 +720,9 @@ public static class Builder { @Nullable private String contentMD5; + @Nullable + private MetadataDirective metadataDirective; + public Builder acl(@Nullable String acl) { this.acl = acl; return this; @@ -696,6 +792,11 @@ public Builder metadata(@Nullable String key, String value) { return this; } + public Builder metadataDirective(@Nullable MetadataDirective metadataDirective) { + this.metadataDirective = metadataDirective; + return this; + } + public Builder serverSideEncryption(@Nullable String serverSideEncryption) { this.serverSideEncryption = serverSideEncryption; return this; @@ -809,7 +910,7 @@ public Builder contentMD5(@Nullable String contentMD5) { public ObjectMetadata build() { return new ObjectMetadata(acl, cacheControl, contentDisposition, contentEncoding, contentLanguage, contentType, contentLength, expires, grantFullControl, grantRead, grantReadACP, grantWriteACP, - metadata, serverSideEncryption, storageClass, websiteRedirectLocation, sseCustomerAlgorithm, + metadata, metadataDirective, serverSideEncryption, storageClass, websiteRedirectLocation, sseCustomerAlgorithm, sseCustomerKey, sseCustomerKeyMD5, ssekmsKeyId, ssekmsEncryptionContext, bucketKeyEnabled, requestPayer, tagging, objectLockMode, objectLockRetainUntilDate, objectLockLegalHoldStatus, expectedBucketOwner, checksumAlgorithm, contentMD5); diff --git a/spring-cloud-aws-s3/src/main/java/io/awspring/cloud/s3/S3Operations.java b/spring-cloud-aws-s3/src/main/java/io/awspring/cloud/s3/S3Operations.java index 0fca61e3e..3b687129e 100644 --- a/spring-cloud-aws-s3/src/main/java/io/awspring/cloud/s3/S3Operations.java +++ b/spring-cloud-aws-s3/src/main/java/io/awspring/cloud/s3/S3Operations.java @@ -164,6 +164,19 @@ default S3Resource upload(String bucketName, String key, InputStream inputStream */ S3Resource download(String bucketName, String key); + /** + * Copies an object from one S3 location to another. + * + * @param sourceBucketName - the source bucket name + * @param sourceKey - the source object key + * @param destinationBucketName - the destination bucket name + * @param destinationKey - the destination object key + * @param objectMetadata - the object metadata + * @return copied object represented as {@link S3Resource} + */ + S3Resource copy(String sourceBucketName, String sourceKey, String destinationBucketName, String destinationKey, + @Nullable ObjectMetadata objectMetadata); + /** * Creates a signed URL for retrieving an object from S3. * diff --git a/spring-cloud-aws-s3/src/main/java/io/awspring/cloud/s3/S3Template.java b/spring-cloud-aws-s3/src/main/java/io/awspring/cloud/s3/S3Template.java index 92194bc14..c3b72ee96 100644 --- a/spring-cloud-aws-s3/src/main/java/io/awspring/cloud/s3/S3Template.java +++ b/spring-cloud-aws-s3/src/main/java/io/awspring/cloud/s3/S3Template.java @@ -24,6 +24,7 @@ import org.springframework.util.Assert; import org.springframework.util.StreamUtils; import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CopyObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; @@ -194,6 +195,25 @@ public S3Resource download(String bucketName, String key) { return new S3Resource(bucketName, key, s3Client, s3OutputStreamProvider); } + @Override + public S3Resource copy(String sourceBucketName, String sourceKey, String destinationBucketName, + String destinationKey, @Nullable ObjectMetadata objectMetadata) { + Assert.notNull(sourceBucketName, "sourceBucketName is required"); + Assert.notNull(sourceKey, "sourceKey is required"); + Assert.notNull(destinationBucketName, "destinationBucketName is required"); + Assert.notNull(destinationKey, "destinationKey is required"); + + CopyObjectRequest.Builder requestBuilder = CopyObjectRequest.builder().sourceBucket(sourceBucketName) + .sourceKey(sourceKey).destinationBucket(destinationBucketName).destinationKey(destinationKey); + + if (objectMetadata != null) { + objectMetadata.apply(requestBuilder); + } + + s3Client.copyObject(requestBuilder.build()); + return new S3Resource(destinationBucketName, destinationKey, s3Client, s3OutputStreamProvider); + } + @Override public URL createSignedGetURL(String bucketName, String key, Duration duration) { Assert.notNull(bucketName, "bucketName is required"); diff --git a/spring-cloud-aws-s3/src/test/java/io/awspring/cloud/s3/S3TemplateIntegrationTests.java b/spring-cloud-aws-s3/src/test/java/io/awspring/cloud/s3/S3TemplateIntegrationTests.java index d524ab410..6f005657e 100644 --- a/spring-cloud-aws-s3/src/test/java/io/awspring/cloud/s3/S3TemplateIntegrationTests.java +++ b/spring-cloud-aws-s3/src/test/java/io/awspring/cloud/s3/S3TemplateIntegrationTests.java @@ -260,6 +260,35 @@ void downloadsFile() throws IOException { } } + @Test + void copiesObject() throws IOException { + client.putObject(r -> r.bucket(BUCKET_NAME).key("file.txt"), RequestBody.fromString("hello")); + + S3Resource copied = s3Template.copy(BUCKET_NAME, "file.txt", BUCKET_NAME, + "file-copy.txt", null); + + assertThat(copied).isNotNull(); + assertThat(copied.getLocation().getObject()).isEqualTo("file-copy.txt"); + ResponseInputStream response = client + .getObject(r -> r.bucket(BUCKET_NAME).key("file-copy.txt")); + String result = StreamUtils.copyToString(response, StandardCharsets.UTF_8); + assertThat(result).isEqualTo("hello"); + } + + @Test + void copiesObjectWithMetadata() { + client.putObject(r -> r.bucket(BUCKET_NAME).key("file.gz"), RequestBody.fromString("hello")); + + S3Resource copied = s3Template.copy(BUCKET_NAME, "file.gz", BUCKET_NAME, "file-copy.gz", + ObjectMetadata.builder().contentEncoding("gzip").contentType("application/gzip").build()); + + assertThat(copied).isNotNull(); + HeadObjectResponse headObjectResponse = client + .headObject(HeadObjectRequest.builder().bucket(BUCKET_NAME).key("file-copy.gz").build()); + assertThat(headObjectResponse.contentEncoding()).isEqualTo("gzip"); + assertThat(headObjectResponse.contentType()).isEqualTo("application/gzip"); + } + @Test void createsWorkingSignedGetURL() throws IOException { client.putObject(r -> r.bucket(BUCKET_NAME).key("file.txt"), RequestBody.fromString("hello"));