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