diff --git a/src/main/java/org/springframework/integration/aws/outbound/S3MessageHandler.java b/src/main/java/org/springframework/integration/aws/outbound/S3MessageHandler.java new file mode 100644 index 0000000..0a39a5e --- /dev/null +++ b/src/main/java/org/springframework/integration/aws/outbound/S3MessageHandler.java @@ -0,0 +1,555 @@ +/* + * Copyright 2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.springframework.integration.aws.outbound; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; + +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.expression.common.LiteralExpression; +import org.springframework.integration.expression.ExpressionUtils; +import org.springframework.integration.expression.ValueExpression; +import org.springframework.integration.handler.AbstractReplyProducingMessageHandler; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandlingException; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.Assert; +import org.springframework.util.StreamUtils; + +import com.amazonaws.AmazonClientException; +import com.amazonaws.event.ProgressEvent; +import com.amazonaws.event.ProgressEventType; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.internal.Mimetypes; +import com.amazonaws.services.s3.model.AccessControlList; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.CopyObjectRequest; +import com.amazonaws.services.s3.model.GetObjectRequest; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.amazonaws.services.s3.model.SetObjectAclRequest; +import com.amazonaws.services.s3.transfer.ObjectMetadataProvider; +import com.amazonaws.services.s3.transfer.PersistableTransfer; +import com.amazonaws.services.s3.transfer.Transfer; +import com.amazonaws.services.s3.transfer.TransferManager; +import com.amazonaws.services.s3.transfer.internal.S3ProgressListener; +import com.amazonaws.services.s3.transfer.internal.S3ProgressListenerChain; +import com.amazonaws.util.Md5Utils; + +/** + * The {@link AbstractReplyProducingMessageHandler} implementation for the Amazon S3 services. + *

+ * The implementation is fully based on the {@link TransferManager} and support its {@code upload}, + * {@code download} and {@code copy} operations which can be determined by the provided + * or evaluated via SpEL expression at runtime {@link S3MessageHandler.Command}. + *

+ * This {@link AbstractReplyProducingMessageHandler} can behave as a "one-way" (by default) or + * "request-reply" component according to the {@link #produceReply} constructor argument. + *

+ * The "one-way" behavior is also blocking, which is achieved with the {@link Transfer#waitForException()} + * invocation. Consider to use an async upstream hand off if this blocking behavior isn't appropriate. + *

+ * The "request-reply" behavior is async and the {@link Transfer} result from the {@link TransferManager} + * operation is sent to the {@link #outputChannel}, assuming the transfer progress observation in the + * downstream flow. + *

+ * The {@link S3ProgressListener} can be supplied to track the transfer progress. + * Also the listener can be populated into the returned {@link Transfer} afterwards in the downstream flow. + * *

+ * For the upload operation the {@link UploadMetadataProvider} callback can be supplied to populate required + * {@link ObjectMetadata} options, as for a single entry, as well as for each file in directory to upload. + *

+ * For the upload operation the {@link #objectAclExpression} can be provided to {@link AmazonS3#setObjectAcl} + * after the successful transfer. + * The supported SpEL result types are: {@link AccessControlList} or {@link CannedAccessControlList}. + *

+ * For download operation the {@code payload} must be a {@link File} instance, representing a single file + * for downloaded content or directory to download all files from the S3 virtual directory. + *

+ * An S3 Object {@code key} for upload and download can be determined by the provided + * {@link #keyExpression} or the {@link File#getName()} is used directly. The former has precedence. + *

+ * For copy operation all {@link #keyExpression}, {@link #destinationBucketExpression} and + * {@link #destinationKeyExpression} are required and must not evaluate to {@code null}. + * + * @author Artem Bilan + * + * @see TransferManager + */ +public class S3MessageHandler extends AbstractReplyProducingMessageHandler { + + private final TransferManager transferManager; + + private final boolean produceReply; + + private final Expression bucketExpression; + + private EvaluationContext evaluationContext; + + private Expression keyExpression; + + private Expression objectAclExpression; + + private Expression destinationBucketExpression; + + private Expression destinationKeyExpression; + + private Expression commandExpression = new ValueExpression<>(Command.UPLOAD); + + private S3ProgressListener s3ProgressListener; + + private UploadMetadataProvider uploadMetadataProvider; + + public S3MessageHandler(AmazonS3 amazonS3, String bucket) { + this(amazonS3, bucket, false); + } + + public S3MessageHandler(AmazonS3 amazonS3, Expression bucketExpression) { + this(amazonS3, bucketExpression, false); + } + + public S3MessageHandler(AmazonS3 amazonS3, String bucket, boolean produceReply) { + this(new TransferManager(amazonS3), bucket, produceReply); + Assert.notNull(amazonS3, "'amazonS3' must not be null"); + } + + public S3MessageHandler(AmazonS3 amazonS3, Expression bucketExpression, boolean produceReply) { + this(new TransferManager(amazonS3), bucketExpression, produceReply); + Assert.notNull(amazonS3, "'amazonS3' must not be null"); + } + + public S3MessageHandler(TransferManager transferManager, String bucket) { + this(transferManager, bucket, false); + } + + public S3MessageHandler(TransferManager transferManager, Expression bucketExpression) { + this(transferManager, bucketExpression, false); + } + + public S3MessageHandler(TransferManager transferManager, String bucket, boolean produceReply) { + this(transferManager, new LiteralExpression(bucket), produceReply); + Assert.notNull(bucket, "'bucket' must not be null"); + } + + public S3MessageHandler(TransferManager transferManager, Expression bucketExpression, boolean produceReply) { + Assert.notNull(transferManager, "'transferManager' must not be null"); + Assert.notNull(bucketExpression, "'bucketExpression' must not be null"); + this.transferManager = transferManager; + this.bucketExpression = bucketExpression; + this.produceReply = produceReply; + } + + /** + * The SpEL expression to evaluate S3 object key at runtime against {@code requestMessage}. + * @param keyExpression the SpEL expression for S3 key. + */ + public void setKeyExpression(Expression keyExpression) { + this.keyExpression = keyExpression; + } + + /** + * The SpEL expression to evaluate S3 object ACL at runtime against {@code requestMessage} + * for the {@code upload} operation. + * @param objectAclExpression the SpEL expression for S3 object ACL. + */ + public void setObjectAclExpression(Expression objectAclExpression) { + this.objectAclExpression = objectAclExpression; + } + + /** + * Specify a {@link S3MessageHandler.Command} to perform against {@link TransferManager}. + * @param command The {@link S3MessageHandler.Command} to use. + * @see S3MessageHandler.Command + */ + public void setCommand(Command command) { + Assert.notNull(command, "'command' must not be null"); + setCommandExpression(new ValueExpression<>(command)); + } + + /** + * The SpEL expression to evaluate the command to perform on {@link TransferManager}: {@code upload}, + * {@code download} or {@code copy}. + * @param commandExpression the SpEL expression to evaluate the {@link TransferManager} operation. + * @see Command + */ + public void setCommandExpression(Expression commandExpression) { + Assert.notNull(commandExpression, "'commandExpression' must not be null"); + this.commandExpression = commandExpression; + } + + /** + * The SpEL expression to evaluate the target S3 bucket for copy operation. + * @param destinationBucketExpression the SpEL expression for destination bucket. + * @see TransferManager#copy(String, String, String, String) + */ + public void setDestinationBucketExpression(Expression destinationBucketExpression) { + this.destinationBucketExpression = destinationBucketExpression; + } + + /** + * The SpEL expression to evaluate the target S3 key for copy operation. + * @param destinationKeyExpression the SpEL expression for destination key. + * @see TransferManager#copy(String, String, String, String) + */ + public void setDestinationKeyExpression(Expression destinationKeyExpression) { + this.destinationKeyExpression = destinationKeyExpression; + } + + /** + * Specify a {@link S3ProgressListener} for upload and download operations. + * @param s3ProgressListener the {@link S3ProgressListener} to use. + */ + public void setS3ProgressListener(S3ProgressListener s3ProgressListener) { + this.s3ProgressListener = s3ProgressListener; + } + + /** + * Specify an {@link ObjectMetadata} callback to populate the metadata for upload operation, + * e.g. {@code Content-MD5}, {@code Content-Type} or any other required options. + * @param uploadMetadataProvider the {@link UploadMetadataProvider} to use for upload. + */ + public void setUploadMetadataProvider(UploadMetadataProvider uploadMetadataProvider) { + this.uploadMetadataProvider = uploadMetadataProvider; + } + + @Override + protected void doInit() { + Assert.notNull(this.bucketExpression, "The 'bucketExpression' must not be null"); + super.doInit(); + this.evaluationContext = ExpressionUtils.createStandardEvaluationContext(getBeanFactory()); + } + + @Override + protected Object handleRequestMessage(Message requestMessage) { + Command command; + if (this.commandExpression instanceof ValueExpression) { + command = (Command) this.commandExpression.getValue(); + } + else { + command = this.commandExpression.getValue(this.evaluationContext, requestMessage, Command.class); + Assert.state(command != null, "'commandExpression' [" + + this.commandExpression.getExpressionString() + + "] cannot evaluate to null."); + } + + Transfer transfer = null; + + switch (command) { + case UPLOAD: + transfer = upload(requestMessage); + break; + + case DOWNLOAD: + transfer = download(requestMessage); + break; + + case COPY: + transfer = copy(requestMessage); + break; + } + + if (this.produceReply) { + return transfer; + } + else { + try { + AmazonClientException amazonClientException = transfer.waitForException(); + if (amazonClientException != null) { + throw amazonClientException; + } + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return null; + } + } + + private Transfer upload(Message requestMessage) { + Object payload = requestMessage.getPayload(); + String bucketName = obtainBucket(requestMessage); + + String key = null; + if (this.keyExpression != null) { + key = this.keyExpression.getValue(this.evaluationContext, requestMessage, String.class); + } + + if (payload instanceof File && ((File) payload).isDirectory()) { + File fileToUpload = (File) payload; + if (key == null) { + key = fileToUpload.getName(); + } + return this.transferManager.uploadDirectory(bucketName, key, fileToUpload, true, + new MessageHeadersObjectMetadataProvider(requestMessage.getHeaders())); + } + else { + ObjectMetadata metadata = new ObjectMetadata(); + if (this.uploadMetadataProvider != null) { + this.uploadMetadataProvider.populateMetadata(metadata, requestMessage); + } + InputStream inputStream; + if (payload instanceof InputStream) { + inputStream = (InputStream) payload; + } + else if (payload instanceof File) { + File fileToUpload = (File) payload; + if (key == null) { + key = fileToUpload.getName(); + } + try { + inputStream = new FileInputStream(fileToUpload); + + if (metadata.getContentLength() == 0) { + metadata.setContentLength(fileToUpload.length()); + } + if (metadata.getContentType() == null) { + metadata.setContentType(Mimetypes.getInstance().getMimetype(fileToUpload)); + } + + } + catch (FileNotFoundException e) { + throw new AmazonClientException(e); + } + } + else if (payload instanceof byte[]) { + inputStream = new ByteArrayInputStream((byte[]) payload); + } + else { + throw new IllegalArgumentException("Unsupported payload type: [" + + payload.getClass() + + "]. The only supported payloads for the upload request are " + + "java.io.File, java.io.InputStream, byte[] and PutObjectRequest."); + } + + Assert.state(key != null, + "The 'keyExpression' must not be null for non-File payloads and can't evaluate to null. " + + "Root object is: " + requestMessage); + + if (key == null) { + if (this.keyExpression != null) { + throw new IllegalStateException("The 'keyExpression' [" + + this.keyExpression.getExpressionString() + + "] must not evaluate to null. Root object is: " + requestMessage); + } + else { + throw new IllegalStateException("Specify a 'keyExpression' for non-java.io.File payloads"); + } + } + + if (metadata.getContentMD5() == null) { + String contentMd5 = null; + try { + contentMd5 = Md5Utils.md5AsBase64(StreamUtils.copyToByteArray(inputStream)); + if (inputStream.markSupported()) { + inputStream.reset(); + } + metadata.setContentMD5(contentMd5); + } + catch (IOException e) { + throw new MessageHandlingException(requestMessage, e); + } + } + PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, key, inputStream, metadata); + + S3ProgressListener progressListener = this.s3ProgressListener; + + if (this.objectAclExpression != null) { + Object acl = this.objectAclExpression.getValue(this.evaluationContext, requestMessage); + Assert.state(acl instanceof AccessControlList || acl instanceof CannedAccessControlList, + "The 'objectAclExpression' [" + + this.objectAclExpression.getExpressionString() + + "] must evaluate to com.amazonaws.services.s3.model.AccessControlList " + + "or must evaluate to com.amazonaws.services.s3.model.CannedAccessControlList. " + + "Gotten: [" + acl + "]"); + + SetObjectAclRequest aclRequest; + + if (acl instanceof AccessControlList) { + aclRequest = new SetObjectAclRequest(bucketName, key, (AccessControlList) acl); + } + else { + aclRequest = new SetObjectAclRequest(bucketName, key, (CannedAccessControlList) acl); + } + + final SetObjectAclRequest theAclRequest = aclRequest; + progressListener = new S3ProgressListener() { + + @Override + public void onPersistableTransfer(PersistableTransfer persistableTransfer) { + + } + + @Override + public void progressChanged(ProgressEvent progressEvent) { + if (ProgressEventType.TRANSFER_COMPLETED_EVENT.equals(progressEvent.getEventType())) { + transferManager.getAmazonS3Client().setObjectAcl(theAclRequest); + } + } + + }; + + if (this.s3ProgressListener != null) { + progressListener = new S3ProgressListenerChain(this.s3ProgressListener, progressListener); + } + + } + + if (progressListener != null) { + return this.transferManager.upload(putObjectRequest, progressListener); + } + else { + return this.transferManager.upload(putObjectRequest); + } + } + } + + private Transfer download(Message requestMessage) { + Object payload = requestMessage.getPayload(); + Assert.state(payload instanceof File, "For the 'DOWNLOAD' operation the 'payload' must be of " + + "'java.io.File' type, but gotten: [" + payload.getClass() + ']'); + + File targetFile = (File) payload; + + String bucket = obtainBucket(requestMessage); + + String key = null; + if (this.keyExpression != null) { + key = this.keyExpression.getValue(this.evaluationContext, requestMessage, String.class); + } + else { + key = targetFile.getName(); + } + + Assert.state(key != null, + "The 'keyExpression' must not be null for non-File payloads and can't evaluate to null. " + + "Root object is: " + requestMessage); + + if (targetFile.isDirectory()) { + return this.transferManager.downloadDirectory(bucket, key, targetFile); + } + else { + if (this.s3ProgressListener != null) { + return this.transferManager.download(new GetObjectRequest(bucket, key), targetFile, + this.s3ProgressListener); + } + else { + return this.transferManager.download(bucket, key, targetFile); + } + } + } + + private Transfer copy(Message requestMessage) { + String sourceBucketName = obtainBucket(requestMessage); + + String sourceKey = null; + if (this.keyExpression != null) { + sourceKey = this.keyExpression.getValue(this.evaluationContext, requestMessage, String.class); + } + + Assert.state(sourceKey != null, + "The 'keyExpression' must not be null for 'copy' operation and can't evaluate to null. " + + "Root object is: " + requestMessage); + + String destinationBucketName = null; + if (this.destinationBucketExpression != null) { + destinationBucketName = this.destinationBucketExpression.getValue(this.evaluationContext, requestMessage, + String.class); + } + + Assert.state(destinationBucketName != null, + "The 'destinationBucketExpression' must not be null for 'copy' operation and can't evaluate to null. " + + "Root object is: " + requestMessage); + + String destinationKey = null; + if (this.destinationKeyExpression != null) { + destinationKey = this.destinationKeyExpression.getValue(this.evaluationContext, requestMessage, + String.class); + } + + Assert.state(destinationKey != null, + "The 'destinationKeyExpression' must not be null for 'copy' operation and can't evaluate to null. " + + "Root object is: " + requestMessage); + + + CopyObjectRequest copyObjectRequest = + new CopyObjectRequest(sourceBucketName, sourceKey, destinationBucketName, destinationKey); + return this.transferManager.copy(copyObjectRequest); + } + + private String obtainBucket(Message requestMessage) { + String bucketName; + if (this.bucketExpression instanceof LiteralExpression) { + bucketName = (String) this.bucketExpression.getValue(); + } + else { + bucketName = this.bucketExpression.getValue(this.evaluationContext, requestMessage, String.class); + } + Assert.state(bucketName != null, "The 'bucketExpression' [" + + this.bucketExpression.getExpressionString() + + "] must not evaluate to null. Root object is: " + requestMessage); + + return bucketName; + } + + public enum Command { + UPLOAD, DOWNLOAD, COPY + } + + /** + * The callback to populate an {@link ObjectMetadata} for upload operation. + * The message can be used as a metadata source. + */ + public interface UploadMetadataProvider { + + void populateMetadata(ObjectMetadata metadata, Message message); + + } + + private class MessageHeadersObjectMetadataProvider implements ObjectMetadataProvider { + + private final MessageHeaders messageHeaders; + + public MessageHeadersObjectMetadataProvider(MessageHeaders messageHeaders) { + this.messageHeaders = messageHeaders; + } + + @Override + public void provideObjectMetadata(File file, ObjectMetadata metadata) { + if (uploadMetadataProvider != null) { + uploadMetadataProvider.populateMetadata(metadata, + MessageBuilder.createMessage(file, this.messageHeaders)); + } + if (metadata.getContentMD5() == null) { + try { + String contentMd5 = Md5Utils.md5AsBase64(file); + metadata.setContentMD5(contentMd5); + } + catch (Exception e) { + throw new AmazonClientException(e); + } + } + } + + } + +} diff --git a/src/test/java/org/springframework/integration/aws/outbound/S3MessageHandlerTests.java b/src/test/java/org/springframework/integration/aws/outbound/S3MessageHandlerTests.java new file mode 100644 index 0000000..1f40db3 --- /dev/null +++ b/src/test/java/org/springframework/integration/aws/outbound/S3MessageHandlerTests.java @@ -0,0 +1,423 @@ +/* + * Copyright 2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.springframework.integration.aws.outbound; + +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.apache.http.client.methods.HttpRequestBase; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.http.MediaType; +import org.springframework.integration.annotation.ServiceActivator; +import org.springframework.integration.channel.QueueChannel; +import org.springframework.integration.config.EnableIntegration; +import org.springframework.integration.expression.ValueExpression; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.PollableChannel; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.util.FileCopyUtils; + +import com.amazonaws.event.ProgressEvent; +import com.amazonaws.event.ProgressEventType; +import com.amazonaws.event.ProgressListener; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.Headers; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.CopyObjectRequest; +import com.amazonaws.services.s3.model.CopyObjectResult; +import com.amazonaws.services.s3.model.GetObjectMetadataRequest; +import com.amazonaws.services.s3.model.GetObjectRequest; +import com.amazonaws.services.s3.model.ListObjectsRequest; +import com.amazonaws.services.s3.model.ObjectListing; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.amazonaws.services.s3.model.PutObjectResult; +import com.amazonaws.services.s3.model.S3Object; +import com.amazonaws.services.s3.model.S3ObjectInputStream; +import com.amazonaws.services.s3.model.S3ObjectSummary; +import com.amazonaws.services.s3.model.SetObjectAclRequest; +import com.amazonaws.services.s3.transfer.Copy; +import com.amazonaws.services.s3.transfer.PersistableTransfer; +import com.amazonaws.services.s3.transfer.Transfer; +import com.amazonaws.services.s3.transfer.internal.S3ProgressListener; +import com.amazonaws.services.s3.transfer.internal.S3ProgressPublisher; +import com.amazonaws.util.BinaryUtils; +import com.amazonaws.util.Md5Utils; +import com.amazonaws.util.StringInputStream; +import com.amazonaws.util.StringUtils; + +/** + * @author Artem Bilan + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration +@DirtiesContext +public class S3MessageHandlerTests { + + private static SpelExpressionParser PARSER = new SpelExpressionParser(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Autowired + private AmazonS3 amazonS3; + + @Autowired + private MessageChannel s3SendChannel; + + @Autowired + private CountDownLatch transferCompletedLatch; + + @Autowired + private CountDownLatch aclLatch; + + @Autowired + private MessageChannel s3ProcessChannel; + + @Autowired + private PollableChannel s3ReplyChannel; + + @Test + public void testUploadFile() throws IOException, InterruptedException { + File file = this.temporaryFolder.newFile("foo.mp3"); + Message message = MessageBuilder.withPayload(file) + .setHeader("s3Command", S3MessageHandler.Command.UPLOAD.name()) + .build(); + + this.s3SendChannel.send(message); + + ArgumentCaptor putObjectRequestArgumentCaptor = + ArgumentCaptor.forClass(PutObjectRequest.class); + verify(this.amazonS3, atLeastOnce()).putObject(putObjectRequestArgumentCaptor.capture()); + + PutObjectRequest putObjectRequest = putObjectRequestArgumentCaptor.getValue(); + assertEquals("myBucket", putObjectRequest.getBucketName()); + assertEquals("foo.mp3", putObjectRequest.getKey()); + assertNull(putObjectRequest.getFile()); + assertNotNull(putObjectRequest.getInputStream()); + + ObjectMetadata metadata = putObjectRequest.getMetadata(); + assertEquals(Md5Utils.md5AsBase64(file), metadata.getContentMD5()); + assertEquals(0, metadata.getContentLength()); + assertEquals("audio/mpeg", metadata.getContentType()); + + ProgressListener listener = putObjectRequest.getGeneralProgressListener(); + S3ProgressPublisher.publishProgress(listener, ProgressEventType.TRANSFER_COMPLETED_EVENT); + + assertTrue(this.transferCompletedLatch.await(10, TimeUnit.SECONDS)); + assertTrue(this.aclLatch.await(10, TimeUnit.SECONDS)); + + ArgumentCaptor setObjectAclRequestArgumentCaptor = + ArgumentCaptor.forClass(SetObjectAclRequest.class); + verify(this.amazonS3).setObjectAcl(setObjectAclRequestArgumentCaptor.capture()); + + SetObjectAclRequest setObjectAclRequest = setObjectAclRequestArgumentCaptor.getValue(); + + assertEquals("myBucket", setObjectAclRequest.getBucketName()); + assertEquals("foo.mp3", setObjectAclRequest.getKey()); + assertNull(setObjectAclRequest.getAcl()); + assertEquals(CannedAccessControlList.PublicReadWrite, setObjectAclRequest.getCannedAcl()); + } + + @Test + public void testUploadInputStream() throws IOException { + InputStream payload = new StringInputStream("a"); + Message message = MessageBuilder.withPayload(payload) + .setHeader("s3Command", S3MessageHandler.Command.UPLOAD.name()) + .setHeader("key", "myStream") + .build(); + + this.s3SendChannel.send(message); + + ArgumentCaptor putObjectRequestArgumentCaptor = + ArgumentCaptor.forClass(PutObjectRequest.class); + verify(this.amazonS3, atLeastOnce()).putObject(putObjectRequestArgumentCaptor.capture()); + + PutObjectRequest putObjectRequest = putObjectRequestArgumentCaptor.getValue(); + assertEquals("myBucket", putObjectRequest.getBucketName()); + assertEquals("myStream", putObjectRequest.getKey()); + assertNull(putObjectRequest.getFile()); + assertNotNull(putObjectRequest.getInputStream()); + + ObjectMetadata metadata = putObjectRequest.getMetadata(); + assertEquals(Md5Utils.md5AsBase64(payload), metadata.getContentMD5()); + assertEquals(1, metadata.getContentLength()); + assertEquals(MediaType.APPLICATION_JSON_VALUE, metadata.getContentType()); + assertEquals("test.json", metadata.getContentDisposition()); + } + + @Test + public void testDownloadDirectory() throws IOException { + File directoryForDownload = this.temporaryFolder.newFolder("myFolder"); + Message message = MessageBuilder.withPayload(directoryForDownload) + .setHeader("s3Command", S3MessageHandler.Command.DOWNLOAD) + .build(); + + this.s3SendChannel.send(message); + + File[] fileArray = directoryForDownload.listFiles(); + assertNotNull(fileArray); + assertEquals(2, fileArray.length); + + List files = Arrays.asList(fileArray); + Collections.sort(files, new Comparator() { + + @Override + public int compare(File o1, File o2) { + return o1.getName().compareTo(o2.getName()); + } + + }); + + File file1 = files.get(0); + assertEquals("bar", file1.getName()); + assertEquals("bb", FileCopyUtils.copyToString(new FileReader(file1))); + + File file2 = files.get(1); + assertEquals("foo", file2.getName()); + assertEquals("f", FileCopyUtils.copyToString(new FileReader(file2))); + } + + @Test + public void testCopy() throws InterruptedException { + Map payload = new HashMap<>(); + payload.put("key", "mySource"); + payload.put("destination", "theirBucket"); + payload.put("destinationKey", "theirTarget"); + this.s3ProcessChannel.send(new GenericMessage<>(payload)); + + Message receive = this.s3ReplyChannel.receive(10000); + assertNotNull(receive); + + Assert.assertThat(receive.getPayload(), instanceOf(Copy.class)); + Copy copy = (Copy) receive.getPayload(); + assertEquals("Copying object from myBucket/mySource to theirBucket/theirTarget", copy.getDescription()); + + copy.waitForCompletion(); + + assertEquals(Transfer.TransferState.Completed, copy.getState()); + } + + @Configuration + @EnableIntegration + public static class ContextConfiguration { + + @Bean + public AmazonS3 amazonS3() { + AmazonS3 amazonS3 = mock(AmazonS3.class); + + when(amazonS3.putObject(any(PutObjectRequest.class))).thenReturn(new PutObjectResult()); + when(amazonS3.getObjectMetadata(any(GetObjectMetadataRequest.class))).thenReturn(new ObjectMetadata()); + when(amazonS3.copyObject(any(CopyObjectRequest.class))).thenReturn(new CopyObjectResult()); + + ObjectListing objectListing = spy(new ObjectListing()); + + List s3ObjectSummaries = new LinkedList<>(); + + S3ObjectSummary fileSummary1 = new S3ObjectSummary(); + fileSummary1.setBucketName("myBucket"); + fileSummary1.setKey("foo"); + fileSummary1.setSize(1); + s3ObjectSummaries.add(fileSummary1); + + S3ObjectSummary fileSummary2 = new S3ObjectSummary(); + fileSummary2.setBucketName("myBucket"); + fileSummary2.setKey("bar"); + fileSummary2.setSize(2); + s3ObjectSummaries.add(fileSummary2); + + when(objectListing.getObjectSummaries()).thenReturn(s3ObjectSummaries); + when(amazonS3.listObjects(any(ListObjectsRequest.class))).thenReturn(objectListing); + + final S3Object file1 = new S3Object(); + file1.setBucketName("myBucket"); + file1.setKey("foo"); + try { + byte[] data = "f".getBytes(StringUtils.UTF8); + byte[] md5 = Md5Utils.computeMD5Hash(data); + file1.getObjectMetadata().setHeader(Headers.ETAG, BinaryUtils.toHex(md5)); + S3ObjectInputStream content = new S3ObjectInputStream(new ByteArrayInputStream(data), + mock(HttpRequestBase.class)); + file1.setObjectContent(content); + } + catch (Exception e) { + // no-op + } + + final S3Object file2 = new S3Object(); + file2.setBucketName("myBucket"); + file2.setKey("bar"); + try { + byte[] data = "bb".getBytes(StringUtils.UTF8); + byte[] md5 = Md5Utils.computeMD5Hash(data); + file2.getObjectMetadata().setHeader(Headers.ETAG, BinaryUtils.toHex(md5)); + S3ObjectInputStream content = new S3ObjectInputStream(new ByteArrayInputStream(data), + mock(HttpRequestBase.class)); + file2.setObjectContent(content); + } + catch (Exception e) { + // no-op + } + + doAnswer(new Answer() { + + @Override + public S3Object answer(InvocationOnMock invocation) throws Throwable { + GetObjectRequest getObjectRequest = (GetObjectRequest) invocation.getArguments()[0]; + String key = getObjectRequest.getKey(); + if ("foo".equals(key)) { + return file1; + } + else if ("bar".equals(key)) { + return file2; + } + else { + return (S3Object) invocation.callRealMethod(); + } + } + + }).when(amazonS3).getObject(any(GetObjectRequest.class)); + + doAnswer(new Answer() { + + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + aclLatch().countDown(); + return null; + } + + }).when(amazonS3).setObjectAcl(any(SetObjectAclRequest.class)); + + return amazonS3; + } + + + @Bean + public CountDownLatch aclLatch() { + return new CountDownLatch(1); + } + + @Bean + public CountDownLatch transferCompletedLatch() { + return new CountDownLatch(1); + } + + @Bean + public S3ProgressListener s3ProgressListener() { + return new S3ProgressListener() { + + @Override + public void onPersistableTransfer(PersistableTransfer persistableTransfer) { + + } + + @Override + public void progressChanged(ProgressEvent progressEvent) { + if (ProgressEventType.TRANSFER_COMPLETED_EVENT.equals(progressEvent.getEventType())) { + transferCompletedLatch().countDown(); + } + } + + }; + } + + @Bean + @ServiceActivator(inputChannel = "s3SendChannel") + public MessageHandler s3MessageHandler() { + S3MessageHandler s3MessageHandler = new S3MessageHandler(amazonS3(), "myBucket"); + s3MessageHandler.setCommandExpression(PARSER.parseExpression("headers.s3Command")); + s3MessageHandler.setKeyExpression(PARSER.parseExpression("payload instanceof T(java.io.File) ? payload.name : headers.key")); + s3MessageHandler.setObjectAclExpression(new ValueExpression<>(CannedAccessControlList.PublicReadWrite)); + s3MessageHandler.setUploadMetadataProvider(new S3MessageHandler.UploadMetadataProvider() { + + @Override + public void populateMetadata(ObjectMetadata metadata, Message message) { + if (message.getPayload() instanceof InputStream) { + metadata.setContentLength(1); + metadata.setContentType(MediaType.APPLICATION_JSON_VALUE); + metadata.setContentDisposition("test.json"); + } + } + + }); + s3MessageHandler.setS3ProgressListener(s3ProgressListener()); + return s3MessageHandler; + } + + @Bean + public PollableChannel s3ReplyChannel() { + return new QueueChannel(); + } + + @Bean + @ServiceActivator(inputChannel = "s3ProcessChannel") + public MessageHandler s3ProcessMessageHandler() { + S3MessageHandler s3MessageHandler = new S3MessageHandler(amazonS3(), "myBucket", true); + s3MessageHandler.setOutputChannel(s3ReplyChannel()); + s3MessageHandler.setCommand(S3MessageHandler.Command.COPY); + s3MessageHandler.setKeyExpression(PARSER.parseExpression("payload.key")); + s3MessageHandler.setDestinationBucketExpression(PARSER.parseExpression("payload.destination")); + s3MessageHandler.setDestinationKeyExpression(PARSER.parseExpression("payload.destinationKey")); + return s3MessageHandler; + } + + } + +}