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 4209955441d8 CAMEL-26767: camel-jbang - camel cmd send: --repeat,
--interval and a folder of messages, to feed a route (#29459)
4209955441d8 is described below
commit 4209955441d84c4d0bbf35c8b208db0ba2cda874
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Nov 9 14:07:01 2026 +0100
CAMEL-26767: camel-jbang - camel cmd send: --repeat, --interval and a
folder of messages, to feed a route (#29459)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../apache/camel/impl/console/SendDevConsole.java | 12 +-
.../camel/impl/console/SendDevConsoleTest.java | 25 +++
.../modules/ROOT/pages/camel-jbang-devtools.adoc | 32 +++
.../pages/jbang-commands/camel-jbang-cmd-send.adoc | 2 +
.../camel/cli/connector/LocalCliConnector.java | 139 +++++++++---
.../apache/camel/cli/connector/RepeatedSend.java | 240 +++++++++++++++++++++
.../connector/LocalCliConnectorSendActionTest.java | 228 ++++++++++++++++++++
.../META-INF/camel-jbang-commands-metadata.json | 2 +-
.../core/commands/action/CamelSendAction.java | 123 ++++++++++-
.../dsl/jbang/core/commands/ai/ToolContext.java | 6 +-
.../dsl/jbang/core/commands/ai/ToolRegistry.java | 38 +++-
.../camel/dsl/jbang/core/common/RuntimeHelper.java | 43 +++-
.../commands/action/CamelSendActionResultTest.java | 73 +++++++
.../jbang/core/commands/ai/RuntimeToolsTest.java | 11 +
.../dsl/jbang/core/common/RuntimeHelperTest.java | 17 ++
.../src/test/resources/golden/cli-help.txt | 26 ++-
.../dsl/jbang/core/commands/mcp/RuntimeTools.java | 22 +-
.../jbang/core/commands/mcp/RuntimeToolsTest.java | 2 +-
.../core/commands/mcp/RuntimeToolsWiringTest.java | 3 +-
19 files changed, 981 insertions(+), 63 deletions(-)
diff --git
a/core/camel-console/src/main/java/org/apache/camel/impl/console/SendDevConsole.java
b/core/camel-console/src/main/java/org/apache/camel/impl/console/SendDevConsole.java
index b015c30b303f..cc83ced9e0f1 100644
---
a/core/camel-console/src/main/java/org/apache/camel/impl/console/SendDevConsole.java
+++
b/core/camel-console/src/main/java/org/apache/camel/impl/console/SendDevConsole.java
@@ -16,9 +16,8 @@
*/
package org.apache.camel.impl.console;
-import java.io.Closeable;
import java.io.File;
-import java.io.FileInputStream;
+import java.nio.file.Files;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
@@ -39,7 +38,6 @@ import org.apache.camel.support.ExceptionHelper;
import org.apache.camel.support.MessageHelper;
import org.apache.camel.support.console.AbstractDevConsole;
import org.apache.camel.support.service.ServiceHelper;
-import org.apache.camel.util.IOHelper;
import org.apache.camel.util.StopWatch;
import org.apache.camel.util.TimeUtils;
import org.apache.camel.util.json.JsonRecordSupport;
@@ -282,9 +280,6 @@ public class SendDevConsole extends AbstractDevConsole {
"InOut".equals(mep) ? ExchangePattern.InOut :
ExchangePattern.InOnly);
});
}
- if (inputBody instanceof Closeable c) {
- IOHelper.close(c);
- }
}
return out;
}
@@ -339,8 +334,9 @@ public class SendDevConsole extends AbstractDevConsole {
}
Object b = body;
if (body.startsWith("file:")) {
- File file = new File(body.substring(5));
- b = new FileInputStream(file);
+ // the content of the file, not a stream: an endpoint that hands
the message to another thread (seda, a
+ // stub) reads the body after the send returns, when a stream
would be closed already
+ b = Files.readAllBytes(new File(body.substring(5)).toPath());
}
return b;
}
diff --git
a/core/camel-console/src/test/java/org/apache/camel/impl/console/SendDevConsoleTest.java
b/core/camel-console/src/test/java/org/apache/camel/impl/console/SendDevConsoleTest.java
index acbc85970291..331046d4bbda 100644
---
a/core/camel-console/src/test/java/org/apache/camel/impl/console/SendDevConsoleTest.java
+++
b/core/camel-console/src/test/java/org/apache/camel/impl/console/SendDevConsoleTest.java
@@ -17,6 +17,8 @@
package org.apache.camel.impl.console;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
@@ -29,6 +31,7 @@ import org.apache.camel.console.DevConsole;
import org.apache.camel.support.service.ServiceHelper;
import org.apache.camel.util.json.JsonObject;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -41,6 +44,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
*/
public class SendDevConsoleTest extends AbstractDevConsoleTest {
+ @TempDir
+ Path dir;
+
@Override
protected RouteBuilder createRouteBuilder() {
return new RouteBuilder() {
@@ -131,6 +137,25 @@ public class SendDevConsoleTest extends
AbstractDevConsoleTest {
assertEquals("error", jsonOut.getString("status"));
}
+ @Test
+ public void testSendConsoleFileBodyToAQueue() throws Exception {
+ // seda hands the message to another thread, which reads the body
after the send returns
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("seda:refunds").delay(200).to("mock:refunds");
+ }
+ });
+ Path file = Files.writeString(dir.resolve("refund.json"),
"{\"refund\": 1}");
+ MockEndpoint mock = getMockEndpoint("mock:refunds");
+ mock.expectedBodiesReceived("{\"refund\": 1}");
+
+ JsonObject jsonOut = callJson(startedSendConsole(),
createOptions("seda:refunds", "file:" + file));
+
+ assertEquals("success", jsonOut.getString("status"), jsonOut.toJson());
+ mock.assertIsSatisfied();
+ }
+
@Test
public void testSendConsoleGettersSetters() {
SendDevConsole console = new SendDevConsole();
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 6cb4838dbd7b..6ca1d495ee63 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc
@@ -57,6 +57,38 @@ $ camel cmd send mqtt --body=file:payload.json
--endpoint=mqtt
TIP: Run `camel cmd send --help` for all options. Full example on
https://github.com/apache/camel-jbang-examples/tree/main/mqtt[GitHub].
+=== Sending many messages
+
+*Available since Camel 4.24*
+
+`--repeat` sends the message that many times, with `--interval` between two
(in millis, or a duration such as
+`200ms`), and prints a summary instead of each reply. It exits with `1` when a
message failed. A range such as
+`--interval=100..500` (or `100ms..2s`, as the `duration=` of a chaos rule)
draws each pause at random, so the messages
+do not come at a steady pace:
+
+[source,bash]
+----
+$ camel cmd send shop --endpoint=kafka:refunds --body=file:refund.json
--repeat=50 --interval=200ms
+2026-11-09 12:01:14.101 58473 --- kafka://refunds :
Sent 50 messages (48 ok, 2 failed) (10s)
+ 2 x IllegalStateException: Refund amount is missing
+----
+
+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:
+
+[source,bash]
+----
+$ ls test/chaos/refunds-fails
+001.json 001.properties 002.json 003.json
+$ camel cmd send shop --endpoint=kafka:refunds
--body=file:test/chaos/refunds-fails/ --interval=100ms
+----
+
+The integration reads the files, as it reads the file of a single body, and
sends the messages on a thread of its own,
+so `camel get` and the other commands keep working meanwhile. At most 10000
messages are sent at a time, with at most
+60 seconds between two. A broker consumer stubbed with `camel run
--stub=remote` is fed this way, which is how chaos
+rules can be tried on it (see
xref:next@components:others:chaos.adoc[camel-chaos]).
+
=== Sending without a running integration
*Available since Camel 4.12*
diff --git
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-send.adoc
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-send.adoc
index 18b33ecf00e9..c111a03b94d8 100644
---
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-send.adoc
+++
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-send.adoc
@@ -25,11 +25,13 @@ camel cmd send [options]
| `--fresh` | Make sure we use fresh (i.e. non-cached) resources | false |
boolean
| `--header` | Message header (key=value) | | List
| `--infra` | Send to infrastructure service (e.g., nats, kafka) | | String
+| `--interval` | The pause between two messages with --repeat or a folder
body, in millis or as a duration such as 200ms or 1s, or a range such as
100..500 or 100ms..2s to draw each pause at random (at most 60s) | | String
| `--logging-color` | Use colored logging | true | boolean
| `--poll` | Poll instead of sending a message. This can be used to receive
latest message from a Kafka topic or JMS queue. | | boolean
| `--pretty` | Pretty print response message body (InOut) when using JSon or
XML format | | boolean
| `--prop,--property` | Additional properties; override existing (only
applicable when NOT using an existing running Camel) | | String[]
| `--properties` | comma separated list of properties file (only applicable
when NOT using an existing running Camel) (ex.
/path/to/file.properties,/path/to/other.properties | | String
+| `--repeat` | Send the message this many times (going round the files of a
folder body), and print a summary instead of each reply (at most 10000) | | int
| `--reply` | Whether to expect a reply message (InOut vs InOut messaging
style) | | boolean
| `--reply-file` | Saves reply message to the file with the given name
(override if exists) | | String
| `--repo,--repos` | Additional maven repositories for download on-demand (Use
commas to separate multiple repositories) | | String
diff --git
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
index d008e364d619..ef4d34f672d2 100644
---
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
+++
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
@@ -129,6 +129,7 @@ public class LocalCliConnector extends ServiceSupport
private volatile ExecutorService terminateExecutor;
private ExecutorService semanticExecutor;
private ExecutorService auditExecutor;
+ private ExecutorService sendExecutor;
private final AtomicLong semanticConsoleLookup = new
AtomicLong(System.nanoTime() - SEMANTIC_CONSOLE_RETRY_NANOS);
private ProducerTemplate producer;
private ConsumerTemplate consumer;
@@ -244,6 +245,9 @@ public class LocalCliConnector extends ServiceSupport
@Override
public CompletableFuture<Boolean> dispatchAsync(JsonObject root,
CliActionOutput output) {
+ if ("send".equals(root.getString("action")) &&
RepeatedSend.isRepeated(root)) {
+ return repeatedSend(root, output);
+ }
boolean auditQuery = "semantic-audit".equals(root.getString("action"));
if (!auditQuery &&
!"semantic-evaluate".equals(root.getString("action"))) {
return CliActionDispatcher.super.dispatchAsync(root, output);
@@ -292,6 +296,64 @@ public class LocalCliConnector extends ServiceSupport
return result;
}
+ /**
+ * The send action with a repeat or a folder of messages, on a thread of
its own: it may take a while, and the
+ * status and the other actions go on meanwhile. The transport cancels it
when the client gives up the request.
+ */
+ private CompletableFuture<Boolean> repeatedSend(JsonObject root,
CliActionOutput output) {
+ CompletableFuture<Boolean> result = new CompletableFuture<>();
+ FutureTask<Void> task = new FutureTask<>(() -> {
+ try {
+ // capture this request's output; never use the dispatcher's
mutable actionOutput from a worker
+ output.write(repeatedSendResult(root));
+ result.complete(true);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ result.cancel(true);
+ } catch (Exception e) {
+ result.completeExceptionally(e);
+ }
+ return null;
+ });
+ result.whenComplete((value, failure) -> {
+ if (failure != null) {
+ task.cancel(true);
+ }
+ });
+ try {
+ synchronized (this) {
+ if (isStoppingOrStopped()) {
+ throw new IllegalStateException("CLI connector is stopping
or stopped");
+ }
+ if (sendExecutor == null) {
+ sendExecutor = new
ThreadPoolBuilder(camelContext).poolSize(2).maxPoolSize(2).maxQueueSize(0)
+
.rejectedPolicy(ThreadPoolRejectedPolicy.Abort).build("cli-send",
"CliRepeatedSend");
+ }
+ sendExecutor.execute(task);
+ }
+ } catch (RejectedExecutionException busy) {
+ result.completeExceptionally(new IllegalStateException("Busy: two
repeated sends are already running"));
+ } catch (Exception e) {
+ result.completeExceptionally(e);
+ }
+ return result;
+ }
+
+ private JsonObject repeatedSendResult(JsonObject root) throws Exception {
+ DevConsole dc =
camelContext.getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class)
+ .resolveById("send");
+ if (dc == null) {
+ return new JsonObject();
+ }
+ if (root.getBooleanOrDefault("poll", false)) {
+ throw new IllegalArgumentException("A poll does not repeat: send
with repeat, or poll once");
+ }
+ waitUntilStarted();
+ return RepeatedSend.send(dc, sendOptions(root), sendHeaders(root),
root.getString("body"),
+ root.getLongOrDefault("repeat", 0),
root.getLongOrDefault("interval", 0),
+ root.getLongOrDefault("intervalMax", 0));
+ }
+
private ExecutorService newSemanticExecutor(String source, String name)
throws Exception {
return new
ThreadPoolBuilder(camelContext).poolSize(2).maxPoolSize(2).maxQueueSize(0)
.rejectedPolicy(ThreadPoolRejectedPolicy.Abort).build(source,
name);
@@ -667,51 +729,58 @@ public class LocalCliConnector extends ServiceSupport
.resolveById("send");
if (dc != null) {
- // wait for camel to be ready
- StopWatch watch = new StopWatch();
- while (!camelContext.isStarted() && watch.taken() < 5000) {
- Thread.sleep(100);
+ if (RepeatedSend.isRepeated(root)) {
+ // a transport that dispatches on its own thread, rather than
with dispatchAsync
+ writeOutput(repeatedSendResult(root));
+ return;
}
+ waitUntilStarted();
- JsonObject json;
- String endpoint = root.getString("endpoint");
+ final Map<String, Object> args = sendOptions(root);
String body = root.getString("body");
- String exchangePattern = root.getString("exchangePattern");
- String poll = root.getString("poll");
- String pollTimeout = root.getString("pollTimeout");
- final Map<String, Object> args = new LinkedHashMap<>();
- if (endpoint != null) {
- args.put("endpoint", endpoint);
- }
if (body != null) {
args.put("body", body);
}
- String bodyEncoding = root.getString("bodyEncoding");
- if (bodyEncoding != null) {
- args.put("bodyEncoding", bodyEncoding);
- }
- if (exchangePattern != null) {
- args.put("exchangePattern", exchangePattern);
- }
- if (poll != null) {
- args.put("poll", poll);
- }
- if (pollTimeout != null) {
- args.put("pollTimeout", pollTimeout);
- }
- Collection<JsonObject> headers = root.getCollection("headers");
- if (headers != null) {
- for (JsonObject jo : headers) {
- args.put(jo.getString("key"), jo.getString("value"));
- }
- }
- json = (JsonObject) dc.call(DevConsole.MediaType.JSON, args);
+ args.putAll(sendHeaders(root));
+ JsonObject json = (JsonObject) dc.call(DevConsole.MediaType.JSON,
args);
writeOutput(json);
} else {
writeOutput(new JsonObject());
}
}
+ private void waitUntilStarted() throws InterruptedException {
+ // wait for camel to be ready
+ StopWatch watch = new StopWatch();
+ while (!camelContext.isStarted() && watch.taken() < 5000) {
+ Thread.sleep(100);
+ }
+ }
+
+ /** The options of the send console from the send action, without the body
and the headers. */
+ private static Map<String, Object> sendOptions(JsonObject root) {
+ final Map<String, Object> args = new LinkedHashMap<>();
+ for (String key : List.of("endpoint", "bodyEncoding",
"exchangePattern", "poll", "pollTimeout")) {
+ String value = root.getString(key);
+ if (value != null) {
+ args.put(key, value);
+ }
+ }
+ return args;
+ }
+
+ /** The headers of the send action, as the send console takes them. */
+ private static Map<String, Object> sendHeaders(JsonObject root) {
+ final Map<String, Object> args = new LinkedHashMap<>();
+ Collection<JsonObject> headers = root.getCollection("headers");
+ if (headers != null) {
+ for (JsonObject jo : headers) {
+ args.put(jo.getString("key"), jo.getString("value"));
+ }
+ }
+ return args;
+ }
+
private void doActionStubTask(JsonObject root) throws Exception {
String filter = root.getString("filter");
String limit = root.getString("limit");
@@ -2015,6 +2084,10 @@ public class LocalCliConnector extends ServiceSupport
camelContext.getExecutorServiceManager().shutdownNow(semanticExecutor);
semanticExecutor = null;
}
+ if (sendExecutor != null) {
+
camelContext.getExecutorServiceManager().shutdownNow(sendExecutor);
+ sendExecutor = null;
+ }
}
ServiceHelper.stopService(producer, consumer);
}
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
new file mode 100644
index 000000000000..a1956dd53f6d
--- /dev/null
+++
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/RepeatedSend.java
@@ -0,0 +1,240 @@
+/*
+ * 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.cli.connector;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.ThreadLocalRandom;
+
+import org.apache.camel.console.DevConsole;
+import org.apache.camel.util.FileUtil;
+import org.apache.camel.util.TimeUtils;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * The send action with a repeat or a folder of messages ({@code camel cmd
send --repeat=50 --interval=200ms}, and
+ * {@code --body=file:<folder>/}): it sends the messages through the send dev
console one after another, with a pause
+ * between them, and answers with a summary when done (CAMEL-26767). It runs
on a thread of its own, so the status and
+ * the other actions of the connector go on meanwhile, and it stops when the
client gives up the request.
+ * <p/>
+ * The messages of a folder are its files in name order, one message per file,
leaving out hidden files and the
+ * {@code .properties} files: {@code 001.properties} has the headers of {@code
001.json}. The folder is read by the
+ * integration, relative to its working directory, as the file of a single
body is.
+ */
+public final class RepeatedSend {
+
+ /** The most messages one send action sends; camel cmd send checks it
before it asks. */
+ 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;
+ private static final int MAX_ERRORS = 5;
+
+ private RepeatedSend() {
+ }
+
+ /** A message to send: its body, and its own headers (from the properties
file next to it). */
+ record Message(String body, Map<String, Object> headers) {
+ }
+
+ /**
+ * The pause between two messages, as text: a time such as {@code 200},
{@code 200ms} or {@code 1s}, or a range such
+ * as {@code 100..500} or {@code 100ms..2s} (as the {@code duration=} of a
chaos rule), from which each pause is
+ * drawn at random, so the messages do not come at a steady pace.
+ *
+ * @return the shortest and the longest pause in
millis (the same for a time)
+ * @throws IllegalArgumentException when it is not a time or a range, or
longer than {@link #MAX_INTERVAL}
+ */
+ public static long[] interval(String text) {
+ if (text == null || text.isBlank()) {
+ return new long[] { 0, 0 };
+ }
+ String[] parts = text.strip().split("\\.\\.", -1);
+ if (parts.length > 2) {
+ throw new IllegalArgumentException("The interval is not a time or
a range such as 100..500: " + text);
+ }
+ long[] answer = new long[2];
+ try {
+ answer[0] = TimeUtils.toMilliSeconds(parts[0].strip());
+ answer[1] = parts.length == 2 ?
TimeUtils.toMilliSeconds(parts[1].strip()) : answer[0];
+ } catch (RuntimeException e) {
+ throw new IllegalArgumentException("The interval is not a time or
a range such as 100..500: " + text);
+ }
+ if (answer[0] < 0 || answer[1] < answer[0]) {
+ throw new IllegalArgumentException("The interval must go from the
shortest to the longest pause: " + text);
+ }
+ if (answer[1] > MAX_INTERVAL) {
+ throw new IllegalArgumentException("The interval must be at most
60s, was: " + text);
+ }
+ return answer;
+ }
+
+ /** 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;
+ }
+
+ /** The folder a body names ({@code file:<folder>}), or null when it names
none. */
+ static File folder(String body) {
+ if (body == null || !body.startsWith("file:")) {
+ return null;
+ }
+ File file = new File(body.substring(5));
+ return file.isDirectory() ? file : null;
+ }
+
+ /**
+ * The messages to send: the files of the folder the body names, or the
body itself.
+ *
+ * @throws IllegalArgumentException when the folder has no message
+ */
+ static List<Message> messages(String body) throws IOException {
+ File folder = folder(body);
+ if (folder == null) {
+ return List.of(new Message(body, Map.of()));
+ }
+ File[] files = folder.listFiles(f -> f.isFile() &&
!f.getName().startsWith(".")
+ && !f.getName().endsWith(".properties"));
+ if (files == null || files.length == 0) {
+ throw new IllegalArgumentException("No messages in the folder " +
folder);
+ }
+ Arrays.sort(files, Comparator.comparing(File::getName));
+ List<Message> answer = new ArrayList<>();
+ for (File file : files) {
+ answer.add(new Message("file:" + file.getAbsolutePath(),
headers(file)));
+ }
+ return answer;
+ }
+
+ /** The headers of a message of a folder: the properties file with the
same name, such as 001.properties. */
+ private static Map<String, Object> headers(File file) throws IOException {
+ File props = new File(file.getParentFile(),
FileUtil.stripExt(file.getName()) + ".properties");
+ if (!props.isFile()) {
+ return Map.of();
+ }
+ Properties properties = new Properties();
+ try (InputStream is = Files.newInputStream(props.toPath())) {
+ properties.load(is);
+ }
+ Map<String, Object> answer = new LinkedHashMap<>();
+ properties.stringPropertyNames().stream().sorted().forEach(k ->
answer.put(k, properties.getProperty(k)));
+ return answer;
+ }
+
+ /**
+ * Sends the messages and sums up how it went.
+ *
+ * @param console the send dev console
+ * @param options the options of the send console without
the body and headers (endpoint, exchange
+ * pattern and so on)
+ * @param headers the headers of every message; the headers
of a message of a folder win
+ * @param body the body: a message, a file, or a folder
of messages
+ * @param repeat how many messages to send in all, going
round the messages again; 0 or less to send
+ * each message once
+ * @param interval the pause between two messages, in millis,
or the shortest pause of a range
+ * @param intervalMax the longest pause of a range, from which
each pause is drawn at random; the interval
+ * or less for the same pause each time
+ * @throws InterruptedException when the client gave up the request
+ */
+ static JsonObject send(
+ DevConsole console, Map<String, Object> options, Map<String,
Object> headers, String body, long repeat,
+ long interval, long intervalMax)
+ throws IOException, InterruptedException {
+ if (repeat > MAX_MESSAGES) {
+ throw new IllegalArgumentException("At most " + MAX_MESSAGES + "
messages can be sent at a time, not " + repeat);
+ }
+ long longest = Math.max(interval, intervalMax);
+ if (interval < 0 || longest > MAX_INTERVAL) {
+ throw new IllegalArgumentException("The interval must be between 0
and " + MAX_INTERVAL + " millis");
+ }
+ List<Message> messages = messages(body);
+ long count = repeat > 0 ? repeat : messages.size();
+ long start = System.currentTimeMillis();
+ String endpoint = null;
+ long sent = 0;
+ long failed = 0;
+ Map<String, Long> errors = new LinkedHashMap<>();
+ for (long i = 0; i < count; i++) {
+ if (i > 0 && longest > 0) {
+ Thread.sleep(longest > interval ?
ThreadLocalRandom.current().nextLong(interval, longest + 1) : interval);
+ }
+ if (Thread.currentThread().isInterrupted()) {
+ throw new InterruptedException("The client gave up the
request");
+ }
+ Message message = messages.get((int) (i % messages.size()));
+ Map<String, Object> args = new LinkedHashMap<>(options);
+ args.putAll(headers);
+ args.putAll(message.headers());
+ if (message.body() != null) {
+ args.put("body", message.body());
+ }
+ JsonObject response = (JsonObject)
console.call(DevConsole.MediaType.JSON, args);
+ if (endpoint == null) {
+ endpoint = response.getString("endpoint");
+ }
+ sent++;
+ if ("success".equals(response.getString("status"))) {
+ continue;
+ }
+ failed++;
+ JsonObject exception = response.getMap("exception");
+ String type = exception != null ? exception.getString("type") :
null;
+ String error = exception != null
+ ? simpleName(type) + ": " + exception.getString("message")
+ : response.getString("status");
+ if (errors.containsKey(error) || errors.size() < MAX_ERRORS) {
+ errors.merge(error, 1L, Long::sum);
+ }
+ if (type != null && type.endsWith("NoSuchEndpointException")) {
+ // nothing will reach an endpoint that is not there
+ break;
+ }
+ }
+ JsonObject answer = new JsonObject();
+ answer.put("timestamp", start);
+ answer.put("status", failed > 0 ? "failed" : "success");
+ answer.put("elapsed", System.currentTimeMillis() - start);
+ if (endpoint != null) {
+ answer.put("endpoint", endpoint);
+ }
+ answer.put("messages", count);
+ answer.put("sent", sent);
+ answer.put("failed", failed);
+ JsonArray list = new JsonArray();
+ errors.forEach((message, n) -> {
+ JsonObject jo = new JsonObject();
+ jo.put("message", message);
+ jo.put("count", n);
+ list.add(jo);
+ });
+ answer.put("errors", list);
+ return answer;
+ }
+
+ private static String simpleName(String type) {
+ return type != null && type.lastIndexOf('.') >= 0 ?
type.substring(type.lastIndexOf('.') + 1) : type;
+ }
+}
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
new file mode 100644
index 000000000000..3bdb264a73c8
--- /dev/null
+++
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorSendActionTest.java
@@ -0,0 +1,228 @@
+/*
+ * 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.cli.connector;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.spi.CliConnectorFactory;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * The send action with a repeat or a folder of messages (camel cmd send
--repeat --interval, CAMEL-26767): it runs on a
+ * thread of its own and answers with a summary.
+ */
+class LocalCliConnectorSendActionTest {
+
+ @TempDir
+ Path dir;
+
+ private final List<String> received = new CopyOnWriteArrayList<>();
+ private CamelContext context;
+ private LocalCliConnector connector;
+
+ @BeforeEach
+ void start() throws Exception {
+ context = new DefaultCamelContext();
+ // the action is dispatched here: a connector of the context would
poll the {pid} files of other tests
+ var disabled = new DefaultCliConnectorFactory();
+ disabled.setEnabled(false);
+
context.getCamelContextExtension().addContextPlugin(CliConnectorFactory.class,
disabled);
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:refunds").routeId("refunds")
+ .process(e ->
received.add(e.getMessage().getBody(String.class) + " "
+ +
e.getMessage().getHeader("country", "-", String.class)))
+ .filter(body().contains("bad")).throwException(new
IllegalStateException("bad refund"));
+ }
+ });
+ context.start();
+ connector = new LocalCliConnector(new DefaultCliConnectorFactory());
+ connector.setCamelContext(context);
+ connector.start();
+ }
+
+ @AfterEach
+ void stop() {
+ connector.stop();
+ context.stop();
+ }
+
+ private JsonObject send(JsonObject request) throws Exception {
+ AtomicReference<JsonObject> answer = new AtomicReference<>();
+ assertThat(connector.dispatchAsync(request, answer::set).get(20,
TimeUnit.SECONDS)).isTrue();
+ return answer.get();
+ }
+
+ private static JsonObject request(String body) {
+ JsonObject request = new JsonObject();
+ request.put("action", "send");
+ request.put("endpoint", "direct:refunds");
+ request.put("exchangePattern", "InOnly");
+ request.put("body", body);
+ return request;
+ }
+
+ @Test
+ void theSameMessageAgainWithAPause() throws Exception {
+ JsonObject request = request("R-1");
+ request.put("repeat", 5);
+ request.put("interval", 20);
+
+ JsonObject answer = send(request);
+
+
assertThat(answer.getString("status")).as(answer.toJson()).isEqualTo("success");
+ assertThat(answer.getLong("messages")).isEqualTo(5);
+ assertThat(answer.getLong("sent")).isEqualTo(5);
+ assertThat(answer.getLong("failed")).isZero();
+ assertThat(answer.getString("endpoint")).isEqualTo("direct://refunds");
+ // four pauses of 20 millis
+ assertThat(answer.getLong("elapsed")).isGreaterThanOrEqualTo(80);
+ assertThat(received).hasSize(5).containsOnly("R-1 -");
+ }
+
+ @Test
+ void aPauseDrawnFromARange() throws Exception {
+ JsonObject request = request("R-1");
+ request.put("repeat", 5);
+ request.put("interval", 20);
+ request.put("intervalMax", 60);
+
+ JsonObject answer = send(request);
+
+ assertThat(answer.getLong("sent")).isEqualTo(5);
+ // four pauses of 20 to 60 millis
+ assertThat(answer.getLong("elapsed")).isGreaterThanOrEqualTo(80);
+ }
+
+ @Test
+ void anIntervalIsATimeOrARange() {
+ assertThat(RepeatedSend.interval("200")).containsExactly(200, 200);
+ assertThat(RepeatedSend.interval("200ms")).containsExactly(200, 200);
+ assertThat(RepeatedSend.interval("100..500")).containsExactly(100,
500);
+ assertThat(RepeatedSend.interval("100ms..2s")).containsExactly(100,
2000);
+ assertThat(RepeatedSend.interval(null)).containsExactly(0, 0);
+ assertThatThrownBy(() ->
RepeatedSend.interval("500..100")).hasMessageContaining("from the shortest to
the longest");
+ assertThatThrownBy(() ->
RepeatedSend.interval("1s..2m")).hasMessageContaining("at most 60s");
+ assertThatThrownBy(() ->
RepeatedSend.interval("soon")).hasMessageContaining("not a time or a range");
+ assertThatThrownBy(() ->
RepeatedSend.interval("1..2..3")).hasMessageContaining("not a time or a range");
+ }
+
+ @Test
+ void theFilesOfAFolderInNameOrderWithTheirHeaders() throws Exception {
+ Path feed = Files.createDirectories(dir.resolve("refunds-fails"));
+ Files.writeString(feed.resolve("002.json"), "R-2");
+ Files.writeString(feed.resolve("001.json"), "R-1");
+ Files.writeString(feed.resolve("001.properties"), "country=ES\n");
+ Files.writeString(feed.resolve(".hidden"), "not a message");
+
+ JsonObject answer = send(request("file:" + feed));
+
+ assertThat(answer.getLong("sent")).as(answer.toJson()).isEqualTo(2);
+ assertThat(received).containsExactly("R-1 ES", "R-2 -");
+
+ // a repeat goes round the files again, and a header of the action
goes with every message
+ received.clear();
+ JsonObject request = request("file:" + feed);
+ request.put("repeat", 5);
+ JsonObject header = new JsonObject();
+ header.put("key", "country");
+ header.put("value", "DK");
+ request.put("headers", new JsonArray(List.of(header)));
+ answer = send(request);
+ assertThat(answer.getLong("sent")).isEqualTo(5);
+ assertThat(received).containsExactly("R-1 ES", "R-2 DK", "R-1 ES",
"R-2 DK", "R-1 ES");
+ }
+
+ @Test
+ void theFailuresAreCountedWithTheirErrors() throws Exception {
+ Path feed = Files.createDirectories(dir.resolve("mixed"));
+ Files.writeString(feed.resolve("1.txt"), "good");
+ Files.writeString(feed.resolve("2.txt"), "bad");
+ JsonObject request = request("file:" + feed);
+ request.put("repeat", 4);
+
+ JsonObject answer = send(request);
+
+ assertThat(answer.getString("status")).isEqualTo("failed");
+ assertThat(answer.getLong("sent")).isEqualTo(4);
+ assertThat(answer.getLong("failed")).isEqualTo(2);
+ JsonObject error = (JsonObject)
answer.getCollection("errors").iterator().next();
+
assertThat(error.getString("message")).isEqualTo("IllegalStateException: bad
refund");
+ assertThat(error.getLong("count")).isEqualTo(2);
+ }
+
+ @Test
+ void anEndpointThatIsNotThereStopsAtTheFirstMessage() throws Exception {
+ JsonObject request = request("R-1");
+ request.put("endpoint", "nosuchroute");
+ request.put("repeat", 10);
+
+ JsonObject answer = send(request);
+
+ assertThat(answer.getLong("sent")).isEqualTo(1);
+ assertThat(answer.getLong("failed")).isEqualTo(1);
+ }
+
+ @Test
+ void whatCannotBeSent() throws Exception {
+ JsonObject poll = request("R-1");
+ poll.put("repeat", 3);
+ poll.put("poll", true);
+ assertThatThrownBy(() ->
send(poll)).isInstanceOf(ExecutionException.class)
+ .hasMessageContaining("A poll does not repeat");
+
+ JsonObject many = request("R-1");
+ many.put("repeat", RepeatedSend.MAX_MESSAGES + 1);
+ assertThatThrownBy(() -> send(many)).hasMessageContaining("At most
10000 messages");
+
+ Files.createDirectories(dir.resolve("empty"));
+ assertThatThrownBy(() -> send(request("file:" +
dir.resolve("empty")))).hasMessageContaining("No messages");
+ assertThat(received).isEmpty();
+ }
+
+ @Test
+ void oneMessageIsAPlainSend() throws Exception {
+ JsonObject request = request("R-1");
+ request.put("repeat", 1);
+ assertThat(RepeatedSend.isRepeated(request)).isFalse();
+
+ JsonObject answer = send(request);
+
+ // the answer of a single send, with no summary
+ assertThat(answer.getString("status")).isEqualTo("success");
+ assertThat(answer.containsKey("sent")).isFalse();
+ assertThat(received).containsExactly("R-1 -");
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
index e58c1560d2bc..d04b44c65d5d 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
+++
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
@@ -3,7 +3,7 @@
{ "name": "ask", "fullName": "ask", "description": "Ask a question about a
running Camel application using AI", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names":
"--api-key", "description": "API key. Also reads ANTHROPIC_API_KEY,
OPENAI_API_KEY, WATSONX_APIKEY, or LLM_API_KEY env vars", "javaType":
"java.lang.String", "type": "string" }, { "names": "--api-type", "description":
"API type: 'ollama', 'openai', 'anthropic', or 'watsonx'", "javaType": [...]
{ "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind
source and sink Kamelets as a new Camel integration", "deprecated": true,
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options":
[ { "names": "--error-handler", "description": "Add error handler
(none|log|sink:<endpoint>). Sink endpoints are expected in the format
\"[[apigroup\/]version:]kind:[namespace\/]name\", plain Camel URIs or Kamelet
name.", "javaType": "java.lang.String", "type": " [...]
{ "name": "catalog", "fullName": "catalog", "description": "List artifacts
from Camel Catalog", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [
{ "names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"component", "fullName": "catalog component", "description": "List components
from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...]
- { "name": "cmd", "fullName": "cmd", "description": "Performs commands in
the running Camel integrations, such as start\/stop route, or change logging
levels.", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"browse", "fullName": "cmd browse", "description": "Browse pending messages on
endpoints [...]
+ { "name": "cmd", "fullName": "cmd", "description": "Performs commands in
the running Camel integrations, such as start\/stop route, or change logging
levels.", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"browse", "fullName": "cmd browse", "description": "Browse pending messages on
endpoints [...]
{ "name": "completion", "fullName": "completion", "description": "Generate
completion script for bash\/zsh", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.Complete", "options": [ { "names":
"-h,--help", "description": "Display the help and sub-commands", "javaType":
"boolean", "type": "boolean" } ] },
{ "name": "config", "fullName": "config", "description": "Get and set user
configuration values", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.config.ConfigCommand", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get",
"fullName": "config get", "description": "Display user configuration value",
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.config. [...]
{ "name": "debug", "fullName": "debug", "description": "Debug local Camel
integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Debug",
"options": [ { "names": "--ago", "description": "Use ago instead of yyyy-MM-dd
HH:mm:ss in timestamp.", "javaType": "boolean", "type": "boolean" }, { "names":
"--background", "description": "Run in the background", "defaultValue":
"false", "javaType": "boolean", "type": "boolean" }, { "names":
"--background-wait", "description": "How [...]
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSendAction.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSendAction.java
index 9a6341172609..e61e4aac412d 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSendAction.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSendAction.java
@@ -22,6 +22,7 @@ import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
+import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -36,6 +37,7 @@ import java.util.stream.Stream;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.cli.connector.RepeatedSend;
import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
import org.apache.camel.dsl.jbang.core.commands.MavenResolverMixin;
import org.apache.camel.dsl.jbang.core.commands.Run;
@@ -60,9 +62,17 @@ import picocli.CommandLine;
description = "Send messages to endpoints", sortOptions =
false,
showDefaultValues = true,
footer = {
+ "%nWith --repeat the message is sent that many
times, with --interval between two",
+ "(a range such as 100..500 draws each pause at
random), and a summary is printed",
+ "instead of each reply. A body of file:<folder>
sends the",
+ "files of the folder in name order, one message
per file; 001.properties has the",
+ "headers of 001.json. With --repeat it goes round
the files again.",
"%nExamples:",
" camel cmd send --endpoint=kafka:myTopic
--body='Hello World'",
- " camel cmd send hello --endpoint=direct:foo
--body='Hello'" })
+ " camel cmd send hello --endpoint=direct:foo
--body='Hello'",
+ " camel cmd send --endpoint=kafka:refunds
--body=file:refund.json --repeat=50 --interval=200ms",
+ " camel cmd send --endpoint=kafka:refunds
--body=file:refund.json --repeat=50 --interval=100..500",
+ " camel cmd send --endpoint=kafka:refunds
--body=file:test/chaos/refunds-fails/" })
public class CamelSendAction extends ActionBaseCommand {
@CommandLine.Parameters(description = "To use an existing running Camel
integration for sending the message (name or pid)",
@@ -103,6 +113,21 @@ public class CamelSendAction extends ActionBaseCommand {
description = "Message header (key=value)")
List<String> headers;
+ @CommandLine.Option(names = { "--repeat" },
+ description = "Send the message this many times (going
round the files of a folder body), and"
+ + " print a summary instead of each
reply (at most 10000)")
+ int repeat;
+
+ @CommandLine.Option(names = { "--interval" },
+ description = "The pause between two messages with
--repeat or a folder body, in millis or as a"
+ + " duration such as 200ms or 1s, or a
range such as 100..500 or 100ms..2s to draw"
+ + " each pause at random (at most 60s)")
+ String interval;
+
+ // the shortest and the longest pause of --interval, in millis
+ private long intervalMin;
+ private long intervalMax;
+
@CommandLine.Option(names = { "--timeout" }, defaultValue = "20000",
converter = TimeoutConverter.class,
description = "Timeout waiting for message to be sent
(and reply message if InOut messaging),"
+ " in millis or as a duration such as
30s")
@@ -147,6 +172,9 @@ public class CamelSendAction extends ActionBaseCommand {
private MessageTableHelper tableHelper;
+ // the limit of the integration, so the CLI refuses what the integration
would
+ private static final int MAX_MESSAGES = RepeatedSend.MAX_MESSAGES;
+
private final CamelCatalog catalog = new DefaultCamelCatalog();
public CamelSendAction(CamelJBangMain main) {
@@ -166,6 +194,22 @@ public class CamelSendAction extends ActionBaseCommand {
if (timeout < 5000) {
timeout = 5000;
}
+ if (repeat < 0 || repeat > MAX_MESSAGES) {
+ throw CliException.usage("--repeat must be between 1 and " +
MAX_MESSAGES + ", was: " + repeat);
+ }
+ try {
+ long[] pause = RepeatedSend.interval(interval);
+ intervalMin = pause[0];
+ intervalMax = pause[1];
+ } catch (IllegalArgumentException e) {
+ throw CliException.usage(e.getMessage().replace("The interval",
"--interval"));
+ }
+ if (isRepeated() && poll) {
+ throw CliException.usage("A poll does not repeat: use --repeat or
a folder body to send, or --poll alone");
+ }
+ if (isRepeated() && replyFile != null) {
+ throw CliException.usage("--reply-file saves one reply: it cannot
be used with --repeat or a folder body");
+ }
if (infra != null) {
return doCallInfra(infra);
@@ -186,16 +230,25 @@ public class CamelSendAction extends ActionBaseCommand {
String mep = (reply || replyFile != null) ? "InOut" : "InOnly";
root.put("exchangePattern", mep);
if (body != null) {
- // favour using absolute path to file as the send command can be
called from another
+ // favour using absolute path to file (or folder of messages) as
the send command can be called from another
// folder than where camel is running
if (body.startsWith("file:")) {
File f = new File(body.substring(5));
- if (f.exists() && f.isFile()) {
+ if (f.exists()) {
body = "file:" + f.getAbsolutePath();
}
}
root.put("body", body);
}
+ if (repeat > 0) {
+ root.put("repeat", repeat);
+ }
+ if (intervalMax > 0) {
+ root.put("interval", intervalMin);
+ if (intervalMax > intervalMin) {
+ root.put("intervalMax", intervalMax);
+ }
+ }
if (headers != null) {
JsonArray arr = new JsonArray();
for (String h : headers) {
@@ -213,7 +266,39 @@ public class CamelSendAction extends ActionBaseCommand {
this.pid = process.pid();
// wait longer than the timeout of sending
- return showStatus(ActionClient.execute(process, "send",
this::sendOptions, timeout + 10000));
+ return showStatus(ActionClient.execute(process, "send",
this::sendOptions, waitMillis()));
+ }
+
+ /** Whether more than one message is sent: a repeat, or the files of a
folder. */
+ private boolean isRepeated() {
+ return repeat > 1 || folder() != null;
+ }
+
+ /** The folder of messages the body names (file:<folder>), when it is
here. */
+ private File folder() {
+ if (body == null || !body.startsWith("file:")) {
+ return null;
+ }
+ File f = new File(body.substring(5));
+ return f.isDirectory() ? f : null;
+ }
+
+ /**
+ * How long to wait for the answer: longer than the timeout of sending,
and for more messages the pauses between
+ * them and a second for each.
+ */
+ long waitMillis() {
+ long wait = timeout + 10000;
+ if (isRepeated()) {
+ long count = repeat;
+ File folder = folder();
+ if (count <= 1 && folder != null) {
+ String[] files = folder.list((d, n) -> !n.startsWith(".") &&
!n.endsWith(".properties"));
+ count = files != null ? files.length : 1;
+ }
+ wait += count * (intervalMax + 1000);
+ }
+ return wait;
}
/**
@@ -229,6 +314,9 @@ public class CamelSendAction extends ActionBaseCommand {
throw new CliException(result.exitCode(), "Send timeout: " +
result.error());
}
printStatusLine(jo);
+ if (jo.containsKey("sent")) {
+ return showSummary(jo);
+ }
String exchangeId = jo.getString("exchangeId");
JsonObject message = jo.getMap("message");
JsonObject cause = jo.getMap("exception");
@@ -284,6 +372,17 @@ public class CamelSendAction extends ActionBaseCommand {
return CliExitCode.OK;
}
+ /** The summary of a repeat or a folder of messages: the errors, as the
status line has the counts. */
+ private int showSummary(JsonObject jo) {
+ Collection<JsonObject> errors = jo.getCollection("errors");
+ if (errors != null) {
+ for (JsonObject error : errors) {
+ printer().println(" " + error.getLong("count") + " x " +
error.getString("message"));
+ }
+ }
+ return "success".equals(jo.getString("status")) ? CliExitCode.OK :
CliExitCode.FAILURE;
+ }
+
private Integer doCallLocal() throws Exception {
AtomicReference<KameletMain> ref = new AtomicReference<>();
Run run = new Run(this.getMain()) {
@@ -310,7 +409,7 @@ public class CamelSendAction extends ActionBaseCommand {
@Override
public void run() {
try {
- status.set(showStatus(ActionClient.execute(pid, request,
timeout + 10000)));
+ status.set(showStatus(ActionClient.execute(pid, request,
waitMillis())));
} catch (CliException e) {
failure.set(e);
} catch (Exception e) {
@@ -329,8 +428,8 @@ public class CamelSendAction extends ActionBaseCommand {
t.start();
Integer exit = run.call();
- if (!latch.await(timeout + 10000, TimeUnit.MILLISECONDS)) {
- throw CliException.timeout("Send timeout: no answer within " +
(timeout + 10000) + " millis");
+ if (!latch.await(waitMillis(), TimeUnit.MILLISECONDS)) {
+ throw CliException.timeout("Send timeout: no answer within " +
waitMillis() + " millis");
}
if (exit != null && exit != 0) {
return exit;
@@ -393,7 +492,15 @@ public class CamelSendAction extends ActionBaseCommand {
boolean reply = r.containsKey("message");
String status;
Ansi.Color c = Ansi.Color.GREEN;
- if (failed) {
+ if (r.containsKey("sent")) {
+ long sent = r.getLongOrDefault("sent", 0);
+ long count = r.getLongOrDefault("failed", 0);
+ status = "Sent " + sent + (sent == 1 ? " message" : " messages")
+ + (count > 0 ? " (" + (sent - count) + " ok, " + count +
" failed)" : " (success)");
+ if (count > 0) {
+ c = Ansi.Color.RED;
+ }
+ } else if (failed) {
status = "Failed (exception)";
c = Ansi.Color.RED;
} else if (replyFile != null) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolContext.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolContext.java
index 754fcc18856d..7d56b3725e23 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolContext.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolContext.java
@@ -378,8 +378,12 @@ public class ToolContext {
}
public JsonObject sendMessage(String endpoint, String body, String
headers) {
+ return sendMessage(endpoint, body, headers, 0, null);
+ }
+
+ public JsonObject sendMessage(String endpoint, String body, String
headers, int repeat, String interval) {
requireProcess();
- return RuntimeHelper.sendMessage(pid, endpoint, body, headers);
+ return RuntimeHelper.sendMessage(pid, endpoint, body, headers, repeat,
interval);
}
public List<RuntimeHelper.ProcessInfo> discoverProcesses() {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
index 6a9f5f96fc6b..476dbf4030cc 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
@@ -27,6 +27,7 @@ import java.util.Objects;
import java.util.stream.Collectors;
import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.cli.connector.RepeatedSend;
import
org.apache.camel.dsl.jbang.core.commands.action.CamelRouteStructureAction;
import org.apache.camel.dsl.jbang.core.common.CliException;
import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
@@ -114,6 +115,19 @@ public final class ToolRegistry {
BY_NAME.put(descriptor.name(), descriptor);
}
+ /** A whole number argument, or the default when it is not given. */
+ private static int intArg(Map<String, String> args, String name, int
defaultValue) {
+ String value = args.get(name);
+ if (value == null || value.isBlank()) {
+ return defaultValue;
+ }
+ try {
+ return Integer.parseInt(value.trim());
+ } catch (NumberFormatException e) {
+ throw new ToolExecutionException(name + " must be a whole number,
was: " + value);
+ }
+ }
+
// ---- Process discovery and selection ----
private static void registerProcessTools() {
@@ -396,12 +410,22 @@ public final class ToolRegistry {
}));
register(tool("send_message",
- "Send a test message to a Camel endpoint in the running
application.")
+ "Send a test message to a Camel endpoint in the running
application. With repeat, or a body of"
+ + " file:<folder> (one message per file,
in name order; 001.properties has the"
+ + " headers of 001.json), it sends
several messages with interval millis between"
+ + " them, and answers with a summary:
sent, failed and the errors.")
.param("endpoint", "string",
"Endpoint URI to send to (e.g., direct:myRoute,
seda:queue)", true)
- .param("body", "string", "Message body to send", false)
+ .param("body", "string", "Message body to send (file:<path>
for a file, file:<folder> for its files)",
+ false)
.param("headers", "string",
"Message headers as key=value pairs separated by
newlines", false)
+ .param("repeat", "string",
+ "How many messages to send in all (at most 10000),
going round the files of a folder", false)
+ .param("interval", "string",
+ "The pause between two messages in millis, or a range
such as 100..500 to draw each pause at"
+ + " random (at most 60000)",
+ false)
// the message runs through the routes and whatever they call,
so it can change data
.readOnly(false).destructive(true)
.core(true)
@@ -410,7 +434,15 @@ public final class ToolRegistry {
if (endpoint == null || endpoint.isBlank()) {
throw new ToolExecutionException("endpoint is
required");
}
- return ctx.sendMessage(endpoint, args.get("body"),
args.get("headers")).toJson();
+ int repeat = intArg(args, "repeat", 0);
+ try {
+ RepeatedSend.interval(args.get("interval"));
+ } catch (IllegalArgumentException e) {
+ // an interval that is not a time or a range
+ throw new ToolExecutionException(e.getMessage());
+ }
+ return ctx.sendMessage(endpoint, args.get("body"),
args.get("headers"), repeat,
+ args.get("interval")).toJson();
}));
register(tool("eval_expression",
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
index 9e6e724c50ea..97056c9d448e 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
@@ -16,6 +16,7 @@
*/
package org.apache.camel.dsl.jbang.core.common;
+import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -23,6 +24,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
+import org.apache.camel.cli.connector.RepeatedSend;
import org.apache.camel.dsl.jbang.run.common.PathUtils;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
@@ -223,7 +225,22 @@ public final class RuntimeHelper {
* @throws IllegalArgumentException when a header is not a key=value pair
*/
public static JsonObject sendMessage(long pid, String endpoint, String
body, String headers) {
+ return sendMessage(pid, endpoint, body, headers, 0, null);
+ }
+
+ /**
+ * Sends a message, or with a repeat or a body of {@code file:<folder>}
several messages, with a pause between them;
+ * the answer is then a summary (sent, failed and the errors) rather than
the reply.
+ *
+ * @param repeat how many messages to send in all, going round the files
of a folder; 0 to send once (each file of
+ * a folder once)
+ * @param interval the pause between two messages: millis, a duration such
as 200ms, or a range such as 100..500 to
+ * draw each pause at random
+ */
+ public static JsonObject sendMessage(
+ long pid, String endpoint, String body, String headers, int
repeat, String interval) {
JsonArray headerList = headers != null ? parseHeaders(headers) : null;
+ long[] pause = RepeatedSend.interval(interval);
String result = executeAction(pid, "send", root -> {
root.put("endpoint", endpoint);
if (body != null) {
@@ -232,7 +249,16 @@ public final class RuntimeHelper {
if (headerList != null) {
root.put("headers", headerList);
}
- });
+ if (repeat > 0) {
+ root.put("repeat", repeat);
+ }
+ if (pause[1] > 0) {
+ root.put("interval", pause[0]);
+ if (pause[1] > pause[0]) {
+ root.put("intervalMax", pause[1]);
+ }
+ }
+ }, sendTimeout(body, repeat, pause[1]));
try {
return (JsonObject) Jsoner.deserialize(result);
} catch (Exception e) {
@@ -242,6 +268,21 @@ public final class RuntimeHelper {
}
}
+ /**
+ * How long to wait for the answer of a send: for more messages also the
pauses between them and a second for each.
+ * The files of a folder are counted when the folder is here (the
integration reads it, relative to its working
+ * directory), else 100 are assumed.
+ */
+ static long sendTimeout(String body, int repeat, long interval) {
+ long count = repeat;
+ if (count <= 1 && body != null && body.startsWith("file:")) {
+ File folder = new File(body.substring(5));
+ String[] files = folder.list((d, n) -> !n.startsWith(".") &&
!n.endsWith(".properties"));
+ count = files != null ? files.length : folder.isFile() ? 1 : 100;
+ }
+ return count <= 1 ? ACTION_TIMEOUT_MS : ACTION_TIMEOUT_MS + count *
(interval + 1000);
+ }
+
/**
* The headers as the send action of the CLI connector reads them: a key
and a value for each header.
*/
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSendActionResultTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSendActionResultTest.java
index d21b7a4dcf9e..3e2ca538658e 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSendActionResultTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSendActionResultTest.java
@@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.action;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.List;
import java.util.Map;
import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
@@ -125,6 +126,78 @@ class CamelSendActionResultTest extends
ActionCommandTestSupport {
assertThat(printer.getStderr()).contains("No endpoint could be found
for: foo:bar");
}
+ @Test
+ void repeatSendsASummary() throws Exception {
+ writeStatusFile(TEST_PID, "orders");
+ Path feed = Files.createDirectories(dir.resolve("refunds-fails"));
+ Files.writeString(feed.resolve("001.json"), "{}");
+ Files.writeString(feed.resolve("001.properties"), "country=ES\n");
+ Files.writeString(feed.resolve("002.json"), "{}");
+ JsonObject summary = new JsonObject();
+ summary.put("timestamp", 1_700_000_000_000L);
+ summary.put("status", "failed");
+ summary.put("elapsed", 1200L);
+ summary.put("endpoint", "kafka://refunds");
+ summary.put("messages", 50L);
+ summary.put("sent", 50L);
+ summary.put("failed", 2L);
+ JsonObject error = new JsonObject();
+ error.put("message", "ChaosException: Chaos error of rule r1");
+ error.put("count", 2L);
+ summary.put("errors", new JsonArray(List.of(error)));
+
+ CamelSendAction command = command("orders",
"--endpoint=kafka:refunds", "--body=file:" + feed, "--repeat=50",
+ "--interval=200ms");
+ int exit = callWithResponse(command, summary);
+
+ assertThat(exit).isEqualTo(CliExitCode.FAILURE);
+ JsonObject action = readActionFile(TEST_PID);
+ assertThat(action.getString("body")).isEqualTo("file:" +
feed.toAbsolutePath());
+ assertThat(action.getLong("repeat")).isEqualTo(50L);
+ assertThat(action.getLong("interval")).isEqualTo(200L);
+ assertThat(printer.getStdout()).contains("kafka://refunds", "Sent 50
messages (48 ok, 2 failed)")
+ .contains("2 x ChaosException: Chaos error of rule r1");
+ // the wait: the timeout, and the pauses and a second for each of the
50 messages
+ assertThat(command.waitMillis()).isEqualTo(20000 + 10000 + 50 * 1200);
+
+ // a folder without a repeat: one message per file, which the wait
counts
+ CamelSendAction folder = command("orders", "--endpoint=kafka:refunds",
"--body=file:" + feed);
+ assertThat(folder.waitMillis()).isEqualTo(20000 + 10000 + 2 * 1000);
+ }
+
+ @Test
+ void whatCannotBeRepeated() {
+ assertThatThrownBy(() -> command("orders", "--endpoint=direct:start",
"--repeat=3", "--poll").doCall())
+ .isInstanceOf(CliException.class).hasMessageContaining("A poll
does not repeat")
+ .extracting(e -> ((CliException)
e).getExitCode()).isEqualTo(CliExitCode.USAGE);
+ assertThatThrownBy(() -> command("orders", "--endpoint=direct:start",
"--repeat=3", "--reply-file=x.json")
+ .doCall()).hasMessageContaining("--reply-file saves one
reply");
+ assertThatThrownBy(() -> command("orders", "--endpoint=direct:start",
"--repeat=10001").doCall())
+ .hasMessageContaining("--repeat must be between 1 and 10000");
+ assertThatThrownBy(() -> command("orders", "--endpoint=direct:start",
"--repeat=3", "--interval=2m").doCall())
+ .hasMessageContaining("--interval must be at most 60s");
+ assertThatThrownBy(() -> command("orders", "--endpoint=direct:start",
"--repeat=3", "--interval=500..100").doCall())
+ .isInstanceOf(CliException.class).hasMessageContaining("from
the shortest to the longest pause");
+ }
+
+ @Test
+ void aRangeDrawsEachPauseAtRandom() throws Exception {
+ writeStatusFile(TEST_PID, "orders");
+ JsonObject summary = new JsonObject(
+ Map.of("status", "success", "sent", 10L, "failed", 0L,
"elapsed", 3000L,
+ "endpoint", "direct://start", "errors", new
JsonArray()));
+
+ CamelSendAction command = command("orders", "--endpoint=direct:start",
"--repeat=10", "--interval=100..500");
+ assertThat(callWithResponse(command,
summary)).isEqualTo(CliExitCode.OK);
+
+ JsonObject action = readActionFile(TEST_PID);
+ assertThat(action.getLong("interval")).isEqualTo(100L);
+ assertThat(action.getLong("intervalMax")).isEqualTo(500L);
+ // the wait counts the longest pause
+ assertThat(command.waitMillis()).isEqualTo(20000 + 10000 + 10 * 1500);
+ assertThat(printer.getStdout()).contains("Sent 10 messages (success)");
+ }
+
@Test
void headerWithoutValueIsAUsageError() {
assertThatThrownBy(() -> command("orders", "--endpoint=direct:start",
"--header=id").doCall())
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RuntimeToolsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RuntimeToolsTest.java
index 0cc151139ba5..faa59ecfc6a9 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RuntimeToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RuntimeToolsTest.java
@@ -214,6 +214,17 @@ class RuntimeToolsTest {
.hasMessage("Header must be in key=value format, was: id");
}
+ @Test
+ void anIntervalThatIsNotATimeOrARangeIsAToolError() {
+ answers.put("send", Map.of("status", "success"));
+
+ assertThatThrownBy(() -> call("send_message", Map.of("endpoint",
"direct:orders", "repeat", "5",
+ "interval", "500..100")))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessageContaining("from the shortest to the longest");
+ assertThat(integration.lastAction()).isNull();
+ }
+
@Test
void aSqlQuery() throws Exception {
answers.put("sql-query", Map.of("columns", List.of("id"), "rows",
List.of()));
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelperTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelperTest.java
index 967152e936cd..02c9481b5b1f 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelperTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelperTest.java
@@ -142,4 +142,21 @@ class RuntimeHelperTest {
assertEquals(0, files.count());
}
}
+
+ @Test
+ void sendTimeoutCountsTheMessages() throws IOException {
+ // one message: the time of an action
+ assertEquals(10_000, RuntimeHelper.sendTimeout("hello", 0, 0));
+ assertEquals(10_000, RuntimeHelper.sendTimeout("hello", 1, 500));
+ // a repeat: the pauses between the messages, and a second for each
+ assertEquals(10_000 + 50 * 1200, RuntimeHelper.sendTimeout("hello",
50, 200));
+ // the files of a folder that is here, leaving out the headers of a
message
+ Path feed = Files.createDirectories(home.resolve("feed"));
+ Files.writeString(feed.resolve("001.json"), "{}");
+ Files.writeString(feed.resolve("001.properties"), "country=ES");
+ Files.writeString(feed.resolve("002.json"), "{}");
+ assertEquals(10_000 + 2 * 1000, RuntimeHelper.sendTimeout("file:" +
feed, 0, 0));
+ // a folder of the integration that is not here: 100 messages assumed
+ assertEquals(10_000 + 100 * 1000, RuntimeHelper.sendTimeout("file:" +
feed.resolve("elsewhere"), 0, 0));
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/resources/golden/cli-help.txt
b/dsl/camel-jbang/camel-jbang-core/src/test/resources/golden/cli-help.txt
index 9f7445566d67..dd3453c3640e 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/test/resources/golden/cli-help.txt
+++ b/dsl/camel-jbang/camel-jbang-core/src/test/resources/golden/cli-help.txt
@@ -1356,9 +1356,9 @@ Usage: camel cmd send [-h] [--fresh] [--poll] [--pretty]
[--reply]
[=<loggingColor>]] [--show-body[=<showBody>]]
[--show-exception[=<showException>]] [--show-headers
[=<showHeaders>]] [--body=<body>] [--infra=<infra>]
- [--properties=<propertiesFiles>]
- [--reply-file=<replyFile>] [--repo=<repos>]
- [--timeout=<timeout>] [--uri=<endpoint>]
+ [--interval=<interval>] [--properties=<propertiesFiles>]
+ [--repeat=<repeat>] [--reply-file=<replyFile>]
+ [--repo=<repos>] [--timeout=<timeout>] [--uri=<endpoint>]
[--header=<headers>]... [--prop=<property>]... [<name>]
Send messages to endpoints
[<name>] To use an existing running Camel integration for
@@ -1387,6 +1387,15 @@ Send messages to endpoints
--body=<body> Message body to send (prefix with file: to refer to
loading message body from file)
--header=<headers> Message header (key=value)
+ --repeat=<repeat> Send the message this many times (going round the
+ files of a folder body), and print a summary
+ instead of each reply (at most 10000)
+ Default: 0
+ --interval=<interval> The pause between two messages with --repeat or a
+ folder body, in millis or as a duration such as
+ 200ms or 1s, or a range such as 100..500 or
+ 100ms..2s to draw each pause at random (at most
+ 60s)
--timeout=<timeout> Timeout waiting for message to be sent (and reply
message if InOut messaging), in millis or as a
duration such as 30s
@@ -1417,9 +1426,20 @@ Send messages to endpoints
repositories)
--fresh Make sure we use fresh (i.e. non-cached) resources
+With --repeat the message is sent that many times, with --interval between two
+(a range such as 100..500 draws each pause at random), and a summary is printed
+instead of each reply. A body of file:<folder> sends the
+files of the folder in name order, one message per file; 001.properties has the
+headers of 001.json. With --repeat it goes round the files again.
+
Examples:
camel cmd send --endpoint=kafka:myTopic --body='Hello World'
camel cmd send hello --endpoint=direct:foo --body='Hello'
+ camel cmd send --endpoint=kafka:refunds --body=file:refund.json --repeat=50
+--interval=200ms
+ camel cmd send --endpoint=kafka:refunds --body=file:refund.json --repeat=50
+--interval=100..500
+ camel cmd send --endpoint=kafka:refunds --body=file:test/chaos/refunds-fails/
== camel cmd span --help
Usage: camel cmd span [-h] [--flat] [--json] [--logging-color[=<loggingColor>]]
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java
index d15661829100..1a13c17f93ed 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java
@@ -533,13 +533,23 @@ public class RuntimeTools {
@ToolGuardrails(input = McpArgumentGuardrail.class, output =
McpArgumentGuardrail.class)
@Tool(annotations = @Tool.Annotations(readOnlyHint = false,
destructiveHint = true, openWorldHint = false),
- description = "Send a test message to a Camel endpoint in the
running application.")
+ description = """
+ Send a test message to a Camel endpoint in the running
application. With repeat, or a body of \
+ file:<folder> (one message per file, in name order;
001.properties has the headers of 001.json), it \
+ sends several messages with interval millis between them,
and answers with a summary: sent, failed \
+ and the errors. A broker consumer stubbed with camel run
--stub=remote is fed this way.""")
public JsonObject camel_runtime_send(
@ToolArg(description = NAME_OR_PID_DESC, required = false) String
nameOrPid,
@ToolArg(description = "Endpoint URI to send to (e.g.,
direct:myRoute, seda:queue)") String endpoint,
- @ToolArg(description = "Message body to send", required = false)
String body,
+ @ToolArg(description = "Message body to send (file:<path> for a
file, file:<folder> for its files)",
+ required = false) String body,
@ToolArg(description = "Message headers as key=value pairs
separated by newlines",
- required = false) String headers) {
+ required = false) String headers,
+ @ToolArg(description = "How many messages to send in all (at most
10000), going round the files of a folder",
+ required = false) Integer repeat,
+ @ToolArg(description = "The pause between two messages in millis,
or a range such as 100..500 to draw"
+ + " each pause at random (at most 60000)",
+ required = false) String interval) {
if (endpoint == null || endpoint.isBlank()) {
throw new ToolCallException("endpoint is required", null);
}
@@ -551,6 +561,12 @@ public class RuntimeTools {
if (headers != null) {
args.put("headers", headers);
}
+ if (repeat != null) {
+ args.put("repeat", String.valueOf(repeat));
+ }
+ if (interval != null) {
+ args.put("interval", interval);
+ }
return delegateToRegistry("send_message", nameOrPid, args);
}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsTest.java
index 4cdeac541d40..af40f151361b 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsTest.java
@@ -134,7 +134,7 @@ class RuntimeToolsTest {
@Test
void sendRequiresEndpoint() {
RuntimeTools tools = createTools();
- assertThatThrownBy(() -> tools.camel_runtime_send(null, null, "body",
null))
+ assertThatThrownBy(() -> tools.camel_runtime_send(null, null, "body",
null, null, null))
.isInstanceOf(ToolCallException.class)
.hasMessageContaining("endpoint is required");
}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsWiringTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsWiringTest.java
index d664bb379cec..76b3d3c9a471 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsWiringTest.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsWiringTest.java
@@ -396,7 +396,8 @@ class RuntimeToolsWiringTest {
void theHintsMatchWhatTheToolsDo() throws Exception {
assertThat(hints("camel_runtime_receive", String.class, String.class,
Integer.class, Integer.class))
.as("receive takes the message from the
routes").containsExactly(false, true);
- assertThat(hints("camel_runtime_send", String.class, String.class,
String.class, String.class))
+ assertThat(hints("camel_runtime_send", String.class, String.class,
String.class, String.class, Integer.class,
+ String.class))
.containsExactly(false, true);
assertThat(hints("camel_runtime_trace_dump",
String.class)).containsExactly(true, false);