INT-4231: Add FileExistsMode.REPLACE_IF_MODIFIED

JIRA: https://jira.spring.io/browse/INT-4231

For `FileWritingMessageHandler` and (S)FTP outbound gateways, support
`FileExistsMode.REPLACE_IF_MODIFIED` to allow overwriting an existing file if
the source file modified time is different to the existing file.

Polishing
This commit is contained in:
Gary Russell
2017-02-21 15:32:09 -05:00
committed by Artem Bilan
parent b30f404173
commit 2d4a7cf92f
18 changed files with 217 additions and 44 deletions

View File

@@ -213,8 +213,18 @@ public class FileWritingMessageHandler extends AbstractReplyProducingMessageHand
* <p>
* Otherwise the LockRegistry is set to {@link PassThruLockRegistry} which
* has no effect.
* <p>
* With {@link FileExistsMode#REPLACE_IF_MODIFIED}, if the file exists,
* it is only replaced if its last modified timestamp is different to the
* source; otherwise, the write is ignored. For {@link File} payloads,
* the actual timestamp of the {@link File} is compared; for other payloads,
* the {@link FileHeaders#SET_MODIFIED} is compared to the existing file.
* If the header is missing, or its value is not a {@link Number}, the file
* is always replaced. This mode will typically only make sense if
* {@link #setPreserveTimestamp(boolean) preserveTimestamp} is true.
*
* @param fileExistsMode Must not be null
* @see #setPreserveTimestamp(boolean)
*/
public void setFileExistsMode(FileExistsMode fileExistsMode) {
@@ -426,26 +436,30 @@ public class FileWritingMessageHandler extends AbstractReplyProducingMessageHand
File tempFile = new File(destinationDirectoryToUse, generatedFileName + this.temporaryFileSuffix);
File resultFile = new File(destinationDirectoryToUse, generatedFileName);
if (FileExistsMode.FAIL.equals(this.fileExistsMode) && resultFile.exists()) {
boolean exists = resultFile.exists();
if (exists && FileExistsMode.FAIL.equals(this.fileExistsMode)) {
throw new MessageHandlingException(requestMessage,
"The destination file already exists at '" + resultFile.getAbsolutePath() + "'.");
}
final boolean ignore = FileExistsMode.IGNORE.equals(this.fileExistsMode) &&
(resultFile.exists() ||
(StringUtils.hasText(this.temporaryFileSuffix) && tempFile.exists()));
Object timestamp = requestMessage.getHeaders().get(FileHeaders.SET_MODIFIED);
if (payload instanceof File) {
timestamp = ((File) payload).lastModified();
}
boolean ignore = (FileExistsMode.IGNORE.equals(this.fileExistsMode)
&& (exists || (StringUtils.hasText(this.temporaryFileSuffix) && tempFile.exists())))
|| ((exists && FileExistsMode.REPLACE_IF_MODIFIED.equals(this.fileExistsMode))
&& (timestamp instanceof Number
&& ((Number) timestamp).longValue() == resultFile.lastModified()));
if (!ignore) {
try {
Object timestamp = requestMessage.getHeaders().get(FileHeaders.SET_MODIFIED);
if (!resultFile.exists() &&
if (!exists &&
generatedFileName.replaceAll("/", Matcher.quoteReplacement(File.separator))
.contains(File.separator)) {
resultFile.getParentFile().mkdirs(); //NOSONAR - will fail on the writing below
}
if (payload instanceof File) {
resultFile = handleFileMessage((File) payload, tempFile, resultFile);
timestamp = ((File) payload).lastModified();
}
else if (payload instanceof InputStream) {
resultFile = handleInputStreamMessage((InputStream) payload, originalFileFromHeader, tempFile,
@@ -711,6 +725,7 @@ public class FileWritingMessageHandler extends AbstractReplyProducingMessageHand
case FAIL:
case IGNORE:
case REPLACE:
case REPLACE_IF_MODIFIED:
fileToWriteTo = tempFile;
break;
default:

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2013-2016 the original author or authors.
* Copyright 2013-2017 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.
@@ -282,6 +282,8 @@ public class RemoteFileTemplate<F> implements RemoteFileOperations<F>, Initializ
Assert.notNull(this.directoryExpressionProcessor, "'remoteDirectoryExpression' is required");
Assert.isTrue(!FileExistsMode.APPEND.equals(mode) || !this.useTemporaryFileName,
"Cannot append when using a temporary file name");
Assert.isTrue(!FileExistsMode.REPLACE_IF_MODIFIED.equals(mode),
"FilExistsMode.REPLACE_IF_MODIFIED can only be used for local files");
final StreamHolder inputStreamHolder = this.payloadToInputStream(message);
if (inputStreamHolder != null) {
try {

View File

@@ -587,7 +587,7 @@ public abstract class AbstractRemoteFileOutboundGateway<F> extends AbstractReply
}
else {
payload = this.remoteFileTemplate.execute(session1 ->
get(requestMessage, session1, remoteDir, remoteFilePath, remoteFilename, true));
get(requestMessage, session1, remoteDir, remoteFilePath, remoteFilename, null));
}
return getMessageBuilderFactory().withPayload(payload)
.setHeader(FileHeaders.REMOTE_DIRECTORY, remoteDir)
@@ -831,33 +831,38 @@ public abstract class AbstractRemoteFileOutboundGateway<F> extends AbstractReply
* Copy a remote file to the configured local directory.
*
*
* @param message The message.
* @param session The session.
* @param remoteDir The remote directory.
* @param remoteFilePath The remote file path.
* @param remoteFilename The remote file name.
* @param lsFirst true to execute an 'ls' command first.
* @param message the message.
* @param session the session.
* @param remoteDir the remote directory.
* @param remoteFilePath the remote file path.
* @param remoteFilename the remote file name.
* @param fileInfoParam the remote file info; if null we will execute an 'ls' command
* first.
* @return The file.
* @throws IOException Any IOException.
*/
protected File get(Message<?> message, Session<F> session, String remoteDir, String remoteFilePath,
String remoteFilename, boolean lsFirst) throws IOException {
F[] files = null;
if (lsFirst) {
files = session.list(remoteFilePath);
String remoteFilename, F fileInfoParam) throws IOException {
F fileInfo = fileInfoParam;
if (fileInfo == null) {
F[] files = session.list(remoteFilePath);
if (files == null) {
throw new MessagingException("Session returned null when listing " + remoteFilePath);
}
if (files.length != 1 || isDirectory(files[0]) || isLink(files[0])) {
if (files.length != 1 || files[0] == null || isDirectory(files[0]) || isLink(files[0])) {
throw new MessagingException(remoteFilePath + " is not a file");
}
fileInfo = files[0];
}
File localFile =
new File(generateLocalDirectory(message, remoteDir), generateLocalFileName(message, remoteFilename));
FileExistsMode fileExistsMode = this.fileExistsMode;
boolean appending = FileExistsMode.APPEND.equals(fileExistsMode);
boolean replacing = FileExistsMode.REPLACE.equals(fileExistsMode);
if (!localFile.exists() || appending || replacing) {
boolean exists = localFile.exists();
boolean replacing = FileExistsMode.REPLACE.equals(fileExistsMode)
|| (exists && FileExistsMode.REPLACE_IF_MODIFIED.equals(fileExistsMode)
&& localFile.lastModified() != getModified(fileInfo));
if (!exists || appending || replacing) {
OutputStream outputStream;
String tempFileName = localFile.getAbsolutePath() + this.remoteFileTemplate.getTemporaryFileSuffix();
File tempFile = new File(tempFileName);
@@ -898,11 +903,14 @@ public abstract class AbstractRemoteFileOutboundGateway<F> extends AbstractReply
if (!appending && !tempFile.renameTo(localFile)) {
throw new MessagingException("Failed to rename local file");
}
if (lsFirst && this.options.contains(Option.PRESERVE_TIMESTAMP)) {
localFile.setLastModified(getModified(files[0]));
if (this.options.contains(Option.PRESERVE_TIMESTAMP)) {
localFile.setLastModified(getModified(fileInfo));
}
}
else if (FileExistsMode.IGNORE != fileExistsMode) {
else if (FileExistsMode.REPLACE_IF_MODIFIED.equals(fileExistsMode)) {
logger.debug("Local file '" + localFile + "' has the same modified timestamp, ignored");
}
else if (!FileExistsMode.IGNORE.equals(fileExistsMode)) {
throw new MessageHandlingException(message, "Local file " + localFile + " already exists");
}
else {
@@ -955,10 +963,7 @@ public abstract class AbstractRemoteFileOutboundGateway<F> extends AbstractReply
String fileName = this.getRemoteFilename(fullFileName);
String actualRemoteDirectory = this.getRemoteDirectory(fullFileName, fileName);
File file = get(message, session, actualRemoteDirectory,
fullFileName, fileName, false);
if (this.options.contains(Option.PRESERVE_TIMESTAMP)) {
file.setLastModified(getModified(lsEntry.getFileInfo()));
}
fullFileName, fileName, lsEntry.getFileInfo());
files.add(file);
}
}
@@ -1001,10 +1006,7 @@ public abstract class AbstractRemoteFileOutboundGateway<F> extends AbstractReply
String fileName = this.getRemoteFilename(fullFileName);
String actualRemoteDirectory = this.getRemoteDirectory(fullFileName, fileName);
File file = get(message, session, actualRemoteDirectory,
fullFileName, fileName, false);
if (this.options.contains(Option.PRESERVE_TIMESTAMP)) {
file.setLastModified(getModified(lsEntry.getFileInfo()));
}
fullFileName, fileName, lsEntry.getFileInfo());
files.add(file);
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2016 the original author or authors.
* Copyright 2002-2017 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.
@@ -55,7 +55,14 @@ public enum FileExistsMode {
/**
* If the file already exists, replace it.
*/
REPLACE;
REPLACE,
/**
* If the file already exists, replace it only if the last modified time
* is different. Only applies to local files.
* @since 5.0
*/
REPLACE_IF_MODIFIED;
/**
* For a given non-null and not-empty input string, this method returns the

View File

@@ -828,6 +828,15 @@ Only files matching this regular expression will be picked up by this adapter.
]]></xsd:documentation>
</xsd:annotation>
</xsd:enumeration>
<xsd:enumeration value="REPLACE_IF_MODIFIED">
<xsd:annotation>
<xsd:documentation><![CDATA[
If the local file already exists, it will be overwritten only
if the last modified timestamp does not match the source
timestamp. Only applies to local files.
]]></xsd:documentation>
</xsd:annotation>
</xsd:enumeration>
<xsd:enumeration value="APPEND">
<xsd:annotation>
<xsd:documentation><![CDATA[
@@ -1004,6 +1013,12 @@ Only files matching this regular expression will be picked up by this adapter.
This is the default behavior when writing files. If the
target file already exists, it will be overwritten.
REPLACE_IF_MODIFIED:
If the local file already exists, it will be overwritten only
if the last modified timestamp does not match the source
timestamp. Only applies to local files.
]]></xsd:documentation>
</xsd:annotation>
<xsd:simpleType>

View File

@@ -518,6 +518,61 @@ public class FileWritingMessageHandlerTests {
handler.stop();
}
@Test
public void replaceIfDifferent() throws IOException {
QueueChannel output = new QueueChannel();
this.handler.setOutputChannel(output);
this.handler.setPreserveTimestamp(true);
this.handler.setFileExistsMode(FileExistsMode.REPLACE_IF_MODIFIED);
this.handler.handleMessage(MessageBuilder.withPayload("foo")
.setHeader(FileHeaders.FILENAME, "replaceIfDifferent.txt")
.setHeader(FileHeaders.SET_MODIFIED, 42_000_000)
.build());
Message<?> result = output.receive(0);
assertFileContentIs(result, "foo");
assertLastModifiedIs(result, 42_000_000);
this.handler.handleMessage(MessageBuilder.withPayload("bar")
.setHeader(FileHeaders.FILENAME, "replaceIfDifferent.txt")
.setHeader(FileHeaders.SET_MODIFIED, 42_000_000)
.build());
result = output.receive(0);
assertFileContentIs(result, "foo"); // no overwrite - timestamp same
assertLastModifiedIs(result, 42_000_000);
this.handler.handleMessage(MessageBuilder.withPayload("bar")
.setHeader(FileHeaders.FILENAME, "replaceIfDifferent.txt")
.setHeader(FileHeaders.SET_MODIFIED, 43_000_000)
.build());
result = output.receive(0);
assertFileContentIs(result, "bar");
assertLastModifiedIs(result, 43_000_000);
}
@Test
public void replaceIfDifferentFile() throws IOException {
File file = new File(this.temp.newFolder(), "foo.txt");
FileCopyUtils.copy("foo".getBytes(), new FileOutputStream(file));
file.setLastModified(42_000_000);
QueueChannel output = new QueueChannel();
this.handler.setOutputChannel(output);
this.handler.setPreserveTimestamp(true);
this.handler.setFileExistsMode(FileExistsMode.REPLACE_IF_MODIFIED);
this.handler.handleMessage(MessageBuilder.withPayload(file).build());
Message<?> result = output.receive(0);
assertFileContentIs(result, "foo");
assertLastModifiedIs(result, 42_000_000);
FileCopyUtils.copy("bar".getBytes(), new FileOutputStream(file));
file.setLastModified(42_000_000);
this.handler.handleMessage(MessageBuilder.withPayload(file).build());
result = output.receive(0);
assertFileContentIs(result, "foo"); // no overwrite - timestamp same
assertLastModifiedIs(result, 42_000_000);
file.setLastModified(43_000_000);
this.handler.handleMessage(MessageBuilder.withPayload(file).build());
result = output.receive(0);
assertFileContentIs(result, "bar");
assertLastModifiedIs(result, 43_000_000);
}
void assertFileContentIsMatching(Message<?> result) throws IOException {
assertFileContentIs(result, SAMPLE_CONTENT);
}

View File

@@ -53,6 +53,7 @@
command="mget"
expression="payload"
command-options="-R -P"
mode="REPLACE_IF_MODIFIED"
filter="starDotTxtFilter"
local-directory-expression="@extraConfig.targetLocalDirectoryName + #remoteDirectory"
local-filename-generator-expression="#remoteFileName.replaceFirst('ftpSource', 'localTarget')"

View File

@@ -36,6 +36,7 @@ import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
@@ -48,6 +49,7 @@ import java.util.Set;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.commons.io.FileUtils;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPFile;
import org.hamcrest.Matchers;
@@ -247,9 +249,11 @@ public class FtpServerOutboundTests extends FtpTestSupport {
@Test
@SuppressWarnings("unchecked")
public void testInt3172LocalDirectoryExpressionMGETRecursive() {
public void testInt3172LocalDirectoryExpressionMGETRecursive() throws IOException {
String dir = "ftpSource/";
long modified = setModifiedOnSource1();
File secondRemote = new File(getSourceRemoteDirectory(), "ftpSource2.txt");
secondRemote.setLastModified(System.currentTimeMillis() - 1_000_000);
this.inboundMGetRecursive.send(new GenericMessage<Object>("*"));
Message<?> result = this.output.receive(1000);
assertNotNull(result);
@@ -268,6 +272,30 @@ public class FtpServerOutboundTests extends FtpTestSupport {
assertThat(localFiles.get(2).getPath().replaceAll(quoteReplacement(File.separator), "/"),
containsString(dir + "subFtpSource"));
File secondTarget = new File(getTargetLocalDirectory() + File.separator + "ftpSource", "localTarget2.txt");
ByteArrayOutputStream remoteContents = new ByteArrayOutputStream();
ByteArrayOutputStream localContents = new ByteArrayOutputStream();
FileUtils.copyFile(secondRemote, remoteContents);
FileUtils.copyFile(secondTarget, localContents);
String localAsString = new String(localContents.toByteArray());
assertEquals(new String(remoteContents.toByteArray()), localAsString);
long oldLastModified = secondRemote.lastModified();
FileUtils.copyInputStreamToFile(new ByteArrayInputStream("junk".getBytes()), secondRemote);
long newLastModified = secondRemote.lastModified();
secondRemote.setLastModified(oldLastModified);
this.inboundMGetRecursive.send(new GenericMessage<Object>("*"));
this.output.receive(0);
localContents = new ByteArrayOutputStream();
FileUtils.copyFile(secondTarget, localContents);
assertEquals(localAsString, new String(localContents.toByteArray()));
secondRemote.setLastModified(newLastModified);
this.inboundMGetRecursive.send(new GenericMessage<Object>("*"));
this.output.receive(0);
localContents = new ByteArrayOutputStream();
FileUtils.copyFile(secondTarget, localContents);
assertEquals("junk", new String(localContents.toByteArray()));
// restore the remote file contents
FileUtils.copyInputStreamToFile(new ByteArrayInputStream(localAsString.getBytes()), secondRemote);
}
private long setModifiedOnSource1() {

View File

@@ -25,4 +25,4 @@
<appender-ref ref="console" />
</root>
</log4j:configuration>
</log4j:configuration>

View File

@@ -26,4 +26,4 @@
<appender-ref ref="console" />
</root>
</log4j:configuration>
</log4j:configuration>

View File

@@ -53,6 +53,7 @@
command="mget"
expression="payload"
command-options="-R -P"
mode="REPLACE_IF_MODIFIED"
filter="dotStarDotTxtFilter"
local-directory-expression="@extraConfig.targetLocalDirectoryName + #remoteDirectory"
local-filename-generator-expression="#remoteFileName.replaceFirst('sftpSource', 'localTarget')"

View File

@@ -30,6 +30,7 @@ import static org.junit.Assert.fail;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
@@ -40,6 +41,7 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.apache.commons.io.FileUtils;
import org.hamcrest.Matchers;
import org.junit.Before;
import org.junit.Test;
@@ -208,9 +210,11 @@ public class SftpServerOutboundTests extends SftpTestSupport {
@Test
@SuppressWarnings("unchecked")
public void testInt3172LocalDirectoryExpressionMGETRecursive() {
public void testInt3172LocalDirectoryExpressionMGETRecursive() throws IOException {
String dir = "sftpSource/";
long modified = setModifiedOnSource1();
File secondRemote = new File(getSourceRemoteDirectory(), "sftpSource2.txt");
secondRemote.setLastModified(System.currentTimeMillis() - 1_000_000);
this.inboundMGetRecursive.send(new GenericMessage<Object>(dir + "*"));
Message<?> result = this.output.receive(1000);
assertNotNull(result);
@@ -229,6 +233,30 @@ public class SftpServerOutboundTests extends SftpTestSupport {
assertThat(localFiles.get(2).getPath().replaceAll(quoteReplacement(File.separator), "/"),
containsString(dir + "subSftpSource"));
File secondTarget = new File(getTargetLocalDirectory() + File.separator + "sftpSource", "localTarget2.txt");
ByteArrayOutputStream remoteContents = new ByteArrayOutputStream();
ByteArrayOutputStream localContents = new ByteArrayOutputStream();
FileUtils.copyFile(secondRemote, remoteContents);
FileUtils.copyFile(secondTarget, localContents);
String localAsString = new String(localContents.toByteArray());
assertEquals(new String(remoteContents.toByteArray()), localAsString);
long oldLastModified = secondRemote.lastModified();
FileUtils.copyInputStreamToFile(new ByteArrayInputStream("junk".getBytes()), secondRemote);
long newLastModified = secondRemote.lastModified();
secondRemote.setLastModified(oldLastModified);
this.inboundMGetRecursive.send(new GenericMessage<Object>(dir + "*"));
this.output.receive(0);
localContents = new ByteArrayOutputStream();
FileUtils.copyFile(secondTarget, localContents);
assertEquals(localAsString, new String(localContents.toByteArray()));
secondRemote.setLastModified(newLastModified);
this.inboundMGetRecursive.send(new GenericMessage<Object>(dir + "*"));
this.output.receive(0);
localContents = new ByteArrayOutputStream();
FileUtils.copyFile(secondTarget, localContents);
assertEquals("junk", new String(localContents.toByteArray()));
// restore the remote file contents
FileUtils.copyInputStreamToFile(new ByteArrayInputStream(localAsString.getBytes()), secondRemote);
}
private long setModifiedOnSource1() {

View File

@@ -617,6 +617,7 @@ This behavior, though, can be changed by setting the _mode_ attribute on the res
The following options exist:
* REPLACE (Default)
* REPLACE_IF_MODIFIED
* APPEND
* APPEND_NO_FLUSH
* FAIL
@@ -631,6 +632,13 @@ _REPLACE_
If the target file already exists, it will be overwritten.
If the _mode_ attribute is not specified, then this is the default behavior when writing files.
_REPLACE_IF_MODIFIED_
If the target file already exists, it will be overwritten only if the last modified timestamp is different to the source file.
For `File` payloads, the payload `lastModified` time is compared to the existing file.
For other payloads, the `FileHeaders.SET_MODIFIED` (`file_setModified`) header is compared to the existing file.
If the header is missing, or has a value that is not a `Number`, the file is always replaced.
_APPEND_
This mode allows you to append Message content to the existing file instead of creating a new file each time.

View File

@@ -848,12 +848,15 @@ _mget_ retrieves multiple remote files based on a pattern and supports the follo
* -P - preserve the timestamps of the remote files
* -R - retrieve the entire directory tree recursively
* -x - Throw an exception if no files match the pattern (otherwise an empty list is returned)
The message payload resulting from an _mget_ operation is a `List<File>` object - a List of File objects, each representing a retrieved file.
The remote directory is provided in the `file_remoteDirectory` header, and the pattern for the file names is provided in the `file_remoteFile` header.
The expression used to determine the remote path should produce a result that ends with `*` - e.g. `foo/*` will fetch the complete tree under `foo`.
Starting with _version 5.0_, a recursive `MGET`, combined with the new `FileExistsMode.REPLACE_IF_MODIFIED` mode, can be used to periodically synchronize an entire remote directory tree locally.
[NOTE]
.Notes for when using recursion (`-R`)

View File

@@ -410,4 +410,4 @@ private MongoDbOutboundGatewaySpec collectionCallbackOutboundGateway() {
.collectionCallback(MongoCollection::count)
.collectionName("foo");
}
----
----

View File

@@ -183,4 +183,4 @@ public interface SecuredGateway {
Future<String> send(String payload);
}
----
----

View File

@@ -866,12 +866,15 @@ _mget_ retrieves multiple remote files based on a pattern and supports the follo
* -P - preserve the timestamps of the remote files
* -R - retrieve the entire directory tree recursively
* -x - Throw an exception if no files match the pattern (otherwise an empty list is returned)
The message payload resulting from an _mget_ operation is a `List<File>` object - a List of File objects, each representing a retrieved file.
The remote directory is provided in the `file_remoteDirectory` header, and the pattern for the filenames is provided in the `file_remoteFile` header.
The expression used to determine the remote path should produce a result that ends with `*` - e.g. `foo/*` will fetch the complete tree under `foo`.
Starting with _version 5.0_, a recursive `MGET`, combined with the new `FileExistsMode.REPLACE_IF_MODIFIED` mode, can be used to periodically synchronize an entire remote directory tree locally.
[NOTE]
.Notes for when using recursion (`-R`)

View File

@@ -69,6 +69,8 @@ See <<file-tailing>> for more information.
The flush predicates for the `FileWritingMessageHandler` now have an additional parameter.
See <<file-flushing>> for more information.
The file outbound channel adapter (`FileWritingMessageHandler`) now supports the `REPLACE_IF_MODIFIED` `FileExistsMode`.
==== (S)FTP Changes
The inbound channel adapters now have a property `max-fetch-size` which is used to limit the number of files fetched during a poll when there are no files currently in the local directory.
@@ -77,6 +79,9 @@ The regex and pattern filters can now be configured to always pass directories.
This can be useful when using recursion in the outbound gateways.
See <<ftp-outbound-gateway>> and <<sftp-outbound-gateway>> for more information.
The FTP and SFTP outbound gateways now support the `REPLACE_IF_MODIFIED` `FileExistsMode` when fetching remote files.
See <<ftp-outbound-gateway>> and <<sftp-outbound-gateway>> for more information.
==== Integration Properties
Since _version 4.3.2_ a new `spring.integration.readOnly.headers` global property has been added to customize the list of headers which should not be copied to a newly created `Message` by the `MessageBuilder`.