This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new ca8c66b214dd CAMEL-26795: camel-jbang - a chaos experiment feeds a
file route with files its filter takes, and camel cmd send --repeat numbers a
CamelFileName (#29523)
ca8c66b214dd is described below
commit ca8c66b214ddb02e0d9ce23dfcb9c9b67433c317
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Nov 10 01:59:18 2026 +0100
CAMEL-26795: camel-jbang - a chaos experiment feeds a file route with files
its filter takes, and camel cmd send --repeat numbers a CamelFileName (#29523)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../modules/ROOT/pages/camel-jbang-devtools.adoc | 4 ++
.../modules/ROOT/pages/camel-jbang-managing.adoc | 4 ++
.../apache/camel/cli/connector/RepeatedSend.java | 20 ++++++
.../connector/LocalCliConnectorSendActionTest.java | 24 +++++++
.../core/commands/ai/ChaosExplorePlaybook.java | 4 +-
.../dsl/jbang/core/common/ChaosExperimentRun.java | 78 +++++++++++++++++++++-
.../jbang/core/common/ChaosExperimentRunTest.java | 38 +++++++++++
7 files changed, 169 insertions(+), 3 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc
index 6ca1d495ee63..019ef6c84562 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc
@@ -73,6 +73,10 @@ $ camel cmd send shop --endpoint=kafka:refunds
--body=file:refund.json --repeat=
2 x IllegalStateException: Refund amount is missing
----
+When the messages are repeated (or come from a folder), a `CamelFileName`
header is numbered per message, so a file
+endpoint writes one file per message rather than the same file again:
`--header=CamelFileName=order.json` writes
+`order-1.json`, `order-2.json` and so on.
+
A body of `file:<folder>` sends the files of the folder in name order, one
message per file. A `.properties` file
with the same name has the headers of a message: `001.properties` for
`001.json`. With `--repeat` it goes round the
files again, and the headers of `--header` go with every message:
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
index ade54afe6470..8a1fbac0b265 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
@@ -968,6 +968,10 @@ For a path with a parameter, `--feed` is the path to call,
with an optional meth
server with its own address on this machine (netty-http, undertow, jetty) is
fed at that address, called as localhost;
one on another host is not fed.
+A route that starts from `file:<folder>` is fed with files: each message is
written as a file into the folder the route
+polls, named so the route takes it: `chaos-<time>.json`, numbered as
`chaos-<time>-<n>.json` when the messages are
+repeated, with the extension of its `include` or `antInclude` filter, else of
the body. A `CamelFileName` in `--header` names the files instead.
+
A feed to a remote system that is not stubbed, such as a Kafka broker, has a
warning, as the messages go to that system:
use a local one (`camel infra run kafka`), or stub it.
diff --git
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/RepeatedSend.java
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/RepeatedSend.java
index a1956dd53f6d..afe6b54c53a4 100644
---
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/RepeatedSend.java
+++
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/RepeatedSend.java
@@ -51,6 +51,9 @@ public final class RepeatedSend {
public static final int MAX_MESSAGES = 10000;
/** The longest pause between two messages, in millis; camel cmd send
checks it before it asks. */
public static final long MAX_INTERVAL = 60000;
+
+ // the name of the file a file endpoint writes a message to
+ private static final String FILE_NAME = "CamelFileName";
private static final int MAX_ERRORS = 5;
private RepeatedSend() {
@@ -92,6 +95,19 @@ public final class RepeatedSend {
return answer;
}
+ /**
+ * A file name with the number of the message before its extension:
order.json is order-3.json for the third
+ * message, and order is order-3.
+ */
+ static String numbered(String name, long number) {
+ int slash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\'));
+ int dot = name.lastIndexOf('.');
+ if (dot <= slash + 1) {
+ return name + "-" + number;
+ }
+ return name.substring(0, dot) + "-" + number + name.substring(dot);
+ }
+
/** Whether the send action repeats, or sends a folder of messages, rather
than one message. */
static boolean isRepeated(JsonObject root) {
return root.getLongOrDefault("repeat", 0) > 1 ||
folder(root.getString("body")) != null;
@@ -191,6 +207,10 @@ public final class RepeatedSend {
if (message.body() != null) {
args.put("body", message.body());
}
+ // a file endpoint gets one file per message, rather than the same
file again (CAMEL-26795)
+ if (count > 1 && args.get(FILE_NAME) instanceof String name &&
!name.isBlank()) {
+ args.put(FILE_NAME, numbered(name, i + 1));
+ }
JsonObject response = (JsonObject)
console.call(DevConsole.MediaType.JSON, args);
if (endpoint == null) {
endpoint = response.getString("endpoint");
diff --git
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorSendActionTest.java
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorSendActionTest.java
index 40f8a8af5ab3..4c770966cd21 100644
---
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorSendActionTest.java
+++
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorSendActionTest.java
@@ -147,6 +147,30 @@ class LocalCliConnectorSendActionTest {
assertThat(answer.getLong("elapsed")).isGreaterThanOrEqualTo(80);
}
+ @Test
+ void aRepeatedFileNameIsNumbered() throws Exception {
+ // a file endpoint writes one file per message (CAMEL-26795)
+ Path inbox = dir.resolve("inbox");
+ JsonObject request = request("{}");
+ request.put("endpoint", "file:" + inbox);
+ request.put("repeat", 3);
+ JsonObject header = new JsonObject();
+ header.put("key", "CamelFileName");
+ header.put("value", "order.json");
+ request.put("headers", new JsonArray(List.of(header)));
+
+ JsonObject answer = send(request);
+
+ assertThat(answer.getLong("sent")).as(answer.toJson()).isEqualTo(3);
+ try (var files = Files.list(inbox)) {
+ assertThat(files.map(f -> f.getFileName().toString()).sorted())
+ .containsExactly("order-1.json", "order-2.json",
"order-3.json");
+ }
+ assertThat(RepeatedSend.numbered("order", 2)).isEqualTo("order-2");
+ assertThat(RepeatedSend.numbered("in.box/order",
2)).isEqualTo("in.box/order-2");
+ assertThat(RepeatedSend.numbered(".hidden", 2)).isEqualTo(".hidden-2");
+ }
+
@Test
void anIntervalIsATimeOrARange() {
assertThat(RepeatedSend.interval("200")).containsExactly(200, 200);
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExplorePlaybook.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExplorePlaybook.java
index 7570f58137a6..d548793c77a8 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExplorePlaybook.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExplorePlaybook.java
@@ -91,8 +91,8 @@ public final class ChaosExplorePlaybook {
only when the route feeds itself (kind self). A route of kind
http (platform-http, rest, or an HTTP server of its own) is fed \
with HTTP requests to the server of the integration: give the
id of the route as feed, or the path \
to call when its path has a parameter (such as POST
/orders/42); the traffic counts the status codes \
- the clients got. Routes fed from files cannot be fed by an
experiment yet. An experiment refuses a \
- feed that cannot reach its route, with the reason and \
+ the clients got. A route of kind file is fed with its id too:
each message is written as a file the \
+ route takes. An experiment refuses a feed that cannot reach
its route, with the reason and \
what to do instead, and warns when the feed goes to a real
system that is not stubbed
- a rule at=dlq fails the dead letter channel, so it fires
only when a message goes there: use \
the combined suggestions, which add the fault that sends
messages there too. A message the \
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRun.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRun.java
index 8eef718c9a2d..f7e9b75a7bdd 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRun.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRun.java
@@ -16,8 +16,11 @@
*/
package org.apache.camel.dsl.jbang.core.common;
+import java.io.File;
import java.io.IOException;
import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLDecoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@@ -25,6 +28,7 @@ import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.StringJoiner;
import java.util.function.Consumer;
@@ -35,6 +39,7 @@ import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
import org.apache.camel.tooling.model.ComponentModel;
import org.apache.camel.util.TimeUtils;
+import org.apache.camel.util.URISupport;
import org.apache.camel.util.json.JsonObject;
/**
@@ -180,7 +185,17 @@ public final class ChaosExperimentRun {
}
// a route that serves HTTP is fed with requests to the server of
the integration
List<Object> routes = status != null ?
status.getCollection("routes") : null;
- http = ChaosHttpFeed.target(status, routes != null ? route(routes,
feed.endpoint()) : null, feed.endpoint());
+ JsonObject route = routes != null &&
!feed.endpoint().endsWith("*") ? route(routes, feed.endpoint()) : null;
+ http = ChaosHttpFeed.target(status, route, feed.endpoint());
+ // a file route reads the files its filter takes: the messages are
written as such files
+ String from = route != null ? route.getString("from") : null;
+ String name = from != null && from.startsWith("file:") ?
fileName(from, feed, clock.now()) : null;
+ if (name != null) {
+ feed = new Feed(
+ feed.endpoint(), feed.body(),
+ (feed.headers() != null ? feed.headers() + "\n" : "")
+ "CamelFileName=" + name, feed.repeat(),
+ feed.interval());
+ }
}
// an expectation such as failed<=0 is decided when its within= is up:
a shorter wait leaves it undecided
long within = longestWithin(rules);
@@ -262,6 +277,67 @@ public final class ChaosExperimentRun {
return answer;
}
+ // the extension in an include filter (.*\.json, .*\.(csv|txt)) or an
antInclude one (*.json)
+ private static final Pattern INCLUDE_EXT =
Pattern.compile("\\\\\\.\\(?([A-Za-z0-9]+)");
+ private static final Pattern ANT_EXT =
Pattern.compile("\\*\\.([A-Za-z0-9]+)");
+
+ /**
+ * The name of the files of a feed to a route that starts from
file:<folder> (CAMEL-26795), so the route takes them:
+ * chaos-<time>.<extension>, the extension from its include or antInclude
filter, else from the body. The send
+ * numbers it per message when it repeats. Null when the feed names its
files itself, or the route reads one file
+ * (fileName=), which the send writes as the endpoint has it.
+ */
+ static String fileName(String from, Feed feed, long now) {
+ if (feed.headers() != null &&
feed.headers().toLowerCase(Locale.ROOT).contains("camelfilename")) {
+ return null;
+ }
+ Map<String, Object> options;
+ try {
+ options = URISupport.parseQuery(URISupport.extractQuery(from));
+ } catch (URISyntaxException e) {
+ options = Map.of();
+ }
+ if (options.containsKey("fileName")) {
+ return null;
+ }
+ String ext = null;
+ if (options.get("include") instanceof String include) {
+ Matcher m = INCLUDE_EXT.matcher(decode(include));
+ ext = m.find() ? m.group(1) : null;
+ }
+ if (ext == null && options.get("antInclude") instanceof String ant) {
+ Matcher m = ANT_EXT.matcher(decode(ant));
+ ext = m.find() ? m.group(1) : null;
+ }
+ if (ext == null) {
+ ext = extension(feed.body());
+ }
+ return "chaos-" + now + "." + ext;
+ }
+
+ // an option of the endpoint URI as the status has it, %5C for a
backslash; a + stays a +, as in a regular expression
+ private static String decode(String value) {
+ try {
+ return URLDecoder.decode(value.replace("+", "%2B"),
StandardCharsets.UTF_8);
+ } catch (IllegalArgumentException e) {
+ return value;
+ }
+ }
+
+ // the extension of a body: of the file it names, else json, xml or txt by
its first character
+ private static String extension(String body) {
+ if (body == null) {
+ return "txt";
+ }
+ if (body.startsWith("file:")) {
+ String name = new File(body.substring(5)).getName();
+ int dot = name.lastIndexOf('.');
+ return dot > 0 && dot < name.length() - 1 ? name.substring(dot +
1) : "txt";
+ }
+ String b = body.stripLeading();
+ return b.startsWith("{") || b.startsWith("[") ? "json" :
b.startsWith("<") ? "xml" : "txt";
+ }
+
/** The longest within= of the rules, in millis, or 0 (an expectation
without it has the time of its rule). */
static long longestWithin(String rules) {
long answer = 0;
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRunTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRunTest.java
index f8462bfea3fc..bd7a3e007a03 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRunTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRunTest.java
@@ -371,6 +371,44 @@ class ChaosExperimentRunTest {
.hasMessageContaining("the port of the HTTP server of the
integration is not known");
}
+ @Test
+ void theFilesOfAFileFeedPassTheFilterOfTheRoute() {
+ ChaosExperimentRun.Feed json = new ChaosExperimentRun.Feed("invoices",
"{\"n\": 1}", null, 5, null);
+ // the extension of the include or antInclude filter (CAMEL-26795)
+
assertThat(ChaosExperimentRun.fileName("file://invoices?delete=true&include=.*%5C.json",
json, 42))
+ .isEqualTo("chaos-42.json");
+
assertThat(ChaosExperimentRun.fileName("file://in?include=%28%3Fi%29.*%5C.%28csv%7Ctxt%29",
json, 42))
+ .isEqualTo("chaos-42.csv");
+ assertThat(ChaosExperimentRun.fileName("file://in?antInclude=*.xml",
json, 42)).isEqualTo("chaos-42.xml");
+
assertThat(ChaosExperimentRun.fileName("file://invoices?include=.*%5C.json",
+ new ChaosExperimentRun.Feed("invoices", "plain text", null, 0,
null), 42)).isEqualTo("chaos-42.json");
+ // no filter: by the body, or the file it names
+ assertThat(ChaosExperimentRun.fileName("file://in", json,
42)).isEqualTo("chaos-42.json");
+ assertThat(ChaosExperimentRun.fileName("file://in",
+ new ChaosExperimentRun.Feed("in", "<order/>", null, 0, null),
42)).isEqualTo("chaos-42.xml");
+ assertThat(ChaosExperimentRun.fileName("file://in",
+ new ChaosExperimentRun.Feed("in", "file:/feed/order.csv",
null, 0, null), 42)).isEqualTo("chaos-42.csv");
+ // the feed names its files, or the route reads one file
+ assertThat(ChaosExperimentRun.fileName("file://in",
+ new ChaosExperimentRun.Feed("in", "x",
"CamelFileName=mine.txt", 0, null), 42)).isNull();
+
assertThat(ChaosExperimentRun.fileName("file://in?fileName=orders.csv", json,
42)).isNull();
+ }
+
+ @Test
+ void aFileRouteIsFedWithNamedFiles() throws Exception {
+ JsonObject status = statusWith(experiment("PENDING", true));
+ status.put("routes", new JsonArray(
+ List.of(
+ new JsonObject(Map.of("routeId", "invoices", "from",
"file://invoices?include=.*%5C.json")))));
+ integration.statuses.add(status);
+ integration.shows("FAIL", true);
+
+ ChaosExperimentRun.run(integration, null, RULE,
+ new ChaosExperimentRun.Feed("invoices", "{}", "country=DK", 5,
null), 60_000, null, clock);
+
+
assertThat(integration.sent.headers()).isEqualTo("country=DK\nCamelFileName=chaos-1000000.json");
+ }
+
@Test
void aFeedToARealSystemIsWarned() {
JsonObject report = new JsonObject(