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 90560e226036 CAMEL-25390: camel-cli-connector - Always reply to CLI
actions, including errors and acks (#27912)
90560e226036 is described below
commit 90560e226036cd38247ec3c55156429730131462
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 15 22:52:56 2026 +0200
CAMEL-25390: camel-cli-connector - Always reply to CLI actions, including
errors and acks (#27912)
An action with a request id now always gets an answer from the file
transport: its result, {"status":"failed","error":...} when it is unknown,
throws or reports an error (with "reason":"not-found" when what it was asked
to work on does not exist, and a partial result under "result"), or
{"status":"success"} when it has no result. The shared action file of older
Camel CLI versions keeps its behaviour.
Both transports turn an action into an answer the same way
(CliActionOutcome).
Several reported errors are joined, the processor action reports its errors
and "No processor matching", and an action file that cannot be read yet is
retried for two seconds. This also fixes CAMEL-25386: the MCP route tools no
longer report a timeout for a route command that worked.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../apache/camel/catalog/docs/cli-connector.adoc | 34 +-
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 15 +
.../src/main/docs/cli-connector.adoc | 34 +-
.../camel/cli/connector/CliActionOutcome.java | 207 ++++++++++++
.../camel/cli/connector/CliActionOutput.java | 11 +
.../cli/connector/FileCliConnectorTransport.java | 120 +++++--
.../camel/cli/connector/LocalCliConnector.java | 10 +-
.../connector/WebSocketCliConnectorTransport.java | 36 +--
.../camel/cli/connector/CliActionOutcomeTest.java | 171 ++++++++++
.../connector/FileCliConnectorTransportTest.java | 354 +++++++++++++++++++++
.../LocalCliConnectorFileTransportTest.java | 43 ++-
.../WebSocketCliConnectorTransportTest.java | 3 +-
12 files changed, 967 insertions(+), 71 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/cli-connector.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/cli-connector.adoc
index 6ec4916195b0..8dc2e8170fdd 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/cli-connector.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/cli-connector.adoc
@@ -40,6 +40,35 @@ connector. Choose it with the `camel.cli.transport` property:
| `websocket` | Dials out to a developer tool (for example an IDE) over a
WebSocket.
|===
+=== File transport
+
+A tool writes an action to `~/.camel/<pid>-action-<requestId>.json`, and the
connector answers in
+`~/.camel/<pid>-output-<requestId>.json`, then deletes the action file. Every
action gets an answer:
+
+[source,json]
+----
+// the result of the action, such as the routes of a route dump
+{"routes":[...]}
+
+// the action failed: it is unknown, threw an exception, or reported an error
+{"status":"failed","error":"No route matching: route9","reason":"not-found"}
+
+// the action has no result, such as a route stop
+{"status":"success"}
+----
+
+`reason` is `not-found` when what the action was asked to work on does not
exist, such as a route id that matches no
+route. When the action wrote a result before it failed, the answer has it
under `result`. Several problems, such as
+two routes that fail to stop, are joined with `;` in `error`. A result can
also report a failure of its own, such as a
+message sent to a route that throws an exception: it is written as it is.
+
+Write the action file atomically (write a temporary file, then move it), so
the connector never reads half an action.
+An action file that cannot be read yet is retried for two seconds. Deleting
the action file before the answer is
+written cancels the action.
+
+The shared `~/.camel/<pid>-action.json` and `~/.camel/<pid>-output.json` are
still read and written for older Camel
+CLI versions: an action there only gets its result, or the error when it
throws an exception.
+
=== WebSocket transport
The integration connects to the tool, so the tool does not need access to the
filesystem of the integration
@@ -88,13 +117,14 @@ same JSON the file transport reads and writes:
// integration -> tool
{"v":1,"type":"hello","camelVersion":"...","name":"...","transport":"jdk","runtime":{"pid":1234,"platform":"...",...}}
{"v":1,"type":"result","requestId":"42","ok":true,"result":{...}}
-{"v":1,"type":"result","requestId":"43","ok":false,"error":"No route matching:
route9","result":{}}
+{"v":1,"type":"result","requestId":"43","ok":false,"error":"No route matching:
route9","reason":"not-found","result":{}}
{"v":1,"type":"snapshot","kind":"status","data":{...}}
----
* `hello` is sent on every connection, once Camel has started. Actions sent
before it are refused.
* `result` answers each action, with the same `requestId`. `ok` is `false`
when the action is unknown, fails, or
- reports a failure in its result (for example a message sent to a route that
throws an exception).
+ reports a failure in its result (for example a message sent to a route that
throws an exception). `reason` is
+ `not-found` when what the action was asked to work on does not exist.
* `snapshot` kinds are `status`, `trace`, `receive`, `debug`, `history`,
`error` and `activity`. `trace` and
`receive` only hold the messages not sent yet on this connection, split over
several frames of about 128 KB at most
(many WebSocket servers refuse messages over 256 KB by default); `debug`,
`history`, `error` and `activity` are only
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 23ffb50c014e..2cf91c597b2e 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -232,6 +232,21 @@ Errors, warnings and tips are printed on stderr instead of
stdout, so stdout onl
as JSON. If a script reads the error text of a command from stdout, read
stderr instead. The list of plugins that are
not installed, after `camel --help`, is printed on stderr, and only in an
interactive terminal.
+=== camel-cli-connector - every action with a request id gets an answer
+
+An action written to `~/.camel/<pid>-action-<requestId>.json` now always gets
an answer in
+`~/.camel/<pid>-output-<requestId>.json`, so a tool no longer waits for its
timeout:
+
+* an action without a result (such as a route stop) answers
`{"status":"success"}`,
+* an action that is unknown, throws an exception or reports an error answers
+ `{"status":"failed","error":"..."}`, with `"reason":"not-found"` when what
it was asked to work on does not exist
+ (such as `No route matching: route9`, and the new `No processor matching:
...`).
+
+The WebSocket transport adds the same `reason` to its `result` frame. The
shared `~/.camel/<pid>-action.json` used by
+older Camel CLI versions is unchanged. A tool built on the file transport that
relied on actions without a result
+writing nothing, or that waited for a timeout, sees these answers now. The
Camel MCP route tools, for example, now
+report a route stop as done instead of a timeout.
+
=== camel-jpa, camel-hazelcast - an exchange marked rollback only is not
consumed
When a route marks the exchange rollback only (for example with
`markRollbackOnly()` or `markRollbackOnlyLast()`)
diff --git a/dsl/camel-cli-connector/src/main/docs/cli-connector.adoc
b/dsl/camel-cli-connector/src/main/docs/cli-connector.adoc
index 6ec4916195b0..8dc2e8170fdd 100644
--- a/dsl/camel-cli-connector/src/main/docs/cli-connector.adoc
+++ b/dsl/camel-cli-connector/src/main/docs/cli-connector.adoc
@@ -40,6 +40,35 @@ connector. Choose it with the `camel.cli.transport` property:
| `websocket` | Dials out to a developer tool (for example an IDE) over a
WebSocket.
|===
+=== File transport
+
+A tool writes an action to `~/.camel/<pid>-action-<requestId>.json`, and the
connector answers in
+`~/.camel/<pid>-output-<requestId>.json`, then deletes the action file. Every
action gets an answer:
+
+[source,json]
+----
+// the result of the action, such as the routes of a route dump
+{"routes":[...]}
+
+// the action failed: it is unknown, threw an exception, or reported an error
+{"status":"failed","error":"No route matching: route9","reason":"not-found"}
+
+// the action has no result, such as a route stop
+{"status":"success"}
+----
+
+`reason` is `not-found` when what the action was asked to work on does not
exist, such as a route id that matches no
+route. When the action wrote a result before it failed, the answer has it
under `result`. Several problems, such as
+two routes that fail to stop, are joined with `;` in `error`. A result can
also report a failure of its own, such as a
+message sent to a route that throws an exception: it is written as it is.
+
+Write the action file atomically (write a temporary file, then move it), so
the connector never reads half an action.
+An action file that cannot be read yet is retried for two seconds. Deleting
the action file before the answer is
+written cancels the action.
+
+The shared `~/.camel/<pid>-action.json` and `~/.camel/<pid>-output.json` are
still read and written for older Camel
+CLI versions: an action there only gets its result, or the error when it
throws an exception.
+
=== WebSocket transport
The integration connects to the tool, so the tool does not need access to the
filesystem of the integration
@@ -88,13 +117,14 @@ same JSON the file transport reads and writes:
// integration -> tool
{"v":1,"type":"hello","camelVersion":"...","name":"...","transport":"jdk","runtime":{"pid":1234,"platform":"...",...}}
{"v":1,"type":"result","requestId":"42","ok":true,"result":{...}}
-{"v":1,"type":"result","requestId":"43","ok":false,"error":"No route matching:
route9","result":{}}
+{"v":1,"type":"result","requestId":"43","ok":false,"error":"No route matching:
route9","reason":"not-found","result":{}}
{"v":1,"type":"snapshot","kind":"status","data":{...}}
----
* `hello` is sent on every connection, once Camel has started. Actions sent
before it are refused.
* `result` answers each action, with the same `requestId`. `ok` is `false`
when the action is unknown, fails, or
- reports a failure in its result (for example a message sent to a route that
throws an exception).
+ reports a failure in its result (for example a message sent to a route that
throws an exception). `reason` is
+ `not-found` when what the action was asked to work on does not exist.
* `snapshot` kinds are `status`, `trace`, `receive`, `debug`, `history`,
`error` and `activity`. `trace` and
`receive` only hold the messages not sent yet on this connection, split over
several frames of about 128 KB at most
(many WebSocket servers refuse messages over 256 KB by default); `debug`,
`history`, `error` and `activity` are only
diff --git
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/CliActionOutcome.java
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/CliActionOutcome.java
new file mode 100644
index 000000000000..febea69e1f86
--- /dev/null
+++
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/CliActionOutcome.java
@@ -0,0 +1,207 @@
+/*
+ * 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.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * The outcome of an action: its result, and the error when it failed. Both
transports reply with it, so a tool gets the
+ * same answer whether it uses files or a WebSocket.
+ * <p/>
+ * An action failed when it threw an exception, is unknown, reported an error
to {@link CliActionOutput}, or wrote a
+ * result with the status <tt>error</tt> or <tt>failed</tt>.
+ */
+final class CliActionOutcome {
+
+ /**
+ * The reason of an error about something that does not exist, see {@link
CliActionOutput#notFound(String)}.
+ */
+ static final String NOT_FOUND = "not-found";
+
+ private final JsonObject result;
+ private final String error;
+ private final String reason;
+ private final Throwable failure;
+ private final boolean failedResult;
+
+ private CliActionOutcome(JsonObject result, String error, String reason,
Throwable failure, boolean failedResult) {
+ this.result = result;
+ this.error = error;
+ this.reason = reason;
+ this.failure = failure;
+ this.failedResult = failedResult;
+ }
+
+ /**
+ * An action that could not be dispatched, such as an action file that is
not valid JSON.
+ */
+ static CliActionOutcome failed(String error) {
+ return new CliActionOutcome(null, error, null, null, false);
+ }
+
+ /**
+ * Dispatches the action. The future completes with the outcome, and
cancelling it interrupts the action.
+ */
+ static CompletableFuture<CliActionOutcome> dispatch(CliActionDispatcher
dispatcher, JsonObject action) {
+ Collector output = new Collector();
+ CompletableFuture<Boolean> completion;
+ try {
+ completion = dispatcher.dispatchAsync(action, output);
+ } catch (Exception e) {
+ completion = CompletableFuture.failedFuture(e);
+ }
+ CompletableFuture<Boolean> dispatched = completion;
+ CompletableFuture<CliActionOutcome> answer
+ = dispatched.handle((known, failure) -> of(action, output,
known, failure));
+ answer.whenComplete((value, failure) -> {
+ if (answer.isCancelled()) {
+ dispatched.cancel(true);
+ }
+ });
+ return answer;
+ }
+
+ private static CliActionOutcome of(JsonObject action, Collector output,
Boolean known, Throwable failure) {
+ if (failure != null) {
+ Throwable cause = failure;
+ while (cause instanceof CompletionException && cause.getCause() !=
null) {
+ cause = cause.getCause();
+ }
+ String message = cause.getMessage() != null ? cause.getMessage() :
cause.getClass().getSimpleName();
+ return new CliActionOutcome(output.result, message, null, cause,
false);
+ }
+ if (Boolean.FALSE.equals(known)) {
+ String name = action.getString("action");
+ return new CliActionOutcome(
+ output.result, name != null ? "Unknown action: " + name :
"Missing action", null, null, false);
+ }
+ String reported = output.error();
+ if (reported != null) {
+ return new CliActionOutcome(output.result, reported,
output.reason(), null, false);
+ }
+ if (output.result != null) {
+ // many actions report failures inside their result, as the file
transport had no other way to do it
+ String status = output.result.getString("status");
+ if ("error".equals(status) || "failed".equals(status)) {
+ return new CliActionOutcome(output.result,
messageOf(output.result), null, null, true);
+ }
+ }
+ return new CliActionOutcome(output.result, null, null, null, false);
+ }
+
+ private static String messageOf(JsonObject result) {
+ JsonObject exception = result.getMap("exception");
+ if (exception != null && exception.getString("message") != null) {
+ return exception.getString("message");
+ }
+ Object error = result.get("error");
+ if (error instanceof String text && !text.isBlank()) {
+ return text;
+ }
+ String message = result.getString("message");
+ return message != null && !message.isBlank() ? message : "Action
failed";
+ }
+
+ /**
+ * The result the action wrote, or <tt>null</tt>.
+ */
+ JsonObject result() {
+ return result;
+ }
+
+ /**
+ * Why the action failed, or <tt>null</tt> when it succeeded.
+ */
+ String error() {
+ return error;
+ }
+
+ /**
+ * The kind of error, such as {@link #NOT_FOUND}, or <tt>null</tt>.
+ */
+ String reason() {
+ return reason;
+ }
+
+ /**
+ * The exception the action threw, or <tt>null</tt>.
+ */
+ Throwable failure() {
+ return failure;
+ }
+
+ /**
+ * Whether the error comes from the result itself, which reports a failure
of its own (status <tt>error</tt> or
+ * <tt>failed</tt>), rather than from an exception, an unknown action or a
reported error.
+ */
+ boolean failedResult() {
+ return failedResult;
+ }
+
+ /**
+ * The error as a reply for the file transport:
<tt>{"status":"failed","error":"..."}</tt>, the shape the CLI has
+ * read since Camel 4.23, with the result the action wrote before it
failed, if any.
+ */
+ JsonObject errorReply() {
+ JsonObject reply = new JsonObject();
+ reply.put("status", "failed");
+ reply.put("error", error);
+ if (reason != null) {
+ reply.put("reason", reason);
+ }
+ if (result != null) {
+ reply.put("result", result);
+ }
+ return reply;
+ }
+
+ private static final class Collector implements CliActionOutput {
+ private volatile JsonObject result;
+ // an action can report several problems, such as one per route it
fails to stop
+ private final List<String> errors = new ArrayList<>();
+ private boolean onlyNotFound = true;
+
+ @Override
+ public void write(JsonObject result) {
+ this.result = result;
+ }
+
+ @Override
+ public synchronized void error(String message) {
+ errors.add(message);
+ onlyNotFound = false;
+ }
+
+ @Override
+ public synchronized void notFound(String message) {
+ errors.add(message);
+ }
+
+ synchronized String error() {
+ return errors.isEmpty() ? null : String.join("; ", errors);
+ }
+
+ synchronized String reason() {
+ return !errors.isEmpty() && onlyNotFound ? NOT_FOUND : null;
+ }
+ }
+}
diff --git
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/CliActionOutput.java
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/CliActionOutput.java
index 0d5bf9da7110..359182bb3c95 100644
---
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/CliActionOutput.java
+++
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/CliActionOutput.java
@@ -39,4 +39,15 @@ public interface CliActionOutput {
default void error(String message) {
// noop
}
+
+ /**
+ * A problem the action reports without failing because what it was asked
to work on does not exist, such as a route
+ * id that matches no route. Transports that can reply to the tool pass it
on as an error with the reason
+ * <tt>not-found</tt>, so the tool can tell it apart from other failures.
+ *
+ * @since 4.24
+ */
+ default void notFound(String message) {
+ error(message);
+ }
}
diff --git
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/FileCliConnectorTransport.java
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/FileCliConnectorTransport.java
index d49e2cea803a..4195b13a4723 100644
---
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/FileCliConnectorTransport.java
+++
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/FileCliConnectorTransport.java
@@ -26,7 +26,6 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicReference;
import org.apache.camel.CamelContext;
import org.apache.camel.support.service.ServiceSupport;
@@ -44,6 +43,14 @@ import org.slf4j.LoggerFactory;
* Transport used by the Camel CLI: polls <tt>~/.camel/{pid}-action*.json</tt>
files for actions, writes their results
* to <tt>{pid}-output*.json</tt>, and the snapshots to
<tt>{pid}-status.json</tt>, <tt>{pid}-trace.json</tt> and so on.
* Deleting the <tt>{pid}</tt> lock file shuts down the integration.
+ * <p/>
+ * An action with a request id (<tt>{pid}-action-{requestId}.json</tt>) always
gets a reply in
+ * <tt>{pid}-output-{requestId}.json</tt>:
<tt>{"status":"failed","error":"..."}</tt> when it is unknown, throws or
+ * reports an error (with <tt>"reason":"not-found"</tt> when what it was asked
to work on does not exist, and the result
+ * it wrote before it failed under <tt>"result"</tt>), its result, or
<tt>{"status":"success"}</tt> when it has no
+ * result. A result that reports a failure of its own is written as it is. An
action in the shared
+ * <tt>{pid}-action.json</tt> only gets its result, or the error when it threw
an exception, as a Camel CLI before 4.24
+ * never reads the other replies, and the next command could read them instead
of its own result.
*/
public class FileCliConnectorTransport extends ServiceSupport implements
CliConnectorTransport {
@@ -60,7 +67,10 @@ public class FileCliConnectorTransport extends
ServiceSupport implements CliConn
private long counter;
private final Map<File, PendingAction> pending = new HashMap<>();
- private record PendingAction(File output, CompletableFuture<Boolean>
completion, AtomicReference<JsonObject> result) {
+ // an action file this young that cannot be read may still be written: the
Camel CLI before 4.24 writes it in place
+ private static final long UNREADABLE_GRACE_MS = 2000;
+
+ private record PendingAction(File output, boolean replyAlways,
CompletableFuture<CliActionOutcome> outcome) {
}
private final AtomicBoolean terminating = new AtomicBoolean();
@@ -167,10 +177,10 @@ public class FileCliConnectorTransport extends
ServiceSupport implements CliConn
File request = entry.getKey();
PendingAction action = entry.getValue();
if (!request.exists()) {
- action.completion().cancel(true);
+ action.outcome().cancel(true);
return true;
}
- if (action.completion().isDone()) {
+ if (action.outcome().isDone()) {
finishAction(request, action);
return true;
}
@@ -193,20 +203,44 @@ public class FileCliConnectorTransport extends
ServiceSupport implements CliConn
? new File(dir, lockFile.getName() + "-output-" +
requestId + ".json")
: this.outputFile;
if (!pending.containsKey(af)) {
- processAction(af, of);
+ processAction(af, of, requestId != null);
}
}
}
- private void processAction(File af, File of) {
- JsonObject root = loadAction(af);
- if (root == null || root.isEmpty()) {
+ private void processAction(File af, File of, boolean replyAlways) {
+ String text = readAction(af);
+ if (text == null) {
+ // deleted meanwhile
+ return;
+ }
+ JsonObject root = null;
+ String invalid = null;
+ if (!text.isBlank()) {
+ try {
+ Object parsed = Jsoner.deserialize(text);
+ if (parsed instanceof JsonObject jo) {
+ root = jo;
+ } else {
+ invalid = "not a JSON object";
+ }
+ } catch (Exception e) {
+ invalid = e.getMessage();
+ }
+ }
+ if (root == null) {
+ if (System.currentTimeMillis() - af.lastModified() <
UNREADABLE_GRACE_MS) {
+ // try again at the next poll, the file may still be written
+ return;
+ }
+ if (invalid != null && replyAlways) {
+ writeReply(af, of, CliActionOutcome.failed("Invalid action: "
+ invalid).errorReply());
+ }
FileUtil.deleteFile(af);
return;
}
- AtomicReference<JsonObject> result = new AtomicReference<>();
- PendingAction action = new PendingAction(of,
dispatcher.dispatchAsync(root, result::set), result);
- if (action.completion().isDone()) {
+ PendingAction action = new PendingAction(of, replyAlways,
CliActionOutcome.dispatch(dispatcher, root));
+ if (action.outcome().isDone()) {
finishAction(af, action);
} else {
// Keep the request file until completion; its removal means the
client abandoned the request.
@@ -216,19 +250,13 @@ public class FileCliConnectorTransport extends
ServiceSupport implements CliConn
private void finishAction(File request, PendingAction action) {
try {
- JsonObject result;
- try {
- action.completion().join();
- result = action.result().get();
- } catch (Exception failure) {
- Throwable cause = failure.getCause() != null ?
failure.getCause() : failure;
- LOG.warn("Error executing action: {} due to: {}", request,
cause.getMessage(), cause);
- result = new JsonObject();
- result.put("status", "failed");
- result.put("error", cause.getMessage() != null ?
cause.getMessage() : cause.getClass().getSimpleName());
+ CliActionOutcome outcome = action.outcome().join();
+ if (outcome.failure() != null) {
+ LOG.warn("Error executing action: {} due to: {}", request,
outcome.error(), outcome.failure());
}
- if (result != null && request.exists()) {
- IOHelper.writeText(result.toJson(), action.output());
+ JsonObject reply = reply(outcome, action.replyAlways());
+ if (reply != null) {
+ writeReply(request, action.output(), reply);
}
} catch (Exception e) {
LOG.warn("Error writing action result: {} due to: {}", request,
e.getMessage(), e);
@@ -237,18 +265,50 @@ public class FileCliConnectorTransport extends
ServiceSupport implements CliConn
}
}
- private static JsonObject loadAction(File file) {
+ /**
+ * The reply to an action: the error, its result, or an ack. The shared
output file only gets the result, or the
+ * error of an exception (as before 4.24), so the next command does not
read a reply meant for another command.
+ */
+ private static JsonObject reply(CliActionOutcome outcome, boolean
replyAlways) {
+ if (outcome.error() != null && !outcome.failedResult()) {
+ // an exception, an unknown action or a reported error wins over a
(partial) result
+ if (replyAlways || outcome.failure() != null) {
+ return outcome.errorReply();
+ }
+ return outcome.result();
+ }
+ if (outcome.result() != null) {
+ // also a result that reports a failure of its own: the CLI reads
it as it is
+ return outcome.result();
+ }
+ if (replyAlways) {
+ JsonObject ack = new JsonObject();
+ ack.put("status", "success");
+ return ack;
+ }
+ return null;
+ }
+
+ private static void writeReply(File request, File output, JsonObject
reply) {
+ // a deleted request means the tool has given up on it
+ if (request.exists()) {
+ try {
+ IOHelper.writeText(reply.toJson(), output);
+ } catch (Exception e) {
+ LOG.warn("Error writing action result: {} due to: {}",
request, e.getMessage(), e);
+ }
+ }
+ }
+
+ private static String readAction(File file) {
try {
if (file != null && file.exists()) {
try (FileInputStream fis = new FileInputStream(file)) {
- String text = IOHelper.loadText(fis);
- if (!text.isEmpty()) {
- return (JsonObject) Jsoner.deserialize(text);
- }
+ return IOHelper.loadText(fis);
}
}
} catch (Exception e) {
- // ignore
+ // deleted meanwhile
}
return null;
}
@@ -378,7 +438,7 @@ public class FileCliConnectorTransport extends
ServiceSupport implements CliConn
}
synchronized (this) {
pending.forEach((request, action) -> {
- action.completion().cancel(true);
+ action.outcome().cancel(true);
FileUtil.deleteFile(request);
});
pending.clear();
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 39f8162aa266..dfb6d5abaff3 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
@@ -410,6 +410,10 @@ public class LocalCliConnector extends ServiceSupport
actionOutput.error(message);
}
+ private void reportNotFound(String message) {
+ actionOutput.notFound(message);
+ }
+
private void doActionReadmeTask(JsonObject root) throws Exception {
String readmeFiles = camelContext.getPropertiesComponent()
.resolveProperty("camel.jbang.readmeFiles").orElse(null);
@@ -1408,7 +1412,7 @@ public class LocalCliConnector extends ServiceSupport
}
}
if (ids.isEmpty()) {
- reportError("No route matching: " + root.getString("id"));
+ reportNotFound("No route matching: " + root.getString("id"));
}
}
@@ -1461,8 +1465,12 @@ public class LocalCliConnector extends ServiceSupport
} catch (Exception e) {
LOG.warn("Error {} processor: {} due to: {}. This exception is
ignored.", command, mp.getProcessorId(),
e.getMessage(), e);
+ reportError("Error " + command + " processor: " +
mp.getProcessorId() + " due to: " + e.getMessage());
}
}
+ if (mps.isEmpty()) {
+ reportNotFound("No processor matching: " + root.getString("id"));
+ }
}
@Override
diff --git
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/WebSocketCliConnectorTransport.java
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/WebSocketCliConnectorTransport.java
index 5754650c6fbc..1c2b6df12b97 100644
---
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/WebSocketCliConnectorTransport.java
+++
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/WebSocketCliConnectorTransport.java
@@ -619,39 +619,17 @@ public class WebSocketCliConnectorTransport extends
ServiceSupport implements Cl
}
private CompletableFuture<JsonObject> result(String requestId, JsonObject
action) {
- JsonObject[] output = new JsonObject[1];
- String[] error = new String[1];
- CompletableFuture<Boolean> completion =
dispatcher.dispatchAsync(action, new CliActionOutput() {
- @Override
- public void write(JsonObject result) {
- output[0] = result;
+ CompletableFuture<CliActionOutcome> outcome =
CliActionOutcome.dispatch(dispatcher, action);
+ CompletableFuture<JsonObject> answer = outcome.thenApply(o -> {
+ JsonObject frame = result(requestId, o.error(), o.result() != null
? o.result() : new JsonObject());
+ if (o.reason() != null) {
+ frame.put("reason", o.reason());
}
-
- @Override
- public void error(String message) {
- error[0] = message;
- }
- });
- CompletableFuture<JsonObject> answer = completion.handle((known,
failure) -> {
- if (failure != null) {
- error[0] = failure.getMessage() != null ? failure.getMessage()
: failure.getClass().getSimpleName();
- } else if (!known) {
- error[0] = "Unknown action: " + action.getString("action");
- }
- if (error[0] == null && output[0] != null) {
- // many actions report failures inside their result, as the
file transport has no other way to do it
- String status = output[0].getString("status");
- if ("error".equals(status) || "failed".equals(status)) {
- JsonObject exception = output[0].getMap("exception");
- error[0] = exception != null &&
exception.getString("message") != null
- ? exception.getString("message") : "Action failed";
- }
- }
- return result(requestId, error[0], output[0] != null ? output[0] :
new JsonObject());
+ return frame;
});
answer.whenComplete((value, failure) -> {
if (answer.isCancelled()) {
- completion.cancel(true);
+ outcome.cancel(true);
}
});
return answer;
diff --git
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/CliActionOutcomeTest.java
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/CliActionOutcomeTest.java
new file mode 100644
index 000000000000..b373c5406897
--- /dev/null
+++
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/CliActionOutcomeTest.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.cli.connector;
+
+import java.util.concurrent.CompletableFuture;
+
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * How an action turns into a reply, the same for the file and the WebSocket
transport.
+ */
+class CliActionOutcomeTest {
+
+ @Test
+ void resultWithoutFailure() {
+ CliActionOutcome outcome =
outcome(result("{\"status\":\"success\",\"value\":1}"));
+
+ assertThat(outcome.error()).isNull();
+ assertThat(outcome.result()).containsEntry("status", "success");
+ }
+
+ @Test
+ void failedResultTakesTheMessageFromTheResult() {
+
assertThat(outcome(result("{\"status\":\"error\",\"exception\":{\"message\":\"Forced\"}}")).error())
+ .isEqualTo("Forced");
+
assertThat(outcome(result("{\"status\":\"failed\",\"error\":\"Busy\"}")).error()).isEqualTo("Busy");
+ assertThat(outcome(result("{\"status\":\"error\",\"message\":\"No such
datasource\"}")).error())
+ .isEqualTo("No such datasource");
+
assertThat(outcome(result("{\"status\":\"error\"}")).error()).isEqualTo("Action
failed");
+ }
+
+ @Test
+ void unknownAction() {
+ CliActionOutcome outcome = join(CliActionOutcome.dispatch((action,
output) -> false, action("nope")));
+
+ assertThat(outcome.error()).isEqualTo("Unknown action: nope");
+
assertThat(outcome.errorReply().toJson()).isEqualTo("{\"status\":\"failed\",\"error\":\"Unknown
action: nope\"}");
+ }
+
+ @Test
+ void exception() {
+ CliActionOutcome outcome = join(CliActionOutcome.dispatch((action,
output) -> {
+ throw new IllegalArgumentException("Bad input");
+ }, action("eval")));
+
+ assertThat(outcome.error()).isEqualTo("Bad input");
+
assertThat(outcome.failure()).isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void notFoundHasItsReason() {
+ CliActionOutcome outcome = join(CliActionOutcome.dispatch((action,
output) -> {
+ output.notFound("No route matching: nope");
+ return true;
+ }, action("route")));
+
+ assertThat(outcome.error()).isEqualTo("No route matching: nope");
+ assertThat(outcome.reason()).isEqualTo(CliActionOutcome.NOT_FOUND);
+ assertThat(outcome.errorReply()).containsEntry("reason", "not-found");
+ }
+
+ @Test
+ void severalProblemsAreJoined() {
+ CliActionOutcome outcome = join(CliActionOutcome.dispatch((action,
output) -> {
+ output.error("Error stop route: a due to: x");
+ output.error("Error stop route: b due to: y");
+ return true;
+ }, action("route")));
+
+ assertThat(outcome.error()).isEqualTo("Error stop route: a due to: x;
Error stop route: b due to: y");
+ assertThat(outcome.reason()).isNull();
+ }
+
+ @Test
+ void notFoundOnlyWhenEveryProblemIsNotFound() {
+ CliActionOutcome mixed = join(CliActionOutcome.dispatch((action,
output) -> {
+ output.notFound("No route matching: a");
+ output.error("Error stop route: b due to: y");
+ return true;
+ }, action("route")));
+ assertThat(mixed.reason()).isNull();
+
+ CliActionOutcome notFound = join(CliActionOutcome.dispatch((action,
output) -> {
+ output.notFound("No route matching: a");
+ output.notFound("No route matching: b");
+ return true;
+ }, action("route")));
+ assertThat(notFound.reason()).isEqualTo(CliActionOutcome.NOT_FOUND);
+ assertThat(notFound.error()).isEqualTo("No route matching: a; No route
matching: b");
+ }
+
+ @Test
+ void exceptionAfterAPartialResultKeepsBoth() {
+ CliActionOutcome outcome = join(CliActionOutcome.dispatch((action,
output) -> {
+ output.write(result("{\"status\":\"success\",\"value\":1}"));
+ throw new IllegalStateException("Boom");
+ }, action("partial")));
+
+ assertThat(outcome.error()).isEqualTo("Boom");
+ assertThat(outcome.failedResult()).isFalse();
+ assertThat(outcome.errorReply()).containsEntry("status",
"failed").containsEntry("error", "Boom");
+ JsonObject partial = outcome.errorReply().getMap("result");
+ assertThat(partial.getInteger("value")).isEqualTo(1);
+ }
+
+ @Test
+ void failedResultIsMarked() {
+
assertThat(outcome(result("{\"status\":\"error\",\"message\":\"x\"}")).failedResult()).isTrue();
+
assertThat(outcome(result("{\"status\":\"success\"}")).failedResult()).isFalse();
+ }
+
+ @Test
+ void cancellingTheOutcomeCancelsTheAction() {
+ CompletableFuture<Boolean> running = new CompletableFuture<>();
+ CliActionDispatcher dispatcher = new CliActionDispatcher() {
+ @Override
+ public boolean dispatch(JsonObject action, CliActionOutput output)
{
+ return true;
+ }
+
+ @Override
+ public CompletableFuture<Boolean> dispatchAsync(JsonObject action,
CliActionOutput output) {
+ return running;
+ }
+ };
+
+ CompletableFuture<CliActionOutcome> outcome =
CliActionOutcome.dispatch(dispatcher, action("slow"));
+ outcome.cancel(true);
+
+ assertThat(running).isCancelled();
+ }
+
+ private static CliActionOutcome outcome(JsonObject result) {
+ return join(CliActionOutcome.dispatch((action, output) -> {
+ output.write(result);
+ return true;
+ }, action("test")));
+ }
+
+ private static CliActionOutcome join(CompletableFuture<CliActionOutcome>
future) {
+ return future.join();
+ }
+
+ private static JsonObject result(String json) {
+ return (JsonObject) Jsoner.deserialize(json, (JsonObject) null);
+ }
+
+ private static JsonObject action(String name) {
+ JsonObject jo = new JsonObject();
+ jo.put("action", name);
+ return jo;
+ }
+}
diff --git
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/FileCliConnectorTransportTest.java
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/FileCliConnectorTransportTest.java
new file mode 100644
index 000000000000..a76432df4f4b
--- /dev/null
+++
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/FileCliConnectorTransportTest.java
@@ -0,0 +1,354 @@
+/*
+ * 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.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+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 org.junit.jupiter.api.parallel.Isolated;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * The replies of the file transport. An action with a request id always gets
one, so the Camel CLI never has to wait
+ * for its timeout: the result, the error, or an ack. An action in the shared
action file only gets its result or the
+ * error of an exception, as a Camel CLI before 4.24 never reads the other
replies.
+ * <p>
+ * Isolated because it changes {@code user.home}, and every connector in this
JVM writes files named after the same pid.
+ */
+@Isolated
+class FileCliConnectorTransportTest {
+
+ private static final String PID =
String.valueOf(ProcessHandle.current().pid());
+
+ @TempDir
+ Path home;
+
+ private String oldHome;
+ private File dir;
+ private DefaultCamelContext context;
+ private FileCliConnectorTransport transport;
+
+ @BeforeEach
+ void start() {
+ oldHome = System.getProperty("user.home");
+ System.setProperty("user.home", home.toString());
+ dir = home.resolve(".camel").toFile();
+
+ context = new DefaultCamelContext();
+ transport = new FileCliConnectorTransport();
+ transport.configure(context, FileCliConnectorTransportTest::dispatch,
new EmptySnapshots(), () -> {
+ });
+ transport.start();
+ // poll faster than the default second
+ transport.updateDelay(50);
+ }
+
+ @AfterEach
+ void stop() {
+ transport.stop();
+ context.stop();
+ System.setProperty("user.home", oldHome);
+ }
+
+ @Test
+ void resultIsTheReply() {
+ writeAction(action("result"), "r1");
+
+ assertThat(awaitJson(file(PID +
"-output-r1.json"))).containsEntry("status", "success").containsEntry("value",
"42");
+ }
+
+ @Test
+ void actionWithoutResultIsAcknowledged() {
+ writeAction(action("nothing"), "r1");
+
+ assertThat(awaitJson(file(PID +
"-output-r1.json")).toJson()).isEqualTo("{\"status\":\"success\"}");
+ }
+
+ @Test
+ void unknownActionGetsAnError() {
+ writeAction(action("does-not-exist"), "r1");
+
+ JsonObject reply = awaitJson(file(PID + "-output-r1.json"));
+ assertThat(reply).containsEntry("status",
"failed").containsEntry("error", "Unknown action: does-not-exist");
+ }
+
+ @Test
+ void actionWithoutNameGetsAnError() {
+ writeAction(new JsonObject(), "r1");
+
+ assertThat(awaitJson(file(PID +
"-output-r1.json"))).containsEntry("error", "Missing action");
+ }
+
+ @Test
+ void exceptionIsTheError() {
+ writeAction(action("boom"), "r1");
+
+ JsonObject reply = awaitJson(file(PID + "-output-r1.json"));
+ assertThat(reply).containsEntry("status",
"failed").containsEntry("error", "Boom");
+ }
+
+ @Test
+ void reportedErrorIsTheError() {
+ writeAction(action("error"), "r1");
+
+ JsonObject reply = awaitJson(file(PID + "-output-r1.json"));
+ assertThat(reply).containsEntry("status",
"failed").containsEntry("error", "Broken").doesNotContainKey("reason");
+ }
+
+ @Test
+ void notFoundHasItsReason() {
+ writeAction(action("missing"), "r1");
+
+ JsonObject reply = awaitJson(file(PID + "-output-r1.json"));
+ assertThat(reply).containsEntry("status",
"failed").containsEntry("error", "No route matching: nope")
+ .containsEntry("reason", "not-found");
+ }
+
+ @Test
+ void exceptionWinsOverAPartialResult() {
+ writeAction(action("partial"), "r1");
+
+ JsonObject reply = awaitJson(file(PID + "-output-r1.json"));
+ assertThat(reply).containsEntry("status",
"failed").containsEntry("error", "Boom");
+ JsonObject partial = reply.getMap("result");
+ assertThat(partial).containsEntry("value", "42");
+ }
+
+ @Test
+ void reportedErrorWinsOverAResult() {
+ writeAction(action("partial-error"), "r1");
+
+ JsonObject reply = awaitJson(file(PID + "-output-r1.json"));
+ assertThat(reply).containsEntry("status",
"failed").containsEntry("error", "Half done");
+ JsonObject partial = reply.getMap("result");
+ assertThat(partial).containsEntry("value", "42");
+ }
+
+ @Test
+ void resultWithAFailureOfItsOwnIsWrittenAsItIs() {
+ writeAction(action("failed-result"), "r1");
+
+ assertThat(awaitJson(file(PID + "-output-r1.json")).toJson())
+ .isEqualTo("{\"status\":\"error\",\"message\":\"No such
datasource\"}");
+ }
+
+ @Test
+ void invalidActionGetsAnErrorOnceItIsNotBeingWritten() throws Exception {
+ File action = writeText("{\"action\":\"res", "r1");
+
+ // a fresh file may still be written, so the connector waits for it
+ await().during(300, TimeUnit.MILLISECONDS).atMost(2, TimeUnit.SECONDS)
+ .untilAsserted(() -> assertThat(action).exists());
+ assertThat(file(PID + "-output-r1.json")).doesNotExist();
+
+ assertThat(action.setLastModified(System.currentTimeMillis() -
10_000)).isTrue();
+ JsonObject reply = awaitJson(file(PID + "-output-r1.json"));
+ assertThat(reply).containsEntry("status", "failed");
+ assertThat(reply.getString("error")).startsWith("Invalid action: ");
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(action).doesNotExist());
+ }
+
+ @Test
+ void halfWrittenActionIsRunWhenComplete() throws Exception {
+ File action = writeText("{\"action\":\"res", "r1");
+ await().during(300, TimeUnit.MILLISECONDS).atMost(2, TimeUnit.SECONDS)
+ .untilAsserted(() -> assertThat(action).exists());
+
+ Files.writeString(action.toPath(), action("result").toJson());
+
+ assertThat(awaitJson(file(PID +
"-output-r1.json"))).containsEntry("value", "42");
+ }
+
+ @Test
+ void sharedActionFileOnlyGetsResultsAndExceptions() throws Exception {
+ File output = file(PID + "-output.json");
+
+ writeAction(action("result"), null);
+ assertThat(awaitJson(output)).containsEntry("value", "42");
+ Files.writeString(output.toPath(), "");
+
+ writeAction(action("boom"), null);
+ assertThat(awaitJson(output)).containsEntry("status",
"failed").containsEntry("error", "Boom");
+ Files.writeString(output.toPath(), "");
+
+ // as before 4.24: a reported error is not written, but the result is
+ writeAction(action("partial-error"), null);
+
assertThat(awaitJson(output).toJson()).isEqualTo("{\"status\":\"success\",\"value\":\"42\"}");
+ Files.writeString(output.toPath(), "");
+
+ // no reply that a Camel CLI before 4.24 would not read, and the next
command could read instead of its own
+ for (String name : new String[] { "nothing", "error", "missing",
"does-not-exist" }) {
+ writeAction(action(name), null);
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(file(PID + "-action.json")).doesNotExist());
+ assertThat(output).isEmpty();
+ }
+ }
+
+ @Test
+ void emptySharedActionFileIsRemovedWithoutReply() throws Exception {
+ File action = file(PID + "-action.json");
+ // the connector creates it empty on start
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(action).doesNotExist());
+
+ Files.writeString(action.toPath(), "");
+ assertThat(action.setLastModified(System.currentTimeMillis() -
10_000)).isTrue();
+
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(action).doesNotExist());
+ assertThat(file(PID + "-output.json")).isEmpty();
+ }
+
+ private static boolean dispatch(JsonObject action, CliActionOutput output)
throws Exception {
+ String name = action.getString("action");
+ if (name == null) {
+ return false;
+ }
+ switch (name) {
+ case "result" -> output.write(result());
+ case "boom" -> throw new IllegalStateException("Boom");
+ case "partial" -> {
+ output.write(result());
+ throw new IllegalStateException("Boom");
+ }
+ case "partial-error" -> {
+ output.write(result());
+ output.error("Half done");
+ }
+ case "failed-result" -> {
+ JsonObject failed = new JsonObject();
+ failed.put("status", "error");
+ failed.put("message", "No such datasource");
+ output.write(failed);
+ }
+ case "error" -> output.error("Broken");
+ case "missing" -> output.notFound("No route matching: nope");
+ case "nothing" -> {
+ // a fire-and-forget action, such as a route stop
+ }
+ default -> {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static JsonObject result() {
+ JsonObject result = new JsonObject();
+ result.put("status", "success");
+ result.put("value", "42");
+ return result;
+ }
+
+ private File file(String name) {
+ return new File(dir, name);
+ }
+
+ private static JsonObject action(String name) {
+ JsonObject jo = new JsonObject();
+ jo.put("action", name);
+ return jo;
+ }
+
+ private void writeAction(JsonObject action, String requestId) {
+ String name = requestId != null ? PID + "-action-" + requestId +
".json" : PID + "-action.json";
+ if (requestId == null) {
+ // the connector creates an empty shared action file on start, and
removes it a little later
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(file(name)).doesNotExist());
+ }
+ writeText(action.toJson(), requestId);
+ }
+
+ /**
+ * Writes the action atomically, as the Camel CLI does since 4.21 for
actions with a request id.
+ */
+ private File writeText(String text, String requestId) {
+ String name = requestId != null ? PID + "-action-" + requestId +
".json" : PID + "-action.json";
+ try {
+ Path tmp = dir.toPath().resolve(UUID.randomUUID() + ".tmp");
+ Files.writeString(tmp, text);
+ Files.move(tmp, dir.toPath().resolve(name),
StandardCopyOption.ATOMIC_MOVE,
+ StandardCopyOption.REPLACE_EXISTING);
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ return file(name);
+ }
+
+ private static JsonObject awaitJson(File file) {
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(file).isNotEmpty());
+ return await().atMost(10, TimeUnit.SECONDS).until(() ->
readJson(file), jo -> jo != null);
+ }
+
+ private static JsonObject readJson(File file) {
+ try {
+ String text = Files.readString(file.toPath());
+ return text.isBlank() ? null : (JsonObject)
Jsoner.deserialize(text);
+ } catch (Exception e) {
+ // the file is rewritten while we read it
+ return null;
+ }
+ }
+
+ private static final class EmptySnapshots implements CliSnapshotProducer {
+ @Override
+ public JsonObject status() {
+ return new JsonObject();
+ }
+
+ @Override
+ public JsonObject trace() {
+ return null;
+ }
+
+ @Override
+ public JsonObject debug() {
+ return null;
+ }
+
+ @Override
+ public JsonObject messageHistory() {
+ return null;
+ }
+
+ @Override
+ public JsonObject errors() {
+ return null;
+ }
+
+ @Override
+ public JsonObject receive() {
+ return null;
+ }
+
+ @Override
+ public JsonObject activity() {
+ return null;
+ }
+ }
+}
diff --git
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorFileTransportTest.java
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorFileTransportTest.java
index 88b52fc9670b..f4f3d719a96f 100644
---
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorFileTransportTest.java
+++
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorFileTransportTest.java
@@ -158,7 +158,7 @@ class LocalCliConnectorFileTransportTest extends
CamelTestSupport {
startConnector();
assertThat(dir).isDirectoryContaining(f -> f.getName().equals(PID));
- // {pid}-action.json is created too, but the first poll consumes it
(it is empty)
+ // {pid}-action.json is created too, but the connector removes it a
little later (it is empty)
for (String suffix : List.of("status", "output", "trace", "history",
"error", "debug", "receive",
"activity")) {
assertThat(file(PID + "-" + suffix + ".json")).exists();
@@ -232,14 +232,45 @@ class LocalCliConnectorFileTransportTest extends
CamelTestSupport {
}
@Test
- void unknownActionIsIgnored() {
+ void unknownActionGetsAnError() {
startConnector();
writeAction(action("does-not-exist"), "r1");
- await().atMost(10, TimeUnit.SECONDS)
- .untilAsserted(() -> assertThat(file(PID +
"-action-r1.json")).doesNotExist());
- assertThat(file(PID + "-output-r1.json")).doesNotExist();
+ JsonObject reply = awaitJson(file(PID + "-output-r1.json"));
+ assertThat(reply).containsEntry("status",
"failed").containsEntry("error", "Unknown action: does-not-exist");
+ }
+
+ @Test
+ void routeActionWithRequestIdIsAcknowledged() {
+ startConnector();
+
+ writeAction(action("route", "command", "stop", "id", "hello"), "r1");
+
+ assertThat(awaitJson(file(PID +
"-output-r1.json")).toJson()).isEqualTo("{\"status\":\"success\"}");
+
assertThat(context.getRouteController().getRouteStatus("hello")).isEqualTo(ServiceStatus.Stopped);
+ }
+
+ @Test
+ void routeActionForAnUnknownRouteIsNotFound() {
+ startConnector();
+
+ writeAction(action("route", "command", "stop", "id", "nope"), "r1");
+
+ JsonObject reply = awaitJson(file(PID + "-output-r1.json"));
+ assertThat(reply).containsEntry("status",
"failed").containsEntry("error", "No route matching: nope")
+ .containsEntry("reason", "not-found");
+ }
+
+ @Test
+ void processorActionForAnUnknownProcessorIsNotFound() {
+ startConnector();
+
+ writeAction(action("processor", "command", "disable", "id", "nope"),
"r1");
+
+ JsonObject reply = awaitJson(file(PID + "-output-r1.json"));
+ assertThat(reply).containsEntry("status",
"failed").containsEntry("error", "No processor matching: nope")
+ .containsEntry("reason", "not-found");
}
@Test
@@ -311,7 +342,7 @@ class LocalCliConnectorFileTransportTest extends
CamelTestSupport {
private void writeAction(JsonObject action, String requestId) {
String name = requestId != null ? PID + "-action-" + requestId +
".json" : PID + "-action.json";
if (requestId == null) {
- // the connector creates an empty legacy action file on start and
deletes it on its first poll, which would
+ // the connector creates an empty legacy action file on start and
deletes it a little later, which would
// also delete an action written in between
await().atMost(10, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(file(name)).doesNotExist());
}
diff --git
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/WebSocketCliConnectorTransportTest.java
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/WebSocketCliConnectorTransportTest.java
index 8da07e5be9cf..589051cead32 100644
---
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/WebSocketCliConnectorTransportTest.java
+++
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/WebSocketCliConnectorTransportTest.java
@@ -181,7 +181,8 @@ class WebSocketCliConnectorTransportTest extends
CamelTestSupport {
tool.send(action("r1", "route", "command", "start", "id",
"does-not-exist"));
JsonObject result = tool.awaitResult("r1");
assertThat(result.getBoolean("ok")).isFalse();
- assertThat(result.getString("error")).contains("does-not-exist");
+ assertThat(result.getString("error")).isEqualTo("No route matching:
does-not-exist");
+ assertThat(result.getString("reason")).isEqualTo("not-found");
tool.send(action("r2", "does-not-exist"));
result = tool.awaitResult("r2");