diff --git a/.github/dco.yml b/.github/dco.yml
new file mode 100644
index 00000000000..0c4b142e9a7
--- /dev/null
+++ b/.github/dco.yml
@@ -0,0 +1,2 @@
+require:
+ members: false
diff --git a/.github/workflows/auto-cherry-pick.yml b/.github/workflows/auto-cherry-pick.yml
index 4a9c4479efd..6ba14dde29d 100644
--- a/.github/workflows/auto-cherry-pick.yml
+++ b/.github/workflows/auto-cherry-pick.yml
@@ -8,6 +8,6 @@ on:
jobs:
cherry-pick-commit:
- uses: spring-io/spring-github-workflows/.github/workflows/spring-cherry-pick.yml@main
+ uses: spring-io/spring-github-workflows/.github/workflows/spring-cherry-pick.yml@v5
secrets:
GH_ACTIONS_REPO_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }}
\ No newline at end of file
diff --git a/.github/workflows/backport-issue.yml b/.github/workflows/backport-issue.yml
index ae3ea05130e..71e42771d5c 100644
--- a/.github/workflows/backport-issue.yml
+++ b/.github/workflows/backport-issue.yml
@@ -7,6 +7,6 @@ on:
jobs:
backport-issue:
- uses: spring-io/spring-github-workflows/.github/workflows/spring-backport-issue.yml@main
+ uses: spring-io/spring-github-workflows/.github/workflows/spring-backport-issue.yml@v5
secrets:
GH_ACTIONS_REPO_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }}
\ No newline at end of file
diff --git a/.github/workflows/ci-snapshot.yml b/.github/workflows/ci-snapshot.yml
index a94396f7729..a21192cd2cb 100644
--- a/.github/workflows/ci-snapshot.yml
+++ b/.github/workflows/ci-snapshot.yml
@@ -21,6 +21,6 @@ jobs:
with:
gradleTasks: ${{ github.event_name == 'schedule' && '--rerun-tasks' || '' }}
secrets:
- GRADLE_ENTERPRISE_SECRET_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_SECRET_ACCESS_KEY }}
+ DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }}
ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }}
\ No newline at end of file
diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
index 1771c582651..2065ee71873 100644
--- a/.github/workflows/deploy-docs.yml
+++ b/.github/workflows/deploy-docs.yml
@@ -16,4 +16,4 @@ permissions:
jobs:
dispatch-docs-build:
if: github.repository_owner == 'spring-projects'
- uses: spring-io/spring-github-workflows/.github/workflows/spring-dispatch-docs-build.yml@main
+ uses: spring-io/spring-github-workflows/.github/workflows/spring-dispatch-docs-build.yml@v5
diff --git a/.github/workflows/merge-dependabot-pr.yml b/.github/workflows/merge-dependabot-pr.yml
index 743781a0dc3..f513c72567a 100644
--- a/.github/workflows/merge-dependabot-pr.yml
+++ b/.github/workflows/merge-dependabot-pr.yml
@@ -12,6 +12,7 @@ jobs:
merge-dependabot-pr:
permissions: write-all
- uses: spring-io/spring-github-workflows/.github/workflows/spring-merge-dependabot-pr.yml@main
+ uses: spring-io/spring-github-workflows/.github/workflows/spring-merge-dependabot-pr.yml@v5
with:
- mergeArguments: --auto --squash
\ No newline at end of file
+ mergeArguments: --auto --squash
+ autoMergeSnapshots: true
\ No newline at end of file
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f7e832bdef2..90f17f1609d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,14 +15,13 @@ jobs:
uses: spring-io/spring-github-workflows/.github/workflows/spring-artifactory-gradle-release.yml@main
secrets:
GH_ACTIONS_REPO_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }}
- GRADLE_ENTERPRISE_SECRET_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_SECRET_ACCESS_KEY }}
+ DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
JF_ARTIFACTORY_SPRING: ${{ secrets.JF_ARTIFACTORY_SPRING }}
ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }}
ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }}
- OSSRH_URL: ${{ secrets.OSSRH_URL }}
OSSRH_S01_TOKEN_USERNAME: ${{ secrets.OSSRH_S01_TOKEN_USERNAME }}
OSSRH_S01_TOKEN_PASSWORD: ${{ secrets.OSSRH_S01_TOKEN_PASSWORD }}
OSSRH_STAGING_PROFILE_NAME: ${{ secrets.OSSRH_STAGING_PROFILE_NAME }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
- SPRING_RELEASE_SLACK_WEBHOOK_URL: ${{ secrets.SPRING_RELEASE_SLACK_WEBHOOK_URL }}
\ No newline at end of file
+ SPRING_RELEASE_CHAT_WEBHOOK_URL: ${{ secrets.SPRING_RELEASE_GCHAT_WEBHOOK_URL }}
\ No newline at end of file
diff --git a/.github/workflows/verify-staged-artifacts.yml b/.github/workflows/verify-staged-artifacts.yml
index baee455d611..eceb0143c7e 100644
--- a/.github/workflows/verify-staged-artifacts.yml
+++ b/.github/workflows/verify-staged-artifacts.yml
@@ -9,7 +9,7 @@ on:
type: string
env:
- DEVELOCITY_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_SECRET_ACCESS_KEY }}
+ DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }}
ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }}
diff --git a/CONTRIBUTING.adoc b/CONTRIBUTING.adoc
index 1d9a4e2713a..526641b6007 100644
--- a/CONTRIBUTING.adoc
+++ b/CONTRIBUTING.adoc
@@ -24,12 +24,10 @@ Is there already an issue that addresses your concern?
Search the https://github.com/spring-projects/spring-integration/issues[GitHub issue tracker] to see if you can find something similar.
If not, please create a new issue in GitHub before submitting a pull request unless the change is truly trivial, e.g. typo fixes, removing compiler warnings, etc.
-== Sign the contributor license agreement
+== Developer Certificate of Origin
-If you have not previously done so, please fill out and submit the https://cla.pivotal.io/sign/spring[Contributor License Agreement (CLA)].
-
-Very important, before we can accept any *Spring Integration contributions*, we will need you to sign the CLA.
-Signing the CLA does not grant anyone commit rights to the main repository, but it does mean that we can accept your contributions, and you will get an author credit if we do.
+All commits must include a __Signed-off-by__ trailer at the end of each commit message to indicate that the contributor agrees to the Developer Certificate of Origin.
+For additional details, please refer to the blog post https://spring.io/blog/2025/01/06/hello-dco-goodbye-cla-simplifying-contributions-to-spring[Hello DCO, Goodbye CLA: Simplifying Contributions to Spring].
== Fork the Repository
diff --git a/build.gradle b/build.gradle
index 09f0a2e2ebc..ef3f4f6e9be 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,5 +1,5 @@
buildscript {
- ext.kotlinVersion = '1.9.24'
+ ext.kotlinVersion = '1.9.25'
ext.isCI = System.getenv('GITHUB_ACTION')
repositories {
gradlePluginPortal()
@@ -18,12 +18,13 @@ plugins {
id 'base'
id 'io.spring.nohttp' version '0.0.11' apply false
id 'org.ajoberstar.grgit' version '5.2.2'
- id 'io.spring.dependency-management' version '1.1.5'
+ id 'io.spring.dependency-management' version '1.1.7'
id 'org.jetbrains.dokka' version '1.9.20'
id 'org.antora' version '1.0.0'
id 'io.spring.antora.generate-antora-yml' version '0.0.1'
- id 'com.github.spotbugs' version '6.0.14'
- id 'com.google.protobuf' version '0.9.4' apply false
+ id 'com.github.spotbugs' version '6.0.28'
+ id 'com.google.protobuf' version '0.9.5' apply false
+ id 'io.freefair.aggregate-javadoc' version '8.6'
}
if (isCI) {
@@ -55,71 +56,71 @@ ext {
apacheSshdVersion = '2.12.1'
artemisVersion = '2.33.0'
- aspectjVersion = '1.9.22.1'
+ aspectjVersion = '1.9.24'
assertjVersion = '3.25.3'
assertkVersion = '0.28.1'
- avroVersion = '1.11.3'
- awaitilityVersion = '4.2.1'
+ avroVersion = '1.11.4'
+ awaitilityVersion = '4.2.2'
camelVersion = '4.5.0'
commonsDbcp2Version = '2.12.0'
commonsIoVersion = '2.16.1'
commonsNetVersion = '3.10.0'
curatorVersion = '5.6.0'
- debeziumVersion = '2.6.1.Final'
+ debeziumVersion = '2.6.2.Final'
derbyVersion = '10.16.1.1'
- ftpServerVersion = '1.2.0'
- graalvmVersion = '23.1.3'
- greenmailVersion = '2.1.0-alpha-4'
- groovyVersion = '4.0.21'
+ ftpServerVersion = '1.2.1'
+ graalvmVersion = '23.1.7'
+ greenmailVersion = '2.1.3'
+ groovyVersion = '4.0.27'
hamcrestVersion = '2.2'
hazelcastVersion = '5.4.0'
- hibernateVersion = '6.4.8.Final'
- hsqldbVersion = '2.7.2'
+ hibernateVersion = '6.4.10.Final'
+ hsqldbVersion = '2.7.4'
h2Version = '2.2.224'
- jacksonVersion = '2.17.1'
+ jacksonVersion = '2.17.3'
jaxbVersion = '4.0.5'
- jcifsVersion = '2.1.37'
+ jcifsVersion = '2.1.39'
jeroMqVersion = '0.6.0'
jmsApiVersion = '3.1.0'
jpaApiVersion = '3.1.0'
- jrubyVersion = '9.4.7.0'
+ jrubyVersion = '9.4.13.0'
jsonpathVersion = '2.9.0'
junit4Version = '4.13.2'
- junitJupiterVersion = '5.10.2'
- jythonVersion = '2.7.3'
+ junitJupiterVersion = '5.10.5'
+ jythonVersion = '2.7.4'
kotlinCoroutinesVersion = '1.8.1'
- kryoVersion = '5.6.0'
+ kryoVersion = '5.6.2'
lettuceVersion = '6.3.2.RELEASE'
log4jVersion = '2.23.1'
mailVersion = '2.0.3'
- micrometerTracingVersion = '1.3.0'
- micrometerVersion = '1.13.0'
+ micrometerTracingVersion = '1.3.13'
+ micrometerVersion = '1.13.15'
mockitoVersion = '5.11.0'
mongoDriverVersion = '5.0.1'
mysqlVersion = '8.3.0'
oracleVersion = '23.3.0.23.09'
pahoMqttClientVersion = '1.2.5'
- postgresVersion = '42.7.3'
+ postgresVersion = '42.7.7'
protobufVersion = '4.26.1'
r2dbch2Version = '1.0.0.RELEASE'
- reactorVersion = '2023.0.6'
+ reactorVersion = '2023.0.19'
resilience4jVersion = '2.2.0'
romeToolsVersion = '2.1.0'
- rsocketVersion = '1.1.4'
+ rsocketVersion = '1.1.5'
servletApiVersion = '6.0.0'
smackVersion = '4.4.8'
- springAmqpVersion = '3.1.5'
- springDataVersion = '2024.0.0'
- springGraphqlVersion = '1.3.0'
- springKafkaVersion = '3.2.0'
- springRetryVersion = '2.0.6'
- springSecurityVersion = '6.3.0'
- springVersion = '6.1.7'
- springWsVersion = '4.0.11'
+ springAmqpVersion = '3.1.12'
+ springDataVersion = '2024.0.13'
+ springGraphqlVersion = '1.3.5'
+ springKafkaVersion = '3.2.10'
+ springRetryVersion = '2.0.12'
+ springSecurityVersion = '6.3.10'
+ springVersion = '6.1.21'
+ springWsVersion = '4.0.15'
testcontainersVersion = '1.19.8'
- tomcatVersion = '10.1.24'
+ tomcatVersion = '10.1.42'
xmlUnitVersion = '2.9.1'
- xstreamVersion = '1.4.20'
+ xstreamVersion = '1.4.21'
ztZipVersion = '1.17'
javaProjects = subprojects - project(':spring-integration-bom')
@@ -204,6 +205,26 @@ configure(javaProjects) { subproject ->
apply from: "${rootDir}/gradle/publish-maven.gradle"
+ def scopeAttribute = Attribute.of('dependency.scope', String)
+
+ configurations {
+ provided {
+ attributes {
+ attribute(scopeAttribute, 'provided')
+ }
+ }
+
+ [compileClasspath, testCompileClasspath, testRuntimeClasspath].each {
+ it.extendsFrom(provided)
+ }
+ }
+
+ components.java.with {
+ it.addVariantsFromConfiguration(configurations.provided) {
+ mapToMavenScope('compile') // This is temporary. Gradle doesn't natively support the provided scope
+ }
+ }
+
sourceSets {
test {
resources {
@@ -213,14 +234,14 @@ configure(javaProjects) { subproject ->
}
java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(17)
+ }
withJavadocJar()
withSourcesJar()
registerFeature('optional') {
usingSourceSet(sourceSets.main)
}
- registerFeature('provided') {
- usingSourceSet(sourceSets.main)
- }
}
compileJava {
@@ -256,6 +277,10 @@ configure(javaProjects) { subproject ->
// dependencies that are common across all java projects
dependencies {
+ attributesSchema {
+ attribute(scopeAttribute)
+ }
+
if (!(subproject.name ==~ /.*-test.*/)) {
testImplementation(project(':spring-integration-test-support')) {
exclude group: 'org.hamcrest'
@@ -426,14 +451,8 @@ configure(javaProjects) { subproject ->
from components.java
pom.withXml {
def pomDeps = asNode().dependencies.first()
- subproject.configurations.providedImplementation.allDependencies.each { dep ->
- pomDeps.remove(pomDeps.'*'.find { it.artifactId.text() == dep.name })
- pomDeps.appendNode('dependency').with {
- it.appendNode('groupId', dep.group)
- it.appendNode('artifactId', dep.name)
- it.appendNode('version', dep.version)
- it.appendNode('scope', 'provided')
- }
+ subproject.configurations.provided.allDependencies.each { dep ->
+ pomDeps.'*'.find { it.artifactId.text() == dep.name }.scope.first().value = 'provided'
}
}
}
@@ -543,7 +562,7 @@ project('spring-integration-core') {
}
dokkaHtmlPartial {
- outputDirectory.set(new File(buildDir, 'kdoc'))
+ outputDirectory.set(new File('build', 'kdoc'))
dokkaSourceSets {
main {
sourceRoots.setFrom(file('src/main/kotlin'))
@@ -677,7 +696,7 @@ project('spring-integration-http') {
description = 'Spring Integration HTTP Support'
dependencies {
api 'org.springframework:spring-webmvc'
- providedImplementation "jakarta.servlet:jakarta.servlet-api:$servletApiVersion"
+ provided "jakarta.servlet:jakarta.servlet-api:$servletApiVersion"
optionalApi "com.rometools:rome:$romeToolsVersion"
optionalApi 'org.springframework:spring-webflux'
@@ -736,7 +755,7 @@ project('spring-integration-jms') {
description = 'Spring Integration JMS Support'
dependencies {
api 'org.springframework:spring-jms'
- providedImplementation "jakarta.jms:jakarta.jms-api:$jmsApiVersion"
+ provided "jakarta.jms:jakarta.jms-api:$jmsApiVersion"
testImplementation("org.apache.activemq:artemis-server:$artemisVersion") {
exclude group: 'org.jboss.logmanager'
@@ -782,7 +801,7 @@ project('spring-integration-mail') {
dependencies {
api 'org.springframework:spring-context-support'
- providedImplementation "org.eclipse.angus:jakarta.mail:$mailVersion"
+ provided "org.eclipse.angus:jakarta.mail:$mailVersion"
testImplementation "com.icegreen:greenmail:$greenmailVersion"
@@ -848,8 +867,8 @@ project('spring-integration-scripting') {
description = 'Spring Integration Scripting Support'
dependencies {
optionalApi 'org.jetbrains.kotlin:kotlin-scripting-jsr223'
- providedImplementation "org.graalvm.sdk:graal-sdk:$graalvmVersion"
- providedImplementation "org.graalvm.polyglot:js:$graalvmVersion"
+ provided "org.graalvm.sdk:graal-sdk:$graalvmVersion"
+ provided "org.graalvm.polyglot:js:$graalvmVersion"
testImplementation "org.jruby:jruby-complete:$jrubyVersion"
testImplementation 'org.apache.groovy:groovy-jsr223'
@@ -969,7 +988,7 @@ project('spring-integration-websocket') {
dependencies {
api 'org.springframework:spring-websocket'
optionalApi 'org.springframework:spring-webmvc'
- providedImplementation "jakarta.servlet:jakarta.servlet-api:$servletApiVersion"
+ provided "jakarta.servlet:jakarta.servlet-api:$servletApiVersion"
testImplementation project(':spring-integration-event')
testImplementation "org.apache.tomcat.embed:tomcat-embed-websocket:$tomcatVersion"
@@ -994,7 +1013,7 @@ project('spring-integration-ws') {
exclude group: 'org.glassfish.jaxb'
}
- providedImplementation "com.sun.xml.bind:jaxb-impl:$jaxbVersion"
+ provided "com.sun.xml.bind:jaxb-impl:$jaxbVersion"
testImplementation "com.thoughtworks.xstream:xstream:$xstreamVersion"
testImplementation('org.springframework.ws:spring-ws-support') {
@@ -1090,15 +1109,19 @@ project('spring-integration-bom') {
}
}
-tasks.register('api', Javadoc) {
- group = 'Documentation'
- description = 'Generates aggregated Javadoc API documentation.'
+dependencies {
+ javaProjects.each {
+ javadoc it
+ }
+}
+
+javadoc {
title = "${rootProject.description} ${version} API"
options {
encoding = 'UTF-8'
memberLevel = JavadocMemberLevel.PROTECTED
author = true
- header = rootProject.description
+ header = project.description
use = true
overview = 'src/api/overview.html'
splitIndex = true
@@ -1106,13 +1129,14 @@ tasks.register('api', Javadoc) {
addBooleanOption('Xdoclint:syntax', true) // only check syntax with doclint
}
- source javaProjects.collect { project ->
- project.sourceSets.main.allJava
- }
- destinationDir = new File(buildDir, 'api')
- classpath = files(javaProjects.collect { project ->
- project.sourceSets.main.compileClasspath
- })
+ destinationDir = file('build/api')
+ classpath = files().from { files(javaProjects.collect { it.sourceSets.main.compileClasspath }) }
+}
+
+tasks.register('api') {
+ group = 'Documentation'
+ description = 'Generates aggregated Javadoc API documentation.'
+ dependsOn javadoc
}
dokkaHtmlMultiModule {
@@ -1169,7 +1193,7 @@ tasks.register('docsZip', Zip) {
include 'changelog.txt'
}
- from(api) {
+ from(javadoc) {
into 'api'
}
diff --git a/gradle.properties b/gradle.properties
index fe335413987..518acfb0e72 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,4 +1,4 @@
-version=6.3.1-SNAPSHOT
+version=6.3.12-SNAPSHOT
org.gradle.jvmargs=-Xmx2g -XX:+HeapDumpOnOutOfMemoryError -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
kotlin.jvm.target.validation.mode=IGNORE
org.gradle.caching=true
diff --git a/gradle/publish-maven.gradle b/gradle/publish-maven.gradle
index 649f5f2b4e7..1388fce23ce 100644
--- a/gradle/publish-maven.gradle
+++ b/gradle/publish-maven.gradle
@@ -41,7 +41,7 @@ publishing {
developer {
id = 'markfisher'
name = 'Mark Fisher'
- email = 'mark.fisher@broadcom.com'
+ email = 'mark.ryan.fisher@gmail.com'
roles = ['project founder and lead emeritus']
}
}
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index d64cd491770..a4b76b9530d 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 4baf5a11d45..d71047787f8 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionSha256Sum=9631d53cf3e74bfa726893aee1f8994fee4e060c401335946dba2156f440f24c
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
+distributionSha256Sum=8d97a97984f6cbd2b85fe4c60a743440a347544bf18818048e611f5288d46c94
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
index 1aa94a42690..f3b75f3b0d4 100755
--- a/gradlew
+++ b/gradlew
@@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
+# SPDX-License-Identifier: Apache-2.0
+#
##############################################################################
#
@@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -84,7 +86,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
-APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
diff --git a/gradlew.bat b/gradlew.bat
index 6689b85beec..9b42019c791 100755
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@@ -43,11 +45,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
goto fail
@@ -57,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
goto fail
diff --git a/settings.gradle b/settings.gradle
index dac569f1480..69be99a2f47 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,6 +1,13 @@
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ mavenCentral()
+ }
+}
+
plugins {
- id 'com.gradle.develocity' version '3.17.4'
- id 'io.spring.ge.conventions' version '0.0.17'
+ id 'io.spring.develocity.conventions' version '0.0.23'
+ id 'org.gradle.toolchains.foojay-resolver-convention' version '0.9.0'
}
rootProject.name = 'spring-integration'
diff --git a/spring-integration-amqp/src/main/java/org/springframework/integration/amqp/config/AbstractAmqpInboundAdapterParser.java b/spring-integration-amqp/src/main/java/org/springframework/integration/amqp/config/AbstractAmqpInboundAdapterParser.java
index 32833e80122..44a615b499c 100644
--- a/spring-integration-amqp/src/main/java/org/springframework/integration/amqp/config/AbstractAmqpInboundAdapterParser.java
+++ b/spring-integration-amqp/src/main/java/org/springframework/integration/amqp/config/AbstractAmqpInboundAdapterParser.java
@@ -59,6 +59,7 @@ abstract class AbstractAmqpInboundAdapterParser extends AbstractSingleBeanDefini
"receive-timeout",
"shutdown-timeout",
"tx-size",
+ "batch-size",
"missing-queues-fatal"
};
@@ -154,7 +155,13 @@ private BeanDefinition buildListenerContainer(Element element, ParserContext par
}
builder.addConstructorArgReference(connectionFactoryRef);
for (String attributeName : CONTAINER_VALUE_ATTRIBUTES) {
- IntegrationNamespaceUtils.setValueIfAttributeDefined(builder, element, attributeName);
+ // TODO remove 'tx-size' in 6.5
+ if ("tx-size".equals(attributeName)) {
+ IntegrationNamespaceUtils.setValueIfAttributeDefined(builder, element, attributeName, "batchSize");
+ }
+ else {
+ IntegrationNamespaceUtils.setValueIfAttributeDefined(builder, element, attributeName);
+ }
}
for (String attributeName : CONTAINER_REFERENCE_ATTRIBUTES) {
IntegrationNamespaceUtils.setReferenceIfAttributeDefined(builder, element, attributeName);
diff --git a/spring-integration-amqp/src/main/resources/org/springframework/integration/amqp/config/spring-integration-amqp.xsd b/spring-integration-amqp/src/main/resources/org/springframework/integration/amqp/config/spring-integration-amqp.xsd
index bf514627342..e6172835ef1 100644
--- a/spring-integration-amqp/src/main/resources/org/springframework/integration/amqp/config/spring-integration-amqp.xsd
+++ b/spring-integration-amqp/src/main/resources/org/springframework/integration/amqp/config/spring-integration-amqp.xsd
@@ -969,7 +969,7 @@ standard headers to also be mapped. To map all non-standard headers the 'NON_STA
Acknowledge Mode for the MessageListenerContainer; default 'AUTO'
meaning the adapter automatically acknowledges the message(s)
- according to the tx-size.
+ according to the batch-size.
@@ -1039,7 +1039,7 @@ standard headers to also be mapped. To map all non-standard headers the 'NON_STA
Specifies how many messages to send to each consumer in a single request. Often this can be set
quite high
- to improve throughput. It should be greater than or equal to the tx-size value.
+ to improve throughput. It should be greater than or equal to the batch-size value.
@@ -1117,10 +1117,23 @@ standard headers to also be mapped. To map all non-standard headers the 'NON_STA
+ [DEPRECATED]
How many messages to process in a single transaction (if the channel is transactional). For best
results it should be
less than or equal to the prefetch count.
Not allowed when 'consumers-per-queue' is set.
+ Deprecated in favor of 'batch-size'.
+
+
+
+
+
+
+
+
+ How many messages to process in a single request.
+ For best results it should be less than or equal to the prefetch count.
+ Not allowed when 'consumers-per-queue' is set.
diff --git a/spring-integration-amqp/src/test/java/org/springframework/integration/amqp/config/AmqpInboundChannelAdapterParserTests-context.xml b/spring-integration-amqp/src/test/java/org/springframework/integration/amqp/config/AmqpInboundChannelAdapterParserTests-context.xml
index 8b105f34e6c..85e4ee30bb8 100644
--- a/spring-integration-amqp/src/test/java/org/springframework/integration/amqp/config/AmqpInboundChannelAdapterParserTests-context.xml
+++ b/spring-integration-amqp/src/test/java/org/springframework/integration/amqp/config/AmqpInboundChannelAdapterParserTests-context.xml
@@ -1,14 +1,12 @@
@@ -20,32 +18,34 @@
+ batch-mode="EXTRACT_PAYLOADS" tx-size="2"/>
+ auto-startup="false" phase="123" acknowledge-mode="${ackMode}"
+ missing-queues-fatal="false"
+ batch-size="3"/>
+ channel="requestChannel" queue-names="inboundchanneladapter.test.2"
+ auto-startup="false" phase="123"
+ mapped-request-headers="foo*, STANDARD_REQUEST_HEADERS"/>
+ channel="requestChannel" queue-names="inboundchanneladapter.test.2"
+ auto-startup="false" phase="123"
+ mapped-request-headers="foo*"/>
+ channel="requestChannel" queue-names="inboundchanneladapter.test.2"
+ auto-startup="false" phase="123"
+ mapped-request-headers=""/>
+ channel="requestChannel" queue-names="inboundchanneladapter.test.2"
+ auto-startup="false" phase="123"/>
+ auto-startup="false"/>
diff --git a/spring-integration-amqp/src/test/java/org/springframework/integration/amqp/config/AmqpInboundChannelAdapterParserTests.java b/spring-integration-amqp/src/test/java/org/springframework/integration/amqp/config/AmqpInboundChannelAdapterParserTests.java
index 1bc7a1703b3..04eb584d5f3 100644
--- a/spring-integration-amqp/src/test/java/org/springframework/integration/amqp/config/AmqpInboundChannelAdapterParserTests.java
+++ b/spring-integration-amqp/src/test/java/org/springframework/integration/amqp/config/AmqpInboundChannelAdapterParserTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2024 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.
@@ -16,8 +16,7 @@
package org.springframework.integration.amqp.config;
-import org.junit.Test;
-import org.junit.runner.RunWith;
+import org.junit.jupiter.api.Test;
import org.springframework.amqp.core.AcknowledgeMode;
import org.springframework.amqp.core.Message;
@@ -37,10 +36,10 @@
import org.springframework.integration.channel.QueueChannel;
import org.springframework.integration.test.util.TestUtils;
import org.springframework.test.annotation.DirtiesContext;
-import org.springframework.test.context.ContextConfiguration;
-import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* @author Mark Fisher
@@ -49,8 +48,7 @@
*
* @since 2.1
*/
-@ContextConfiguration
-@RunWith(SpringJUnit4ClassRunner.class)
+@SpringJUnitConfig
@DirtiesContext
public class AmqpInboundChannelAdapterParserTests {
@@ -71,6 +69,8 @@ public void verifyIdAsChannel() {
.isInstanceOf(SimpleMessageListenerContainer.class);
assertThat(TestUtils.getPropertyValue(adapter, "batchMode", BatchMode.class))
.isEqualTo(BatchMode.EXTRACT_PAYLOADS);
+ assertThat(TestUtils.getPropertyValue(adapter, "messageListenerContainer.batchSize", Integer.class))
+ .isEqualTo(2);
}
@Test
@@ -95,6 +95,8 @@ public void verifyLifeCycle() {
.isEqualTo(AcknowledgeMode.NONE);
assertThat(TestUtils.getPropertyValue(adapter, "messageListenerContainer.missingQueuesFatal", Boolean.class))
.isFalse();
+ assertThat(TestUtils.getPropertyValue(adapter, "messageListenerContainer.batchSize", Integer.class))
+ .isEqualTo(3);
}
@Test
@@ -216,14 +218,13 @@ public void withHeaderMapperDefaultMapping() throws Exception {
@Test
public void testInt2971HeaderMapperAndMappedHeadersExclusivity() {
- try {
- new ClassPathXmlApplicationContext("AmqpInboundChannelAdapterParserTests-headerMapper-fail-context.xml",
- this.getClass()).close();
- }
- catch (BeanDefinitionParsingException e) {
- assertThat(e.getMessage().startsWith("Configuration problem: The 'header-mapper' attribute " +
- "is mutually exclusive with 'mapped-request-headers' or 'mapped-reply-headers'")).isTrue();
- }
+ assertThatExceptionOfType(BeanDefinitionParsingException.class)
+ .isThrownBy(() ->
+ new ClassPathXmlApplicationContext(
+ "AmqpInboundChannelAdapterParserTests-headerMapper-fail-context.xml",
+ getClass()))
+ .withMessageStartingWith("Configuration problem: The 'header-mapper' attribute " +
+ "is mutually exclusive with 'mapped-request-headers' or 'mapped-reply-headers'");
}
}
diff --git a/spring-integration-core/src/main/java/org/springframework/integration/aggregator/AbstractCorrelatingMessageHandler.java b/spring-integration-core/src/main/java/org/springframework/integration/aggregator/AbstractCorrelatingMessageHandler.java
index 911101139b4..59629ab4f1d 100644
--- a/spring-integration-core/src/main/java/org/springframework/integration/aggregator/AbstractCorrelatingMessageHandler.java
+++ b/spring-integration-core/src/main/java/org/springframework/integration/aggregator/AbstractCorrelatingMessageHandler.java
@@ -282,7 +282,7 @@ public void setMinimumTimeoutForEmptyGroups(long minimumTimeoutForEmptyGroups) {
*/
public void setReleasePartialSequences(boolean releasePartialSequences) {
if (!this.releaseStrategySet && releasePartialSequences) {
- setReleaseStrategy(new SequenceSizeReleaseStrategy());
+ setReleaseStrategy(new SequenceSizeReleaseStrategy(releasePartialSequences));
}
this.releasePartialSequences = releasePartialSequences;
}
diff --git a/spring-integration-core/src/main/java/org/springframework/integration/aggregator/BarrierMessageHandler.java b/spring-integration-core/src/main/java/org/springframework/integration/aggregator/BarrierMessageHandler.java
index fd0ed2475ee..deb126142f1 100644
--- a/spring-integration-core/src/main/java/org/springframework/integration/aggregator/BarrierMessageHandler.java
+++ b/spring-integration-core/src/main/java/org/springframework/integration/aggregator/BarrierMessageHandler.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2015-2020 the original author or authors.
+ * Copyright 2015-2024 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.
@@ -27,6 +27,7 @@
import org.springframework.integration.handler.DiscardingMessageHandler;
import org.springframework.integration.handler.MessageTriggerAction;
import org.springframework.integration.store.SimpleMessageGroup;
+import org.springframework.lang.Nullable;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.MessageHandlingException;
@@ -170,7 +171,8 @@ public BarrierMessageHandler(long requestTimeout, long triggerTimeout, MessageGr
}
/**
- * Set the name of the channel to which late arriving trigger messages are sent.
+ * Set the name of the channel to which late arriving trigger messages are sent,
+ * or request message does not arrive in time.
* @param discardChannelName the discard channel.
* @since 5.0
*/
@@ -179,7 +181,8 @@ public void setDiscardChannelName(String discardChannelName) {
}
/**
- * Set the channel to which late arriving trigger messages are sent.
+ * Set the channel to which late arriving trigger messages are sent,
+ * or request message does not arrive in time.
* @param discardChannel the discard channel.
* @since 5.0
*/
@@ -188,8 +191,11 @@ public void setDiscardChannel(MessageChannel discardChannel) {
}
/**
+ * Return the discard message channel for trigger action message.
+ * @return a discard message channel.
* @since 5.0
*/
+ @Nullable
@Override
public MessageChannel getDiscardChannel() {
String channelName = this.discardChannelName;
diff --git a/spring-integration-core/src/main/java/org/springframework/integration/channel/AbstractMessageChannel.java b/spring-integration-core/src/main/java/org/springframework/integration/channel/AbstractMessageChannel.java
index 2ddc0c0d241..a46824b2def 100644
--- a/spring-integration-core/src/main/java/org/springframework/integration/channel/AbstractMessageChannel.java
+++ b/spring-integration-core/src/main/java/org/springframework/integration/channel/AbstractMessageChannel.java
@@ -30,6 +30,7 @@
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
+import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.beans.factory.BeanFactory;
@@ -59,6 +60,7 @@
import org.springframework.messaging.MessageDeliveryException;
import org.springframework.messaging.converter.MessageConverter;
import org.springframework.messaging.support.ChannelInterceptor;
+import org.springframework.messaging.support.ErrorMessage;
import org.springframework.messaging.support.InterceptableChannel;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
@@ -335,12 +337,22 @@ else if (this.metricsCaptor != null) {
private boolean sendWithObservation(Message> message, long timeout) {
MutableMessage> messageToSend = MutableMessage.of(message);
- return IntegrationObservation.PRODUCER.observation(
- this.observationConvention,
- DefaultMessageSenderObservationConvention.INSTANCE,
- () -> new MessageSenderContext(messageToSend, getComponentName()),
- this.observationRegistry)
- .observe(() -> sendInternal(messageToSend, timeout)); // NOSONAR - never null
+ Observation observation = IntegrationObservation.PRODUCER.observation(
+ this.observationConvention,
+ DefaultMessageSenderObservationConvention.INSTANCE,
+ () -> new MessageSenderContext(messageToSend, getComponentName()),
+ this.observationRegistry);
+ Boolean observe = observation.observe(() -> {
+ Message> messageToSendInternal = messageToSend;
+ if (message instanceof ErrorMessage errorMessage) {
+ messageToSendInternal =
+ new ErrorMessage(errorMessage.getPayload(),
+ messageToSend.getHeaders(),
+ errorMessage.getOriginalMessage());
+ }
+ return sendInternal(messageToSendInternal, timeout);
+ });
+ return Boolean.TRUE.equals(observe);
}
private boolean sendWithMetrics(Message> message, long timeout) {
diff --git a/spring-integration-core/src/main/java/org/springframework/integration/channel/FluxMessageChannel.java b/spring-integration-core/src/main/java/org/springframework/integration/channel/FluxMessageChannel.java
index 01de10f5b23..13c1bbca54a 100644
--- a/spring-integration-core/src/main/java/org/springframework/integration/channel/FluxMessageChannel.java
+++ b/spring-integration-core/src/main/java/org/springframework/integration/channel/FluxMessageChannel.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2015-2024 the original author or authors.
+ * Copyright 2015-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.
@@ -17,6 +17,8 @@
package org.springframework.integration.channel;
import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
@@ -28,10 +30,9 @@
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
-import reactor.core.scheduler.Scheduler;
-import reactor.core.scheduler.Schedulers;
import reactor.util.context.ContextView;
+import org.springframework.context.Lifecycle;
import org.springframework.core.log.LogMessage;
import org.springframework.integration.IntegrationMessageHeaderAccessor;
import org.springframework.integration.StaticMessageHeaderAccessor;
@@ -44,6 +45,9 @@
/**
* The {@link AbstractMessageChannel} implementation for the
* Reactive Streams {@link Publisher} based on the Project Reactor {@link Flux}.
+ *
+ * This class implements {@link Lifecycle} to control subscriptions to publishers
+ * attached via {@link #subscribeTo(Publisher)}, when this channel is restarted.
*
* @author Artem Bilan
* @author Gary Russell
@@ -52,15 +56,13 @@
* @since 5.0
*/
public class FluxMessageChannel extends AbstractMessageChannel
- implements Publisher>, ReactiveStreamsSubscribableChannel {
-
- private final Scheduler scheduler = Schedulers.boundedElastic();
+ implements Publisher>, ReactiveStreamsSubscribableChannel, Lifecycle {
private final Sinks.Many> sink = Sinks.many().multicast().onBackpressureBuffer(1, false);
- private final Sinks.Many subscribedSignal = Sinks.many().replay().limit(1);
+ private final List>> sourcePublishers = new ArrayList<>();
- private final Disposable.Composite upstreamSubscriptions = Disposables.composite();
+ private volatile Disposable.Composite upstreamSubscriptions = Disposables.composite();
private volatile boolean active = true;
@@ -92,47 +94,47 @@ private boolean tryEmitMessage(Message> message) {
.setHeader(IntegrationMessageHeaderAccessor.REACTOR_CONTEXT, contextView)
.build();
}
- return switch (this.sink.tryEmitNext(messageToEmit)) {
- case OK -> true;
- case FAIL_NON_SERIALIZED, FAIL_OVERFLOW -> false;
- case FAIL_ZERO_SUBSCRIBER ->
- throw new IllegalStateException("The [" + this + "] doesn't have subscribers to accept messages");
- case FAIL_TERMINATED, FAIL_CANCELLED ->
- throw new IllegalStateException("Cannot emit messages into the cancelled or terminated sink: "
- + this.sink);
- };
+
+ if (this.active) {
+ return switch (this.sink.tryEmitNext(messageToEmit)) {
+ case OK -> true;
+ case FAIL_NON_SERIALIZED, FAIL_OVERFLOW -> false;
+ case FAIL_ZERO_SUBSCRIBER ->
+ throw new IllegalStateException("The [" + this + "] doesn't have subscribers to accept messages");
+ case FAIL_TERMINATED, FAIL_CANCELLED ->
+ throw new IllegalStateException("Cannot emit messages into the cancelled or terminated sink: "
+ + this.sink);
+ };
+ }
+ else {
+ return false;
+ }
}
@Override
public void subscribe(Subscriber super Message>> subscriber) {
this.sink.asFlux()
- .doFinally((s) -> this.subscribedSignal.tryEmitNext(this.sink.currentSubscriberCount() > 0))
- .share()
+ .publish(1)
+ .refCount()
.subscribe(subscriber);
-
- Mono subscribersBarrier =
- Mono.fromCallable(() -> this.sink.currentSubscriberCount() > 0)
- .filter(Boolean::booleanValue)
- .doOnNext(this.subscribedSignal::tryEmitNext)
- .repeatWhenEmpty((repeat) ->
- this.active ? repeat.delayElements(Duration.ofMillis(100)) : repeat); // NOSONAR
-
- addPublisherToSubscribe(Flux.from(subscribersBarrier));
}
- private void addPublisherToSubscribe(Flux> publisher) {
- AtomicReference disposableReference = new AtomicReference<>();
+ @Override
+ public void start() {
+ this.active = true;
+ this.upstreamSubscriptions = Disposables.composite();
+ this.sourcePublishers.forEach(this::doSubscribeTo);
+ }
- Disposable disposable =
- publisher
- .doOnTerminate(() -> disposeUpstreamSubscription(disposableReference))
- .subscribe();
+ @Override
+ public void stop() {
+ this.active = false;
+ this.upstreamSubscriptions.dispose();
+ }
- if (!disposable.isDisposed()) {
- if (this.upstreamSubscriptions.add(disposable)) {
- disposableReference.set(disposable);
- }
- }
+ @Override
+ public boolean isRunning() {
+ return this.active;
}
private void disposeUpstreamSubscription(AtomicReference disposableReference) {
@@ -145,10 +147,19 @@ private void disposeUpstreamSubscription(AtomicReference disposableR
@Override
public void subscribeTo(Publisher extends Message>> publisher) {
+ this.sourcePublishers.add(publisher);
+ doSubscribeTo(publisher);
+ }
+
+ private void doSubscribeTo(Publisher extends Message>> publisher) {
Flux
+
+
+
+ Set the 'group.id' KafkaConsumer property.
+
+
+
@@ -764,13 +771,6 @@
-
-
-
- Set the 'group.id' KafkaConsumer property.
-
-
-
diff --git a/spring-integration-kafka/src/test/java/org/springframework/integration/kafka/config/xml/KafkaInboundGatewayTests-context.xml b/spring-integration-kafka/src/test/java/org/springframework/integration/kafka/config/xml/KafkaInboundGatewayTests-context.xml
index 91e330442f6..08a10bf84bb 100644
--- a/spring-integration-kafka/src/test/java/org/springframework/integration/kafka/config/xml/KafkaInboundGatewayTests-context.xml
+++ b/spring-integration-kafka/src/test/java/org/springframework/integration/kafka/config/xml/KafkaInboundGatewayTests-context.xml
@@ -21,7 +21,8 @@
retry-template="retryTemplate"
recovery-callback="recoveryCallback"
bind-source-record="true"
- on-partitions-assigned-seek-callback="onPartitionsAssignedSeekCallback"/>
+ on-partitions-assigned-seek-callback="onPartitionsAssignedSeekCallback"
+ role="testRole"/>
diff --git a/spring-integration-kafka/src/test/java/org/springframework/integration/kafka/config/xml/KafkaInboundGatewayTests.java b/spring-integration-kafka/src/test/java/org/springframework/integration/kafka/config/xml/KafkaInboundGatewayTests.java
index da03ee622ac..b95d99e0ec9 100644
--- a/spring-integration-kafka/src/test/java/org/springframework/integration/kafka/config/xml/KafkaInboundGatewayTests.java
+++ b/spring-integration-kafka/src/test/java/org/springframework/integration/kafka/config/xml/KafkaInboundGatewayTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2019-2023 the original author or authors.
+ * Copyright 2019-2024 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.
@@ -21,6 +21,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.integration.kafka.inbound.KafkaInboundGateway;
+import org.springframework.integration.support.SmartLifecycleRoleController;
import org.springframework.integration.test.util.TestUtils;
import org.springframework.kafka.listener.KafkaMessageListenerContainer;
import org.springframework.test.annotation.DirtiesContext;
@@ -45,6 +46,9 @@ public class KafkaInboundGatewayTests {
@Autowired
private ApplicationContext context;
+ @Autowired
+ SmartLifecycleRoleController roleController;
+
@Test
public void testProps() {
assertThat(this.gateway1.isAutoStartup()).isFalse();
@@ -69,6 +73,8 @@ public void testProps() {
assertThat(TestUtils.getPropertyValue(this.gateway1, "messagingTemplate.sendTimeout")).isEqualTo(5000L);
assertThat(TestUtils.getPropertyValue(this.gateway1, "messagingTemplate.receiveTimeout")).isEqualTo(43L);
assertThat(TestUtils.getPropertyValue(this.gateway1, "bindSourceRecord", Boolean.class)).isTrue();
+ assertThat(this.roleController.getRoles()).contains("testRole");
+ assertThat(this.roleController.getEndpointsRunningStatus("testRole")).containsEntry("gateway1", false);
}
}
diff --git a/spring-integration-kafka/src/test/java/org/springframework/integration/kafka/config/xml/KafkaOutboundAdapterParserTests.java b/spring-integration-kafka/src/test/java/org/springframework/integration/kafka/config/xml/KafkaOutboundAdapterParserTests.java
index d5ba60362a6..b60793bda86 100644
--- a/spring-integration-kafka/src/test/java/org/springframework/integration/kafka/config/xml/KafkaOutboundAdapterParserTests.java
+++ b/spring-integration-kafka/src/test/java/org/springframework/integration/kafka/config/xml/KafkaOutboundAdapterParserTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2013-2022 the original author or authors.
+ * Copyright 2013-2024 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.
@@ -19,6 +19,7 @@
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
+import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeoutException;
@@ -125,8 +126,8 @@ public void close(Duration timeout) {
handler.setTimeoutBuffer(200);
handler.setTopicExpression(new LiteralExpression("foo"));
- Executors.newSingleThreadExecutor()
- .submit(() -> {
+ ExecutorService executorService = Executors.newSingleThreadExecutor();
+ executorService.submit(() -> {
RuntimeException exception = new RuntimeException("Async Producer Mock exception");
while (!mockProducer.errorNext(exception)) {
Thread.sleep(100);
@@ -146,6 +147,8 @@ public void close(Duration timeout) {
.isThrownBy(() -> handler.handleMessage(new GenericMessage<>("foo")))
.withCauseInstanceOf(TimeoutException.class)
.withStackTraceContaining("Timeout waiting for response from KafkaProducer");
+
+ executorService.shutdown();
}
}
diff --git a/spring-integration-mail/src/main/java/org/springframework/integration/mail/AbstractMailReceiver.java b/spring-integration-mail/src/main/java/org/springframework/integration/mail/AbstractMailReceiver.java
index c91fb692709..b3e118f5ace 100755
--- a/spring-integration-mail/src/main/java/org/springframework/integration/mail/AbstractMailReceiver.java
+++ b/spring-integration-mail/src/main/java/org/springframework/integration/mail/AbstractMailReceiver.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2022 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.
@@ -67,6 +67,8 @@
* @author Artem Bilan
* @author Dominik Simmen
* @author Yuxin Wang
+ * @author Filip Hrisafov
+ * @author Jiandong Ma
*/
public abstract class AbstractMailReceiver extends IntegrationObjectSupport implements MailReceiver, DisposableBean {
@@ -356,6 +358,9 @@ protected void openFolder() throws MessagingException {
}
private Folder obtainFolderInstance() throws MessagingException {
+ if (this.url == null) {
+ return this.store.getDefaultFolder();
+ }
return this.store.getFolder(this.url);
}
@@ -502,18 +507,30 @@ private Object byteArrayToContent(Map headers, ByteArrayOutputSt
}
private void postProcessFilteredMessages(Message[] filteredMessages) throws MessagingException {
- setMessageFlags(filteredMessages);
-
- if (shouldDeleteMessages()) {
- deleteMessages(filteredMessages);
- }
// Copy messages to cause an eager fetch
+ Message[] messages = filteredMessages;
if (this.headerMapper == null && (this.autoCloseFolder || this.simpleContent)) {
+ messages = new Message[filteredMessages.length];
for (int i = 0; i < filteredMessages.length; i++) {
- MimeMessage mimeMessage = new IntegrationMimeMessage((MimeMessage) filteredMessages[i]);
+ Message originalMessage = filteredMessages[i];
+ messages[i] = originalMessage;
+ MimeMessage mimeMessage = new IntegrationMimeMessage((MimeMessage) originalMessage);
filteredMessages[i] = mimeMessage;
}
}
+
+ setMessageFlagsAndMaybeDeleteMessages(messages);
+ if (filteredMessages.length > 0 && filteredMessages[0] instanceof IntegrationMimeMessage) {
+ setMessageFlagsAndMaybeDeleteMessages(filteredMessages);
+ }
+ }
+
+ private void setMessageFlagsAndMaybeDeleteMessages(Message[] messages) throws MessagingException {
+ setMessageFlags(messages);
+
+ if (shouldDeleteMessages()) {
+ deleteMessages(messages);
+ }
}
private void setMessageFlags(Message[] filteredMessages) throws MessagingException {
diff --git a/spring-integration-mail/src/main/java/org/springframework/integration/mail/ImapIdleChannelAdapter.java b/spring-integration-mail/src/main/java/org/springframework/integration/mail/ImapIdleChannelAdapter.java
index 8782442c06c..943a854a6d4 100755
--- a/spring-integration-mail/src/main/java/org/springframework/integration/mail/ImapIdleChannelAdapter.java
+++ b/spring-integration-mail/src/main/java/org/springframework/integration/mail/ImapIdleChannelAdapter.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2023 the original author or authors.
+ * Copyright 2002-2024 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.
@@ -36,6 +36,7 @@
import org.springframework.integration.transaction.IntegrationResourceHolder;
import org.springframework.integration.transaction.IntegrationResourceHolderSynchronization;
import org.springframework.integration.transaction.TransactionSynchronizationFactory;
+import org.springframework.lang.Nullable;
import org.springframework.messaging.MessagingException;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@@ -200,22 +201,24 @@ private void callIdle() {
}
catch (Exception ex) {
publishException(ex);
- if (this.shouldReconnectAutomatically
- && ex.getCause() instanceof jakarta.mail.MessagingException messagingException) {
-
- //run again after a delay
- logger.info(messagingException,
- () -> "Failed to execute IDLE task. Will attempt to resubmit in "
- + this.reconnectDelay + " milliseconds.");
- delayNextIdleCall();
- }
- else {
- logger.warn(ex,
- "Failed to execute IDLE task. " +
- "Won't resubmit since not a 'shouldReconnectAutomatically' " +
- "or not a 'jakarta.mail.MessagingException'");
- break;
+ if (this.shouldReconnectAutomatically) {
+ jakarta.mail.MessagingException messagingException =
+ getJakartaMailMessagingExceptionFromCause(ex.getCause());
+
+ if (messagingException != null) {
+ //run again after a delay
+ logger.info(messagingException,
+ () -> "Failed to execute IDLE task. Will attempt to resubmit in "
+ + this.reconnectDelay + " milliseconds.");
+ delayNextIdleCall();
+ continue;
+ }
}
+ logger.warn(ex,
+ "Failed to execute IDLE task. " +
+ "Won't resubmit since not a 'shouldReconnectAutomatically' " +
+ "or not a 'jakarta.mail.MessagingException'");
+ break;
}
}
}
@@ -256,6 +259,21 @@ private void delayNextIdleCall() {
}
}
+ @Nullable
+ private static jakarta.mail.MessagingException getJakartaMailMessagingExceptionFromCause(Throwable cause) {
+ if (cause == null) {
+ return null;
+ }
+ if (cause instanceof jakarta.mail.MessagingException messagingException) {
+ return messagingException;
+ }
+ Throwable nextCause = cause.getCause();
+ if (cause == nextCause) {
+ return null;
+ }
+ return getJakartaMailMessagingExceptionFromCause(nextCause);
+ }
+
private class MessageSender implements Consumer {
MessageSender() {
diff --git a/spring-integration-mail/src/main/java/org/springframework/integration/mail/ImapMailReceiver.java b/spring-integration-mail/src/main/java/org/springframework/integration/mail/ImapMailReceiver.java
index 2f61d4fc979..8eadb19f4dd 100755
--- a/spring-integration-mail/src/main/java/org/springframework/integration/mail/ImapMailReceiver.java
+++ b/spring-integration-mail/src/main/java/org/springframework/integration/mail/ImapMailReceiver.java
@@ -18,6 +18,7 @@
import java.time.Instant;
import java.util.Arrays;
+import java.util.Locale;
import java.util.Objects;
import java.util.Properties;
import java.util.concurrent.ScheduledFuture;
@@ -79,7 +80,7 @@ public ImapMailReceiver() {
public ImapMailReceiver(String url) {
super(url);
if (url != null) {
- Assert.isTrue(url.toLowerCase().startsWith(PROTOCOL),
+ Assert.isTrue(url.toLowerCase(Locale.ROOT).startsWith(PROTOCOL),
"URL must start with 'imap' for the IMAP Mail receiver.");
}
else {
diff --git a/spring-integration-mail/src/main/java/org/springframework/integration/mail/config/MailReceiverFactoryBean.java b/spring-integration-mail/src/main/java/org/springframework/integration/mail/config/MailReceiverFactoryBean.java
index 169085b7a8a..23eb391b179 100644
--- a/spring-integration-mail/src/main/java/org/springframework/integration/mail/config/MailReceiverFactoryBean.java
+++ b/spring-integration-mail/src/main/java/org/springframework/integration/mail/config/MailReceiverFactoryBean.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2021 the original author or authors.
+ * Copyright 2002-2024 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.
@@ -16,6 +16,7 @@
package org.springframework.integration.mail.config;
+import java.util.Locale;
import java.util.Properties;
import jakarta.mail.Authenticator;
@@ -162,8 +163,8 @@ public Class> getObjectType() {
private MailReceiver createReceiver() { // NOSONAR
verifyProtocol();
- boolean isPop3 = this.protocol.toLowerCase().startsWith("pop3");
- boolean isImap = this.protocol.toLowerCase().startsWith("imap");
+ boolean isPop3 = this.protocol.toLowerCase(Locale.ROOT).startsWith("pop3");
+ boolean isImap = this.protocol.toLowerCase(Locale.ROOT).startsWith("imap");
Assert.isTrue(isPop3 || isImap, "the store URI must begin with 'pop3' or 'imap'");
AbstractMailReceiver mailReceiver = isPop3
? new Pop3MailReceiver(this.storeUri)
diff --git a/spring-integration-mail/src/test/java/org/springframework/integration/mail/ImapMailReceiverTests.java b/spring-integration-mail/src/test/java/org/springframework/integration/mail/ImapMailReceiverTests.java
index dbb3976bb0c..375ae722ba4 100644
--- a/spring-integration-mail/src/test/java/org/springframework/integration/mail/ImapMailReceiverTests.java
+++ b/spring-integration-mail/src/test/java/org/springframework/integration/mail/ImapMailReceiverTests.java
@@ -17,6 +17,7 @@
package org.springframework.integration.mail;
import java.io.IOException;
+import java.io.OutputStream;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
@@ -24,6 +25,7 @@
import java.util.Properties;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Handler;
@@ -88,6 +90,7 @@
import org.springframework.util.MimeTypeUtils;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
@@ -107,6 +110,7 @@
* @author Artem Bilan
* @author Alexander Pinske
* @author Dominik Simmen
+ * @author Filip Hrisafov
*/
@SpringJUnitConfig
@ContextConfiguration(
@@ -151,7 +155,7 @@ void startImapServer() {
imapSearches.stores.clear();
ServerSetup imap = ServerSetupTest.IMAP.verbose(true).dynamicPort();
imap.setServerStartupTimeout(10000);
- imap.setReadTimeout(2000);
+ imap.setReadTimeout(10000);
imapIdleServer = new GreenMail(imap);
user = imapIdleServer.setUser("user", "pw");
imapIdleServer.start();
@@ -187,7 +191,6 @@ public void testIdleWithServerDefaultSearch() throws Exception {
}
@Test
- @Disabled("GreenMail server closes socket for some reason")
public void testIdleWithMessageMapping() throws Exception {
ImapMailReceiver receiver =
new ImapMailReceiver("imap://user:pw@localhost:" + imapIdleServer.getImap().getPort() + "/INBOX");
@@ -196,6 +199,7 @@ public void testIdleWithMessageMapping() throws Exception {
}
@Test
+ @Disabled
public void testIdleWithServerDefaultSearchSimple() throws Exception {
ImapMailReceiver receiver =
new ImapMailReceiver("imap://user:pw@localhost:" + imapIdleServer.getImap().getPort() + "/INBOX");
@@ -258,7 +262,7 @@ public void testIdleWithServerGuts(ImapMailReceiver receiver, boolean mapped, bo
assertThat(received).isNotNull();
MessageHeaders headers = received.getHeaders();
assertThat(headers.get(MailHeaders.RAW_HEADERS)).isNotNull();
- assertThat(headers.get(MailHeaders.CONTENT_TYPE)).isEqualTo("TEXT/PLAIN; charset=us-ascii");
+ assertThat(headers.get(MailHeaders.CONTENT_TYPE)).isEqualTo("text/plain; charset=us-ascii");
assertThat(headers.get(MessageHeaders.CONTENT_TYPE)).isEqualTo(MimeTypeUtils.TEXT_PLAIN_VALUE);
assertThat(headers.get(MailHeaders.FROM)).isEqualTo("Bar ");
String[] toHeader = headers.get(MailHeaders.TO, String[].class);
@@ -299,6 +303,11 @@ public void receiveAndMarkAsReadDontDelete() throws Exception {
private AbstractMailReceiver receiveAndMarkAsReadDontDeleteGuts(AbstractMailReceiver receiver, Message msg1,
Message msg2) throws NoSuchFieldException, IllegalAccessException, MessagingException {
+ return receiveAndMarkAsReadDontDeleteGuts(receiver, msg1, msg2, true);
+ }
+
+ private AbstractMailReceiver receiveAndMarkAsReadDontDeleteGuts(AbstractMailReceiver receiver, Message msg1,
+ Message msg2, boolean receive) throws NoSuchFieldException, IllegalAccessException, MessagingException {
((ImapMailReceiver) receiver).setShouldMarkMessagesAsRead(true);
receiver = spy(receiver);
@@ -326,7 +335,9 @@ private AbstractMailReceiver receiveAndMarkAsReadDontDeleteGuts(AbstractMailRece
willAnswer(invocation -> messages).given(folder).search(any(SearchTerm.class));
willAnswer(invocation -> null).given(receiver).fetchMessages(messages);
- receiver.receive();
+ if (receive) {
+ receiver.receive();
+ }
return receiver;
}
@@ -347,14 +358,14 @@ public void receiveAndMarkAsReadDontDeletePassingFilter() throws Exception {
public void receiveAndMarkAsReadDontDeleteFiltered() throws Exception {
AbstractMailReceiver receiver = new ImapMailReceiver();
Message msg1 = GreenMailUtil.newMimeMessage("test1");
- Message msg2 = GreenMailUtil.newMimeMessage("test2");
+ Message msg2 = spy(GreenMailUtil.newMimeMessage("test2"));
given(msg2.getSubject()).willReturn("foo"); // should not be marked seen
Expression selectorExpression = new SpelExpressionParser()
.parseExpression("subject == null OR !subject.equals('foo')");
receiver.setSelectorExpression(selectorExpression);
receiver = receiveAndMarkAsReadDontDeleteGuts(receiver, msg1, msg2);
assertThat(msg1.getFlags().contains(Flag.SEEN)).isTrue();
- assertThat(msg2.getFlags().contains(Flag.SEEN)).isTrue();
+ assertThat(msg2.getFlags().contains(Flag.SEEN)).isFalse();
verify(receiver, times(0)).deleteMessages(Mockito.any());
}
@@ -441,7 +452,7 @@ public void receiveMarkAsReadAndDelete() throws Exception {
assertThat(msg1.getFlags().contains(Flag.SEEN)).isTrue();
assertThat(msg2.getFlags().contains(Flag.SEEN)).isTrue();
- verify(receiver, times(1)).deleteMessages(Mockito.any());
+ verify(receiver, times(2)).deleteMessages(Mockito.any());
}
@Test
@@ -980,6 +991,31 @@ private void setUpScheduler(ImapMailReceiver mailReceiver, ThreadPoolTaskSchedul
mailReceiver.setBeanFactory(bf);
}
+ @Test
+ public void receiveAndMarkAsReadDontDeleteWithThrowingWhenCopying() throws Exception {
+ AbstractMailReceiver receiver = new ImapMailReceiver();
+ MimeMessage msg1 = spy(GreenMailUtil.newMimeMessage("test1"));
+ MimeMessage greenMailMsg2 = GreenMailUtil.newMimeMessage("test2");
+ TestThrowingMimeMessage msg2 = new TestThrowingMimeMessage(greenMailMsg2);
+ receiver = receiveAndMarkAsReadDontDeleteGuts(receiver, msg1, msg2, false);
+ assertThatThrownBy(receiver::receive)
+ .isInstanceOf(MessagingException.class)
+ .hasMessage("IOException while copying message")
+ .cause()
+ .isInstanceOf(IOException.class)
+ .hasMessage("Simulated exception");
+ assertThat(msg1.getFlags().contains(Flag.SEEN)).isFalse();
+ assertThat(msg2.getFlags().contains(Flag.SEEN)).isFalse();
+ verify(msg1, times(0)).setFlags(Mockito.any(), Mockito.anyBoolean());
+
+ receiver.receive();
+ assertThat(msg1.getFlags().contains(Flag.SEEN)).isTrue();
+ assertThat(msg2.getFlags().contains(Flag.SEEN)).isTrue();
+ // msg2 is marked with the user and seen flags
+ verify(msg1, times(2)).setFlags(Mockito.any(), Mockito.anyBoolean());
+ verify(receiver, times(0)).deleteMessages(Mockito.any());
+ }
+
private static class ImapSearchLoggingHandler extends Handler {
private final List searches = new ArrayList<>();
@@ -1015,4 +1051,21 @@ public void close() throws SecurityException {
}
+ private static class TestThrowingMimeMessage extends MimeMessage {
+
+ protected final AtomicBoolean throwExceptionBeforeWrite = new AtomicBoolean(true);
+
+ private TestThrowingMimeMessage(MimeMessage source) throws MessagingException {
+ super(source);
+ }
+
+ @Override
+ public void writeTo(OutputStream os) throws IOException, MessagingException {
+ if (this.throwExceptionBeforeWrite.getAndSet(false)) {
+ throw new IOException("Simulated exception");
+ }
+ super.writeTo(os);
+ }
+ }
+
}
diff --git a/spring-integration-mail/src/test/java/org/springframework/integration/mail/MailReceiverTests.java b/spring-integration-mail/src/test/java/org/springframework/integration/mail/MailReceiverTests.java
index 4de6537ee18..752a1b8319a 100644
--- a/spring-integration-mail/src/test/java/org/springframework/integration/mail/MailReceiverTests.java
+++ b/spring-integration-mail/src/test/java/org/springframework/integration/mail/MailReceiverTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2014-2022 the original author or authors.
+ * Copyright 2014-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.
@@ -22,7 +22,6 @@
import jakarta.mail.Message;
import jakarta.mail.Session;
import jakarta.mail.Store;
-import jakarta.mail.URLName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.DirectFieldAccessor;
@@ -68,7 +67,7 @@ protected Message[] searchForNewMessages() {
Folder folder = mock(Folder.class);
when(folder.exists()).thenReturn(true);
when(folder.isOpen()).thenReturn(false, true);
- doReturn(folder).when(store).getFolder((URLName) null);
+ doReturn(folder).when(store).getDefaultFolder();
doNothing().when(store).connect();
receiver.openFolder();
receiver.openFolder();
diff --git a/spring-integration-mqtt/src/main/java/org/springframework/integration/mqtt/inbound/Mqttv5PahoMessageDrivenChannelAdapter.java b/spring-integration-mqtt/src/main/java/org/springframework/integration/mqtt/inbound/Mqttv5PahoMessageDrivenChannelAdapter.java
index 3d015b8713f..86053614cc1 100644
--- a/spring-integration-mqtt/src/main/java/org/springframework/integration/mqtt/inbound/Mqttv5PahoMessageDrivenChannelAdapter.java
+++ b/spring-integration-mqtt/src/main/java/org/springframework/integration/mqtt/inbound/Mqttv5PahoMessageDrivenChannelAdapter.java
@@ -19,15 +19,14 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.ConcurrentModificationException;
import java.util.List;
import java.util.Map;
-import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.IntStream;
import org.eclipse.paho.mqttv5.client.IMqttAsyncClient;
-import org.eclipse.paho.mqttv5.client.IMqttMessageListener;
import org.eclipse.paho.mqttv5.client.IMqttToken;
import org.eclipse.paho.mqttv5.client.MqttAsyncClient;
import org.eclipse.paho.mqttv5.client.MqttCallback;
@@ -109,8 +108,6 @@ public class Mqttv5PahoMessageDrivenChannelAdapter
private volatile boolean readyToSubscribeOnStart;
- private final AtomicInteger subscriptionIdentifierCounter = new AtomicInteger(0);
-
/**
* Create an instance based on the MQTT url, client id and subscriptions.
* @param url the MQTT url to connect.
@@ -293,7 +290,7 @@ protected void doStop() {
try {
if (this.mqttClient != null && this.mqttClient.isConnected()) {
if (this.connectionOptions.isCleanStart()) {
- this.mqttClient.unsubscribe(topics).waitForCompletion(getCompletionTimeout());
+ unsubscribe(topics);
// Have to re-subscribe on next start if connection is not lost.
this.readyToSubscribeOnStart = true;
@@ -314,12 +311,22 @@ protected void doStop() {
}
}
+ private void unsubscribe(String... topics) throws MqttException {
+ try {
+ // Catch ConcurrentModificationException: https://github.com/eclipse/paho.mqtt.java/issues/986
+ this.mqttClient.unsubscribe(topics).waitForCompletion(getCompletionTimeout());
+ }
+ catch (ConcurrentModificationException ex) {
+ logger.error(ex, () -> "Error unsubscribing from " + Arrays.toString(topics));
+ }
+ }
+
@Override
public void destroy() {
super.destroy();
try {
if (getClientManager() == null && this.mqttClient != null) {
- this.mqttClient.close(true);
+ this.mqttClient.close();
}
}
catch (MqttException ex) {
@@ -344,9 +351,10 @@ public void addTopic(String topic, int qos) {
}
if (this.mqttClient != null && this.mqttClient.isConnected()) {
MqttProperties subscriptionProperties = new MqttProperties();
- subscriptionProperties.setSubscriptionIdentifier(this.subscriptionIdentifierCounter.incrementAndGet());
+ // Make use of mqttSession.getNextSubscriptionIdentifier() if available in connection
+ subscriptionProperties.setSubscriptionIdentifiers(List.of(0));
this.mqttClient.subscribe(new MqttSubscription[] {subscription},
- null, null, new IMqttMessageListener[] {this::messageArrived}, subscriptionProperties)
+ null, null, this::messageArrived, subscriptionProperties)
.waitForCompletion(getCompletionTimeout());
}
}
@@ -363,7 +371,7 @@ public void removeTopic(String... topic) {
this.topicLock.lock();
try {
if (this.mqttClient != null && this.mqttClient.isConnected()) {
- this.mqttClient.unsubscribe(topic).waitForCompletion(getCompletionTimeout());
+ unsubscribe(topic);
}
super.removeTopic(topic);
if (!CollectionUtils.isEmpty(this.subscriptions)) {
@@ -472,15 +480,10 @@ private void subscribe() {
ApplicationEventPublisher applicationEventPublisher = getApplicationEventPublisher();
this.topicLock.lock();
try {
- IMqttMessageListener listener = this::messageArrived;
- IMqttMessageListener[] listeners = IntStream.range(0, mqttSubscriptions.length)
- .mapToObj(t -> listener)
- .toArray(IMqttMessageListener[]::new);
MqttProperties subscriptionProperties = new MqttProperties();
- subscriptionProperties.setSubscriptionIdentifiers(IntStream.range(0, mqttSubscriptions.length)
- .mapToObj(i -> this.subscriptionIdentifierCounter.incrementAndGet())
- .toList());
- this.mqttClient.subscribe(mqttSubscriptions, null, null, listeners, subscriptionProperties)
+ // Make use of mqttSession.getNextSubscriptionIdentifier() if available in connection
+ subscriptionProperties.setSubscriptionIdentifiers(List.of(0));
+ this.mqttClient.subscribe(mqttSubscriptions, null, null, this::messageArrived, subscriptionProperties)
.waitForCompletion(getCompletionTimeout());
String message = "Connected and subscribed to " + Arrays.toString(mqttSubscriptions);
logger.debug(message);
diff --git a/spring-integration-mqtt/src/main/java/org/springframework/integration/mqtt/outbound/Mqttv5PahoMessageHandler.java b/spring-integration-mqtt/src/main/java/org/springframework/integration/mqtt/outbound/Mqttv5PahoMessageHandler.java
index 60ce021d45b..6189e6c644c 100644
--- a/spring-integration-mqtt/src/main/java/org/springframework/integration/mqtt/outbound/Mqttv5PahoMessageHandler.java
+++ b/spring-integration-mqtt/src/main/java/org/springframework/integration/mqtt/outbound/Mqttv5PahoMessageHandler.java
@@ -266,7 +266,15 @@ protected void publish(String topic, Object mqttMessage, Message> message) {
long completionTimeout = getCompletionTimeout();
try {
if (!this.mqttClient.isConnected()) {
- this.mqttClient.connect(this.connectionOptions).waitForCompletion(completionTimeout);
+ this.lock.lock();
+ try {
+ if (!this.mqttClient.isConnected()) {
+ this.mqttClient.connect(this.connectionOptions).waitForCompletion(completionTimeout);
+ }
+ }
+ finally {
+ this.lock.unlock();
+ }
}
IMqttToken token = this.mqttClient.publish(topic, (MqttMessage) mqttMessage);
ApplicationEventPublisher applicationEventPublisher = getApplicationEventPublisher();
diff --git a/spring-integration-mqtt/src/test/java/org/springframework/integration/mqtt/Mqttv5AdapterTests.java b/spring-integration-mqtt/src/test/java/org/springframework/integration/mqtt/Mqttv5AdapterTests.java
index 61dd1dab34e..8b77caf8a8b 100644
--- a/spring-integration-mqtt/src/test/java/org/springframework/integration/mqtt/Mqttv5AdapterTests.java
+++ b/spring-integration-mqtt/src/test/java/org/springframework/integration/mqtt/Mqttv5AdapterTests.java
@@ -57,7 +57,7 @@ public void testStop() throws Exception {
adapter.stop();
verify(client).connect(any(MqttConnectionOptions.class));
- verify(client).subscribe(any(MqttSubscription[].class), any(), any(), any(IMqttMessageListener[].class), any());
+ verify(client).subscribe(any(MqttSubscription[].class), any(), any(), any(IMqttMessageListener.class), any());
verify(client).unsubscribe(any(String[].class));
}
@@ -71,11 +71,12 @@ public void testStopNotClean() throws Exception {
adapter.stop();
verify(client).connect(any(MqttConnectionOptions.class));
- verify(client).subscribe(any(MqttSubscription[].class), any(), any(), any(IMqttMessageListener[].class), any());
+ verify(client).subscribe(any(MqttSubscription[].class), any(), any(), any(IMqttMessageListener.class), any());
verify(client, never()).unsubscribe(any(String[].class));
}
- private static Mqttv5PahoMessageDrivenChannelAdapter buildAdapterIn(final IMqttAsyncClient client, boolean cleanStart) throws MqttException {
+ private static Mqttv5PahoMessageDrivenChannelAdapter buildAdapterIn(final IMqttAsyncClient client,
+ boolean cleanStart) throws MqttException {
MqttConnectionOptions connectionOptions = new MqttConnectionOptions();
connectionOptions.setServerURIs(new String[] {"tcp://localhost:1883"});
@@ -85,9 +86,11 @@ private static Mqttv5PahoMessageDrivenChannelAdapter buildAdapterIn(final IMqttA
IMqttToken token = mock(IMqttToken.class);
given(client.disconnect()).willReturn(token);
given(client.connect(any(MqttConnectionOptions.class))).willReturn(token);
- given(client.subscribe(any(MqttSubscription[].class), any(), any(), any(IMqttMessageListener[].class), any())).willReturn(token);
+ given(client.subscribe(any(MqttSubscription[].class), any(), any(), any(IMqttMessageListener.class), any()))
+ .willReturn(token);
given(client.unsubscribe(any(String[].class))).willReturn(token);
- Mqttv5PahoMessageDrivenChannelAdapter adapter = new Mqttv5PahoMessageDrivenChannelAdapter(connectionOptions, "client", "foo");
+ Mqttv5PahoMessageDrivenChannelAdapter adapter =
+ new Mqttv5PahoMessageDrivenChannelAdapter(connectionOptions, "client", "foo");
ReflectionTestUtils.setField(adapter, "mqttClient", client);
adapter.setBeanFactory(mock(BeanFactory.class));
adapter.setApplicationEventPublisher(mock(ApplicationEventPublisher.class));
diff --git a/spring-integration-mqtt/src/test/java/org/springframework/integration/mqtt/ResubscribeAfterAutomaticReconnectTests.java b/spring-integration-mqtt/src/test/java/org/springframework/integration/mqtt/ResubscribeAfterAutomaticReconnectTests.java
index e6a21ff609d..c09afc9d45f 100644
--- a/spring-integration-mqtt/src/test/java/org/springframework/integration/mqtt/ResubscribeAfterAutomaticReconnectTests.java
+++ b/spring-integration-mqtt/src/test/java/org/springframework/integration/mqtt/ResubscribeAfterAutomaticReconnectTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023 the original author or authors.
+ * Copyright 2023-2024 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.
@@ -16,6 +16,7 @@
package org.springframework.integration.mqtt;
+import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
@@ -82,7 +83,7 @@ void messageReceivedAfterResubscriptionOnLostConnection() throws InterruptedExce
assertThat(this.config.subscribeSecondLatch.await(10, TimeUnit.SECONDS)).isTrue();
- await().untilAsserted(() -> this.mqttOutFlowInput.send(testMessage));
+ await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> this.mqttOutFlowInput.send(testMessage));
assertThat(this.fromMqttChannel.receive(10_000)).isNotNull();
// Re-subscription on channel adapter restart with cleanStart
diff --git a/spring-integration-redis/src/main/java/org/springframework/integration/redis/util/RedisLockRegistry.java b/spring-integration-redis/src/main/java/org/springframework/integration/redis/util/RedisLockRegistry.java
index 7f73eebbc7c..1f1d44a38e5 100644
--- a/spring-integration-redis/src/main/java/org/springframework/integration/redis/util/RedisLockRegistry.java
+++ b/spring-integration-redis/src/main/java/org/springframework/integration/redis/util/RedisLockRegistry.java
@@ -17,6 +17,7 @@
package org.springframework.integration.redis.util;
import java.text.SimpleDateFormat;
+import java.time.Duration;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
@@ -86,6 +87,7 @@
* @author Eddie Cho
* @author Myeonghyeon Lee
* @author Roman Zabaluev
+ * @author Alex Peelman
*
* @since 4.0
*
@@ -98,8 +100,12 @@ public final class RedisLockRegistry implements ExpirableLockRegistry, Disposabl
private static final int DEFAULT_CAPACITY = 100_000;
+ private static final int DEFAULT_IDLE = 100;
+
private final Lock lock = new ReentrantLock();
+ private Duration idleBetweenTries = Duration.ofMillis(DEFAULT_IDLE);
+
private final Map locks =
new LinkedHashMap<>(16, 0.75F, true) {
@@ -209,6 +215,17 @@ public void setCacheCapacity(int cacheCapacity) {
this.cacheCapacity = cacheCapacity;
}
+ /**
+ * Specify a @link Duration} to sleep between obtainLock attempts.
+ * Defaults to 100 milliseconds.
+ * @param idleBetweenTries the {@link Duration} to sleep between obtainLock attempts.
+ * @since 6.4.0
+ */
+ public void setIdleBetweenTries(Duration idleBetweenTries) {
+ Assert.notNull(idleBetweenTries, "'idleBetweenTries' must not be null");
+ this.idleBetweenTries = idleBetweenTries;
+ }
+
/**
* Set {@link RedisLockType} mode to work in.
* By default, the {@link RedisLockType#SPIN_LOCK} is used - works in all the environment.
@@ -279,7 +296,7 @@ public void destroy() {
public enum RedisLockType {
/**
- * The lock is acquired by periodically(100ms) checking whether the lock can be acquired.
+ * The lock is acquired by periodically(idleBetweenTries property) checking whether the lock can be acquired.
*/
SPIN_LOCK,
@@ -657,7 +674,7 @@ private boolean subscribeLock(long time) throws ExecutionException, InterruptedE
}
private void runRedisMessageListenerContainer() {
- RedisLockRegistry.this.lock.tryLock();
+ RedisLockRegistry.this.lock.lock();
try {
if (!(RedisLockRegistry.this.isRunningRedisMessageListenerContainer
&& RedisLockRegistry.this.redisMessageListenerContainer != null
@@ -741,7 +758,7 @@ protected boolean tryRedisLockInner(long time) throws InterruptedException {
long now = System.currentTimeMillis();
if (time == -1L) {
while (!obtainLock()) {
- Thread.sleep(100); //NOSONAR
+ Thread.sleep(RedisLockRegistry.this.idleBetweenTries.toMillis()); //NOSONAR
}
return true;
}
@@ -749,7 +766,7 @@ protected boolean tryRedisLockInner(long time) throws InterruptedException {
long expire = now + TimeUnit.MILLISECONDS.convert(time, TimeUnit.MILLISECONDS);
boolean acquired;
while (!(acquired = obtainLock()) && System.currentTimeMillis() < expire) { //NOSONAR
- Thread.sleep(100); //NOSONAR
+ Thread.sleep(RedisLockRegistry.this.idleBetweenTries.toMillis()); //NOSONAR
}
return acquired;
}
diff --git a/spring-integration-redis/src/test/java/org/springframework/integration/redis/store/RedisMessageGroupStoreTests.java b/spring-integration-redis/src/test/java/org/springframework/integration/redis/store/RedisMessageGroupStoreTests.java
index 57efe56cacb..68bd2d112ce 100644
--- a/spring-integration-redis/src/test/java/org/springframework/integration/redis/store/RedisMessageGroupStoreTests.java
+++ b/spring-integration-redis/src/test/java/org/springframework/integration/redis/store/RedisMessageGroupStoreTests.java
@@ -20,7 +20,6 @@
import java.util.Date;
import java.util.Iterator;
import java.util.List;
-import java.util.Objects;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
@@ -35,13 +34,16 @@
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
+import org.springframework.beans.BeanUtils;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.SerializationException;
import org.springframework.integration.channel.DirectChannel;
import org.springframework.integration.channel.NullChannel;
import org.springframework.integration.channel.QueueChannel;
+import org.springframework.integration.handler.DelayHandler;
import org.springframework.integration.history.MessageHistory;
import org.springframework.integration.message.AdviceMessage;
import org.springframework.integration.redis.RedisContainerTest;
@@ -56,14 +58,15 @@
import org.springframework.messaging.support.GenericMessage;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatNoException;
-import static org.assertj.core.api.Assertions.fail;
/**
* @author Oleg Zhurakousky
* @author Artem Bilan
* @author Gary Russell
* @author Artem Vozhdayenko
+ * @author Youbin Wu
*/
class RedisMessageGroupStoreTests implements RedisContainerTest {
@@ -316,7 +319,7 @@ void testConcurrentModifications() throws Exception {
executor.execute(() -> {
store2.removeMessagesFromGroup(this.groupId, message);
MessageGroup group = store2.getMessageGroup(this.groupId);
- if (group.getMessages().size() != 0) {
+ if (!group.getMessages().isEmpty()) {
failures.add("REMOVE");
throw new AssertionFailedError("Failed on Remove");
}
@@ -400,11 +403,17 @@ void testJsonSerialization() {
Message> mutableMessage = new MutableMessage<>(UUID.randomUUID());
Message> adviceMessage = new AdviceMessage<>("foo", genericMessage);
ErrorMessage errorMessage = new ErrorMessage(new RuntimeException("test exception"), mutableMessage);
+ var delayedMessageWrapperConstructor =
+ BeanUtils.getResolvableConstructor(DelayHandler.DelayedMessageWrapper.class);
+ Message> delayMessage = new GenericMessage<>(
+ BeanUtils.instantiateClass(delayedMessageWrapperConstructor, genericMessage,
+ System.currentTimeMillis()));
- store.addMessagesToGroup(this.groupId, genericMessage, mutableMessage, adviceMessage, errorMessage);
+ store.addMessagesToGroup(this.groupId,
+ genericMessage, mutableMessage, adviceMessage, errorMessage, delayMessage);
MessageGroup messageGroup = store.getMessageGroup(this.groupId);
- assertThat(messageGroup.size()).isEqualTo(4);
+ assertThat(messageGroup.size()).isEqualTo(5);
List> messages = new ArrayList<>(messageGroup.getMessages());
assertThat(messages.get(0)).isEqualTo(genericMessage);
assertThat(messages.get(0).getHeaders()).containsKeys(MessageHistory.HEADER_NAME);
@@ -417,22 +426,21 @@ void testJsonSerialization() {
.isEqualTo(errorMessage.getOriginalMessage());
assertThat(((ErrorMessage) errorMessageResult).getPayload().getMessage())
.isEqualTo(errorMessage.getPayload().getMessage());
+ assertThat(messages.get(4)).isEqualTo(delayMessage);
Message fooMessage = new GenericMessage<>(new Foo("foo"));
- try {
- store.addMessageToGroup(this.groupId, fooMessage)
- .getMessages()
- .iterator()
- .next();
- fail("SerializationException expected");
- }
- catch (Exception e) {
- assertThat(e.getCause().getCause()).isInstanceOf(IllegalArgumentException.class);
- assertThat(e.getMessage()).contains("The class with " +
- "org.springframework.integration.redis.store.RedisMessageGroupStoreTests$Foo and name of " +
- "org.springframework.integration.redis.store.RedisMessageGroupStoreTests$Foo " +
- "is not in the trusted packages:");
- }
+
+ assertThatExceptionOfType(SerializationException.class)
+ .isThrownBy(() ->
+ store.addMessageToGroup(this.groupId, fooMessage)
+ .getMessages()
+ .iterator()
+ .next())
+ .withRootCauseInstanceOf(IllegalArgumentException.class)
+ .withMessageContaining("The class with " +
+ "org.springframework.integration.redis.store.RedisMessageGroupStoreTests$Foo and name of " +
+ "org.springframework.integration.redis.store.RedisMessageGroupStoreTests$Foo " +
+ "is not in the trusted packages:");
mapper = JacksonJsonUtils.messagingAwareMapper(getClass().getPackage().getName());
@@ -485,43 +493,7 @@ public void removeMessagesFromGroupDontRemoveSameMessageInOtherGroup() {
assertThat(store.messageGroupSize("2")).isEqualTo(1);
}
- private static class Foo {
-
- private String foo;
-
- Foo() {
- }
-
- Foo(String foo) {
- this.foo = foo;
- }
-
- public String getFoo() {
- return this.foo;
- }
-
- public void setFoo(String foo) {
- this.foo = foo;
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) {
- return true;
- }
- if (o == null || getClass() != o.getClass()) {
- return false;
- }
-
- Foo foo1 = (Foo) o;
-
- return this.foo != null ? this.foo.equals(foo1.foo) : foo1.foo == null;
- }
-
- @Override
- public int hashCode() {
- return Objects.hashCode(this.foo);
- }
+ private record Foo(String foo) {
}
diff --git a/spring-integration-redis/src/test/java/org/springframework/integration/redis/util/AggregatorWithRedisLocksTests.java b/spring-integration-redis/src/test/java/org/springframework/integration/redis/util/AggregatorWithRedisLocksTests.java
index b49922895dc..05e02dcd3ad 100644
--- a/spring-integration-redis/src/test/java/org/springframework/integration/redis/util/AggregatorWithRedisLocksTests.java
+++ b/spring-integration-redis/src/test/java/org/springframework/integration/redis/util/AggregatorWithRedisLocksTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2014-2022 the original author or authors.
+ * Copyright 2014-2024 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.
@@ -20,6 +20,7 @@
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
@@ -88,8 +89,9 @@ public void setup() {
@Test
void testLockSingleGroup() throws Exception {
this.releaseStrategy.reset(1);
- Executors.newSingleThreadExecutor().execute(asyncSend("foo", 1, 1));
- Executors.newSingleThreadExecutor().execute(asyncSend("bar", 2, 1));
+ ExecutorService executorService = Executors.newCachedThreadPool();
+ executorService.execute(asyncSend("foo", 1, 1));
+ executorService.execute(asyncSend("bar", 2, 1));
assertThat(this.releaseStrategy.latch2.await(10, TimeUnit.SECONDS)).isTrue();
assertThat(this.template.keys("aggregatorWithRedisLocksTests:*")).hasSize(1);
this.releaseStrategy.latch1.countDown();
@@ -98,17 +100,19 @@ void testLockSingleGroup() throws Exception {
this.assertNoLocksAfterTest();
assertThat(this.exception)
.as("Unexpected exception:" + (this.exception != null ? this.exception.toString() : "")).isNull();
+ executorService.shutdown();
}
@Test
void testLockThreeGroups() throws Exception {
this.releaseStrategy.reset(3);
- Executors.newSingleThreadExecutor().execute(asyncSend("foo", 1, 1));
- Executors.newSingleThreadExecutor().execute(asyncSend("bar", 2, 1));
- Executors.newSingleThreadExecutor().execute(asyncSend("foo", 1, 2));
- Executors.newSingleThreadExecutor().execute(asyncSend("bar", 2, 2));
- Executors.newSingleThreadExecutor().execute(asyncSend("foo", 1, 3));
- Executors.newSingleThreadExecutor().execute(asyncSend("bar", 2, 3));
+ ExecutorService executorService = Executors.newCachedThreadPool();
+ executorService.execute(asyncSend("foo", 1, 1));
+ executorService.execute(asyncSend("bar", 2, 1));
+ executorService.execute(asyncSend("foo", 1, 2));
+ executorService.execute(asyncSend("bar", 2, 2));
+ executorService.execute(asyncSend("foo", 1, 3));
+ executorService.execute(asyncSend("bar", 2, 3));
assertThat(this.releaseStrategy.latch2.await(10, TimeUnit.SECONDS)).isTrue();
assertThat(this.template.keys("aggregatorWithRedisLocksTests:*")).hasSize(3);
this.releaseStrategy.latch1.countDown();
@@ -121,13 +125,15 @@ void testLockThreeGroups() throws Exception {
this.assertNoLocksAfterTest();
assertThat(this.exception)
.as("Unexpected exception:" + (this.exception != null ? this.exception.toString() : "")).isNull();
+ executorService.shutdown();
}
@RepeatedTest(10)
void testDistributedAggregator() throws Exception {
this.releaseStrategy.reset(1);
- Executors.newSingleThreadExecutor().execute(asyncSend("foo", 1, 1));
- Executors.newSingleThreadExecutor().execute(() -> {
+ ExecutorService executorService = Executors.newCachedThreadPool();
+ executorService.execute(asyncSend("foo", 1, 1));
+ executorService.execute(() -> {
try {
in2.send(new GenericMessage<>("bar", stubHeaders(2, 2, 1)));
}
@@ -143,6 +149,7 @@ void testDistributedAggregator() throws Exception {
this.assertNoLocksAfterTest();
assertThat(this.exception)
.as("Unexpected exception:" + (this.exception != null ? this.exception.toString() : "")).isNull();
+ executorService.shutdown();
}
private void assertNoLocksAfterTest() throws Exception {
diff --git a/spring-integration-redis/src/test/java/org/springframework/integration/redis/util/RedisLockRegistryTests.java b/spring-integration-redis/src/test/java/org/springframework/integration/redis/util/RedisLockRegistryTests.java
index a0874de6202..c654b6b85d3 100644
--- a/spring-integration-redis/src/test/java/org/springframework/integration/redis/util/RedisLockRegistryTests.java
+++ b/spring-integration-redis/src/test/java/org/springframework/integration/redis/util/RedisLockRegistryTests.java
@@ -228,7 +228,8 @@ void testTwoThreadsSecondFailsToGetLock(RedisLockType testRedisLockType) throws
lock1.lockInterruptibly();
final AtomicBoolean locked = new AtomicBoolean();
final CountDownLatch latch = new CountDownLatch(1);
- Future result = Executors.newSingleThreadExecutor().submit(() -> {
+ ExecutorService executorService = Executors.newSingleThreadExecutor();
+ Future result = executorService.submit(() -> {
Lock lock2 = registry.obtain("foo");
locked.set(lock2.tryLock(200, TimeUnit.MILLISECONDS));
latch.countDown();
@@ -249,6 +250,7 @@ void testTwoThreadsSecondFailsToGetLock(RedisLockType testRedisLockType) throws
registry.expireUnusedOlderThan(-1000);
assertThat(getRedisLockRegistryLocks(registry)).isEmpty();
registry.destroy();
+ executorService.shutdown();
}
@ParameterizedTest
@@ -263,7 +265,8 @@ void testTwoThreads(RedisLockType testRedisLockType) throws Exception {
CountDownLatch latch3 = new CountDownLatch(1);
lock1.lockInterruptibly();
assertThat(getRedisLockRegistryLocks(registry)).hasSize(1);
- Executors.newSingleThreadExecutor().execute(() -> {
+ ExecutorService executorService = Executors.newSingleThreadExecutor();
+ executorService.execute(() -> {
Lock lock2 = registry.obtain("foo");
try {
latch1.countDown();
@@ -289,6 +292,7 @@ void testTwoThreads(RedisLockType testRedisLockType) throws Exception {
registry.expireUnusedOlderThan(-1000);
assertThat(getRedisLockRegistryLocks(registry)).isEmpty();
registry.destroy();
+ executorService.shutdown();
}
@ParameterizedTest
@@ -305,7 +309,8 @@ void testTwoThreadsDifferentRegistries(RedisLockType testRedisLockType) throws E
CountDownLatch latch3 = new CountDownLatch(1);
lock1.lockInterruptibly();
assertThat(getRedisLockRegistryLocks(registry1)).hasSize(1);
- Executors.newSingleThreadExecutor().execute(() -> {
+ ExecutorService executorService = Executors.newSingleThreadExecutor();
+ executorService.execute(() -> {
Lock lock2 = registry2.obtain("foo");
try {
latch1.countDown();
@@ -340,6 +345,7 @@ void testTwoThreadsDifferentRegistries(RedisLockType testRedisLockType) throws E
assertThat(getRedisLockRegistryLocks(registry2)).isEmpty();
registry1.destroy();
registry2.destroy();
+ executorService.shutdown();
}
@ParameterizedTest
@@ -351,7 +357,8 @@ void testTwoThreadsWrongOneUnlocks(RedisLockType testRedisLockType) throws Excep
lock.lockInterruptibly();
AtomicBoolean locked = new AtomicBoolean();
CountDownLatch latch = new CountDownLatch(1);
- Future result = Executors.newSingleThreadExecutor().submit(() -> {
+ ExecutorService executorService = Executors.newSingleThreadExecutor();
+ Future result = executorService.submit(() -> {
try {
lock.unlock();
}
@@ -370,6 +377,7 @@ void testTwoThreadsWrongOneUnlocks(RedisLockType testRedisLockType) throws Excep
registry.expireUnusedOlderThan(-1000);
assertThat(getRedisLockRegistryLocks(registry)).isEmpty();
registry.destroy();
+ executorService.shutdown();
}
@ParameterizedTest
@@ -481,7 +489,8 @@ void testExpireNotChanged(RedisLockType testRedisLockType) throws Exception {
Long expire = getExpire(registry, "foo");
- Future result = Executors.newSingleThreadExecutor().submit(() -> {
+ ExecutorService executorService = Executors.newSingleThreadExecutor();
+ Future result = executorService.submit(() -> {
Lock lock2 = registry.obtain("foo");
assertThat(lock2.tryLock()).isFalse();
return null;
@@ -490,6 +499,7 @@ void testExpireNotChanged(RedisLockType testRedisLockType) throws Exception {
assertThat(getExpire(registry, "foo")).isEqualTo(expire);
lock.unlock();
registry.destroy();
+ executorService.shutdown();
}
@ParameterizedTest
diff --git a/spring-integration-sftp/src/main/java/org/springframework/integration/sftp/session/DefaultSftpSessionFactory.java b/spring-integration-sftp/src/main/java/org/springframework/integration/sftp/session/DefaultSftpSessionFactory.java
index da50831a722..8a0d06b7060 100644
--- a/spring-integration-sftp/src/main/java/org/springframework/integration/sftp/session/DefaultSftpSessionFactory.java
+++ b/spring-integration-sftp/src/main/java/org/springframework/integration/sftp/session/DefaultSftpSessionFactory.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2023 the original author or authors.
+ * Copyright 2002-2024 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.
@@ -18,6 +18,7 @@
import java.io.IOException;
import java.io.InputStream;
+import java.io.UncheckedIOException;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.time.Duration;
@@ -49,6 +50,7 @@
import org.apache.sshd.sftp.client.impl.AbstractSftpClient;
import org.apache.sshd.sftp.client.impl.DefaultSftpClient;
+import org.springframework.beans.factory.DisposableBean;
import org.springframework.core.io.Resource;
import org.springframework.integration.context.IntegrationContextUtils;
import org.springframework.integration.file.remote.session.SessionFactory;
@@ -74,10 +76,11 @@
* @author Auke Zaaiman
* @author Christian Tzolov
* @author Adama Sorho
+ * @author Darryl Smith
*
* @since 2.0
*/
-public class DefaultSftpSessionFactory implements SessionFactory, SharedSessionCapable {
+public class DefaultSftpSessionFactory implements SessionFactory, SharedSessionCapable, DisposableBean {
private final Lock lock = new ReentrantLock();
@@ -294,7 +297,7 @@ public SftpSession getSession() {
sftpClient = createSftpClient(initClientSession(), this.sftpVersionSelector, SftpErrorDataHandler.EMPTY);
freshSftpClient = true;
}
- sftpSession = new SftpSession(sftpClient);
+ sftpSession = new SftpSession(sftpClient, this.isSharedSession);
sftpSession.connect();
if (this.isSharedSession && freshSftpClient) {
this.sharedSftpClient = sftpClient;
@@ -421,6 +424,33 @@ protected SftpClient createSftpClient(
return new ConcurrentSftpClient(clientSession, initialVersionSelector, errorDataHandler);
}
+ @Override
+ public void destroy() throws Exception {
+ if (this.isInnerClient && this.sshClient != null && this.sshClient.isStarted()) {
+ this.sshClient.stop();
+ }
+
+ SftpClient sharedSftpClientToClose = this.sharedSftpClient;
+ if (sharedSftpClientToClose != null) {
+ try {
+ sharedSftpClientToClose.close();
+ }
+ catch (IOException ex) {
+ throw new UncheckedIOException("failed to close an SFTP client", ex);
+ }
+
+ try {
+ ClientSession session = sharedSftpClientToClose.getSession();
+ if (session != null && session.isOpen()) {
+ session.close();
+ }
+ }
+ catch (IOException ex) {
+ throw new UncheckedIOException("failed to close an SFTP client (session)", ex);
+ }
+ }
+ }
+
/**
* The {@link DefaultSftpClient} extension to lock the {@link #send(int, Buffer)}
* for concurrent interaction.
diff --git a/spring-integration-sftp/src/main/java/org/springframework/integration/sftp/session/SftpSession.java b/spring-integration-sftp/src/main/java/org/springframework/integration/sftp/session/SftpSession.java
index 414cd2fa5de..9255fc4e25d 100644
--- a/spring-integration-sftp/src/main/java/org/springframework/integration/sftp/session/SftpSession.java
+++ b/spring-integration-sftp/src/main/java/org/springframework/integration/sftp/session/SftpSession.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.
@@ -25,6 +25,7 @@
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
+import org.apache.sshd.client.session.ClientSession;
import org.apache.sshd.common.util.net.SshdSocketAddress;
import org.apache.sshd.sftp.SftpModuleProperties;
import org.apache.sshd.sftp.client.SftpClient;
@@ -47,15 +48,30 @@
* @author Gary Russell
* @author Artem Bilan
* @author Christian Tzolov
+ * @author Darryl Smith
* @since 2.0
*/
public class SftpSession implements Session {
private final SftpClient sftpClient;
+ private final boolean isSharedClient;
+
public SftpSession(SftpClient sftpClient) {
+ this(sftpClient, false);
+ }
+
+ /**
+ * Construct an instance based on a {@link SftpClient} and its {@code shared} status.
+ * When {@code isSharedClient == true}, the {@link #close()} is void.
+ * @param sftpClient the {@link SftpClient} to use.
+ * @param isSharedClient whether the {@link SftpClient} is shared.
+ * @since 6.3.9
+ */
+ public SftpSession(SftpClient sftpClient, boolean isSharedClient) {
Assert.notNull(sftpClient, "'sftpClient' must not be null");
this.sftpClient = sftpClient;
+ this.isSharedClient = isSharedClient;
}
@Override
@@ -99,7 +115,7 @@ public Stream doList(String path) throws IOException {
boolean isPattern = remoteFile != null && remoteFile.contains("*");
if (!isPattern && remoteFile != null) {
- SftpClient.Attributes attributes = this.sftpClient.lstat(path);
+ SftpClient.Attributes attributes = this.sftpClient.stat(path);
if (!attributes.isDirectory()) {
return Stream.of(new SftpClient.DirEntry(remoteFile, path, attributes));
}
@@ -150,12 +166,26 @@ public void append(InputStream inputStream, String destination) throws IOExcepti
@Override
public void close() {
+ if (this.isSharedClient) {
+ return;
+ }
+
try {
this.sftpClient.close();
}
catch (IOException ex) {
throw new UncheckedIOException("failed to close an SFTP client", ex);
}
+
+ try {
+ ClientSession session = this.sftpClient.getSession();
+ if (session != null && session.isOpen()) {
+ session.close();
+ }
+ }
+ catch (IOException ex) {
+ throw new UncheckedIOException("failed to close an SFTP client (session)", ex);
+ }
}
@Override
@@ -189,7 +219,7 @@ public boolean rmdir(String remoteDirectory) throws IOException {
@Override
public boolean exists(String path) {
try {
- this.sftpClient.lstat(normalizePath(path));
+ this.sftpClient.stat(normalizePath(path));
return true;
}
catch (SftpException ex) {
diff --git a/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/dsl/SftpTests.java b/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/dsl/SftpTests.java
index 15a7a49922f..f9762a33044 100644
--- a/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/dsl/SftpTests.java
+++ b/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/dsl/SftpTests.java
@@ -31,6 +31,7 @@
import org.junit.jupiter.api.condition.OS;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.IntegrationMessageHeaderAccessor;
import org.springframework.integration.channel.QueueChannel;
@@ -43,6 +44,7 @@
import org.springframework.integration.file.FileHeaders;
import org.springframework.integration.file.remote.RemoteFileTemplate;
import org.springframework.integration.file.remote.gateway.AbstractRemoteFileOutboundGateway;
+import org.springframework.integration.file.remote.session.SessionFactory;
import org.springframework.integration.file.support.FileExistsMode;
import org.springframework.integration.sftp.SftpTestSupport;
import org.springframework.integration.sftp.session.SftpRemoteFileTemplate;
@@ -59,6 +61,7 @@
* @author Gary Russell
* @author Joaquin Santana
* @author Deepak Gunasekaran
+ * @author Darryl Smith
*
* @since 5.0
*/
@@ -69,11 +72,14 @@ public class SftpTests extends SftpTestSupport {
@Autowired
private IntegrationFlowContext flowContext;
+ @Autowired
+ private SessionFactory sessionFactory;
+
@Test
public void testSftpInboundFlow() {
QueueChannel out = new QueueChannel();
IntegrationFlow flow = IntegrationFlow
- .from(Sftp.inboundAdapter(sessionFactory())
+ .from(Sftp.inboundAdapter(sessionFactory)
.preserveTimestamp(true)
.remoteDirectory("/sftpSource")
.regexFilter(".*\\.txt$")
@@ -106,7 +112,7 @@ public void testSftpInboundFlow() {
public void testSftpInboundStreamFlow() throws Exception {
QueueChannel out = new QueueChannel();
StandardIntegrationFlow flow = IntegrationFlow.from(
- Sftp.inboundStreamingAdapter(new SftpRemoteFileTemplate(sessionFactory()))
+ Sftp.inboundStreamingAdapter(new SftpRemoteFileTemplate(sessionFactory))
.remoteDirectory("sftpSource")
.regexFilter(".*\\.txt$"),
e -> e.id("sftpInboundAdapter").poller(Pollers.fixedDelay(100)))
@@ -133,7 +139,7 @@ public void testSftpInboundStreamFlow() throws Exception {
@Test
public void testSftpOutboundFlow() {
- IntegrationFlow flow = f -> f.handle(Sftp.outboundAdapter(sessionFactory(), FileExistsMode.FAIL)
+ IntegrationFlow flow = f -> f.handle(Sftp.outboundAdapter(sessionFactory, FileExistsMode.FAIL)
.useTemporaryFileName(false)
.fileNameExpression("headers['" + FileHeaders.FILENAME + "']")
.remoteDirectory("sftpTarget"));
@@ -143,7 +149,7 @@ public void testSftpOutboundFlow() {
.setHeader(FileHeaders.FILENAME, fileName)
.build());
- RemoteFileTemplate template = new RemoteFileTemplate<>(sessionFactory());
+ RemoteFileTemplate template = new RemoteFileTemplate<>(sessionFactory);
SftpClient.DirEntry[] files =
template.execute(session -> session.list(getTargetRemoteDirectory().getName() + "/" + fileName));
assertThat(files.length).isEqualTo(1);
@@ -154,7 +160,7 @@ public void testSftpOutboundFlow() {
@Test
public void testSftpOutboundFlowSftpTemplate() {
- SftpRemoteFileTemplate sftpTemplate = new SftpRemoteFileTemplate(sessionFactory());
+ SftpRemoteFileTemplate sftpTemplate = new SftpRemoteFileTemplate(sessionFactory);
IntegrationFlow flow = f -> f.handle(Sftp.outboundAdapter(sftpTemplate)
.useTemporaryFileName(false)
.fileNameExpression("headers['" + FileHeaders.FILENAME + "']")
@@ -175,7 +181,7 @@ public void testSftpOutboundFlowSftpTemplate() {
@Test
public void testSftpOutboundFlowSftpTemplateAndMode() {
- SftpRemoteFileTemplate sftpTemplate = new SftpRemoteFileTemplate(sessionFactory());
+ SftpRemoteFileTemplate sftpTemplate = new SftpRemoteFileTemplate(sessionFactory);
IntegrationFlow flow = f -> f.handle(Sftp.outboundAdapter(sftpTemplate, FileExistsMode.APPEND)
.useTemporaryFileName(false)
.fileNameExpression("headers['" + FileHeaders.FILENAME + "']")
@@ -200,7 +206,7 @@ public void testSftpOutboundFlowSftpTemplateAndMode() {
@Test
@DisabledOnOs(OS.WINDOWS)
public void testSftpOutboundFlowWithChmod() {
- IntegrationFlow flow = f -> f.handle(Sftp.outboundAdapter(sessionFactory(), FileExistsMode.FAIL)
+ IntegrationFlow flow = f -> f.handle(Sftp.outboundAdapter(sessionFactory, FileExistsMode.FAIL)
.useTemporaryFileName(false)
.fileNameExpression("headers['" + FileHeaders.FILENAME + "']")
.chmod(0644)
@@ -211,7 +217,7 @@ public void testSftpOutboundFlowWithChmod() {
.setHeader(FileHeaders.FILENAME, fileName)
.build());
- RemoteFileTemplate template = new RemoteFileTemplate<>(sessionFactory());
+ RemoteFileTemplate template = new RemoteFileTemplate<>(sessionFactory);
SftpClient.DirEntry[] files =
template.execute(session -> session.list(getTargetRemoteDirectory().getName() + "/" + fileName));
assertThat(files.length).isEqualTo(1);
@@ -230,7 +236,7 @@ public void testSftpOutboundFlowWithChmod() {
public void testSftpMgetFlow() {
QueueChannel out = new QueueChannel();
IntegrationFlow flow = f -> f
- .handle(Sftp.outboundGateway(sessionFactory(), AbstractRemoteFileOutboundGateway.Command.MGET,
+ .handle(Sftp.outboundGateway(sessionFactory, AbstractRemoteFileOutboundGateway.Command.MGET,
"payload")
.options(AbstractRemoteFileOutboundGateway.Option.RECURSIVE)
.regexFileNameFilter("(subSftpSource|.*1.txt)")
@@ -259,7 +265,7 @@ public void testSftpMgetFlow() {
public void testSftpSessionCallback() {
QueueChannel out = new QueueChannel();
IntegrationFlow flow = f -> f
- .handle((p, h) -> new SftpRemoteFileTemplate(sessionFactory()).execute(s -> s.list(p)))
+ .handle((p, h) -> new SftpRemoteFileTemplate(sessionFactory).execute(s -> s.list(p)))
.channel(out);
IntegrationFlowRegistration registration = this.flowContext.registration(flow).register();
registration.getInputChannel().send(new GenericMessage<>("sftpSource"));
@@ -277,7 +283,7 @@ public void testSftpSessionCallback() {
public void testSftpMv() {
QueueChannel out = new QueueChannel();
IntegrationFlow flow = f -> f
- .handle(Sftp.outboundGateway(sessionFactory(), AbstractRemoteFileOutboundGateway.Command.MV, "payload")
+ .handle(Sftp.outboundGateway(sessionFactory, AbstractRemoteFileOutboundGateway.Command.MV, "payload")
.renameExpression("payload.concat('.done')")
.remoteDirectoryExpression("'sftpSource'"))
.channel(out);
@@ -301,6 +307,11 @@ public void testSftpMv() {
@EnableIntegration
public static class ContextConfiguration {
+ @Bean
+ public SessionFactory ftpsessionFactory() {
+ return SftpTests.sessionFactory();
+ }
+
}
}
diff --git a/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/inbound/SftpInboundRemoteFileSystemSynchronizerTests.java b/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/inbound/SftpInboundRemoteFileSystemSynchronizerTests.java
index acb1383ea7e..1b6cda0141a 100644
--- a/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/inbound/SftpInboundRemoteFileSystemSynchronizerTests.java
+++ b/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/inbound/SftpInboundRemoteFileSystemSynchronizerTests.java
@@ -61,6 +61,7 @@
* @author Gary Russell
* @author Artem Bilan
* @author Joaquin Santana
+ * @author Darryl Smith
*
* @since 2.0
*/
@@ -160,6 +161,8 @@ public void testCopyFileToLocalDir() throws Exception {
ms.stop();
verify(synchronizer).close();
verify(store).close();
+
+ ftpSessionFactory.destroy();
}
public static class TestSftpSessionFactory extends DefaultSftpSessionFactory {
diff --git a/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/inbound/SftpMessageSourceTests.java b/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/inbound/SftpMessageSourceTests.java
index 399738e9cd3..72303bd1df6 100644
--- a/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/inbound/SftpMessageSourceTests.java
+++ b/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/inbound/SftpMessageSourceTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2018-2022 the original author or authors.
+ * Copyright 2018-2024 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.
@@ -16,12 +16,15 @@
package org.springframework.integration.sftp.inbound;
+import org.apache.sshd.sftp.client.SftpClient;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
+import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.file.FileHeaders;
+import org.springframework.integration.file.remote.session.SessionFactory;
import org.springframework.integration.sftp.SftpTestSupport;
import org.springframework.messaging.Message;
import org.springframework.test.annotation.DirtiesContext;
@@ -32,6 +35,7 @@
/**
* @author Gary Russell
* @author Artem bilan
+ * @author Darryl Smith
*
* @since 5.0.7
*
@@ -43,6 +47,9 @@ public class SftpMessageSourceTests extends SftpTestSupport {
@Autowired
private ApplicationContext context;
+ @Autowired
+ private SessionFactory sessionFactory;
+
@Test
public void testMaxFetch() {
SftpInboundFileSynchronizingMessageSource messageSource = buildSource();
@@ -53,7 +60,7 @@ public void testMaxFetch() {
}
private SftpInboundFileSynchronizingMessageSource buildSource() {
- SftpInboundFileSynchronizer sync = new SftpInboundFileSynchronizer(sessionFactory());
+ SftpInboundFileSynchronizer sync = new SftpInboundFileSynchronizer(sessionFactory);
sync.setRemoteDirectory("/sftpSource/");
sync.setBeanFactory(this.context);
SftpInboundFileSynchronizingMessageSource messageSource = new SftpInboundFileSynchronizingMessageSource(sync);
@@ -68,6 +75,11 @@ private SftpInboundFileSynchronizingMessageSource buildSource() {
@Configuration
public static class Config {
+ @Bean
+ public SessionFactory ftpSessionFactory() {
+ return SftpMessageSourceTests.sessionFactory();
+ }
+
}
}
diff --git a/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/outbound/SftpOutboundTests.java b/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/outbound/SftpOutboundTests.java
index 695b90400a2..d4813d1791b 100644
--- a/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/outbound/SftpOutboundTests.java
+++ b/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/outbound/SftpOutboundTests.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.
@@ -76,6 +76,7 @@
* @author Gary Russell
* @author Gunnar Hillert
* @author Artem Bilan
+ * @author Darryl Smith
*/
public class SftpOutboundTests {
@@ -84,7 +85,7 @@ public void testHandleFileMessage() throws Exception {
File targetDir = new File("remote-target-dir");
assertThat(targetDir.exists()).as("target directory does not exist: " + targetDir.getName()).isTrue();
- SessionFactory sessionFactory = new TestSftpSessionFactory();
+ TestSftpSessionFactory sessionFactory = new TestSftpSessionFactory();
FileTransferringMessageHandler handler =
new FileTransferringMessageHandler<>(sessionFactory);
handler.setRemoteDirectoryExpression(new LiteralExpression(targetDir.getName()));
@@ -103,6 +104,8 @@ public void testHandleFileMessage() throws Exception {
handler.handleMessage(new GenericMessage<>(srcFile));
assertThat(destFile.exists()).as("destination file was not created").isTrue();
+
+ sessionFactory.destroy();
}
@Test
@@ -111,7 +114,7 @@ public void testHandleStringMessage() throws Exception {
if (file.exists()) {
file.delete();
}
- SessionFactory sessionFactory = new TestSftpSessionFactory();
+ TestSftpSessionFactory sessionFactory = new TestSftpSessionFactory();
FileTransferringMessageHandler handler =
new FileTransferringMessageHandler<>(sessionFactory);
DefaultFileNameGenerator fGenerator = new DefaultFileNameGenerator();
@@ -127,6 +130,8 @@ public void testHandleStringMessage() throws Exception {
byte[] inFile = FileCopyUtils.copyToByteArray(file);
assertThat(new String(inFile)).isEqualTo("String data");
file.delete();
+
+ sessionFactory.destroy();
}
@Test
@@ -135,7 +140,7 @@ public void testHandleBytesMessage() throws Exception {
if (file.exists()) {
file.delete();
}
- SessionFactory sessionFactory = new TestSftpSessionFactory();
+ TestSftpSessionFactory sessionFactory = new TestSftpSessionFactory();
FileTransferringMessageHandler handler =
new FileTransferringMessageHandler<>(sessionFactory);
DefaultFileNameGenerator fGenerator = new DefaultFileNameGenerator();
@@ -151,6 +156,8 @@ public void testHandleBytesMessage() throws Exception {
byte[] inFile = FileCopyUtils.copyToByteArray(file);
assertThat(new String(inFile)).isEqualTo("byte[] data");
file.delete();
+
+ sessionFactory.destroy();
}
@Test //INT-2275
@@ -225,7 +232,7 @@ public void testMkDir() throws Exception {
@ParameterizedTest
@ValueSource(booleans = {true, false})
- public void testSharedSession(boolean sharedSession) throws IOException {
+ public void testSharedSession(boolean sharedSession) throws Exception {
try (SshServer server = SshServer.setUpDefaultServer()) {
server.setPasswordAuthenticator((arg0, arg1, arg2) -> true);
server.setPort(0);
@@ -253,6 +260,8 @@ public void testSharedSession(boolean sharedSession) throws IOException {
assertThat(TestUtils.getPropertyValue(s2, "sftpClient"))
.isNotSameAs(TestUtils.getPropertyValue(s1, "sftpClient"));
}
+
+ f.destroy();
}
}
@@ -270,15 +279,15 @@ public void testExists() throws IOException {
willReturn(new SftpClient.Attributes())
.given(sftpClient)
- .lstat("/exist");
+ .stat("/exist");
willThrow(new SftpException(SftpConstants.SSH_FX_NO_SUCH_FILE, "notExist"))
.given(sftpClient)
- .lstat("/notExist");
+ .stat("/notExist");
willThrow(new SshException(SshConstants.SSH_OPEN_CONNECT_FAILED, "Connection lost."))
.given(sftpClient)
- .lstat(and(not(eq("/exist")), not(eq("/notExist"))));
+ .stat(and(not(eq("/exist")), not(eq("/notExist"))));
SftpSession sftpSession = new SftpSession(sftpClient);
diff --git a/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/outbound/SftpServerOutboundTests-context.xml b/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/outbound/SftpServerOutboundTests-context.xml
index 2e210d363b2..fe67b37235c 100644
--- a/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/outbound/SftpServerOutboundTests-context.xml
+++ b/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/outbound/SftpServerOutboundTests-context.xml
@@ -1,15 +1,15 @@
-
+
@@ -18,87 +18,88 @@
+ request-channel="inboundGet"
+ command="get"
+ command-options="-P"
+ expression="payload"
+ local-directory-expression="@extraConfig.targetLocalDirectoryName + #remoteDirectory.toUpperCase()"
+ local-filename-generator-expression="#remoteFileName.replaceFirst('sftpSource', 'localTarget')"
+ reply-channel="output"/>
+ request-channel="invalidDirExpression"
+ command="get"
+ expression="payload"
+ local-directory-expression="T(java.io.File).separator + #remoteDirectory + '?:'"
+ reply-channel="output"/>
+ request-channel="inboundMGet"
+ command="mget"
+ command-options="-P"
+ expression="payload"
+ local-directory-expression="@extraConfig.targetLocalDirectoryName + #remoteDirectory"
+ local-filename-generator-expression="#remoteFileName.replaceFirst('sftpSource', 'localTarget')"
+ reply-channel="output"/>
+ request-channel="inboundMGetRecursive"
+ 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')"
+ reply-channel="output"/>
+ request-channel="inboundLSRecursive"
+ command="ls"
+ expression="payload"
+ command-options="-R -dirs"
+ mode="REPLACE_IF_MODIFIED"
+ filter="dotStarDotTxtFilter"
+ local-directory-expression="@extraConfig.targetLocalDirectoryName + #remoteDirectory"
+ local-filename-generator-expression="#remoteFileName.replaceFirst('sftpSource', 'localTarget')"
+ reply-channel="output"/>
+ request-channel="inboundLSRecursiveALL"
+ command="ls"
+ expression="payload"
+ command-options="-a -R -dirs"
+ mode="REPLACE_IF_MODIFIED"
+ filter="dotStarDotTxtFilter"
+ local-directory-expression="@extraConfig.targetLocalDirectoryName + #remoteDirectory"
+ local-filename-generator-expression="#remoteFileName.replaceFirst('sftpSource', 'localTarget')"
+ reply-channel="output"/>
+ request-channel="inboundLSRecursiveNoDirs"
+ command="ls"
+ expression="payload"
+ command-options="-R"
+ mode="REPLACE_IF_MODIFIED"
+ filter="persistentFilter"
+ local-directory-expression="@extraConfig.targetLocalDirectoryName + #remoteDirectory"
+ local-filename-generator-expression="#remoteFileName.replaceFirst('sftpSource', 'localTarget')"
+ reply-channel="output"/>
-
-
+ class="org.springframework.integration.sftp.filters.SftpRegexPatternFileListFilter">
+
+
-
+
@@ -107,89 +108,95 @@
+ value="#{T(org.springframework.integration.file.remote.RemoteFileTestSupport).getScratchTempFolder().absolutePath}"/>
+ request-channel="inboundMGetRecursiveFiltered"
+ command="mget"
+ expression="payload"
+ command-options="-R"
+ filename-regex="(subSftpSource|.*1.txt)"
+ local-directory-expression="@extraConfig.targetLocalDirectoryName + #remoteDirectory"
+ local-filename-generator-expression="#remoteFileName.replaceFirst('sftpSource', 'localTarget')"
+ reply-channel="output"/>
+ request-channel="inboundMPut"
+ command="mput"
+ auto-create-directory="true"
+ filename-pattern="*.txt"
+ expression="payload"
+ chmod="600"
+ remote-directory="sftpTarget"
+ reply-channel="output"/>
+ request-channel="inboundMPutRecursive"
+ command="mput"
+ command-options="-R"
+ auto-create-directory="true"
+ filename-pattern="*.txt"
+ expression="payload"
+ remote-directory="sftpTarget"
+ reply-channel="output"/>
-
-
+ request-channel="inboundMPutRecursiveFiltered"
+ command="mput"
+ command-options="-R"
+ mput-regex="(.*1.txt|sub.*)"
+ auto-create-directory="true"
+ filename-pattern="*.txt"
+ expression="payload"
+ remote-directory="sftpTarget"
+ reply-channel="output"/>
+
+
+ session-factory="sftpSessionFactory"
+ channel="appending"
+ mode="APPEND"
+ use-temporary-file-name="false"
+ remote-directory="sftpTarget"
+ auto-create-directory="true"
+ remote-file-separator="/"/>
-
+
+ session-factory="sftpSessionFactory"
+ channel="ignoring"
+ mode="IGNORE"
+ remote-directory="sftpTarget"
+ auto-create-directory="true"
+ remote-file-separator="/"/>
-
+
+ session-factory="sftpSessionFactory"
+ channel="failing"
+ mode="FAIL"
+ remote-directory="sftpTarget"
+ auto-create-directory="true"
+ remote-file-separator="/"/>
+
+
@@ -198,23 +205,23 @@
+ request-channel="inboundGetStream"
+ command="get"
+ command-options="-stream"
+ expression="payload"
+ remote-directory="ftpTarget"
+ reply-channel="stream"/>
-
+
+ channel="markers"/>
+ expression="payload.mark.toString().equals('END') ? headers['closeableResource'].close() : null"/>
session = this.sessionFactory.getSession();
String dir = "sftpSource/";
long modified = setModifiedOnSource1();
this.inboundGet.send(new GenericMessage(dir + " sftpSource1.txt"));
@@ -181,7 +183,6 @@ public void testInt2866LocalDirectoryExpressionGET() {
localFile = (File) result.getPayload();
assertThat(localFile.getPath().replaceAll(Matcher.quoteReplacement(File.separator), "/"))
.contains(dir.toUpperCase());
- Session> session2 = this.sessionFactory.getSession();
}
@Test
@@ -282,12 +283,12 @@ void testLSRecursive() {
List files = (List) result.getPayload();
assertThat(files).hasSize(4);
assertThat(files.stream()
- .map(fi -> fi.getFilename())
- .collect(Collectors.toList())).contains(
- " sftpSource1.txt",
- "sftpSource2.txt",
- "subSftpSource",
- "subSftpSource/subSftpSource1.txt");
+ .map(SftpFileInfo::getFilename))
+ .contains(
+ " sftpSource1.txt",
+ "sftpSource2.txt",
+ "subSftpSource",
+ "subSftpSource/subSftpSource1.txt");
}
@Test
@@ -300,16 +301,16 @@ void testLSRecursiveALL() {
List files = (List) result.getPayload();
assertThat(files).hasSize(8);
assertThat(files.stream()
- .map(fi -> fi.getFilename())
- .collect(Collectors.toList())).contains(
- " sftpSource1.txt",
- "sftpSource2.txt",
- "subSftpSource",
- "subSftpSource/subSftpSource1.txt",
- ".",
- "..",
- "subSftpSource/.",
- "subSftpSource/..");
+ .map(SftpFileInfo::getFilename))
+ .contains(
+ " sftpSource1.txt",
+ "sftpSource2.txt",
+ "subSftpSource",
+ "subSftpSource/subSftpSource1.txt",
+ ".",
+ "..",
+ "subSftpSource/.",
+ "subSftpSource/..");
}
@Test
@@ -322,11 +323,11 @@ void testLSRecursiveNoDirs() throws IOException {
List files = (List) result.getPayload();
assertThat(files).hasSize(3);
assertThat(files.stream()
- .map(fi -> fi.getFilename())
- .collect(Collectors.toList())).contains(
- " sftpSource1.txt",
- "sftpSource2.txt",
- "subSftpSource/subSftpSource1.txt");
+ .map(SftpFileInfo::getFilename))
+ .contains(
+ " sftpSource1.txt",
+ "sftpSource2.txt",
+ "subSftpSource/subSftpSource1.txt");
File newDeepFile = new File(this.sourceRemoteDirectory + "/subSftpSource/subSftpSource2.txt");
OutputStream fos = new FileOutputStream(newDeepFile);
fos.write("test".getBytes());
@@ -381,12 +382,12 @@ public void testInt3100RawGET() throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
FileCopyUtils.copy(session.readRaw("sftpSource/ sftpSource1.txt"), baos);
assertThat(session.finalizeRaw()).isTrue();
- assertThat(new String(baos.toByteArray())).isEqualTo("source1");
+ assertThat(baos.toString()).isEqualTo("source1");
baos = new ByteArrayOutputStream();
FileCopyUtils.copy(session.readRaw("sftpSource/sftpSource2.txt"), baos);
assertThat(session.finalizeRaw()).isTrue();
- assertThat(new String(baos.toByteArray())).isEqualTo("source2");
+ assertThat(baos.toString()).isEqualTo("source2");
session.close();
}
@@ -405,12 +406,12 @@ public void testSftpCopy() throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
FileCopyUtils.copy(session.readRaw("sftpTarget/sftpTarget2.txt"), baos);
assertThat(session.finalizeRaw()).isTrue();
- assertThat(new String(baos.toByteArray())).isEqualTo("source2");
+ assertThat(baos.toString()).isEqualTo("source2");
baos = new ByteArrayOutputStream();
FileCopyUtils.copy(session.readRaw("sftpSource/sftpSource2.txt"), baos);
assertThat(session.finalizeRaw()).isTrue();
- assertThat(new String(baos.toByteArray())).isEqualTo("source2");
+ assertThat(baos.toString()).isEqualTo("source2");
session.close();
}
@@ -425,7 +426,8 @@ public void testInt3047ConcurrentSharedSession() throws Exception {
PipedOutputStream out2 = new PipedOutputStream(pipe2);
final CountDownLatch latch1 = new CountDownLatch(1);
final CountDownLatch latch2 = new CountDownLatch(1);
- Executors.newSingleThreadExecutor().execute(() -> {
+ ExecutorService executorService = Executors.newSingleThreadExecutor();
+ executorService.execute(() -> {
try {
session1.write(pipe1, "foo.txt");
}
@@ -434,7 +436,7 @@ public void testInt3047ConcurrentSharedSession() throws Exception {
}
latch1.countDown();
});
- Executors.newSingleThreadExecutor().execute(() -> {
+ executorService.execute(() -> {
try {
session2.write(pipe2, "bar.txt");
}
@@ -458,12 +460,13 @@ public void testInt3047ConcurrentSharedSession() throws Exception {
ByteArrayOutputStream bos2 = new ByteArrayOutputStream();
session1.read("foo.txt", bos1);
session2.read("bar.txt", bos2);
- assertThat(new String(bos1.toByteArray())).isEqualTo("ace");
- assertThat(new String(bos2.toByteArray())).isEqualTo("bdf");
+ assertThat(bos1.toString()).isEqualTo("ace");
+ assertThat(bos2.toString()).isEqualTo("bdf");
session1.remove("foo.txt");
session2.remove("bar.txt");
session1.close();
session2.close();
+ executorService.shutdown();
}
@Test
@@ -620,6 +623,21 @@ public void testInt3088MPutRecursiveFiltered() {
"sftpTarget/subLocalSource/subLocalSource1.txt");
}
+ @Test
+ public void autoCreateTemporaryDirectory() {
+ Message m =
+ MessageBuilder.withPayload("test")
+ .setHeader(FileHeaders.FILENAME, "test.txt")
+ .build();
+ this.sendFileChannel.send(m);
+
+ SftpClient.DirEntry[] files = this.template.execute(session -> session.list("sftpTarget"));
+ // ., .., and file itself
+ assertThat(files).hasSize(3).extracting(SftpClient.DirEntry::getFilename).contains("test.txt");
+ files = this.template.execute(session -> session.list("sftpTarget_tmp"));
+ assertThat(files).hasSize(2); // . and ..
+ }
+
@Test
public void testInt3412FileMode() {
Message m = MessageBuilder.withPayload("foo")
@@ -633,14 +651,9 @@ public void testInt3412FileMode() {
ignoring.send(m);
assertLength6(template);
- try {
- failing.send(m);
- fail("Expected exception");
- }
- catch (MessagingException e) {
- assertThat(e.getCause().getCause().getMessage()).contains("The destination file already exists");
- }
-
+ assertThatExceptionOfType(MessagingException.class)
+ .isThrownBy(() -> failing.send(m))
+ .withStackTraceContaining("The destination file already exists");
}
@Test
@@ -676,15 +689,15 @@ public void testMessageSessionCallback() {
assertThat(receive.getPayload()).isEqualTo("FOO");
}
- private void assertLength6(SftpRemoteFileTemplate template) {
+ private static void assertLength6(SftpRemoteFileTemplate template) {
SftpClient.DirEntry[] files = template.execute(session -> session.list("sftpTarget"));
- assertThat(files.length).isEqualTo(3);
+ assertThat(files).hasSize(3);
assertThat(files[2].getFilename()).isEqualTo("appending.txt");
assertThat(files[2].getAttributes().getSize()).isEqualTo(6);
}
@Test
- public void testSessionExists() throws IOException {
+ public void testSessionExists() throws Exception {
DefaultSftpSessionFactory sessionFactory = new DefaultSftpSessionFactory();
sessionFactory.setHost("localhost");
sessionFactory.setPort(port);
@@ -702,6 +715,8 @@ public void testSessionExists() throws IOException {
.isThrownBy(() -> session.exists("any"))
.withRootCauseInstanceOf(IOException.class)
.withStackTraceContaining("canonicalPath(any) client is closed");
+
+ sessionFactory.destroy();
}
@SuppressWarnings("unused")
@@ -750,7 +765,7 @@ public ApplicationEventListeningMessageProducer events() {
public MessageChannel eventChannel() {
return (msg, timeout) -> {
if (this.latch != null) {
- if (this.events.size() > 0 || msg.getPayload() instanceof SessionOpenedEvent) {
+ if (!this.events.isEmpty() || msg.getPayload() instanceof SessionOpenedEvent) {
this.events.add((ApacheMinaSftpEvent) msg.getPayload());
if (msg.getPayload() instanceof SessionClosedEvent) {
this.latch.countDown();
diff --git a/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/session/SftpRemoteFileTemplateTests.java b/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/session/SftpRemoteFileTemplateTests.java
index acfed39d75e..5ee26f9aa92 100644
--- a/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/session/SftpRemoteFileTemplateTests.java
+++ b/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/session/SftpRemoteFileTemplateTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2014-2023 the original author or authors.
+ * Copyright 2014-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.
@@ -54,6 +54,7 @@
/**
* @author Gary Russell
* @author Artem Bilan
+ * @author Darryl Smith
* @since 4.1
*/
@SpringJUnitConfig
@@ -85,7 +86,7 @@ public void testINT3412AppendStatRmdir() {
assertThat(template.exists("foo/foobar.txt")).isTrue();
template.executeWithClient((ClientCallbackWithoutResult) client -> {
try {
- SftpClient.Attributes file = client.lstat("foo/foobar.txt");
+ SftpClient.Attributes file = client.stat("foo/foobar.txt");
assertThat(file.getSize()).isEqualTo(6);
}
catch (IOException e) {
@@ -138,7 +139,7 @@ public void lsUserHome() throws IOException {
}
@Test
- public void renameWithOldSftpVersion() {
+ public void renameWithOldSftpVersion() throws Exception {
DefaultSftpSessionFactory factory = new DefaultSftpSessionFactory(false);
factory.setHost("localhost");
factory.setPort(port);
@@ -162,6 +163,8 @@ public void renameWithOldSftpVersion() {
"sftpSource/subSftpSource/subSftpSource1.txt"));
oldVersionSession.close();
+
+ factory.destroy();
}
@Test
diff --git a/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/session/SftpServerTests.java b/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/session/SftpServerTests.java
index 1134ec420d9..0ad10e3eb69 100644
--- a/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/session/SftpServerTests.java
+++ b/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/session/SftpServerTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2014-2023 the original author or authors.
+ * Copyright 2014-2024 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.
@@ -50,6 +50,7 @@
* @author Gary Russell
* @author David Liu
* @author Artem Bilan
+ * @author Darryl Smith
*
* @since 4.1
*
@@ -76,6 +77,8 @@ public void testUcPw() throws Exception {
f.setAllowUnknownKeys(true);
Session session = f.getSession();
doTest(server, session);
+
+ f.destroy();
}
}
@@ -111,6 +114,8 @@ private void testKeyExchange(String pubKey, String privKey, String passphrase) t
f.setPrivateKeyPassphrase(passphrase);
Session session = f.getSession();
doTest(server, session);
+
+ f.destroy();
}
}
diff --git a/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/session/SftpSessionFactoryTests.java b/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/session/SftpSessionFactoryTests.java
index 4c81103a1c9..e85f004cc9f 100644
--- a/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/session/SftpSessionFactoryTests.java
+++ b/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/session/SftpSessionFactoryTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2014-2023 the original author or authors.
+ * Copyright 2014-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.
@@ -24,16 +24,24 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
import org.apache.sshd.client.SshClient;
import org.apache.sshd.client.auth.password.PasswordIdentityProvider;
import org.apache.sshd.client.channel.ClientChannel;
import org.apache.sshd.client.keyverifier.AcceptAllServerKeyVerifier;
+import org.apache.sshd.client.session.ClientSession;
import org.apache.sshd.common.SshException;
import org.apache.sshd.server.SshServer;
import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
import org.apache.sshd.sftp.client.SftpClient;
+import org.apache.sshd.sftp.client.SftpErrorDataHandler;
+import org.apache.sshd.sftp.client.SftpVersionSelector;
import org.apache.sshd.sftp.client.impl.AbstractSftpClient;
import org.apache.sshd.sftp.server.SftpSubsystemFactory;
import org.junit.jupiter.api.Test;
@@ -50,6 +58,7 @@
* @author Gary Russell
* @author Artem Bilan
* @author Auke Zaaiman
+ * @author Darryl Smith
*
* @since 3.0.2
*/
@@ -101,11 +110,13 @@ public void testConnectFailSocketOpen() throws Exception {
}
assertThat(server.getActiveSessions().size()).isEqualTo(0);
+
+ f.destroy();
}
}
@Test
- public void concurrentGetSessionDoesntCauseFailure() throws IOException {
+ public void concurrentGetSessionDoesntCauseFailure() throws Exception {
try (SshServer server = SshServer.setUpDefaultServer()) {
server.setPasswordAuthenticator((arg0, arg1, arg2) -> true);
server.setPort(0);
@@ -134,11 +145,13 @@ public void concurrentGetSessionDoesntCauseFailure() throws IOException {
.isNotEqualTo(concurrentSessions.get(2));
assertThat(concurrentSessions.get(1)).isNotEqualTo(concurrentSessions.get(2));
+
+ sftpSessionFactory.destroy();
}
}
@Test
- void externallyProvidedSshClientShouldNotHaveItsConfigurationOverwritten() throws IOException {
+ void externallyProvidedSshClientShouldNotHaveItsConfigurationOverwritten() throws Exception {
try (SshServer server = SshServer.setUpDefaultServer()) {
server.setPasswordAuthenticator((arg0, arg1, arg2) -> true);
server.setPort(0);
@@ -156,11 +169,13 @@ void externallyProvidedSshClientShouldNotHaveItsConfigurationOverwritten() throw
sftpSessionFactory.setUser("user");
assertThatNoException().isThrownBy(sftpSessionFactory::getSession);
+
+ sftpSessionFactory.destroy();
}
}
@Test
- void concurrentSessionListDoesntCauseFailure() throws IOException {
+ void concurrentSessionListDoesntCauseFailure() throws Exception {
try (SshServer server = SshServer.setUpDefaultServer()) {
server.setPasswordAuthenticator((arg0, arg1, arg2) -> true);
server.setPort(0);
@@ -192,11 +207,13 @@ void concurrentSessionListDoesntCauseFailure() throws IOException {
.toList();
assertThat(dirEntries).hasSize(10);
+
+ sftpSessionFactory.destroy();
}
}
@Test
- void customTimeoutIsApplied() throws IOException {
+ void customTimeoutIsApplied() throws Exception {
try (SshServer server = SshServer.setUpDefaultServer()) {
server.setPasswordAuthenticator((arg0, arg1, arg2) -> true);
server.setPort(0);
@@ -215,6 +232,99 @@ void customTimeoutIsApplied() throws IOException {
ClientChannel clientChannel = sftpSessionFactory.getSession().getClientInstance().getClientChannel();
assertThat(AbstractSftpClient.SFTP_CLIENT_CMD_TIMEOUT.getRequired(clientChannel)).hasSeconds(15);
+
+ sftpSessionFactory.destroy();
+ }
+ }
+
+ @Test
+ void clientSessionIsClosedOnSessionClose() throws Exception {
+ try (SshServer server = SshServer.setUpDefaultServer()) {
+ server.setPasswordAuthenticator((arg0, arg1, arg2) -> true);
+ server.setPort(0);
+ server.setKeyPairProvider(new SimpleGeneratorHostKeyProvider(new File("hostkey.ser").toPath()));
+ server.setSubsystemFactories(Collections.singletonList(new SftpSubsystemFactory()));
+ server.start();
+
+ DefaultSftpSessionFactory sftpSessionFactory = new DefaultSftpSessionFactory();
+ sftpSessionFactory.setHost("localhost");
+ sftpSessionFactory.setPort(server.getPort());
+ sftpSessionFactory.setUser("user");
+ sftpSessionFactory.setPassword("pass");
+ sftpSessionFactory.setAllowUnknownKeys(true);
+
+ SftpSession session = sftpSessionFactory.getSession();
+ ClientSession clientSession = session.getClientInstance().getClientSession();
+
+ assertThat(session.isOpen()).isTrue();
+ assertThat(clientSession.isOpen()).isTrue();
+
+ session.close();
+
+ assertThat(session.isOpen()).isFalse();
+ assertThat(clientSession.isClosed()).isTrue();
+
+ sftpSessionFactory.destroy();
+ }
+ }
+
+ @Test
+ void sharedSessionConcurrentAccess() throws Exception {
+ try (SshServer server = SshServer.setUpDefaultServer()) {
+ server.setPasswordAuthenticator((arg0, arg1, arg2) -> true);
+ server.setPort(0);
+ server.setKeyPairProvider(new SimpleGeneratorHostKeyProvider(new File("hostkey.ser").toPath()));
+ server.setSubsystemFactories(Collections.singletonList(new SftpSubsystemFactory()));
+ server.start();
+
+ AtomicInteger clientInstances = new AtomicInteger();
+
+ DefaultSftpSessionFactory sftpSessionFactory = new DefaultSftpSessionFactory(true) {
+
+ @Override
+ protected SftpClient createSftpClient(ClientSession clientSession,
+ SftpVersionSelector initialVersionSelector, SftpErrorDataHandler errorDataHandler)
+ throws IOException {
+
+ clientInstances.incrementAndGet();
+ return super.createSftpClient(clientSession, initialVersionSelector, errorDataHandler);
+ }
+
+ };
+ sftpSessionFactory.setHost("localhost");
+ sftpSessionFactory.setPort(server.getPort());
+ sftpSessionFactory.setUser("user");
+ sftpSessionFactory.setPassword("pass");
+ sftpSessionFactory.setAllowUnknownKeys(true);
+
+ ExecutorService executorService = Executors.newFixedThreadPool(10);
+
+ CountDownLatch executionLatch = new CountDownLatch(20);
+ List errors = Collections.synchronizedList(new ArrayList<>());
+
+ for (int i = 0; i < 20; i++) {
+ executorService.execute(() -> {
+ try (SftpSession session = sftpSessionFactory.getSession()) {
+ session.list(".");
+ }
+ catch (Exception e) {
+ errors.add(e);
+ }
+ executionLatch.countDown();
+ });
+ }
+
+ assertThat(executionLatch.await(10, TimeUnit.SECONDS)).isTrue();
+ synchronized (errors) {
+ assertThat(errors).isEmpty();
+ }
+
+ assertThat(clientInstances).hasValue(1);
+
+ executorService.shutdown();
+ assertThat(executorService.awaitTermination(10, TimeUnit.SECONDS)).isTrue();
+
+ sftpSessionFactory.destroy();
}
}
diff --git a/spring-integration-smb/src/main/java/org/springframework/integration/smb/session/SmbConfig.java b/spring-integration-smb/src/main/java/org/springframework/integration/smb/session/SmbConfig.java
index 27de4cef6cc..bbc8512d616 100644
--- a/spring-integration-smb/src/main/java/org/springframework/integration/smb/session/SmbConfig.java
+++ b/spring-integration-smb/src/main/java/org/springframework/integration/smb/session/SmbConfig.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-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.
@@ -18,6 +18,8 @@
import java.net.URI;
import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
import jcifs.DialectVersion;
@@ -25,7 +27,7 @@
import org.springframework.util.StringUtils;
/**
- * Data holder class for a SMB share configuration.
+ * Data holder class for an SMB share configuration.
*
* SmbFile URLs syntax:
* smb://[[[domain;]username[:password]@]server[:port]/[[share/[dir/]file]]][?[param=value[param2=value2[...]]]
@@ -34,6 +36,7 @@
* @author Prafull Kumar Soni
* @author Artem Bilan
* @author Gregory Bragg
+ * @author Jelle Smits
*
* @since 6.0
*/
@@ -163,16 +166,19 @@ public void setSmbMaxVersion(DialectVersion _smbMaxVersion) {
this.smbMaxVersion = _smbMaxVersion;
}
- String getDomainUserPass(boolean _includePassword) {
+ String getDomainUserPass(boolean _includePassword, boolean _urlEncode) {
String domainUserPass;
+ String username = _urlEncode ? URLEncoder.encode(this.username, StandardCharsets.UTF_8) : this.username;
+ String password = _urlEncode ? URLEncoder.encode(this.password, StandardCharsets.UTF_8) : this.password;
if (StringUtils.hasText(this.domain)) {
- domainUserPass = String.format("%s;%s", this.domain, this.username);
+ String domain = _urlEncode ? URLEncoder.encode(this.domain, StandardCharsets.UTF_8) : this.domain;
+ domainUserPass = String.format("%s;%s", domain, username);
}
else {
- domainUserPass = this.username;
+ domainUserPass = username;
}
- if (StringUtils.hasText(this.password)) {
- domainUserPass += ":" + (_includePassword ? this.password : "********");
+ if (StringUtils.hasText(password)) {
+ domainUserPass += ":" + (_includePassword ? password : "********");
}
return domainUserPass;
}
@@ -197,22 +203,52 @@ public final String getUrl() {
}
public final String getUrl(boolean _includePassword) {
- String domainUserPass = getDomainUserPass(_includePassword);
+ return createUri(_includePassword).toASCIIString();
+ }
- String path = StringUtils.cleanPath(this.shareAndDir);
+ /**
+ * Return the url string for the share connection without encoding.
+ * Used in the {@link SmbShare} constructor delegation.
+ * @return the url string for the share connection without encoding.
+ * @since 6.3.8
+ */
+ public final String rawUrl() {
+ return rawUrl(true);
+ }
- if (!path.startsWith("/")) {
- path = "/" + path;
- }
+ /**
+ * Return the url string for the share connection without encoding
+ * the host and path. The {@code domainUserPass} is encoded, as
+ * {@link java.net.URL} requires them to be encoded otherwise its parsing fails.
+ * Used in the {@link SmbShare} constructor delegation.
+ * @param _includePassword whether password has to be masked in credentials of URL.
+ * @return the url string for the share connection without encoding.
+ * @since 6.3.8
+ */
+ public final String rawUrl(boolean _includePassword) {
+ String domainUserPass = getDomainUserPass(_includePassword, true);
+ String path = cleanPath();
+ return "smb://%s@%s%s".formatted(domainUserPass, getHostPort(), path);
+ }
+ private URI createUri(boolean _includePassword) {
+ String domainUserPass = getDomainUserPass(_includePassword, false);
+ String path = cleanPath();
try {
- return new URI("smb", domainUserPass, this.host, this.port, path, null, null)
- .toASCIIString();
+ return new URI("smb", domainUserPass, this.host, this.port, path, null, null);
}
catch (URISyntaxException e) {
throw new IllegalArgumentException(e);
}
+ }
+
+ private String cleanPath() {
+ String path = StringUtils.cleanPath(this.shareAndDir);
+ if (!path.startsWith("/")) {
+ path = "/" + path;
+ }
+ return path;
}
@Override
diff --git a/spring-integration-smb/src/main/java/org/springframework/integration/smb/session/SmbSession.java b/spring-integration-smb/src/main/java/org/springframework/integration/smb/session/SmbSession.java
index c60a9322e0c..0db839c493b 100644
--- a/spring-integration-smb/src/main/java/org/springframework/integration/smb/session/SmbSession.java
+++ b/spring-integration-smb/src/main/java/org/springframework/integration/smb/session/SmbSession.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2024 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.
@@ -39,10 +39,10 @@
* Implementation of the {@link Session} interface for Server Message Block (SMB)
* also known as Common Internet File System (CIFS). The Samba project set out to
* create non-Windows implementations of SMB. Often Samba is thus used synonymously to SMB.
- *
+ *
* SMB is an application-layer network protocol that manages shared access to files, printers
* and other networked resources.
- *
+ *
* See Server Message Block
* for more details.
*
@@ -53,6 +53,7 @@
* @author Prafull Kumar Soni
* @author Gregory Bragg
* @author Adam Jones
+ * @author Paolo Fosser
*
* @since 6.0
*/
@@ -60,8 +61,6 @@ public class SmbSession implements Session {
private static final LogAccessor logger = new LogAccessor(SmbSession.class);
- private static final String FILE_SEPARATOR = System.getProperty("file.separator");
-
private static final String SMB_FILE_SEPARATOR = "/";
private final SmbShare smbShare;
@@ -118,33 +117,46 @@ public boolean remove(String _path) throws IOException {
}
/**
- * Return the contents of the specified SMB resource as an array of SmbFile objects.
+ * Return the content of the specified SMB resource as an array of SmbFile objects.
* In case the remote resource does not exist, an empty array is returned.
- * @param _path path to a remote directory
+ * @param path path to a remote directory or remote file path
* @return array of SmbFile objects
- * @throws IOException on error conditions returned by a CIFS server or if the remote resource is not a directory.
+ * @throws IOException on error conditions returned by a CIFS server.
*/
@Override
- public SmbFile[] list(String _path) throws IOException {
- try {
- SmbFile smbDir = createSmbDirectoryObject(_path);
- if (!smbDir.exists()) {
- logger.warn(() -> "Remote directory [" + _path + "] does not exist. Cannot list resources.");
- return new SmbFile[0];
- }
- else if (!smbDir.isDirectory()) {
- throw new IOException("[" + _path + "] is not a directory. Cannot list resources.");
+ public SmbFile[] list(String path) throws IOException {
+ String remotePath = StringUtils.trimTrailingCharacter(path, '/');
+ SmbFile[] files = null;
+ int lastIndex = StringUtils.hasText(remotePath) ? remotePath.lastIndexOf('/') : 0;
+ String remoteFileName = lastIndex > 0 ? remotePath.substring(lastIndex + 1) : null;
+ if (StringUtils.hasText(remoteFileName)) {
+ SmbFile remoteFile = createSmbFileObject(remotePath);
+ if (remoteFile.isFile()) {
+ files = new SmbFile[] {remoteFile};
}
+ }
- SmbFile[] files = smbDir.listFiles();
+ if (files == null) {
+ try {
+ SmbFile smbDir = createSmbDirectoryObject(remotePath);
+ if (!smbDir.exists()) {
+ logger.warn(() -> "Remote directory [" + remotePath + "] does not exist. Cannot list resources.");
+ return new SmbFile[0];
+ }
+ else if (!smbDir.isDirectory()) {
+ throw new IOException("[" + remotePath + "] is not a directory. Cannot list resources.");
+ }
+
+ files = smbDir.listFiles();
+ }
+ catch (SmbException _ex) {
+ throw new IOException("Failed to list in [" + remotePath + "].", _ex);
+ }
+ }
- logListedFiles(_path, files);
+ logListedFiles(remotePath, files);
- return files;
- }
- catch (SmbException _ex) {
- throw new IOException("Failed to list in [" + _path + "].", _ex);
- }
+ return files;
}
/**
@@ -324,7 +336,7 @@ public boolean isDirectory(String _path) throws IOException {
* @throws IOException on error conditions returned by a CIFS server
*/
String mkdirs(String _path) throws IOException {
- int idxPath = _path.lastIndexOf(FILE_SEPARATOR);
+ int idxPath = _path.lastIndexOf(SMB_FILE_SEPARATOR);
if (idxPath > -1) {
String path = _path.substring(0, idxPath + 1);
mkdir(path);
diff --git a/spring-integration-smb/src/main/java/org/springframework/integration/smb/session/SmbShare.java b/spring-integration-smb/src/main/java/org/springframework/integration/smb/session/SmbShare.java
index d287a481e19..f96102937e2 100644
--- a/spring-integration-smb/src/main/java/org/springframework/integration/smb/session/SmbShare.java
+++ b/spring-integration-smb/src/main/java/org/springframework/integration/smb/session/SmbShare.java
@@ -63,7 +63,7 @@ public class SmbShare extends SmbFile {
* @throws IOException if an invalid SMB URL was constructed by jCIFS
*/
public SmbShare(SmbConfig _smbConfig) throws IOException {
- super(StringUtils.cleanPath(_smbConfig.validate().getUrl()),
+ super(StringUtils.cleanPath(_smbConfig.validate().rawUrl()),
SingletonContext.getInstance().withCredentials(
new NtlmPasswordAuthenticator(
_smbConfig.getDomain(), _smbConfig.getUsername(), _smbConfig.getPassword())));
@@ -76,7 +76,7 @@ public SmbShare(SmbConfig _smbConfig) throws IOException {
* @throws IOException if an invalid SMB URL was constructed by jCIFS
*/
public SmbShare(SmbConfig _smbConfig, CIFSContext _context) throws IOException {
- super(StringUtils.cleanPath(_smbConfig.validate().getUrl()), _context);
+ super(StringUtils.cleanPath(_smbConfig.validate().rawUrl()), _context);
}
/**
@@ -88,7 +88,7 @@ public SmbShare(SmbConfig _smbConfig, CIFSContext _context) throws IOException {
* @throws IOException if an invalid property was set or an invalid SMB URL was constructed by jCIFS
*/
public SmbShare(SmbConfig _smbConfig, Properties _props) throws IOException {
- super(StringUtils.cleanPath(_smbConfig.validate().getUrl()),
+ super(StringUtils.cleanPath(_smbConfig.validate().rawUrl()),
new BaseContext(
new PropertyConfiguration(_props)).withCredentials(
new NtlmPasswordAuthenticator(
diff --git a/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbMessageHistoryTests-context.xml b/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbMessageHistoryTests-context.xml
index 9c9bf00a913..ecda2bf3478 100644
--- a/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbMessageHistoryTests-context.xml
+++ b/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbMessageHistoryTests-context.xml
@@ -1,30 +1,31 @@
-
-
-
-
-
-
+
+
+
+
+
+
+ session-factory="smbSessionFactory"
+ channel="smbInboundChannel"
+ auto-create-local-directory="true"
+ local-directory="file:test-temp/local-5"
+ remote-directory="test-temp/remote-9"
+ auto-startup="false"
+ delete-remote-files="false">
diff --git a/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbMessageHistoryTests.java b/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbMessageHistoryTests.java
index 643fbe3cd7e..90a30b1cf92 100644
--- a/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbMessageHistoryTests.java
+++ b/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbMessageHistoryTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2012-2023 the original author or authors.
+ * Copyright 2012-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.
@@ -17,8 +17,12 @@
package org.springframework.integration.smb;
import java.net.URI;
-import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.Properties;
+import jcifs.CIFSContext;
+import jcifs.config.PropertyConfiguration;
+import jcifs.context.BaseContext;
import org.junit.jupiter.api.Test;
import org.springframework.context.support.ClassPathXmlApplicationContext;
@@ -32,11 +36,12 @@
* @author Prafull Kumar Soni
* @author Artem Bilan
* @author Gregory Bragg
+ * @author Jelle Smits
*/
public class SmbMessageHistoryTests extends AbstractBaseTests {
@Test
- public void testMessageHistory() throws URISyntaxException {
+ public void testMessageHistory() throws Exception {
try (ClassPathXmlApplicationContext applicationContext = getApplicationContext()) {
SourcePollingChannelAdapter adapter = applicationContext
.getBean("smbInboundChannelAdapter", SourcePollingChannelAdapter.class);
@@ -47,8 +52,16 @@ public void testMessageHistory() throws URISyntaxException {
String url = smbSessionFactory.getUrl();
URI uri = new URI(url);
- assertThat("sambagu%40est:sambag%25uest").isEqualTo(uri.getRawUserInfo());
- assertThat("sambagu@est:sambag%uest").isEqualTo(uri.getUserInfo());
+ assertThat(uri.getRawUserInfo()).isEqualTo("sambagu%40est:sambag%25uest");
+ assertThat(uri.getUserInfo()).isEqualTo("sambagu@est:sambag%uest");
+ assertThat(uri.getPath()).isEqualTo("/smb share/");
+ assertThat(uri.getRawPath()).isEqualTo("/smb%20share/");
+
+ CIFSContext context = new BaseContext(new PropertyConfiguration(new Properties()));
+ URL rawUrl = new URL(null, smbSessionFactory.rawUrl(true), context.getUrlHandler());
+ assertThat(rawUrl.getHost()).isEqualTo("localhost");
+ assertThat(rawUrl.getUserInfo()).isEqualTo("sambagu%40est:sambag%25uest");
+ assertThat(rawUrl.getPath()).isEqualTo("/smb share/");
}
}
diff --git a/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbParserInboundTests-context.xml b/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbParserInboundTests-context.xml
index a3bced155cd..88483c27ac1 100644
--- a/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbParserInboundTests-context.xml
+++ b/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbParserInboundTests-context.xml
@@ -1,48 +1,49 @@
-
-
-
-
+
+
+
+
-
+ session-factory="smbSessionFactory"
+ channel="smbIn"
+ filename-pattern="foo"
+ local-directory="test-temp/local-10"
+ remote-directory="test-temp/remote-10"
+ auto-create-local-directory="true"
+ auto-startup="false"
+ delete-remote-files="false">
+
-
+ session-factory="smbSessionFactory"
+ channel="smbIn"
+ filter="filter"
+ local-directory="test-temp"
+ remote-directory="test-temp/remote-11"
+ auto-create-local-directory="true"
+ auto-startup="false"
+ delete-remote-files="false">
+
-
+
-
+
diff --git a/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbParserInboundTests.java b/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbParserInboundTests.java
index 783d80fb39f..8d6f2dba314 100644
--- a/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbParserInboundTests.java
+++ b/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbParserInboundTests.java
@@ -60,9 +60,4 @@ public void cleanUp() {
delete("test-temp/local-10", "test-temp/local-6");
}
- public static void main(String[] _args) throws Exception {
- new SmbParserInboundTests().cleanUp();
- runTests(SmbParserInboundTests.class, "testLocalFilesAutoCreationTrue", "testLocalFilesAutoCreationFalse");
- }
-
}
diff --git a/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbTestSupport.java b/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbTestSupport.java
index da823f0a030..b8020972572 100644
--- a/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbTestSupport.java
+++ b/spring-integration-smb/src/test/java/org/springframework/integration/smb/SmbTestSupport.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 the original author or authors.
+ * Copyright 2022-2024 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.
@@ -33,7 +33,6 @@
import org.springframework.integration.file.remote.session.Session;
import org.springframework.integration.file.remote.session.SessionFactory;
import org.springframework.integration.smb.session.SmbSessionFactory;
-import org.springframework.integration.test.util.TestUtils;
/**
* Provides a connection to a Testcontainers-driven SMB Server for test cases.
@@ -69,7 +68,7 @@ public class SmbTestSupport extends RemoteFileTestSupport {
public static final String HOST = "127.0.0.1";
- public static final String SHARE_AND_DIR = "smb-share";
+ public static final String SHARE_AND_DIR = "smb share";
public static final String USERNAME = "sambaguest";
@@ -101,22 +100,17 @@ public static void connectToSMBServer() throws IOException {
try (Session smbFileSession = smbSessionFactory.getSession()) {
smbFileSession.mkdir("smbTarget");
Charset charset = StandardCharsets.UTF_8;
- smbFileSession.write(IOUtils.toInputStream("source1", charset),
- TestUtils.applySystemFileSeparator("smbSource/smbSource1.txt"));
- smbFileSession.write(IOUtils.toInputStream("source2", charset),
- TestUtils.applySystemFileSeparator("smbSource/smbSource2.txt"));
+ smbFileSession.write(IOUtils.toInputStream("source1", charset), "smbSource/smbSource1.txt");
+ smbFileSession.write(IOUtils.toInputStream("source2", charset), "smbSource/smbSource2.txt");
smbFileSession.write(IOUtils.toInputStream("", charset), "SMBSOURCE1.TXT.a");
smbFileSession.write(IOUtils.toInputStream("", charset), "SMBSOURCE2.TXT.a");
-
- smbFileSession.write(IOUtils.toInputStream("subSource1", charset),
- TestUtils.applySystemFileSeparator("smbSource/subSmbSource/subSmbSource1.txt"));
- smbFileSession.write(IOUtils.toInputStream("subSource2", charset),
- TestUtils.applySystemFileSeparator("smbSource/subSmbSource/subSmbSource2.txt"));
+ smbFileSession.write(IOUtils.toInputStream("subSource1", charset), "smbSource/subSmbSource/subSmbSource1.txt");
+ smbFileSession.write(IOUtils.toInputStream("subSource2", charset), "smbSource/subSmbSource/subSmbSource2.txt");
}
}
public static String smbServerUrl() {
- return smbSessionFactory.getUrl().replaceFirst('/' + SHARE_AND_DIR + '/', "");
+ return smbSessionFactory.rawUrl().replaceFirst('/' + SHARE_AND_DIR + '/', "");
}
public static SessionFactory sessionFactory() {
diff --git a/spring-integration-smb/src/test/java/org/springframework/integration/smb/dsl/SmbTests.java b/spring-integration-smb/src/test/java/org/springframework/integration/smb/dsl/SmbTests.java
index cfc4800b70a..846baff286b 100644
--- a/spring-integration-smb/src/test/java/org/springframework/integration/smb/dsl/SmbTests.java
+++ b/spring-integration-smb/src/test/java/org/springframework/integration/smb/dsl/SmbTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2022-2023 the original author or authors.
+ * Copyright 2022-2024 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.
@@ -92,7 +92,7 @@ public void testSmbInboundFlow() {
DirectoryScanner scanner = new DefaultDirectoryScanner();
IntegrationFlow flow = IntegrationFlow.from(Smb.inboundAdapter(sessionFactory())
.preserveTimestamp(true)
- .remoteDirectory("smbSource")
+ .remoteDirectory("smbSource/subSmbSource/")
.maxFetchSize(10)
.scanner(scanner)
.regexFilter(".*\\.txt$")
@@ -115,13 +115,13 @@ public void testSmbInboundFlow() {
Object payload = message.getPayload();
assertThat(payload).isInstanceOf(File.class);
File file = (File) payload;
- assertThat(file.getName()).isIn("SMBSOURCE1.TXT.a", "SMBSOURCE2.TXT.a");
+ assertThat(file.getName()).isEqualTo("SUBSMBSOURCE1.TXT.a", "SUBSMBSOURCE2.TXT.a");
assertThat(file.getAbsolutePath()).contains("localTarget");
message = out.receive(10_000);
assertThat(message).isNotNull();
file = (File) message.getPayload();
- assertThat(file.getName()).isIn("SMBSOURCE1.TXT.a", "SMBSOURCE2.TXT.a");
+ assertThat(file.getName()).isIn("SUBSMBSOURCE1.TXT.a", "SUBSMBSOURCE2.TXT.a");
assertThat(file.getAbsolutePath()).contains("localTarget");
assertThat(out.receive(10)).isNull();
@@ -164,7 +164,7 @@ public void testSmbInboundStreamFlow() throws Exception {
}
@Test
- public void testSmbOutboundFlow() {
+ public void testSmbOutboundFlow() throws SmbException {
IntegrationFlow flow = f -> f
.handle(Smb.outboundAdapter(sessionFactory(), FileExistsMode.REPLACE)
.useTemporaryFileName(false)
@@ -181,18 +181,13 @@ public void testSmbOutboundFlow() {
SmbFile[] files = template.execute(session ->
session.list(getTargetRemoteDirectory().getName()));
assertThat(files).hasSize(1);
- try {
- assertThat(files[0].length()).isEqualTo(3);
- }
- catch (SmbException se) {
- se.printStackTrace();
- }
+ assertThat(files[0].length()).isEqualTo(3);
registration.destroy();
}
@Test
- public void testSmbOutboundFlowWithSmbRemoteTemplate() {
+ public void testSmbOutboundFlowWithSmbRemoteTemplate() throws SmbException {
SmbRemoteFileTemplate smbTemplate = new SmbRemoteFileTemplate(sessionFactory());
IntegrationFlow flow = f -> f
.handle(Smb.outboundAdapter(smbTemplate)
@@ -209,18 +204,13 @@ public void testSmbOutboundFlowWithSmbRemoteTemplate() {
SmbFile[] files = smbTemplate.execute(session ->
session.list(getTargetRemoteDirectory().getName()));
assertThat(files).hasSize(1);
- try {
- assertThat(files[0].length()).isEqualTo(3);
- }
- catch (SmbException se) {
- se.printStackTrace();
- }
+ assertThat(files[0].length()).isEqualTo(3);
registration.destroy();
}
@Test
- public void testSmbOutboundFlowWithSmbRemoteTemplateAndMode() {
+ public void testSmbOutboundFlowWithSmbRemoteTemplateAndMode() throws SmbException {
SmbRemoteFileTemplate smbTemplate = new SmbRemoteFileTemplate(sessionFactory());
IntegrationFlow flow = f -> f
.handle(Smb.outboundAdapter(smbTemplate, FileExistsMode.APPEND)
@@ -242,18 +232,33 @@ public void testSmbOutboundFlowWithSmbRemoteTemplateAndMode() {
SmbFile[] files = smbTemplate.execute(session ->
session.list(getTargetRemoteDirectory().getName()));
assertThat(files).hasSize(1);
- try {
- assertThat(files[0].length()).isEqualTo(9);
- }
- catch (SmbException se) {
- se.printStackTrace();
- }
+ assertThat(files[0].length()).isEqualTo(9);
registration.destroy();
}
@Test
public void testSmbGetFlow() {
+ QueueChannel out = new QueueChannel();
+ IntegrationFlow flow = f -> f
+ .handle(
+ Smb.outboundGateway(sessionFactory(), AbstractRemoteFileOutboundGateway.Command.GET, "payload")
+ .localDirectoryExpression("'" + getTargetLocalDirectoryName() + "'"))
+ .channel(out);
+ IntegrationFlowRegistration registration = this.flowContext.registration(flow).register();
+ String fileName = "smbSource/subSmbSource/subSmbSource2.txt";
+ registration.getInputChannel().send(new GenericMessage<>(fileName));
+ Message> result = out.receive(10_000);
+ assertThat(result).isNotNull();
+
+ File sfis = (File) result.getPayload();
+ assertThat(sfis).hasFileName("subSmbSource2.txt");
+
+ registration.destroy();
+ }
+
+ @Test
+ public void testSmbGetStreamFlow() throws IOException {
QueueChannel out = new QueueChannel();
IntegrationFlow flow = f -> f
.handle(
@@ -271,14 +276,10 @@ public void testSmbGetFlow() {
Message> result = out.receive(10_000);
assertThat(result).isNotNull();
- SmbFileInputStream sfis = (SmbFileInputStream) result.getPayload();
- assertThat(sfis).isNotNull();
-
- try {
- sfis.close();
- }
- catch (IOException ioe) {
+ try (SmbFileInputStream sfis = (SmbFileInputStream) result.getPayload()) {
+ assertThat(sfis).isNotNull();
}
+
registration.destroy();
}
diff --git a/spring-integration-test-support/src/main/java/org/springframework/integration/test/support/AbstractRequestResponseScenarioTests.java b/spring-integration-test-support/src/main/java/org/springframework/integration/test/support/AbstractRequestResponseScenarioTests.java
index efceadd475f..c777d4c9763 100644
--- a/spring-integration-test-support/src/main/java/org/springframework/integration/test/support/AbstractRequestResponseScenarioTests.java
+++ b/spring-integration-test-support/src/main/java/org/springframework/integration/test/support/AbstractRequestResponseScenarioTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2024 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.
@@ -28,6 +28,7 @@
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.PollableChannel;
import org.springframework.messaging.SubscribableChannel;
+import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import static org.assertj.core.api.Assertions.assertThat;
@@ -48,6 +49,7 @@
* @author Artem Bilan
*/
@RunWith(SpringJUnit4ClassRunner.class)
+@DirtiesContext
public abstract class AbstractRequestResponseScenarioTests {
private List scenarios = null;
diff --git a/spring-integration-test/src/main/java/org/springframework/integration/test/context/MockIntegrationContext.java b/spring-integration-test/src/main/java/org/springframework/integration/test/context/MockIntegrationContext.java
index 5d3a63183df..81e1a8a13ed 100644
--- a/spring-integration-test/src/main/java/org/springframework/integration/test/context/MockIntegrationContext.java
+++ b/spring-integration-test/src/main/java/org/springframework/integration/test/context/MockIntegrationContext.java
@@ -19,6 +19,7 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
+import java.util.Collections;
import java.util.List;
import reactor.core.publisher.Mono;
@@ -59,6 +60,7 @@
*
* @author Artem Bilan
* @author Yicheng Feng
+ * @author Alexander Hain
*
* @since 5.0
*
@@ -111,7 +113,7 @@ public void afterSingletonsInstantiated() {
}
List getAutoStartupCandidates() {
- return this.autoStartupCandidates;
+ return Collections.unmodifiableList(this.autoStartupCandidates);
}
/**
diff --git a/spring-integration-test/src/main/java/org/springframework/integration/test/context/MockIntegrationContextCustomizerFactory.java b/spring-integration-test/src/main/java/org/springframework/integration/test/context/MockIntegrationContextCustomizerFactory.java
index a29d2eca965..29387bb60da 100644
--- a/spring-integration-test/src/main/java/org/springframework/integration/test/context/MockIntegrationContextCustomizerFactory.java
+++ b/spring-integration-test/src/main/java/org/springframework/integration/test/context/MockIntegrationContextCustomizerFactory.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2017-2022 the original author or authors.
+ * Copyright 2017-2024 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.
@@ -18,17 +18,20 @@
import java.util.List;
-import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.test.context.ContextConfigurationAttributes;
import org.springframework.test.context.ContextCustomizer;
import org.springframework.test.context.ContextCustomizerFactory;
+import org.springframework.test.context.TestContextAnnotationUtils;
/**
* The {@link ContextCustomizerFactory} implementation to produce a
* {@link MockIntegrationContextCustomizer} if a {@link SpringIntegrationTest} annotation
* is present on the test class.
+ *
+ * Honors the {@link org.springframework.test.context.NestedTestConfiguration} semantics.
*
* @author Artem Bilan
+ * @author Chris Bono
*
* @since 5.0
*/
@@ -38,7 +41,7 @@ class MockIntegrationContextCustomizerFactory implements ContextCustomizerFactor
public ContextCustomizer createContextCustomizer(Class> testClass,
List configAttributes) {
- return AnnotatedElementUtils.hasAnnotation(testClass, SpringIntegrationTest.class)
+ return TestContextAnnotationUtils.hasAnnotation(testClass, SpringIntegrationTest.class)
? new MockIntegrationContextCustomizer()
: null;
}
diff --git a/spring-integration-test/src/main/java/org/springframework/integration/test/context/SpringIntegrationTest.java b/spring-integration-test/src/main/java/org/springframework/integration/test/context/SpringIntegrationTest.java
index c6f9dd13df8..02bd8d90abe 100644
--- a/spring-integration-test/src/main/java/org/springframework/integration/test/context/SpringIntegrationTest.java
+++ b/spring-integration-test/src/main/java/org/springframework/integration/test/context/SpringIntegrationTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2017-2022 the original author or authors.
+ * Copyright 2017-2024 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.
@@ -51,6 +51,8 @@
*
* }
*
+ *
+ * Honors the {@link org.springframework.test.context.NestedTestConfiguration} semantics.
*
* @author Artem Bilan
*
diff --git a/spring-integration-test/src/main/java/org/springframework/integration/test/context/SpringIntegrationTestExecutionListener.java b/spring-integration-test/src/main/java/org/springframework/integration/test/context/SpringIntegrationTestExecutionListener.java
index 836fcec26e2..078dc0af97b 100644
--- a/spring-integration-test/src/main/java/org/springframework/integration/test/context/SpringIntegrationTestExecutionListener.java
+++ b/spring-integration-test/src/main/java/org/springframework/integration/test/context/SpringIntegrationTestExecutionListener.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2017-2022 the original author or authors.
+ * Copyright 2017-2024 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.
@@ -19,9 +19,9 @@
import java.util.Arrays;
import org.springframework.context.ApplicationContext;
-import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.integration.endpoint.AbstractEndpoint;
import org.springframework.test.context.TestContext;
+import org.springframework.test.context.TestContextAnnotationUtils;
import org.springframework.test.context.TestExecutionListener;
import org.springframework.util.PatternMatchUtils;
@@ -39,7 +39,7 @@ class SpringIntegrationTestExecutionListener implements TestExecutionListener {
@Override
public void prepareTestInstance(TestContext testContext) {
SpringIntegrationTest springIntegrationTest =
- AnnotatedElementUtils.findMergedAnnotation(testContext.getTestClass(), SpringIntegrationTest.class);
+ TestContextAnnotationUtils.findMergedAnnotation(testContext.getTestClass(), SpringIntegrationTest.class);
String[] patterns = springIntegrationTest != null ? springIntegrationTest.noAutoStartup() : new String[0];
diff --git a/spring-integration-test/src/test/java/org/springframework/integration/test/context/AbstractIntegrationTest.java b/spring-integration-test/src/test/java/org/springframework/integration/test/context/AbstractIntegrationTest.java
new file mode 100644
index 00000000000..466cef50b25
--- /dev/null
+++ b/spring-integration-test/src/test/java/org/springframework/integration/test/context/AbstractIntegrationTest.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.integration.test.context;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.integration.config.EnableIntegration;
+import org.springframework.integration.endpoint.AbstractEndpoint;
+import org.springframework.integration.endpoint.ReactiveMessageSourceProducer;
+import org.springframework.messaging.support.GenericMessage;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+/**
+ * Base integration test that specifies a default {@link SpringIntegrationTest}
+ * to be inherited by concrete subclasses.
+ *
+ * @author Chris Bono
+ *
+ * @since 6.2.10
+ */
+@SpringJUnitConfig
+@SpringIntegrationTest(noAutoStartup = "*")
+class AbstractIntegrationTest {
+
+ @Configuration(proxyBeanMethods = false)
+ @EnableIntegration
+ static class MockEndpointConfig {
+
+ @Bean
+ AbstractEndpoint mockEndpoint() {
+ ReactiveMessageSourceProducer endpoint =
+ new ReactiveMessageSourceProducer(() -> new GenericMessage<>("testFromMockEndpoint"));
+ endpoint.setOutputChannelName("nullChannel");
+ return endpoint;
+ }
+
+ }
+}
diff --git a/spring-integration-test/src/test/java/org/springframework/integration/test/context/NestedSpringIntegrationTestAnnotationTests.java b/spring-integration-test/src/test/java/org/springframework/integration/test/context/NestedSpringIntegrationTestAnnotationTests.java
new file mode 100644
index 00000000000..a9c40970c18
--- /dev/null
+++ b/spring-integration-test/src/test/java/org/springframework/integration/test/context/NestedSpringIntegrationTestAnnotationTests.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.integration.test.context;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.integration.endpoint.AbstractEndpoint;
+import org.springframework.lang.Nullable;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.NestedTestConfiguration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Concrete specialization of {@link AbstractIntegrationTest} used to
+ * test inherit and override behavior of {@link SpringIntegrationTest}
+ * when used with {@link Nested} and {@link NestedTestConfiguration}.
+ *
+ * @author Chris Bono
+ * @author Artem Bilan
+ *
+ * @since 6.2.10
+ */
+class NestedSpringIntegrationTestAnnotationTests extends AbstractIntegrationTest {
+
+ @Test
+ void annotationDefinedOnParentIsInheritedByDefault(@Autowired AbstractEndpoint mockEndpoint) {
+ assertThat(mockEndpoint.isRunning()).isFalse();
+ }
+
+ @Nested
+ class NestedTestDefaultEnclosingConfiguration {
+
+ @Test
+ void annotationDefinedOnParentOfEnclosingIsInheritedByDefault(@Autowired AbstractEndpoint mockEndpoint) {
+ assertThat(mockEndpoint.isRunning()).isFalse();
+ }
+
+ }
+
+ @Nested
+ @NestedTestConfiguration(NestedTestConfiguration.EnclosingConfiguration.INHERIT)
+ class NestedTestWithInheritEnclosingConfiguration {
+
+ @Test
+ void annotationDefinedOnParentOfEnclosingIsInherited(@Autowired AbstractEndpoint mockEndpoint) {
+ assertThat(mockEndpoint.isRunning()).isFalse();
+ }
+
+ }
+
+ @Nested
+ @NestedTestConfiguration(NestedTestConfiguration.EnclosingConfiguration.INHERIT)
+ @SpringIntegrationTest(noAutoStartup = "noSuchEndpointWithThisPatternExists")
+ class NestedTestWithInheritEnclosingConfigurationButOverrideAnnotation {
+
+ @Test
+ void annotationDefinedOnParentOfEnclosingIsOverridden(@Autowired AbstractEndpoint mockEndpoint) {
+ assertThat(mockEndpoint.isRunning()).isTrue();
+ }
+
+ }
+
+ @Nested
+ @NestedTestConfiguration(NestedTestConfiguration.EnclosingConfiguration.OVERRIDE)
+ @ContextConfiguration(classes = MockEndpointConfig.class)
+ class NestedTestWithOverrideEnclosingConfiguration {
+
+ @Test
+ void annotationDefinedOnParentOfEnclosingIsIgnored(@Autowired AbstractEndpoint mockEndpoint,
+ @Nullable @Autowired MockIntegrationContext mockIntegrationContext) {
+
+ assertThat(mockEndpoint.isRunning()).isTrue();
+ assertThat(mockIntegrationContext).isNull();
+ }
+
+ }
+
+}
diff --git a/spring-integration-webflux/src/test/java/org/springframework/integration/webflux/observation/WebFluxObservationPropagationTests.java b/spring-integration-webflux/src/test/java/org/springframework/integration/webflux/observation/WebFluxObservationPropagationTests.java
index 8eb0e4476a3..92a985c5f73 100644
--- a/spring-integration-webflux/src/test/java/org/springframework/integration/webflux/observation/WebFluxObservationPropagationTests.java
+++ b/spring-integration-webflux/src/test/java/org/springframework/integration/webflux/observation/WebFluxObservationPropagationTests.java
@@ -36,26 +36,33 @@
import io.micrometer.tracing.test.simple.SpansAssert;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.integration.channel.FluxMessageChannel;
import org.springframework.integration.config.EnableIntegration;
import org.springframework.integration.config.EnableIntegrationManagement;
+import org.springframework.integration.core.MessagingTemplate;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.webflux.dsl.WebFlux;
import org.springframework.messaging.Message;
+import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.PollableChannel;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
import org.springframework.test.web.reactive.server.HttpHandlerConnector;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.config.EnableWebFlux;
+import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
import static org.assertj.core.api.Assertions.assertThat;
@@ -81,6 +88,10 @@ public class WebFluxObservationPropagationTests {
@Autowired
private PollableChannel testChannel;
+ @Autowired
+ @Qualifier("webFluxRequestReplyClientFlow.input")
+ private MessageChannel webFluxRequestReplyClientFlowInput;
+
@BeforeEach
void setup() {
SPANS.clear();
@@ -122,10 +133,24 @@ void observationIsPropagatedWebFluxRequestReply() {
.haveSameTraceId();
}
+ @Test
+ void observationIsPropagatedWebFluxClientRequestReply() {
+ String result =
+ new MessagingTemplate()
+ .convertSendAndReceive(this.webFluxRequestReplyClientFlowInput, "test", String.class);
+
+ assertThat(result).isEqualTo("SOME REPLY");
+
+ // There is a race condition when we already have a reply, but the span in the last channel is not closed yet.
+ await().untilAsserted(() -> assertThat(SPANS.spans()).hasSize(5));
+ SpansAssert.assertThat(SPANS.spans().stream().map(BraveFinishedSpan::fromBrave).collect(Collectors.toList()))
+ .haveSameTraceId();
+ }
+
@Configuration
@EnableWebFlux
@EnableIntegration
- @EnableIntegrationManagement(observationPatterns = "*")
+ @EnableIntegrationManagement(observationPatterns = "*,!notObserved*")
public static class ContextConfiguration {
@Bean
@@ -188,6 +213,29 @@ FluxMessageChannel webFluxRequestChannel() {
return new FluxMessageChannel();
}
+ @Bean
+ IntegrationFlow webFluxRequestReplyClientFlow(ObservationRegistry registry) {
+ ClientHttpConnector httpConnector =
+ new HttpHandlerConnector((request, response) -> {
+ response.setStatusCode(HttpStatus.OK);
+
+ Mono replyData = Mono.just(response.bufferFactory().wrap("some reply".getBytes()));
+
+ return response.writeWith(replyData)
+ .then(Mono.defer(response::setComplete));
+ });
+ WebClient webClient =
+ WebClient.builder()
+ .clientConnector(httpConnector)
+ .observationRegistry(registry)
+ .build();
+
+ return f -> f
+ .handle(WebFlux.outboundGateway(message -> "/someRequest", webClient)
+ .expectedResponseType(String.class))
+ .transform(String::toUpperCase);
+ }
+
@Bean
IntegrationFlow webFluxRequestReplyFlow(
@Qualifier("webFluxRequestChannel") FluxMessageChannel webFluxRequestChannel) {
@@ -200,6 +248,8 @@ IntegrationFlow webFluxRequestReplyFlow(
.transformWith(t -> t
.transformer(String::toLowerCase)
.id("testTransformer"))
+ .channel("notObservedChannel")
+ .bridge(e -> e.id("notObservedEndpoint"))
.get();
}
diff --git a/spring-integration-webflux/src/test/java/org/springframework/integration/webflux/outbound/WebFluxRequestExecutingMessageHandlerTests.java b/spring-integration-webflux/src/test/java/org/springframework/integration/webflux/outbound/WebFluxRequestExecutingMessageHandlerTests.java
index 29893db7790..1cfaba45020 100644
--- a/spring-integration-webflux/src/test/java/org/springframework/integration/webflux/outbound/WebFluxRequestExecutingMessageHandlerTests.java
+++ b/spring-integration-webflux/src/test/java/org/springframework/integration/webflux/outbound/WebFluxRequestExecutingMessageHandlerTests.java
@@ -32,6 +32,7 @@
import org.springframework.core.io.buffer.DataBufferLimitException;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.client.reactive.ClientHttpConnector;
@@ -95,6 +96,47 @@ void testReactiveReturn() {
.verify(Duration.ofSeconds(10));
}
+ @Test
+ void noContentLengthHeaderForGetMethod() {
+ ClientHttpConnector httpConnector =
+ new HttpHandlerConnector((request, response) -> {
+ assertThat(request.getHeaders())
+ .doesNotContainKey(org.springframework.http.HttpHeaders.CONTENT_LENGTH);
+ response.setStatusCode(HttpStatus.OK);
+ return Mono.defer(response::setComplete);
+ });
+
+ WebClient webClient = WebClient.builder()
+ .clientConnector(httpConnector)
+ .build();
+
+ String destinationUri = "https://www.springsource.org/spring-integration";
+ WebFluxRequestExecutingMessageHandler reactiveHandler =
+ new WebFluxRequestExecutingMessageHandler(destinationUri, webClient);
+ reactiveHandler.setHttpMethod(HttpMethod.GET);
+
+ FluxMessageChannel ackChannel = new FluxMessageChannel();
+ reactiveHandler.setOutputChannel(ackChannel);
+ String testPayload = "hello, world";
+ Message> testMessage =
+ MessageBuilder.withPayload(testPayload)
+ .setHeader(org.springframework.http.HttpHeaders.CONTENT_LENGTH, testPayload.length())
+ .build();
+ reactiveHandler.handleMessage(testMessage);
+ reactiveHandler.handleMessage(testMessage);
+
+ StepVerifier.create(ackChannel, 2)
+ .assertNext(m ->
+ assertThat(m.getHeaders())
+ .containsEntry(HttpHeaders.STATUS_CODE, HttpStatus.OK)
+ // The reply message headers are copied from the request message
+ .containsEntry(org.springframework.http.HttpHeaders.CONTENT_LENGTH, testPayload.length()))
+ .assertNext(m -> assertThat(m.getHeaders()).containsEntry(HttpHeaders.STATUS_CODE, HttpStatus.OK))
+ .expectNoEvent(Duration.ofMillis(100))
+ .thenCancel()
+ .verify(Duration.ofSeconds(10));
+ }
+
@Test
void testReactiveErrorOneWay() {
ClientHttpConnector httpConnector =
diff --git a/spring-integration-websocket/src/main/java/org/springframework/integration/websocket/IntegrationWebSocketContainer.java b/spring-integration-websocket/src/main/java/org/springframework/integration/websocket/IntegrationWebSocketContainer.java
index 1fad3076e8e..3feeb9c7231 100644
--- a/spring-integration-websocket/src/main/java/org/springframework/integration/websocket/IntegrationWebSocketContainer.java
+++ b/spring-integration-websocket/src/main/java/org/springframework/integration/websocket/IntegrationWebSocketContainer.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2014-2023 the original author or authors.
+ * Copyright 2014-2024 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.
@@ -19,6 +19,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
@@ -124,7 +125,7 @@ public void setSupportedProtocols(String... protocols) {
public void addSupportedProtocols(String... protocols) {
for (String protocol : protocols) {
- this.supportedProtocols.add(protocol.toLowerCase());
+ this.supportedProtocols.add(protocol.toLowerCase(Locale.ROOT));
}
}
diff --git a/spring-integration-zeromq/src/main/java/org/springframework/integration/zeromq/dsl/ZeroMqMessageHandlerSpec.java b/spring-integration-zeromq/src/main/java/org/springframework/integration/zeromq/dsl/ZeroMqMessageHandlerSpec.java
index 24e14af7a77..b99fe26996c 100644
--- a/spring-integration-zeromq/src/main/java/org/springframework/integration/zeromq/dsl/ZeroMqMessageHandlerSpec.java
+++ b/spring-integration-zeromq/src/main/java/org/springframework/integration/zeromq/dsl/ZeroMqMessageHandlerSpec.java
@@ -36,6 +36,7 @@
* The {@link ReactiveMessageHandlerSpec} extension for {@link ZeroMqMessageHandler}.
*
* @author Artem Bilan
+ * @author Alessio Matricardi
*
* @since 5.4
*/
@@ -126,6 +127,20 @@ public ZeroMqMessageHandlerSpec topic(String topic) {
return this;
}
+ /**
+ * Specify if the topic that {@link SocketType#PUB} socket is going to use for distributing messages into the
+ * subscriptions must be wrapped with an additional empty frame.
+ * It is ignored for all other {@link SocketType}s supported.
+ * This attribute is set to {@code true} by default.
+ * @param wrapTopic true if the topic must be wrapped with an additional empty frame.
+ * @return the spec
+ * @since 6.2.6
+ */
+ public ZeroMqMessageHandlerSpec wrapTopic(boolean wrapTopic) {
+ this.reactiveMessageHandler.wrapTopic(wrapTopic);
+ return this;
+ }
+
/**
* Specify a {@link Function} to evaluate a topic a {@link SocketType#PUB}
* is going to use for distributing messages into the
diff --git a/spring-integration-zeromq/src/main/java/org/springframework/integration/zeromq/dsl/ZeroMqMessageProducerSpec.java b/spring-integration-zeromq/src/main/java/org/springframework/integration/zeromq/dsl/ZeroMqMessageProducerSpec.java
index 9d53fad05f9..986bb4292eb 100644
--- a/spring-integration-zeromq/src/main/java/org/springframework/integration/zeromq/dsl/ZeroMqMessageProducerSpec.java
+++ b/spring-integration-zeromq/src/main/java/org/springframework/integration/zeromq/dsl/ZeroMqMessageProducerSpec.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2020 the original author or authors.
+ * Copyright 2020-2024 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.
@@ -30,6 +30,7 @@
/**
* @author Artem Bilan
+ * @author Alessio Matricardi
*
* @since 5.4
*/
@@ -108,6 +109,20 @@ public ZeroMqMessageProducerSpec topics(String... topics) {
return this;
}
+ /**
+ * Specify if the topic
+ * that {@link SocketType#SUB} socket is going to receive is wrapped with an additional empty frame.
+ * It is ignored for all other {@link SocketType}s supported.
+ * This attribute is set to {@code true} by default.
+ * @param unwrapTopic true if the received topic is wrapped with an additional empty frame.
+ * @return the spec
+ * @since 6.2.6
+ */
+ public ZeroMqMessageProducerSpec unwrapTopic(boolean unwrapTopic) {
+ this.target.unwrapTopic(unwrapTopic);
+ return this;
+ }
+
/**
* Configure an URL for {@link org.zeromq.ZMQ.Socket#connect(String)}.
* @param connectUrl the URL to connect ZeroMq socket to.
diff --git a/spring-integration-zeromq/src/main/java/org/springframework/integration/zeromq/inbound/ZeroMqMessageProducer.java b/spring-integration-zeromq/src/main/java/org/springframework/integration/zeromq/inbound/ZeroMqMessageProducer.java
index 0f0d3c02850..d2c484c3e19 100644
--- a/spring-integration-zeromq/src/main/java/org/springframework/integration/zeromq/inbound/ZeroMqMessageProducer.java
+++ b/spring-integration-zeromq/src/main/java/org/springframework/integration/zeromq/inbound/ZeroMqMessageProducer.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2024 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.
@@ -26,6 +26,7 @@
import org.zeromq.SocketType;
import org.zeromq.ZContext;
+import org.zeromq.ZFrame;
import org.zeromq.ZMQ;
import org.zeromq.ZMsg;
import reactor.core.publisher.Flux;
@@ -54,6 +55,7 @@
* When the {@link SocketType#SUB} is used, the received topic is stored in the {@link ZeroMqHeaders#TOPIC}.
*
* @author Artem Bilan
+ * @author Alessio Matricardi
*
* @since 5.4
*/
@@ -90,6 +92,8 @@ public class ZeroMqMessageProducer extends MessageProducerSupport {
private volatile Mono socketMono;
+ private volatile boolean unwrapTopic = true;
+
public ZeroMqMessageProducer(ZContext context) {
this(context, SocketType.PAIR);
}
@@ -189,6 +193,18 @@ public int getBoundPort() {
return this.bindPort.get();
}
+ /**
+ * Specify if the topic
+ * that {@link SocketType#SUB} socket is going to receive is wrapped with an additional empty frame.
+ * It is ignored for all other {@link SocketType}s supported.
+ * This attribute is set to {@code true} by default.
+ * @param unwrapTopic true if the received topic is wrapped with an additional empty frame.
+ * @since 6.2.6
+ */
+ public void unwrapTopic(boolean unwrapTopic) {
+ this.unwrapTopic = unwrapTopic;
+ }
+
@Override
public String getComponentType() {
return "zeromq:inbound-channel-adapter";
@@ -284,7 +300,8 @@ private Mono> convertMessage(Mono msgMono) {
return msgMono.map((msg) -> {
Map headers = null;
if (msg.size() > 1) {
- headers = Collections.singletonMap(ZeroMqHeaders.TOPIC, msg.unwrap().getString(ZMQ.CHARSET));
+ ZFrame topicFrame = this.unwrapTopic ? msg.unwrap() : msg.pop();
+ headers = Collections.singletonMap(ZeroMqHeaders.TOPIC, topicFrame.getString(ZMQ.CHARSET));
}
return this.messageMapper.toMessage(msg.getLast().getData(), headers); // NOSONAR
});
diff --git a/spring-integration-zeromq/src/main/java/org/springframework/integration/zeromq/outbound/ZeroMqMessageHandler.java b/spring-integration-zeromq/src/main/java/org/springframework/integration/zeromq/outbound/ZeroMqMessageHandler.java
index df6ac71d04a..2a668148318 100644
--- a/spring-integration-zeromq/src/main/java/org/springframework/integration/zeromq/outbound/ZeroMqMessageHandler.java
+++ b/spring-integration-zeromq/src/main/java/org/springframework/integration/zeromq/outbound/ZeroMqMessageHandler.java
@@ -60,6 +60,7 @@
* the {@link ZMsg} is sent into a socket as is and it is not destroyed for possible further reusing.
*
* @author Artem Bilan
+ * @author Alessio Matricardi
*
* @since 5.4
*/
@@ -88,6 +89,8 @@ public class ZeroMqMessageHandler extends AbstractReactiveMessageHandler
private volatile Disposable socketMonoSubscriber;
+ private volatile boolean wrapTopic = true;
+
/**
* Create an instance based on the provided {@link ZContext} and connection string.
* @param context the {@link ZContext} to use for creating sockets.
@@ -191,6 +194,18 @@ public void setTopicExpression(Expression topicExpression) {
this.topicExpression = topicExpression;
}
+ /**
+ * Specify if the topic that {@link SocketType#PUB} socket is going to use for distributing messages into the
+ * subscriptions must be wrapped with an additional empty frame.
+ * It is ignored for all other {@link SocketType}s supported.
+ * This attribute is set to {@code true} by default.
+ * @param wrapTopic true if the topic must be wrapped with an additional empty frame.
+ * @since 6.2.6
+ */
+ public void wrapTopic(boolean wrapTopic) {
+ this.wrapTopic = wrapTopic;
+ }
+
@Override
public String getComponentType() {
return "zeromq:outbound-channel-adapter";
@@ -244,7 +259,13 @@ protected Mono handleMessageInternal(Message> message) {
if (socket.base() instanceof Pub) {
String topic = this.topicExpression.getValue(this.evaluationContext, message, String.class);
if (topic != null) {
- msg.wrap(new ZFrame(topic));
+ ZFrame topicFrame = new ZFrame(topic);
+ if (this.wrapTopic) {
+ msg.wrap(topicFrame);
+ }
+ else {
+ msg.push(topicFrame);
+ }
}
}
}
diff --git a/spring-integration-zeromq/src/test/java/org/springframework/integration/zeromq/inbound/ZeroMqMessageProducerTests.java b/spring-integration-zeromq/src/test/java/org/springframework/integration/zeromq/inbound/ZeroMqMessageProducerTests.java
index c80c99c230c..0847f8b751e 100644
--- a/spring-integration-zeromq/src/test/java/org/springframework/integration/zeromq/inbound/ZeroMqMessageProducerTests.java
+++ b/spring-integration-zeromq/src/test/java/org/springframework/integration/zeromq/inbound/ZeroMqMessageProducerTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2024 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.
@@ -32,6 +32,7 @@
import org.springframework.beans.factory.BeanFactory;
import org.springframework.integration.channel.FluxMessageChannel;
import org.springframework.integration.test.util.TestUtils;
+import org.springframework.integration.zeromq.ZeroMqHeaders;
import org.springframework.messaging.support.GenericMessage;
import static org.assertj.core.api.Assertions.assertThat;
@@ -40,6 +41,7 @@
/**
* @author Artem Bilan
+ * @author Alessio Matricardi
*
* @since 5.4
*/
@@ -88,7 +90,7 @@ void testMessageProducerForPair() {
stepVerifier.verify();
- messageProducer.destroy();
+ messageProducer.stop();
socket.close();
}
@@ -142,7 +144,42 @@ void testMessageProducerForPubSubReceiveRaw() {
stepVerifier.verify(Duration.ofSeconds(10));
- messageProducer.destroy();
+ messageProducer.stop();
+ socket.close();
+ }
+
+ @Test
+ void testMessageProducerForPubSubDisabledWrapTopic() {
+ String socketAddress = "inproc://messageProducerWrapTopic.test";
+ ZMQ.Socket socket = CONTEXT.createSocket(SocketType.XPUB);
+ socket.bind(socketAddress);
+
+ FluxMessageChannel outputChannel = new FluxMessageChannel();
+
+ StepVerifier stepVerifier =
+ StepVerifier.create(outputChannel)
+ .assertNext((message) -> assertThat(message.getHeaders()).containsEntry(ZeroMqHeaders.TOPIC, "testTopicWithNonWrappedTopic"))
+ .thenCancel()
+ .verifyLater();
+
+ ZeroMqMessageProducer messageProducer = new ZeroMqMessageProducer(CONTEXT, SocketType.SUB);
+ messageProducer.setOutputChannel(outputChannel);
+ messageProducer.setTopics("test");
+ messageProducer.setConnectUrl(socketAddress);
+ messageProducer.setBeanFactory(mock(BeanFactory.class));
+ messageProducer.unwrapTopic(false);
+ messageProducer.afterPropertiesSet();
+ messageProducer.start();
+
+ assertThat(socket.recv()).isNotNull();
+
+ ZMsg msg = ZMsg.newStringMsg("test");
+ msg.push("testTopicWithNonWrappedTopic");
+ msg.send(socket);
+
+ stepVerifier.verify();
+
+ messageProducer.stop();
socket.close();
}
diff --git a/spring-integration-zeromq/src/test/java/org/springframework/integration/zeromq/outbound/ZeroMqMessageHandlerTests.java b/spring-integration-zeromq/src/test/java/org/springframework/integration/zeromq/outbound/ZeroMqMessageHandlerTests.java
index 0d491ae487c..1431047c437 100644
--- a/spring-integration-zeromq/src/test/java/org/springframework/integration/zeromq/outbound/ZeroMqMessageHandlerTests.java
+++ b/spring-integration-zeromq/src/test/java/org/springframework/integration/zeromq/outbound/ZeroMqMessageHandlerTests.java
@@ -42,6 +42,7 @@
/**
* @author Artem Bilan
+ * @author Alessio Matricardi
*
* @since 5.4
*/
@@ -150,4 +151,40 @@ void testMessageHandlerForPushPullOverProxy() {
proxy.destroy();
}
+ @Test
+ void testMessageHandlerForPubSubDisabledWrapTopic() {
+ ZMQ.Socket subSocket = CONTEXT.createSocket(SocketType.SUB);
+ subSocket.setReceiveTimeOut(0);
+ int port = subSocket.bindToRandomPort("tcp://*");
+ subSocket.subscribe("test");
+
+ ZeroMqMessageHandler messageHandler =
+ new ZeroMqMessageHandler(CONTEXT, "tcp://localhost:" + port, SocketType.PUB);
+ messageHandler.setBeanFactory(mock(BeanFactory.class));
+ messageHandler.setTopicExpression(
+ new FunctionExpression>((message) -> message.getHeaders().get("topic")));
+ messageHandler.setMessageMapper(new EmbeddedJsonHeadersMessageMapper());
+ messageHandler.wrapTopic(false);
+ messageHandler.afterPropertiesSet();
+ messageHandler.start();
+
+ Message> testMessage = MessageBuilder.withPayload("test").setHeader("topic", "testTopic").build();
+
+ await().atMost(Duration.ofSeconds(20)).pollDelay(Duration.ofMillis(100))
+ .untilAsserted(() -> {
+ subSocket.subscribe("test");
+ messageHandler.handleMessage(testMessage).subscribe();
+ ZMsg msg = ZMsg.recvMsg(subSocket);
+ assertThat(msg).isNotNull();
+ assertThat(msg.pop().getString(ZMQ.CHARSET)).isEqualTo("testTopic");
+ Message> capturedMessage =
+ new EmbeddedJsonHeadersMessageMapper().toMessage(msg.getFirst().getData());
+ assertThat(capturedMessage).isEqualTo(testMessage);
+ msg.destroy();
+ });
+
+ messageHandler.destroy();
+ subSocket.close();
+ }
+
}
diff --git a/spring-integration-zookeeper/src/test/java/org/springframework/integration/zookeeper/lock/ZkLockRegistryTests.java b/spring-integration-zookeeper/src/test/java/org/springframework/integration/zookeeper/lock/ZkLockRegistryTests.java
index c4cbec63cf4..e91590632ca 100644
--- a/spring-integration-zookeeper/src/test/java/org/springframework/integration/zookeeper/lock/ZkLockRegistryTests.java
+++ b/spring-integration-zookeeper/src/test/java/org/springframework/integration/zookeeper/lock/ZkLockRegistryTests.java
@@ -146,7 +146,8 @@ public void testTwoThreadsSecondFailsToGetLock() throws Exception {
lock1.lockInterruptibly();
final AtomicBoolean locked = new AtomicBoolean();
final CountDownLatch latch = new CountDownLatch(1);
- Future result = Executors.newSingleThreadExecutor().submit(() -> {
+ ExecutorService executorService = Executors.newSingleThreadExecutor();
+ Future result = executorService.submit(() -> {
Lock lock2 = registry.obtain("foo");
locked.set(lock2.tryLock(200, TimeUnit.MILLISECONDS));
latch.countDown();
@@ -165,6 +166,7 @@ public void testTwoThreadsSecondFailsToGetLock() throws Exception {
assertThat(ise).isInstanceOf(IllegalMonitorStateException.class);
assertThat(((Exception) ise).getMessage()).contains("You do not own");
registry.destroy();
+ executorService.shutdown();
}
@Test
@@ -176,7 +178,8 @@ public void testTwoThreads() throws Exception {
final CountDownLatch latch2 = new CountDownLatch(1);
final CountDownLatch latch3 = new CountDownLatch(1);
lock1.lockInterruptibly();
- Executors.newSingleThreadExecutor().execute(() -> {
+ ExecutorService executorService = Executors.newSingleThreadExecutor();
+ executorService.execute(() -> {
Lock lock2 = registry.obtain("foo");
try {
latch1.countDown();
@@ -199,6 +202,7 @@ public void testTwoThreads() throws Exception {
assertThat(latch3.await(10, TimeUnit.SECONDS)).isTrue();
assertThat(locked.get()).isTrue();
registry.destroy();
+ executorService.shutdown();
}
@Test
@@ -211,7 +215,8 @@ public void testTwoThreadsDifferentRegistries() throws Exception {
final CountDownLatch latch2 = new CountDownLatch(1);
final CountDownLatch latch3 = new CountDownLatch(1);
lock1.lockInterruptibly();
- Executors.newSingleThreadExecutor().execute(() -> {
+ ExecutorService executorService = Executors.newSingleThreadExecutor();
+ executorService.execute(() -> {
Lock lock2 = registry2.obtain("foo");
try {
latch1.countDown();
@@ -235,6 +240,7 @@ public void testTwoThreadsDifferentRegistries() throws Exception {
assertThat(locked.get()).isTrue();
registry1.destroy();
registry2.destroy();
+ executorService.shutdown();
}
@Test
@@ -244,7 +250,8 @@ public void testTwoThreadsWrongOneUnlocks() throws Exception {
lock.lockInterruptibly();
final AtomicBoolean locked = new AtomicBoolean();
final CountDownLatch latch = new CountDownLatch(1);
- Future result = Executors.newSingleThreadExecutor().submit(() -> {
+ ExecutorService executorService = Executors.newSingleThreadExecutor();
+ Future result = executorService.submit(() -> {
try {
lock.unlock();
}
@@ -261,6 +268,7 @@ public void testTwoThreadsWrongOneUnlocks() throws Exception {
assertThat(imse).isInstanceOf(IllegalMonitorStateException.class);
assertThat(((Exception) imse).getMessage()).contains("You do not own");
registry.destroy();
+ executorService.shutdown();
}
@Test
diff --git a/src/reference/antora/modules/ROOT/pages/amqp.adoc b/src/reference/antora/modules/ROOT/pages/amqp.adoc
index b6725a17068..827f4952aef 100644
--- a/src/reference/antora/modules/ROOT/pages/amqp.adoc
+++ b/src/reference/antora/modules/ROOT/pages/amqp.adoc
@@ -32,7 +32,7 @@ The following adapters are available:
* xref:amqp/inbound-gateway.adoc[Inbound Gateway]
* xref:amqp/outbound-channel-adapter.adoc[Outbound Channel Adapter]
* xref:amqp/outbound-gateway.adoc[Outbound Gateway]
-* xref:amqp-async-outbound-gateway[Async Outbound Gateway]
+* xref:amqp/async-outbound-gateway.adoc[Async Outbound Gateway]
* xref:amqp/rmq-streams.adoc#rmq-stream-inbound-channel-adapter[RabbitMQ Stream Queue Inbound Channel Adapter]
* xref:amqp/rmq-streams.adoc#rmq-stream-outbound-channel-adapter[RabbitMQ Stream Queue Outbound Channel Adapter]
diff --git a/src/reference/antora/modules/ROOT/pages/amqp/inbound-channel-adapter.adoc b/src/reference/antora/modules/ROOT/pages/amqp/inbound-channel-adapter.adoc
index e8ae8d91de3..906bf071fa4 100644
--- a/src/reference/antora/modules/ROOT/pages/amqp/inbound-channel-adapter.adoc
+++ b/src/reference/antora/modules/ROOT/pages/amqp/inbound-channel-adapter.adoc
@@ -87,7 +87,7 @@ XML::
task-executor="" <22>
transaction-attribute="" <23>
transaction-manager="" <24>
- tx-size="" <25>
+ batch-size="" <25>
consumers-per-queue <26>
batch-mode="MESSAGES"/> <27>
@@ -146,7 +146,7 @@ By default, this value is `Integer.MAX_VALUE`, meaning that this container start
Optional.
<17> Tells the AMQP broker how many messages to send to each consumer in a single request.
Often, you can set this value high to improve throughput.
-It should be greater than or equal to the transaction size (see the `tx-size` attribute, later in this list).
+It should be greater than or equal to the transaction size (see the `batch-size` attribute, later in this list).
Optional (defaults to `1`).
<18> Receive timeout in milliseconds.
Optional (defaults to `1000`).
@@ -173,7 +173,7 @@ If the `channelTransacted` flag is `false`, no transaction semantics apply to th
For further information, see
https://docs.spring.io/spring-amqp/reference/html/%255Freference.html#%5Ftransactions[Transactions with Spring AMQP].
Optional.
-<25> Tells the `SimpleMessageListenerContainer` how many messages to process in a single transaction (if the channel is transactional).
+<25> Tells the `SimpleMessageListenerContainer` how many messages to process in a single request.
For best results, it should be less than or equal to the value set in `prefetch-count`.
Not allowed when 'consumers-per-queue' is set.
Optional (defaults to `1`).
diff --git a/src/reference/antora/modules/ROOT/pages/amqp/inbound-gateway.adoc b/src/reference/antora/modules/ROOT/pages/amqp/inbound-gateway.adoc
index 7fcdf9617e4..cc2762a88ed 100644
--- a/src/reference/antora/modules/ROOT/pages/amqp/inbound-gateway.adoc
+++ b/src/reference/antora/modules/ROOT/pages/amqp/inbound-gateway.adoc
@@ -10,7 +10,7 @@ Java DSL::
+
[source, java, role="primary"]
----
-@Bean // return the upper cased payload
+@Bean // return the upper-cased payload
public IntegrationFlow amqpInboundGateway(ConnectionFactory connectionFactory) {
return IntegrationFlow.from(Amqp.inboundGateway(connectionFactory, "foo"))
.transform(String.class, String::toUpperCase)
diff --git a/src/reference/antora/modules/ROOT/pages/barrier.adoc b/src/reference/antora/modules/ROOT/pages/barrier.adoc
index 95ce680881d..f0fb5e1731a 100644
--- a/src/reference/antora/modules/ROOT/pages/barrier.adoc
+++ b/src/reference/antora/modules/ROOT/pages/barrier.adoc
@@ -75,5 +75,6 @@ XML::
Depending on which one has a message arrive first, either the thread sending a message to `in` or the thread sending a message to `release` waits for up to ten seconds until the other message arrives.
When the message is released, the `out` channel is sent a message that combines the result of invoking the custom `MessageGroupProcessor` bean, named `myOutputProcessor`.
If the main thread times out and a trigger arrives later, you can configure a discard channel to which the late trigger is sent.
+The trigger message is also discarded if request message does not arrive in time.
For an example of this component, see the https://github.com/spring-projects/spring-integration-samples/tree/main/basic/barrier[barrier sample application].
diff --git a/src/reference/antora/modules/ROOT/pages/configuration/global-properties.adoc b/src/reference/antora/modules/ROOT/pages/configuration/global-properties.adoc
index acae7aa00a9..b993b28fd53 100644
--- a/src/reference/antora/modules/ROOT/pages/configuration/global-properties.adoc
+++ b/src/reference/antora/modules/ROOT/pages/configuration/global-properties.adoc
@@ -76,6 +76,6 @@ spring.integration.channels.autoCreate=true
spring.integration.channels.maxBroadcastSubscribers=0x7fffffff
spring.integration.readOnly.headers=
spring.integration.messagingTemplate.throwExceptionOnLateReply=true
-spring.integration.endpoints.defaultTimeout=-1
+spring.integration.endpoints.defaultTimeout=30000
----
diff --git a/src/reference/antora/modules/ROOT/pages/hazelcast.adoc b/src/reference/antora/modules/ROOT/pages/hazelcast.adoc
index 9d7c3449fcc..2736d81c322 100644
--- a/src/reference/antora/modules/ROOT/pages/hazelcast.adoc
+++ b/src/reference/antora/modules/ROOT/pages/hazelcast.adoc
@@ -604,7 +604,7 @@ The xref:channel/implementations.adoc#channel-implementations-queuechannel[`Queu
----
@Bean
PollableChannel hazelcastQueueChannel(HazelcastInstance hazelcastInstance) {
- return new QueueChannel(hazelcastInstance.Message>>getQueue("springIntegrationQueue"));
+ return new QueueChannel(hazelcastInstance.getQueue("springIntegrationQueue"));
}
----
diff --git a/src/reference/antora/modules/ROOT/pages/testing.adoc b/src/reference/antora/modules/ROOT/pages/testing.adoc
index 4e6aae22da5..74017affd61 100644
--- a/src/reference/antora/modules/ROOT/pages/testing.adoc
+++ b/src/reference/antora/modules/ROOT/pages/testing.adoc
@@ -206,6 +206,8 @@ The endpoints are matched to the provided patterns, which support the following
This is useful when we would like to not have real connections to the target systems from inbound channel adapters (for example an AMQP Inbound Gateway, JDBC Polling Channel Adapter, WebSocket Message Producer in client mode, and so on).
+The `@SpringIntegrationTest` honors the `org.springframework.test.context.NestedTestConfiguration` semantics, hence it can be declared on the outer class (or even its super class) - and `@SpringIntegrationTest` environment will be available to inherited `@Nested` tests.
+
The `MockIntegrationContext` is meant to be used in the target test cases for modifications to beans in the real application context.
For example, endpoints that have `autoStartup` overridden to `false` can be replaced with mocks, as the following example shows:
diff --git a/src/reference/antora/modules/ROOT/pages/zeromq.adoc b/src/reference/antora/modules/ROOT/pages/zeromq.adoc
index be9140b1f75..a0b85efdfee 100644
--- a/src/reference/antora/modules/ROOT/pages/zeromq.adoc
+++ b/src/reference/antora/modules/ROOT/pages/zeromq.adoc
@@ -119,6 +119,9 @@ If the `receiveRaw` option is set to `true`, a `ZMsg`, consumed from the socket,
Otherwise, an `InboundMessageMapper` is used to convert the consumed data into a `Message`.
If the received `ZMsg` is multi-frame, the first frame is treated as the `ZeroMqHeaders.TOPIC` header this ZeroMQ message was published to.
+If the `unwrapTopic` option is set to `false`, the incoming message is considered to consist of two frames: the topic and the ZeroMQ message.
+Otherwise, by default, the `ZMsg` is considered to consist of three frames: the first one containing the topic, the last frame containing the message, with an empty frame in the middle.
+
With `SocketType.SUB`, the `ZeroMqMessageProducer` uses the provided `topics` option for subscriptions; defaults to subscribe to all.
Subscriptions can be adjusted at runtime using `subscribeToTopics()` and `unsubscribeFromTopics()` `@ManagedOperation` s.
@@ -146,6 +149,10 @@ Only `SocketType.PAIR`, `SocketType.PUSH` and `SocketType.PUB` are supported.
The `ZeroMqMessageHandler` only supports connecting the ZeroMQ socket; binding is not supported.
When the `SocketType.PUB` is used, the `topicExpression` is evaluated against a request message to inject a topic frame into a ZeroMQ message if it is not null.
The subscriber side (`SocketType.SUB`) must receive the topic frame first before parsing the actual data.
+
+If the `wrapTopic` option is set to `false`, the ZeroMQ message frame is sent after the injected topic, if present.
+By default, an additional empty frame is sent between the topic and the message.
+
When the payload of the request message is a `ZMsg`, no conversion or topic extraction is performed: the `ZMsg` is sent into a socket as is and it is not destroyed for possible further reuse.
Otherwise, an `OutboundMessageMapper` is used to convert a request message (or just its payload) into a ZeroMQ frame to publish.
By default, a `ConvertingBytesMessageMapper` is used supplied with a `ConfigurableCompositeMessageConverter`.