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());
+                    }
+                });
+    }
+}

Reply via email to