From 8e204c4fbae6e6187bba092f1e7910d4437eb773 Mon Sep 17 00:00:00 2001 From: Jooyoung Pyoung Date: Tue, 13 May 2025 11:59:02 +0900 Subject: [PATCH 1/6] GH-9988: Add FileExistsMode expression support Fixes: #9988 Issue link: https://github.com/spring-projects/spring-integration/issues/9988 This change allows dynamic determination of FileExistsMode using SpEL expressions, making the component more flexible when handling file existence conflicts. * Add fileExistsModeExpression field and setter methods * Use resolveFileExistsMode in put and get operations * Add changes to the docs Signed-off-by: Jooyoung Pyoung --- .../AbstractRemoteFileOutboundGateway.java | 45 ++++++- .../RemoteFileOutboundGatewayTests.java | 124 +++++++++++++++++- 2 files changed, 165 insertions(+), 4 deletions(-) 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..b542d7e3796 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,6 +115,8 @@ public abstract class AbstractRemoteFileOutboundGateway extends AbstractReply private Expression localFilenameGeneratorExpression; + private Expression fileExistsModeExpression; + private FileExistsMode fileExistsMode; private Integer chmod; @@ -486,6 +489,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 setFileExistsModeExpression(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 @@ -845,7 +874,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 +1160,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 +1381,15 @@ protected String getRemoteFilename(String remoteFilePath) { } } + private FileExistsMode resolveFileExistsMode(Message message) { + if (this.fileExistsModeExpression != null) { + EvaluationContext evaluationContext = ExpressionUtils.createStandardEvaluationContext(getBeanFactory()); + evaluationContext.setVariable("fileExistsMode", this.fileExistsMode); + return this.fileExistsModeExpression.getValue(evaluationContext, message, FileExistsMode.class); + } + 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..512351e3dbb 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; @@ -640,6 +641,65 @@ 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.setFileExistsModeExpression("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", FileExistsMode.IGNORE))); + assertThat(out.getPayload()).isEqualTo(outFile); + assertContents("foo", outFile); + + out = (MessageBuilder) gw.handleRequestMessage( + new GenericMessage<>("f1", Map.of("file.exists.mode", FileExistsMode.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 +920,68 @@ 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.setFileExistsModeExpression("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", FileExistsMode.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", FileExistsMode.APPEND) + .build(); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> gw.handleRequestMessage(appendMessage)) + .withStackTraceContaining("Cannot append when using a temporary file name"); + + 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(0)).append(any(InputStream.class), anyString()); + } + @Test @SuppressWarnings("unchecked") public void testMput() throws Exception { From c0ac168bb832090cd0103828a45b54addc57eac0 Mon Sep 17 00:00:00 2001 From: Jooyoung Pyoung Date: Tue, 13 May 2025 12:39:14 +0900 Subject: [PATCH 2/6] Ignore temporaryFileName when FileExistsMode is APPEND Improve runtime behavior by ignoring temporary filename settings when file exists mode is APPEND. Now, in FileExistsMode.APPEND mode, content is always appended directly to the original file regardless of useTemporaryFileName setting. In RemoteFileTemplate: - Remove exception validation when APPEND mode is used with temporary filenames - Modify logic to skip applying temporaryFileSuffix in APPEND mode In AbstractRemoteFileOutboundGateway: - Remove logic that disabled temporary filenames when setting APPEND mode Signed-off-by: Jooyoung Pyoung --- .../integration/file/remote/RemoteFileTemplate.java | 10 ++++++---- .../gateway/AbstractRemoteFileOutboundGateway.java | 3 --- .../gateway/RemoteFileOutboundGatewayTests.java | 11 ++++++----- 3 files changed, 12 insertions(+), 12 deletions(-) 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..a894dff9510 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)) { + tempFilePath += this.useTemporaryFileName ? 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 b542d7e3796..803d28e37df 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 @@ -524,9 +524,6 @@ public void setFileExistsModeExpression(String fileExistsModeExpression) { */ public void setFileExistsMode(FileExistsMode fileExistsMode) { this.fileExistsMode = fileExistsMode; - if (FileExistsMode.APPEND.equals(fileExistsMode)) { - this.remoteFileTemplate.setUseTemporaryFileName(false); - } } /** 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 512351e3dbb..6430547c7d6 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 @@ -968,10 +968,11 @@ public void testPutExistsExpression() throws Exception { Message appendMessage = requestMessageBuilder.setHeader("file.exists.mode", FileExistsMode.APPEND) .build(); - - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> gw.handleRequestMessage(appendMessage)) - .withStackTraceContaining("Cannot append when using a temporary file name"); + 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(); @@ -979,7 +980,7 @@ public void testPutExistsExpression() throws Exception { assertThat(path).isEqualTo("foo/bar.txt"); // no more writes/appends verify(session, times(2)).write(any(InputStream.class), anyString()); - verify(session, times(0)).append(any(InputStream.class), anyString()); + verify(session, times(1)).append(any(InputStream.class), anyString()); } @Test From 4ef94fa9bdd6ac2e8e80af9a0172e70e17f7b722 Mon Sep 17 00:00:00 2001 From: Jooyoung Pyoung Date: Tue, 13 May 2025 12:55:59 +0900 Subject: [PATCH 3/6] Add missing author information Signed-off-by: Jooyoung Pyoung --- .../file/remote/gateway/RemoteFileOutboundGatewayTests.java | 1 + 1 file changed, 1 insertion(+) 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 6430547c7d6..90498668ae5 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 @@ -71,6 +71,7 @@ * @author Gary Russell * @author Liu Jiong * @author Artem Bilan + * @author Jooyoung Pyoung * * @since 2.1 */ From d54a840330148578250da7e549b880bdcdcd9d93 Mon Sep 17 00:00:00 2001 From: Jooyoung Pyoung Date: Wed, 14 May 2025 20:39:16 +0900 Subject: [PATCH 4/6] Apply review feedback on FileExistsMode expression - Optimize EvaluationContext usage by creating it once in doInit() - Enhance expression evaluation to support String representation of FileExistsMode - Optimize temporary filename handling logic in RemoteFileTemplate - Add warning message for incompatible APPEND mode with temporary filenames - Rename method to setFileExistsModeExpressionString for consistency - Update Java DSL support in RemoteFileOutboundGatewaySpec - Update reference documentation and release notes Signed-off-by: Jooyoung Pyoung --- .../dsl/RemoteFileOutboundGatewaySpec.java | 31 +++++++++++- .../file/remote/RemoteFileTemplate.java | 4 +- .../AbstractRemoteFileOutboundGateway.java | 48 ++++++++++++++----- .../RemoteFileOutboundGatewayTests.java | 13 ++--- .../antora/modules/ROOT/pages/ftp/rft.adoc | 20 ++++++++ .../antora/modules/ROOT/pages/whats-new.adoc | 7 +++ 6 files changed, 103 insertions(+), 20 deletions(-) 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..56fc7bb6468 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,34 @@ 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(); + } + /** * 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 a894dff9510..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 @@ -565,8 +565,8 @@ private void sendFileToRemoteDirectory(InputStream inputStream, String temporary // write remote file first with temporary file extension if enabled String tempFilePath = tempRemoteFilePath; - if (!FileExistsMode.APPEND.equals(mode)) { - tempFilePath += this.useTemporaryFileName ? this.temporaryFileSuffix : ""; + if (!FileExistsMode.APPEND.equals(mode) && this.useTemporaryFileName) { + tempFilePath += this.temporaryFileSuffix; } if (this.autoCreateDirectory) { 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 803d28e37df..66388d0557a 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 @@ -119,6 +119,8 @@ public abstract class AbstractRemoteFileOutboundGateway extends AbstractReply private FileExistsMode fileExistsMode; + private EvaluationContext standardEvaluationContext; + private Integer chmod; private boolean remoteFileTemplateExplicitlySet; @@ -510,7 +512,7 @@ public void setFileExistsModeExpression(Expression fileExistsModeExpression) { * @param fileExistsModeExpression the String in SpEL syntax. * @since 6.5 */ - public void setFileExistsModeExpression(String fileExistsModeExpression) { + public void setFileExistsModeExpressionString(String fileExistsModeExpression) { Assert.hasText(fileExistsModeExpression, "'fileExistsModeExpression' must not be empty"); this.fileExistsModeExpression = EXPRESSION_PARSER.parseExpression(fileExistsModeExpression); } @@ -579,6 +581,13 @@ 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."); + } + + this.standardEvaluationContext = ExpressionUtils.createStandardEvaluationContext(getBeanFactory()); + populateBeanFactoryIntoComponentsIfAny(); if (!this.remoteFileTemplateExplicitlySet) { this.remoteFileTemplate.afterPropertiesSet(); @@ -599,7 +608,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 { @@ -1380,19 +1389,37 @@ protected String getRemoteFilename(String remoteFilePath) { private FileExistsMode resolveFileExistsMode(Message message) { if (this.fileExistsModeExpression != null) { - EvaluationContext evaluationContext = ExpressionUtils.createStandardEvaluationContext(getBeanFactory()); - evaluationContext.setVariable("fileExistsMode", this.fileExistsMode); - return this.fileExistsModeExpression.getValue(evaluationContext, message, FileExistsMode.class); + Object evaluationResult = this.fileExistsModeExpression.getValue(this.standardEvaluationContext, message); + if (evaluationResult == null) { + return this.fileExistsMode; + } + else 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 { + 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) { - evaluationContext.setVariable("remoteDirectory", remoteDirectory); + this.standardEvaluationContext.setVariable("remoteDirectory", remoteDirectory); } - File localDir = ExpressionUtils.expressionToFile(this.localDirectoryExpression, evaluationContext, message, + File localDir = ExpressionUtils.expressionToFile(this.localDirectoryExpression, this.standardEvaluationContext, message, "Local Directory"); if (!localDir.exists()) { Assert.isTrue(localDir.mkdirs(), () -> "Failed to make local directory: " + localDir); @@ -1402,9 +1429,8 @@ private File generateLocalDirectory(Message message, String remoteDirectory) private String generateLocalFileName(Message message, String remoteFileName) { if (this.localFilenameGeneratorExpression != null) { - EvaluationContext evaluationContext = ExpressionUtils.createStandardEvaluationContext(getBeanFactory()); - evaluationContext.setVariable("remoteFileName", remoteFileName); - return this.localFilenameGeneratorExpression.getValue(evaluationContext, message, String.class); + this.standardEvaluationContext.setVariable("remoteFileName", remoteFileName); + return this.localFilenameGeneratorExpression.getValue(this.standardEvaluationContext, message, String.class); } return remoteFileName; } 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 90498668ae5..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 @@ -647,7 +647,8 @@ public void read(String source, OutputStream outputStream) public void testGetExistsExpression() throws Exception { SessionFactory sessionFactory = mock(SessionFactory.class); TestRemoteFileOutboundGateway gw = new TestRemoteFileOutboundGateway(sessionFactory, "get", "payload"); - gw.setFileExistsModeExpression("headers[\"file.exists.mode\"]"); + gw.setFileExistsModeExpressionString("headers[\"file.exists.mode\"]"); + gw.setLocalDirectory(new File(this.tmpDir)); gw.afterPropertiesSet(); File outFile = new File(this.tmpDir + "/f1"); @@ -684,12 +685,12 @@ public void read(String source, OutputStream outputStream) .withMessageContaining("already exists"); out = (MessageBuilder) gw.handleRequestMessage( - new GenericMessage<>("f1", Map.of("file.exists.mode", FileExistsMode.IGNORE))); + 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", FileExistsMode.APPEND))); + new GenericMessage<>("f1", Map.of("file.exists.mode", "append"))); assertThat(out.getPayload()).isEqualTo(outFile); assertContents("footestfile", outFile); @@ -939,7 +940,7 @@ public void testPutExistsExpression() throws Exception { handler.setBeanFactory(mock(BeanFactory.class)); handler.afterPropertiesSet(); gw.afterPropertiesSet(); - gw.setFileExistsModeExpression("headers[\"file.exists.mode\"]"); + gw.setFileExistsModeExpressionString("headers[\"file.exists.mode\"]"); when(sessionFactory.getSession()).thenReturn(session); MessageBuilder requestMessageBuilder = MessageBuilder.withPayload("hello") .setHeader(FileHeaders.FILENAME, "bar.txt"); @@ -958,7 +959,7 @@ public void testPutExistsExpression() throws Exception { .isThrownBy(() -> gw.handleRequestMessage(failMessage)) .withStackTraceContaining("The destination file already exists"); - Message replaceMessage = requestMessageBuilder.setHeader("file.exists.mode", FileExistsMode.REPLACE) + Message replaceMessage = requestMessageBuilder.setHeader("file.exists.mode", "replace") .build(); path = (String) gw.handleRequestMessage(replaceMessage); assertThat(path).isEqualTo("foo/bar.txt"); @@ -967,7 +968,7 @@ public void testPutExistsExpression() throws Exception { 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", FileExistsMode.APPEND) + Message appendMessage = requestMessageBuilder.setHeader("file.exists.mode", "APPEND") .build(); path = (String) gw.handleRequestMessage(appendMessage); assertThat(path).isEqualTo("foo/bar.txt"); diff --git a/src/reference/antora/modules/ROOT/pages/ftp/rft.adoc b/src/reference/antora/modules/ROOT/pages/ftp/rft.adoc index 55f2924f2dd..a357aa86f78 100644 --- a/src/reference/antora/modules/ROOT/pages/ftp/rft.adoc +++ b/src/reference/antora/modules/ROOT/pages/ftp/rft.adoc @@ -37,3 +37,23 @@ 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..69a7771a071 100644 --- a/src/reference/antora/modules/ROOT/pages/whats-new.adoc +++ b/src/reference/antora/modules/ROOT/pages/whats-new.adoc @@ -79,6 +79,13 @@ 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. This allows determining the action to take when files already exist based on message content or other conditions. +The expression can evaluate to a `FileExistsMode` enum value or a string representation (case-insensitive). If the expression returns `null`, the default `fileExistsMode` configured on the gateway will be used. +See xref:ftp/rft.adoc[Remote File Gateways] for more information. + [[x6.5-hazelcast-changes]] == Hazelcast Module Deprecations From 988963889fe74e9ea0df973b1c28301cff15e5bf Mon Sep 17 00:00:00 2001 From: Jooyoung Pyoung Date: Thu, 15 May 2025 00:49:11 +0900 Subject: [PATCH 5/6] Apply additional review feedback on FileExistsMode expression - Add Function variant to RemoteFileOutboundGatewaySpec - Update documentations to use one-sentence-per-line style - Improve code flow in resolveFileExistsMode method Signed-off-by: Jooyoung Pyoung --- .../file/dsl/RemoteFileOutboundGatewaySpec.java | 14 ++++++++++++++ .../gateway/AbstractRemoteFileOutboundGateway.java | 7 ++----- .../antora/modules/ROOT/pages/ftp/rft.adoc | 10 ++++------ .../antora/modules/ROOT/pages/whats-new.adoc | 3 +-- 4 files changed, 21 insertions(+), 13 deletions(-) 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 56fc7bb6468..2fb4f193d13 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 @@ -387,6 +387,20 @@ public S fileExistsModeExpression(String 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, String> fileExistsModeFunction) { + return remoteDirectoryExpression(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/gateway/AbstractRemoteFileOutboundGateway.java b/spring-integration-file/src/main/java/org/springframework/integration/file/remote/gateway/AbstractRemoteFileOutboundGateway.java index 66388d0557a..1f747a20df8 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 @@ -1390,10 +1390,7 @@ protected String getRemoteFilename(String remoteFilePath) { private FileExistsMode resolveFileExistsMode(Message message) { if (this.fileExistsModeExpression != null) { Object evaluationResult = this.fileExistsModeExpression.getValue(this.standardEvaluationContext, message); - if (evaluationResult == null) { - return this.fileExistsMode; - } - else if (evaluationResult instanceof FileExistsMode resolvedMode) { + if (evaluationResult instanceof FileExistsMode resolvedMode) { return resolvedMode; } else if (evaluationResult instanceof String modeAsString) { @@ -1406,7 +1403,7 @@ else if (evaluationResult instanceof String modeAsString) { Arrays.toString(FileExistsMode.values()), ex); } } - else { + else if (evaluationResult != null) { throw new MessagingException(message, "Expression returned invalid type for FileExistsMode: " + evaluationResult.getClass().getName() + ". Expected FileExistsMode or String."); diff --git a/src/reference/antora/modules/ROOT/pages/ftp/rft.adoc b/src/reference/antora/modules/ROOT/pages/ftp/rft.adoc index a357aa86f78..de806c14a7b 100644 --- a/src/reference/antora/modules/ROOT/pages/ftp/rft.adoc +++ b/src/reference/antora/modules/ROOT/pages/ftp/rft.adoc @@ -37,9 +37,8 @@ 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. +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: @@ -53,7 +52,6 @@ See the https://docs.spring.io/spring-integration/api/org/springframework/integr [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. +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 69a7771a071..d097c05cef6 100644 --- a/src/reference/antora/modules/ROOT/pages/whats-new.adoc +++ b/src/reference/antora/modules/ROOT/pages/whats-new.adoc @@ -82,8 +82,7 @@ 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. This allows determining the action to take when files already exist based on message content or other conditions. -The expression can evaluate to a `FileExistsMode` enum value or a string representation (case-insensitive). If the expression returns `null`, the default `fileExistsMode` configured on the gateway will be used. +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]] From a04521467e36ddb9918706b5cfb2aa0029d9d63c Mon Sep 17 00:00:00 2001 From: Jooyoung Pyoung Date: Thu, 15 May 2025 12:56:17 +0900 Subject: [PATCH 6/6] Fix additional review feedback on FileExistsMode expression - Use Object instead of String in fileExistsModeFunction - Fix return method call in fileExistsModeFunction (remoteDirectoryExpression -> fileExistsModeExpression) - Fix standardEvaluationContext initialization in doInit - Ensure proper EvaluationContext usage in other methods Signed-off-by: Jooyoung Pyoung --- .../file/dsl/RemoteFileOutboundGatewaySpec.java | 4 ++-- .../gateway/AbstractRemoteFileOutboundGateway.java | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) 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 2fb4f193d13..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 @@ -397,8 +397,8 @@ public S fileExistsModeExpression(String fileExistsModeExpression) { * @return the Spec. * @since 6.5 */ - public

S fileExistsModeFunction(Function, String> fileExistsModeFunction) { - return remoteDirectoryExpression(new FunctionExpression<>(fileExistsModeFunction)); + public

S fileExistsModeFunction(Function, Object> fileExistsModeFunction) { + return fileExistsModeExpression(new FunctionExpression<>(fileExistsModeFunction)); } /** 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 1f747a20df8..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 @@ -567,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"); @@ -586,8 +587,6 @@ protected void doInit() { "Temporary filename will be ignored for APPEND mode."); } - this.standardEvaluationContext = ExpressionUtils.createStandardEvaluationContext(getBeanFactory()); - populateBeanFactoryIntoComponentsIfAny(); if (!this.remoteFileTemplateExplicitlySet) { this.remoteFileTemplate.afterPropertiesSet(); @@ -1413,10 +1412,11 @@ else if (evaluationResult != null) { } private File generateLocalDirectory(Message message, String remoteDirectory) { + EvaluationContext evaluationContext = ExpressionUtils.createStandardEvaluationContext(getBeanFactory()); if (remoteDirectory != null) { - this.standardEvaluationContext.setVariable("remoteDirectory", remoteDirectory); + evaluationContext.setVariable("remoteDirectory", remoteDirectory); } - File localDir = ExpressionUtils.expressionToFile(this.localDirectoryExpression, this.standardEvaluationContext, message, + File localDir = ExpressionUtils.expressionToFile(this.localDirectoryExpression, evaluationContext, message, "Local Directory"); if (!localDir.exists()) { Assert.isTrue(localDir.mkdirs(), () -> "Failed to make local directory: " + localDir); @@ -1426,8 +1426,9 @@ private File generateLocalDirectory(Message message, String remoteDirectory) private String generateLocalFileName(Message message, String remoteFileName) { if (this.localFilenameGeneratorExpression != null) { - this.standardEvaluationContext.setVariable("remoteFileName", remoteFileName); - return this.localFilenameGeneratorExpression.getValue(this.standardEvaluationContext, message, String.class); + EvaluationContext evaluationContext = ExpressionUtils.createStandardEvaluationContext(getBeanFactory()); + evaluationContext.setVariable("remoteFileName", remoteFileName); + return this.localFilenameGeneratorExpression.getValue(evaluationContext, message, String.class); } return remoteFileName; }