diff --git a/spring-integration-file/src/main/java/org/springframework/integration/file/dsl/RemoteFileOutboundGatewaySpec.java b/spring-integration-file/src/main/java/org/springframework/integration/file/dsl/RemoteFileOutboundGatewaySpec.java
index a566aea78a6..5e1a2b87463 100644
--- a/spring-integration-file/src/main/java/org/springframework/integration/file/dsl/RemoteFileOutboundGatewaySpec.java
+++ b/spring-integration-file/src/main/java/org/springframework/integration/file/dsl/RemoteFileOutboundGatewaySpec.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2016-2023 the original author or authors.
+ * Copyright 2016-2025 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.
@@ -44,6 +44,7 @@
*
* @author Artem Bilan
* @author Gary Russell
+ * @author Jooyoung Pyoung
*
* @since 5.0
*/
@@ -358,6 +359,48 @@ public S fileExistsMode(FileExistsMode fileExistsMode) {
return _this();
}
+ /**
+ * Specify a SpEL expression to determine the action to take when files already exist.
+ * Expression evaluation should return a {@link FileExistsMode} or a String representation.
+ * Used for GET and MGET operations when the file already exists locally,
+ * or PUT and MPUT when the file exists on the remote system.
+ * @param fileExistsModeExpression a SpEL expression to evaluate the file exists mode
+ * @return the Spec.
+ * @since 6.5
+ */
+ public S fileExistsModeExpression(Expression fileExistsModeExpression) {
+ this.target.setFileExistsModeExpression(fileExistsModeExpression);
+ return _this();
+ }
+
+ /**
+ * Specify a SpEL expression to determine the action to take when files already exist.
+ * Expression evaluation should return a {@link FileExistsMode} or a String representation.
+ * Used for GET and MGET operations when the file already exists locally,
+ * or PUT and MPUT when the file exists on the remote system.
+ * @param fileExistsModeExpression the String in SpEL syntax.
+ * @return the Spec.
+ * @since 6.5
+ */
+ public S fileExistsModeExpression(String fileExistsModeExpression) {
+ this.target.setFileExistsModeExpressionString(fileExistsModeExpression);
+ return _this();
+ }
+
+ /**
+ * Specify a {@link Function} to determine the action to take when files already exist.
+ * Expression evaluation should return a {@link FileExistsMode} or a String representation.
+ * Used for GET and MGET operations when the file already exists locally,
+ * or PUT and MPUT when the file exists on the remote system.
+ * @param fileExistsModeFunction the {@link Function} to use.
+ * @param
the expected payload type.
+ * @return the Spec.
+ * @since 6.5
+ */
+ public
S fileExistsModeFunction(Function, Object> fileExistsModeFunction) {
+ return fileExistsModeExpression(new FunctionExpression<>(fileExistsModeFunction));
+ }
+
/**
* Determine whether the remote directory should automatically be created when
* sending files to the remote system.
diff --git a/spring-integration-file/src/main/java/org/springframework/integration/file/remote/RemoteFileTemplate.java b/spring-integration-file/src/main/java/org/springframework/integration/file/remote/RemoteFileTemplate.java
index 28621adc837..456fac9c70c 100644
--- a/spring-integration-file/src/main/java/org/springframework/integration/file/remote/RemoteFileTemplate.java
+++ b/spring-integration-file/src/main/java/org/springframework/integration/file/remote/RemoteFileTemplate.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2013-2024 the original author or authors.
+ * Copyright 2013-2025 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.
@@ -63,6 +63,7 @@
* @author Gary Russell
* @author Artem Bilan
* @author Alen Turkovic
+ * @author Jooyoung Pyoung
*
* @since 3.0
*
@@ -303,8 +304,6 @@ public String send(Message> message, String subDirectory, FileExistsMode... mo
private String send(Message> message, String subDirectory, FileExistsMode mode) {
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 = payloadToInputStream(message);
@@ -565,7 +564,10 @@ private void sendFileToRemoteDirectory(InputStream inputStream, String temporary
String tempRemoteFilePath = temporaryRemoteDirectory + fileName;
// write remote file first with temporary file extension if enabled
- String tempFilePath = tempRemoteFilePath + (this.useTemporaryFileName ? this.temporaryFileSuffix : "");
+ String tempFilePath = tempRemoteFilePath;
+ if (!FileExistsMode.APPEND.equals(mode) && this.useTemporaryFileName) {
+ tempFilePath += this.temporaryFileSuffix;
+ }
if (this.autoCreateDirectory) {
try {
diff --git a/spring-integration-file/src/main/java/org/springframework/integration/file/remote/gateway/AbstractRemoteFileOutboundGateway.java b/spring-integration-file/src/main/java/org/springframework/integration/file/remote/gateway/AbstractRemoteFileOutboundGateway.java
index 4c1f7e56556..f7f0fa1b614 100644
--- a/spring-integration-file/src/main/java/org/springframework/integration/file/remote/gateway/AbstractRemoteFileOutboundGateway.java
+++ b/spring-integration-file/src/main/java/org/springframework/integration/file/remote/gateway/AbstractRemoteFileOutboundGateway.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2024 the original author or authors.
+ * Copyright 2002-2025 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.
@@ -75,6 +75,7 @@
* @author Gary Russell
* @author Artem Bilan
* @author Mauro Molinari
+ * @author Jooyoung Pyoung
*
* @since 2.1
*/
@@ -114,8 +115,12 @@ public abstract class AbstractRemoteFileOutboundGateway extends AbstractReply
private Expression localFilenameGeneratorExpression;
+ private Expression fileExistsModeExpression;
+
private FileExistsMode fileExistsMode;
+ private EvaluationContext standardEvaluationContext;
+
private Integer chmod;
private boolean remoteFileTemplateExplicitlySet;
@@ -486,6 +491,32 @@ public void setLocalFilenameGeneratorExpressionString(String localFilenameGenera
this.localFilenameGeneratorExpression = EXPRESSION_PARSER.parseExpression(localFilenameGeneratorExpression);
}
+ /**
+ * Specify a SpEL expression to determine the action to take when files already exist.
+ * Expression evaluation should return a {@link FileExistsMode} object.
+ * Used for GET and MGET operations when the file already exists locally,
+ * or PUT and MPUT when the file exists on the remote system.
+ * @param fileExistsModeExpression the expression to use.
+ * @since 6.5
+ */
+ public void setFileExistsModeExpression(Expression fileExistsModeExpression) {
+ Assert.notNull(fileExistsModeExpression, "'fileExistsModeExpression' must not be null");
+ this.fileExistsModeExpression = fileExistsModeExpression;
+ }
+
+ /**
+ * Specify a SpEL expression to determine the action to take when files already exist.
+ * Expression evaluation should return a {@link FileExistsMode} object.
+ * Used for GET and MGET operations when the file already exists locally,
+ * or PUT and MPUT when the file exists on the remote system.
+ * @param fileExistsModeExpression the String in SpEL syntax.
+ * @since 6.5
+ */
+ public void setFileExistsModeExpressionString(String fileExistsModeExpression) {
+ Assert.hasText(fileExistsModeExpression, "'fileExistsModeExpression' must not be empty");
+ this.fileExistsModeExpression = EXPRESSION_PARSER.parseExpression(fileExistsModeExpression);
+ }
+
/**
* Determine the action to take when using GET and MGET operations when the file
* already exists locally, or PUT and MPUT when the file exists on the remote
@@ -495,9 +526,6 @@ public void setLocalFilenameGeneratorExpressionString(String localFilenameGenera
*/
public void setFileExistsMode(FileExistsMode fileExistsMode) {
this.fileExistsMode = fileExistsMode;
- if (FileExistsMode.APPEND.equals(fileExistsMode)) {
- this.remoteFileTemplate.setUseTemporaryFileName(false);
- }
}
/**
@@ -539,6 +567,7 @@ protected void doInit() {
Assert.isNull(this.filter, "Filters are not supported with the rm and get commands");
}
+ this.standardEvaluationContext = ExpressionUtils.createStandardEvaluationContext(getBeanFactory());
if ((Command.GET.equals(this.command) && !this.options.contains(Option.STREAM))
|| Command.MGET.equals(this.command)) {
Assert.notNull(this.localDirectoryExpression, "localDirectory must not be null");
@@ -553,6 +582,11 @@ protected void doInit() {
Option.RECURSIVE.toString() + " to obtain files in subdirectories");
}
+ if (FileExistsMode.APPEND.equals(this.fileExistsMode) && this.remoteFileTemplate.isUseTemporaryFileName()) {
+ logger.warn("FileExistsMode.APPEND is incompatible with useTemporaryFileName=true. " +
+ "Temporary filename will be ignored for APPEND mode.");
+ }
+
populateBeanFactoryIntoComponentsIfAny();
if (!this.remoteFileTemplateExplicitlySet) {
this.remoteFileTemplate.afterPropertiesSet();
@@ -573,7 +607,7 @@ private void populateBeanFactoryIntoComponentsIfAny() {
private void setupLocalDirectory() {
File localDirectory =
ExpressionUtils.expressionToFile(this.localDirectoryExpression,
- ExpressionUtils.createStandardEvaluationContext(getBeanFactory()), null,
+ this.standardEvaluationContext, null,
"localDirectoryExpression");
if (!localDirectory.exists()) {
try {
@@ -845,7 +879,8 @@ private String doPut(Message> requestMessage, String subDirectory) {
* @since 5.0
*/
protected String put(Message> message, Session session, String subDirectory) {
- String path = this.remoteFileTemplate.send(message, subDirectory, this.fileExistsMode);
+ FileExistsMode existsMode = resolveFileExistsMode(message);
+ String path = this.remoteFileTemplate.send(message, subDirectory, existsMode);
if (path == null) {
throw new MessagingException(message, "No local file found for " + message);
}
@@ -1130,7 +1165,7 @@ protected File get(Message> message, Session session, String remoteDir, //
}
final File localFile =
new File(generateLocalDirectory(message, remoteDir), generateLocalFileName(message, remoteFilename));
- FileExistsMode existsMode = this.fileExistsMode;
+ FileExistsMode existsMode = resolveFileExistsMode(message);
boolean appending = FileExistsMode.APPEND.equals(existsMode);
boolean exists = localFile.exists();
boolean replacing = exists && (FileExistsMode.REPLACE.equals(existsMode)
@@ -1351,6 +1386,31 @@ protected String getRemoteFilename(String remoteFilePath) {
}
}
+ private FileExistsMode resolveFileExistsMode(Message> message) {
+ if (this.fileExistsModeExpression != null) {
+ Object evaluationResult = this.fileExistsModeExpression.getValue(this.standardEvaluationContext, message);
+ if (evaluationResult instanceof FileExistsMode resolvedMode) {
+ return resolvedMode;
+ }
+ else if (evaluationResult instanceof String modeAsString) {
+ try {
+ return FileExistsMode.valueOf(modeAsString.toUpperCase());
+ }
+ catch (IllegalArgumentException ex) {
+ throw new MessagingException(message,
+ "Invalid FileExistsMode string: '" + modeAsString + "'. Expected one of: " +
+ Arrays.toString(FileExistsMode.values()), ex);
+ }
+ }
+ else if (evaluationResult != null) {
+ throw new MessagingException(message,
+ "Expression returned invalid type for FileExistsMode: " +
+ evaluationResult.getClass().getName() + ". Expected FileExistsMode or String.");
+ }
+ }
+ return this.fileExistsMode;
+ }
+
private File generateLocalDirectory(Message> message, String remoteDirectory) {
EvaluationContext evaluationContext = ExpressionUtils.createStandardEvaluationContext(getBeanFactory());
if (remoteDirectory != null) {
diff --git a/spring-integration-file/src/test/java/org/springframework/integration/file/remote/gateway/RemoteFileOutboundGatewayTests.java b/spring-integration-file/src/test/java/org/springframework/integration/file/remote/gateway/RemoteFileOutboundGatewayTests.java
index fcf226bf302..3c554f0a007 100644
--- a/spring-integration-file/src/test/java/org/springframework/integration/file/remote/gateway/RemoteFileOutboundGatewayTests.java
+++ b/spring-integration-file/src/test/java/org/springframework/integration/file/remote/gateway/RemoteFileOutboundGatewayTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2024 the original author or authors.
+ * Copyright 2002-2025 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.
@@ -29,6 +29,7 @@
import java.util.Collection;
import java.util.Date;
import java.util.List;
+import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
@@ -70,6 +71,7 @@
* @author Gary Russell
* @author Liu Jiong
* @author Artem Bilan
+ * @author Jooyoung Pyoung
*
* @since 2.1
*/
@@ -640,6 +642,66 @@ public void read(String source, OutputStream outputStream)
outFile.delete();
}
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testGetExistsExpression() throws Exception {
+ SessionFactory sessionFactory = mock(SessionFactory.class);
+ TestRemoteFileOutboundGateway gw = new TestRemoteFileOutboundGateway(sessionFactory, "get", "payload");
+ gw.setFileExistsModeExpressionString("headers[\"file.exists.mode\"]");
+
+ gw.setLocalDirectory(new File(this.tmpDir));
+ gw.afterPropertiesSet();
+ File outFile = new File(this.tmpDir + "/f1");
+ FileOutputStream fos = new FileOutputStream(outFile);
+ fos.write("foo".getBytes());
+ fos.close();
+ when(sessionFactory.getSession()).thenReturn(new TestSession() {
+
+ @Override
+ public TestLsEntry[] list(String path) {
+ return new TestLsEntry[] {
+ new TestLsEntry("f1", 1234, false, false, 12345, "-rw-r--r--")
+ };
+ }
+
+ @Override
+ public void read(String source, OutputStream outputStream)
+ throws IOException {
+ outputStream.write("testfile".getBytes());
+ }
+
+ });
+
+ // default (null)
+ MessageBuilder out;
+
+ assertThatExceptionOfType(MessageHandlingException.class)
+ .isThrownBy(() -> gw.handleRequestMessage(new GenericMessage<>("f1")))
+ .withMessageContaining("already exists");
+
+ assertThatExceptionOfType(MessageHandlingException.class)
+ .isThrownBy(() -> gw.handleRequestMessage(
+ new GenericMessage<>("f1", Map.of("file.exists.mode", FileExistsMode.FAIL))))
+ .withMessageContaining("already exists");
+
+ out = (MessageBuilder) gw.handleRequestMessage(
+ new GenericMessage<>("f1", Map.of("file.exists.mode", "IGNORE")));
+ assertThat(out.getPayload()).isEqualTo(outFile);
+ assertContents("foo", outFile);
+
+ out = (MessageBuilder) gw.handleRequestMessage(
+ new GenericMessage<>("f1", Map.of("file.exists.mode", "append")));
+ assertThat(out.getPayload()).isEqualTo(outFile);
+ assertContents("footestfile", outFile);
+
+ out = (MessageBuilder) gw.handleRequestMessage(
+ new GenericMessage<>("f1", Map.of("file.exists.mode", FileExistsMode.REPLACE)));
+ assertThat(out.getPayload()).isEqualTo(outFile);
+ assertContents("testfile", outFile);
+
+ outFile.delete();
+ }
+
private void assertContents(String expected, File outFile) throws Exception {
BufferedReader reader = new BufferedReader(new FileReader(outFile));
assertThat(reader.readLine()).isEqualTo(expected);
@@ -860,6 +922,69 @@ public void testPutExists() throws Exception {
verify(session, times(1)).append(any(InputStream.class), anyString());
}
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testPutExistsExpression() throws Exception {
+ SessionFactory sessionFactory = mock(SessionFactory.class);
+ Session session = mock(Session.class);
+ willReturn(Boolean.TRUE)
+ .given(session)
+ .exists(anyString());
+ RemoteFileTemplate template = new RemoteFileTemplate<>(sessionFactory);
+ template.setRemoteDirectoryExpression(new LiteralExpression("foo/"));
+ template.setBeanFactory(mock(BeanFactory.class));
+ template.afterPropertiesSet();
+ TestRemoteFileOutboundGateway gw = new TestRemoteFileOutboundGateway(template, "put", "payload");
+ FileTransferringMessageHandler handler = new FileTransferringMessageHandler<>(sessionFactory);
+ handler.setRemoteDirectoryExpression(new LiteralExpression("foo/"));
+ handler.setBeanFactory(mock(BeanFactory.class));
+ handler.afterPropertiesSet();
+ gw.afterPropertiesSet();
+ gw.setFileExistsModeExpressionString("headers[\"file.exists.mode\"]");
+ when(sessionFactory.getSession()).thenReturn(session);
+ MessageBuilder requestMessageBuilder = MessageBuilder.withPayload("hello")
+ .setHeader(FileHeaders.FILENAME, "bar.txt");
+
+ Message defaultMessage = requestMessageBuilder.build();
+ String path = (String) gw.handleRequestMessage(defaultMessage);
+ assertThat(path).isEqualTo("foo/bar.txt");
+ ArgumentCaptor captor = ArgumentCaptor.forClass(String.class);
+ verify(session).write(any(InputStream.class), captor.capture());
+ assertThat(captor.getValue()).isEqualTo("foo/bar.txt.writing");
+ verify(session).rename("foo/bar.txt.writing", "foo/bar.txt");
+
+ Message failMessage = requestMessageBuilder.setHeader("file.exists.mode", FileExistsMode.FAIL)
+ .build();
+ assertThatExceptionOfType(MessageDeliveryException.class)
+ .isThrownBy(() -> gw.handleRequestMessage(failMessage))
+ .withStackTraceContaining("The destination file already exists");
+
+ Message replaceMessage = requestMessageBuilder.setHeader("file.exists.mode", "replace")
+ .build();
+ path = (String) gw.handleRequestMessage(replaceMessage);
+ assertThat(path).isEqualTo("foo/bar.txt");
+ captor = ArgumentCaptor.forClass(String.class);
+ verify(session, times(2)).write(any(InputStream.class), captor.capture());
+ assertThat(captor.getValue()).isEqualTo("foo/bar.txt.writing");
+ verify(session, times(2)).rename("foo/bar.txt.writing", "foo/bar.txt");
+
+ Message appendMessage = requestMessageBuilder.setHeader("file.exists.mode", "APPEND")
+ .build();
+ path = (String) gw.handleRequestMessage(appendMessage);
+ assertThat(path).isEqualTo("foo/bar.txt");
+ captor = ArgumentCaptor.forClass(String.class);
+ verify(session).append(any(InputStream.class), captor.capture());
+ assertThat(captor.getValue()).isEqualTo("foo/bar.txt");
+
+ Message ignoreMessage = requestMessageBuilder.setHeader("file.exists.mode", FileExistsMode.IGNORE)
+ .build();
+ path = (String) gw.handleRequestMessage(ignoreMessage);
+ assertThat(path).isEqualTo("foo/bar.txt");
+ // no more writes/appends
+ verify(session, times(2)).write(any(InputStream.class), anyString());
+ verify(session, times(1)).append(any(InputStream.class), anyString());
+ }
+
@Test
@SuppressWarnings("unchecked")
public void testMput() throws Exception {
diff --git a/src/reference/antora/modules/ROOT/pages/ftp/rft.adoc b/src/reference/antora/modules/ROOT/pages/ftp/rft.adoc
index 55f2924f2dd..de806c14a7b 100644
--- a/src/reference/antora/modules/ROOT/pages/ftp/rft.adoc
+++ b/src/reference/antora/modules/ROOT/pages/ftp/rft.adoc
@@ -37,3 +37,21 @@ This is useful when you need to perform several high-level operations of the `Re
For example, `AbstractRemoteFileOutboundGateway` uses it with the `mput` command implementation, where we perform a `put` operation for each file in the provided directory and recursively for its sub-directories.
See the https://docs.spring.io/spring-integration/api/org/springframework/integration/file/remote/RemoteFileOperations.html#invoke[Javadoc] for more information.
+Starting with version 6.5, the `AbstractRemoteFileOutboundGateway` supports dynamic resolution of `FileExistsMode` at runtime via SpEL expressions.
+This allows you to determine the action to take when files already exist based on message content or other conditions.
+
+To use this feature, configure the `fileExistsModeExpression` property on the gateway.
+The expression can evaluate to:
+
+* A `FileExistsMode` enum value (e.g., `FileExistsMode.REPLACE`)
+* A string representation of a `FileExistsMode` (case-insensitive, e.g., "REPLACE", "append")
+
+If the expression returns `null`, the default `fileExistsMode` configured on the gateway will be used.
+
+See the https://docs.spring.io/spring-integration/api/org/springframework/integration/file/remote/gateway/AbstractRemoteFileOutboundGateway.html#setFileExistsModeExpression(org.springframework.expression.Expression)[Javadoc] for more information.
+
+[IMPORTANT]
+====
+When using `FileExistsMode.APPEND`, temporary filename functionality is automatically disabled regardless of the `useTemporaryFileName` setting.
+This is because appending to a temporary file and then renaming it would not achieve the intended append behavior.
+====
diff --git a/src/reference/antora/modules/ROOT/pages/whats-new.adoc b/src/reference/antora/modules/ROOT/pages/whats-new.adoc
index c4da105c195..d097c05cef6 100644
--- a/src/reference/antora/modules/ROOT/pages/whats-new.adoc
+++ b/src/reference/antora/modules/ROOT/pages/whats-new.adoc
@@ -79,6 +79,12 @@ The `AbstractRecentFileListFilter` strategy has been introduced to accept only t
The respective implementations are provided: `RecentFileListFilter`, `FtpRecentFileListFilter`, `SftpRecentFileListFilter` and `SmbRecentFileListFilter`.
See xref:file/reading.adoc[Reading Files] for more information.
+[[x6.5-file-exists-mode-expression]]
+== FileExistsMode Expression Support
+
+The remote file gateways (`AbstractRemoteFileOutboundGateway`) now support dynamic resolution of `FileExistsMode` at runtime via SpEL expressions.
+See xref:ftp/rft.adoc[Remote File Gateways] for more information.
+
[[x6.5-hazelcast-changes]]
== Hazelcast Module Deprecations