This is an automated email from the ASF dual-hosted git repository.
jamesnetherton pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-quarkus.git
The following commit(s) were added to refs/heads/main by this push:
new 56ac36ae53 Improve file-watch extension docs and test coverage
56ac36ae53 is described below
commit 56ac36ae53c8495377bf5cf3a738c1dd036bb7ff
Author: James Netherton <[email protected]>
AuthorDate: Tue Feb 3 09:10:25 2026 +0000
Improve file-watch extension docs and test coverage
---
.../pages/reference/extensions/file-watch.adoc | 11 +-
.../runtime/src/main/doc/limitations.adoc | 10 +-
.../svm/SubstituteDirectoryWatcherBuilder.java | 2 +-
.../component/file/it/FileWatchResource.java | 51 ++++--
.../camel/quarkus/component/file/it/FileTest.java | 53 -------
.../quarkus/component/file/it/FileWatchIT.java | 23 +++
.../quarkus/component/file/it/FileWatchTest.java | 171 +++++++++++++++++++++
7 files changed, 245 insertions(+), 76 deletions(-)
diff --git a/docs/modules/ROOT/pages/reference/extensions/file-watch.adoc
b/docs/modules/ROOT/pages/reference/extensions/file-watch.adoc
index 1e6515381c..365262b042 100644
--- a/docs/modules/ROOT/pages/reference/extensions/file-watch.adoc
+++ b/docs/modules/ROOT/pages/reference/extensions/file-watch.adoc
@@ -48,11 +48,12 @@ endif::[]
[id="extensions-file-watch-camel-quarkus-limitations"]
== Camel Quarkus limitations
-The underlying Camel component configures the Directory Watcher in a platform
specific way:
+[id="extensions-file-watch-limitations-macos-native-mode-limitations"]
+=== macOS native mode limitations
-* On Mac, the `io.methvin.watchservice.MacOSXListeningWatchService` is used
that depends on
- `https://github.com/java-native-access/jna[net.java.dev.jna:jna]`.
-* Other platforms use `java.nio.file.WatchService` provided by the Java
Runtime.
+Depending on the platform, the underlying Camel component configures the
Directory Watcher differently in native mode.
-Because JNA is https://github.com/oracle/graal/issues/673[not supported on
GraalVM] yet, we made the component to behave differently on Camel Quarkus: We
are substituting the respective Directory Watcher method do use the stock
`java.nio.file.WatchService` also on Mac.
+On macOS, a `WatchService` implementation based on JNA is used to access
native filesystem events. This provides better performance and scalability,
compared to the JDK’s default macOS implementation.
+
+Since JNA is not supported on GraalVM, `camel-quarkus-file-watch` cannot use
the macOS-specific native `WatchService`. To maintain compatibility, the
`WatchService` is forcefully overridden to `java.nio.file.WatchService`. This
implementation may be slower compared to the JNA-based version.
diff --git a/extensions/file-watch/runtime/src/main/doc/limitations.adoc
b/extensions/file-watch/runtime/src/main/doc/limitations.adoc
index e0ba4a1ff9..e8a49f563f 100644
--- a/extensions/file-watch/runtime/src/main/doc/limitations.adoc
+++ b/extensions/file-watch/runtime/src/main/doc/limitations.adoc
@@ -1,7 +1,7 @@
-The underlying Camel component configures the Directory Watcher in a platform
specific way:
+=== macOS native mode limitations
-* On Mac, the `io.methvin.watchservice.MacOSXListeningWatchService` is used
that depends on
- `https://github.com/java-native-access/jna[net.java.dev.jna:jna]`.
-* Other platforms use `java.nio.file.WatchService` provided by the Java
Runtime.
+Depending on the platform, the underlying Camel component configures the
Directory Watcher differently in native mode.
-Because JNA is https://github.com/oracle/graal/issues/673[not supported on
GraalVM] yet, we made the component to behave differently on Camel Quarkus: We
are substituting the respective Directory Watcher method do use the stock
`java.nio.file.WatchService` also on Mac.
+On macOS, a `WatchService` implementation based on JNA is used to access
native filesystem events. This provides better performance and scalability,
compared to the JDK’s default macOS implementation.
+
+Since JNA is not supported on GraalVM, `camel-quarkus-file-watch` cannot use
the macOS-specific native `WatchService`. To maintain compatibility, the
`WatchService` is forcefully overridden to `java.nio.file.WatchService`. This
implementation may be slower compared to the JNA-based version.
diff --git
a/extensions/file-watch/runtime/src/main/java/org/apache/camel/quarkus/component/file/watch/svm/SubstituteDirectoryWatcherBuilder.java
b/extensions/file-watch/runtime/src/main/java/org/apache/camel/quarkus/component/file/watch/svm/SubstituteDirectoryWatcherBuilder.java
index c07ada5d17..aa2ef61c43 100644
---
a/extensions/file-watch/runtime/src/main/java/org/apache/camel/quarkus/component/file/watch/svm/SubstituteDirectoryWatcherBuilder.java
+++
b/extensions/file-watch/runtime/src/main/java/org/apache/camel/quarkus/component/file/watch/svm/SubstituteDirectoryWatcherBuilder.java
@@ -30,7 +30,7 @@ import io.methvin.watcher.visitor.FileTreeVisitor;
public final class SubstituteDirectoryWatcherBuilder {
@Substitute
private Builder osDefaultWatchService(FileTreeVisitor fileTreeVisitor)
throws IOException {
- /* Never call MacOSXListeningWatchService */
+ // Cut out references to JNA dependent MacOSXListeningWatchService and
force the default JDK file watch service
return watchService(FileSystems.getDefault().newWatchService());
}
diff --git
a/integration-tests/file/src/main/java/org/apache/camel/quarkus/component/file/it/FileWatchResource.java
b/integration-tests/file/src/main/java/org/apache/camel/quarkus/component/file/it/FileWatchResource.java
index 9798d46a9a..0e956699ca 100644
---
a/integration-tests/file/src/main/java/org/apache/camel/quarkus/component/file/it/FileWatchResource.java
+++
b/integration-tests/file/src/main/java/org/apache/camel/quarkus/component/file/it/FileWatchResource.java
@@ -16,10 +16,14 @@
*/
package org.apache.camel.quarkus.component.file.it;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.util.Map;
+
+import io.methvin.watcher.hashing.FileHash;
+import io.methvin.watcher.hashing.FileHasher;
+import io.smallrye.common.annotation.Identifier;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
@@ -31,32 +35,55 @@ import org.apache.camel.Exchange;
import org.apache.camel.Message;
import org.apache.camel.component.file.watch.FileWatchConstants;
import org.apache.camel.component.file.watch.constants.FileEventEnum;
+import org.apache.camel.util.ObjectHelper;
@Path("/file-watch")
@ApplicationScoped
public class FileWatchResource {
-
@Inject
ConsumerTemplate consumerTemplate;
@Path("/get-events")
@GET
@Produces(MediaType.APPLICATION_JSON)
- public Response getEvent(@QueryParam("path") String path) throws Exception
{
- final Exchange exchange =
consumerTemplate.receiveNoWait("file-watch://" + path);
+ public Response getEvent(
+ @QueryParam("path") String path,
+ @QueryParam("include") String include,
+ @QueryParam("fileHasher") String fileHasher) {
+
+ String uri = "file-watch:" + path;
+ boolean firstParam = true;
+
+ if (ObjectHelper.isNotEmpty(include)) {
+ uri += "?" + "antInclude=" + include;
+ firstParam = false;
+ }
+
+ if (ObjectHelper.isNotEmpty(fileHasher)) {
+ uri += (firstParam ? "?" : "&") + "fileHasher=" + fileHasher;
+ }
+
+ final Exchange exchange = consumerTemplate.receiveNoWait(uri);
if (exchange == null) {
return Response.noContent().build();
} else {
final Message message = exchange.getMessage();
- final ObjectMapper mapper = new ObjectMapper();
- final ObjectNode node = mapper.createObjectNode();
- node.put("type",
message.getHeader(FileWatchConstants.EVENT_TYPE_HEADER,
FileEventEnum.class).toString());
- node.put("path", message.getHeader("CamelFileAbsolutePath",
String.class));
- return Response
- .ok()
- .entity(node)
+ return Response.ok()
+ .entity(Map.of(
+ "type",
message.getHeader(FileWatchConstants.EVENT_TYPE_HEADER,
FileEventEnum.class).toString(),
+ "path",
message.getHeader(FileWatchConstants.FILE_ABSOLUTE_PATH, String.class)))
.build();
}
}
+ @Identifier("customFileHasher")
+ @Singleton
+ FileHasher customFileHasher() {
+ return new FileHasher() {
+ @Override
+ public FileHash hash(java.nio.file.Path path) {
+ return FileHash.fromLong(1L);
+ }
+ };
+ }
}
diff --git
a/integration-tests/file/src/test/java/org/apache/camel/quarkus/component/file/it/FileTest.java
b/integration-tests/file/src/test/java/org/apache/camel/quarkus/component/file/it/FileTest.java
index 861a88298c..27d53ca66e 100644
---
a/integration-tests/file/src/test/java/org/apache/camel/quarkus/component/file/it/FileTest.java
+++
b/integration-tests/file/src/test/java/org/apache/camel/quarkus/component/file/it/FileTest.java
@@ -17,7 +17,6 @@
package org.apache.camel.quarkus.component.file.it;
import java.io.IOException;
-import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -29,9 +28,6 @@ import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
-import io.restassured.path.json.JsonPath;
-import io.restassured.response.ValidatableResponse;
-import org.apache.camel.quarkus.core.util.FileUtils;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
@@ -231,53 +227,4 @@ class FileTest {
.extract().asString(),
equalTo(SORT_BY_NAME_3_CONTENT + SEPARATOR +
SORT_BY_NAME_2_CONTENT + SEPARATOR + SORT_BY_NAME_1_CONTENT));
}
-
- @Test
- public void fileWatchShouldCatchCreateModifyAndDeleteEvents() throws
IOException {
- final Path fileWatchDirectory =
Files.createTempDirectory(FileTest.class.getSimpleName()).toRealPath();
- RestAssured.given()
- .queryParam("path", fileWatchDirectory.toString())
- .get("/file-watch/get-events")
- .then()
- .statusCode(204);
-
- final Path watchedFilePath =
fileWatchDirectory.resolve("watched-file.txt");
- Files.write(watchedFilePath, "a file
content".getBytes(StandardCharsets.UTF_8));
- awaitEvent(fileWatchDirectory, watchedFilePath, "CREATE");
-
- Files.write(watchedFilePath, "changed
content".getBytes(StandardCharsets.UTF_8));
- awaitEvent(fileWatchDirectory, watchedFilePath, "MODIFY");
-
- Files.delete(watchedFilePath);
- awaitEvent(fileWatchDirectory, watchedFilePath, "DELETE");
- }
-
- private static void awaitEvent(final Path fileWatchDirectory, final Path
watchedFile, final String extepecteEventType) {
- await()
- .pollInterval(100, TimeUnit.MILLISECONDS)
- .atMost(20, TimeUnit.SECONDS)
- .until(() -> {
- final ValidatableResponse getEventsResponse =
RestAssured.given()
- .queryParam("path", fileWatchDirectory.toString())
- .get("/file-watch/get-events")
- .then();
- switch (getEventsResponse.extract().statusCode()) {
- case 204:
- /*
- * the event may come with some delay through all the
OS and Java layers so it is
- * rather normal to get 204 before getting the
expected event
- */
- return false;
- case 200:
- final JsonPath json =
getEventsResponse.extract().jsonPath();
-
- String expectedPath =
FileUtils.nixifyPath(watchedFile);
- String actualPath = json.getString("path");
- return expectedPath.equals(actualPath) &&
extepecteEventType.equals(json.getString("type"));
- default:
- throw new RuntimeException("Unexpected status code " +
getEventsResponse.extract().statusCode());
- }
- });
- }
-
}
diff --git
a/integration-tests/file/src/test/java/org/apache/camel/quarkus/component/file/it/FileWatchIT.java
b/integration-tests/file/src/test/java/org/apache/camel/quarkus/component/file/it/FileWatchIT.java
new file mode 100644
index 0000000000..1241e82e55
--- /dev/null
+++
b/integration-tests/file/src/test/java/org/apache/camel/quarkus/component/file/it/FileWatchIT.java
@@ -0,0 +1,23 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.apache.camel.quarkus.component.file.it;
+
+import io.quarkus.test.junit.QuarkusIntegrationTest;
+
+@QuarkusIntegrationTest
+class FileWatchIT extends FileWatchTest {
+}
diff --git
a/integration-tests/file/src/test/java/org/apache/camel/quarkus/component/file/it/FileWatchTest.java
b/integration-tests/file/src/test/java/org/apache/camel/quarkus/component/file/it/FileWatchTest.java
new file mode 100644
index 0000000000..812adf5784
--- /dev/null
+++
b/integration-tests/file/src/test/java/org/apache/camel/quarkus/component/file/it/FileWatchTest.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.apache.camel.quarkus.component.file.it;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.RestAssured;
+import io.restassured.path.json.JsonPath;
+import io.restassured.response.ValidatableResponse;
+import org.apache.camel.quarkus.core.util.FileUtils;
+import org.apache.camel.util.ObjectHelper;
+import org.eclipse.microprofile.config.ConfigProvider;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.awaitility.Awaitility.await;
+import static org.hamcrest.Matchers.hasItem;
+
+@QuarkusTest
+class FileWatchTest {
+ @Test
+ void fileWatchShouldCatchCreateModifyAndDeleteEvents(@TempDir Path
fileWatchDirectory) throws IOException {
+ RestAssured.given()
+ .queryParam("path", fileWatchDirectory.toString())
+ .get("/file-watch/get-events")
+ .then()
+ .statusCode(204);
+
+ final Path watchedFilePath =
fileWatchDirectory.resolve("watched-file.txt");
+ Files.writeString(watchedFilePath, "a file content");
+ awaitEvent(fileWatchDirectory, watchedFilePath, "CREATE");
+
+ Files.writeString(watchedFilePath, "changed content");
+ awaitEvent(fileWatchDirectory, watchedFilePath, "MODIFY");
+
+ Files.delete(watchedFilePath);
+ awaitEvent(fileWatchDirectory, watchedFilePath, "DELETE");
+ }
+
+ @Test
+ void fileWatchShouldIgnoreFilesWithWrongSuffix(@TempDir Path
fileWatchDirectory) throws IOException {
+ final String includeExpression = "**/*.txt";
+
+ RestAssured.given()
+ .queryParam("path", fileWatchDirectory.toString())
+ .queryParam("include", includeExpression)
+ .get("/file-watch/get-events")
+ .then()
+ .statusCode(204);
+
+ final Path watchedFilePath =
fileWatchDirectory.resolve("watched-file.txt");
+ Files.writeString(watchedFilePath, "a file content");
+ awaitEvent(fileWatchDirectory, watchedFilePath, "CREATE",
includeExpression, null);
+
+ // Create some files that do not match the file inclusion expression
+ for (int i = 1; i <= 5; i++) {
+ final Path path =
fileWatchDirectory.resolve("watched-file-%d.csv".formatted(i));
+ Files.writeString(path, "some CSV content," + i);
+ }
+
+ // The next event should be related to this file modify event and not
for the CSV file creation above
+ Files.writeString(watchedFilePath, "changed content");
+ awaitEvent(fileWatchDirectory, watchedFilePath, "MODIFY",
includeExpression, null);
+
+ Files.delete(watchedFilePath);
+ awaitEvent(fileWatchDirectory, watchedFilePath, "DELETE",
includeExpression, null);
+ }
+
+ @Test
+ void fileWatchWithCustomHasher(@TempDir Path fileWatchDirectory) throws
IOException {
+ final String fileHasher = "#customFileHasher";
+
+ RestAssured.given()
+ .queryParam("path", fileWatchDirectory.toString())
+ .queryParam("fileHasher", fileHasher)
+ .get("/file-watch/get-events")
+ .then()
+ .statusCode(204);
+
+ // Create file
+ final Path watchedFilePath =
fileWatchDirectory.resolve("watched-file.txt");
+ Files.writeString(watchedFilePath, "a file content");
+ awaitEvent(fileWatchDirectory, watchedFilePath, "CREATE", null,
fileHasher);
+
+ // Modify the file again, the custom FileHasher impl will ignore
further updates since it uses a fixed hash
+ Files.writeString(watchedFilePath, "changed content");
+
+ // Take into account file I/O is typically slow on some CI platforms
+ Long assertionTimeout = ConfigProvider.getConfig()
+ .getOptionalValue("fileHasherAssertionTimeoutMillis",
Long.class)
+ .orElseGet(new Supplier<Long>() {
+ @Override
+ public Long get() {
+ String ci = System.getenv("CI");
+ if (ObjectHelper.isNotEmpty(ci) &&
ci.equalsIgnoreCase("true")) {
+ return 3000L;
+ }
+ return 500L;
+ }
+ });
+
+ await().during(Duration.ofMillis(assertionTimeout))
+ .pollInterval(Duration.ofMillis(50))
+ .failFast(() -> {
+ RestAssured.given()
+ .when()
+ .get("/file-watch/get-events")
+ .then()
+ .body("type", hasItem("MODIFY"));
+ });
+ }
+
+ private static void awaitEvent(final Path fileWatchDirectory, final Path
watchedFile, final String expectedEventType) {
+ awaitEvent(fileWatchDirectory, watchedFile, expectedEventType, null,
null);
+ }
+
+ private static void awaitEvent(
+ final Path fileWatchDirectory,
+ final Path watchedFile,
+ final String expectedEventType,
+ final String include,
+ final String fileHasher) {
+
+ await().pollInterval(100, TimeUnit.MILLISECONDS)
+ .atMost(20, TimeUnit.SECONDS)
+ .until(() -> {
+ final ValidatableResponse getEventsResponse =
RestAssured.given()
+ .queryParam("path", fileWatchDirectory.toString())
+ .queryParam("include", include)
+ .queryParam("fileHasher", fileHasher)
+ .get("/file-watch/get-events")
+ .then();
+ switch (getEventsResponse.extract().statusCode()) {
+ case 204:
+ /*
+ * the event may come with some delay through all the
OS and Java layers so it is
+ * rather normal to get 204 before getting the
expected event
+ */
+ return false;
+ case 200:
+ final JsonPath json =
getEventsResponse.extract().jsonPath();
+
+ String expectedPath =
FileUtils.nixifyPath(watchedFile);
+ String actualPath = json.getString("path");
+ return expectedPath.equals(actualPath) &&
expectedEventType.equals(json.getString("type"));
+ default:
+ throw new RuntimeException("Unexpected status code " +
getEventsResponse.extract().statusCode());
+ }
+ });
+ }
+}