This is an automated email from the ASF dual-hosted git repository.
Croway 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 0bd78692be89 CAMEL-26400: camel-jbang - AI tools:
camel_eval_expression takes headers, variables, resultType and predicate
(#28683)
0bd78692be89 is described below
commit 0bd78692be89d647faac60d2053550dce2729fcb
Author: Federico Mariani <[email protected]>
AuthorDate: Thu Oct 29 17:07:52 2026 +0100
CAMEL-26400: camel-jbang - AI tools: camel_eval_expression takes headers,
variables, resultType and predicate (#28683)
* CAMEL-26400: camel-jbang - AI tools: camel_eval_expression takes headers,
variables, resultType and predicate
camel_eval_expression took only the expression, language, body and
integration name, so it could not evaluate an
expression the way the route runs it: an XPath function such as string(),
local-name(), boolean() or count() failed
with "Can not convert #STRING to a NodeList!" (the xpath language gives
nodes unless resultType says otherwise),
predicate mode was guessed for simple only, and a header or variable an
earlier step sets was null. A headers
argument was silently ignored, and a local XPath that worked showed the
class of its node list.
The tool (shared registry and MCP wrapper) now takes headers and variables
(a JSON object, whose values keep their
JSON type, or key=value lines as camel_runtime_send takes headers),
predicate for any language (simple keeps its
guess when it is absent) and resultType, a class name passed to the
language as its resultType option as the
reifiers pass it. Locally they are set on the scratch exchange and the
value is converted to String by the
context's type converter, as the integration does. In the integration the
eval action carries them;
EvalLanguageDevConsole gets a resultType option and LocalCliConnector
passes it on and keeps the JSON type of a
header or variable value. An integration before Camel 4.24 gets a list or
object value as JSON text, and the answer
notes that it ignores resultType.
An XPath that fails with "Can not convert #STRING/#BOOLEAN/#NUMBER to a
NodeList" gets a hint with the argument to
pass and the route form (resultType java.lang.String, predicate=true,
java.lang.Integer); in the integration, where
the failed eval action is a tool error, the hint is appended to the error
message.
camel-jbang-core gets camel-xpath, camel-groovy and camel-jq as test
dependencies, so the tests evaluate them
locally without a download. The prompt budgets of the TUI AI panel are
raised by the ~114 tokens of the four
arguments.
* CAMEL-26400: regenerate dev-consoles-openapi.json for the resultType
option of eval-language
* CAMEL-26400: camel-jbang - AI tools: the elvis rewrite also goes with the
tool error of an integration before 4.24
An integration before Camel 4.24 refuses ${header.missing ?: 10}, and a
failed
eval action is a tool error (CAMEL-25398), so the rewrite that CAMEL-26401
adds
to the answer never reached the model for the integration it was meant for.
It
now goes with the error, as the XPath hint does.
* CAMEL-26400: camel-jbang - AI tools: the JSON of headers and variables is
held to the argument length limit, and a predicate is true or false
McpSecurityInterceptor checks the arguments that are strings; headers and
variables sent as a JSON object are not, so their JSON is now held to the
same
max-argument-length. A predicate other than true or false (such as yes) is
refused instead of being taken for false.
* Update
dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
Co-authored-by: Guillaume Nodet - AI Bot <[email protected]>
* CAMEL-26400: camel-console - the text output of eval-language reports an
error instead of throwing
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---------
Signed-off-by: Claus Ibsen <[email protected]>
Co-authored-by: Guillaume Nodet - AI Bot <[email protected]>
Co-authored-by: Claus Ibsen <[email protected]>
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
---
.../apache/camel/catalog/dev-consoles-openapi.json | 4 +
.../camel/catalog/dev-consoles/eval-language.json | 18 +-
.../apache/camel/dev-console/eval-language.json | 18 +-
.../camel/impl/console/EvalLanguageDevConsole.java | 46 +++-
.../impl/console/EvalLanguageDevConsoleTest.java | 51 ++++
.../modules/ROOT/pages/camel-jbang-mcp.adoc | 10 +-
.../ROOT/pages/camel-jbang-tui-ai-agents.adoc | 3 +-
.../camel/cli/connector/LocalCliConnector.java | 13 +-
.../connector/LocalCliConnectorEvalActionTest.java | 93 +++++++
dsl/camel-jbang/camel-jbang-core/pom.xml | 17 ++
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 35 ++-
.../core/commands/ai/ExpressionEvaluator.java | 287 ++++++++++++++++++---
.../camel/dsl/jbang/core/common/ActionClient.java | 7 +-
.../camel/dsl/jbang/core/common/RuntimeHelper.java | 26 +-
.../core/commands/ai/EvalInTheIntegrationTest.java | 146 ++++++++++-
.../ai/ExpressionEvaluatorRequestTest.java | 209 +++++++++++++++
.../jbang/core/commands/mcp/AuthoringTools.java | 48 +++-
.../core/commands/mcp/AuthoringToolsTest.java | 51 +++-
.../core/commands/tui/AiPanelPromptBudgetTest.java | 16 +-
19 files changed, 1023 insertions(+), 75 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json
index c6f2f1b1939f..d5d9c43a81a0 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json
@@ -1888,6 +1888,10 @@
"default": false,
"description": "Whether to execute as predicate (use expression by default)"
},
+
"resultType": {
+
"type": "string",
+
"description": "Optional type of the result, as a class name such as
java.lang.String, passed to the language as its resultType option, as in a
route (not used for a predicate)"
+ },
"template": {
"type": "string",
"description": "Template to use for executing simple language function"
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/eval-language.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/eval-language.json
index cf657a2c2247..70723c97a8e1 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/eval-language.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/eval-language.json
@@ -71,9 +71,23 @@
"defaultValue": false,
"description": "Whether to execute as predicate (use expression by
default)"
},
- "template": {
+ "resultType": {
"index": 4,
"kind": "option",
+ "displayName": "Result Type",
+ "group": "query",
+ "label": "query",
+ "required": false,
+ "type": "string",
+ "javaType": "java.lang.String",
+ "deprecated": false,
+ "autowired": false,
+ "secret": false,
+ "description": "Optional type of the result, as a class name such as
java.lang.String, passed to the language as its resultType option, as in a
route (not used for a predicate)"
+ },
+ "template": {
+ "index": 5,
+ "kind": "option",
"displayName": "Template",
"group": "query",
"label": "query",
@@ -86,7 +100,7 @@
"description": "Template to use for executing simple language function"
},
"variables": {
- "index": 5,
+ "index": 6,
"kind": "option",
"displayName": "Variables",
"group": "query",
diff --git
a/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/eval-language.json
b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/eval-language.json
index cf657a2c2247..70723c97a8e1 100644
---
a/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/eval-language.json
+++
b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/eval-language.json
@@ -71,9 +71,23 @@
"defaultValue": false,
"description": "Whether to execute as predicate (use expression by
default)"
},
- "template": {
+ "resultType": {
"index": 4,
"kind": "option",
+ "displayName": "Result Type",
+ "group": "query",
+ "label": "query",
+ "required": false,
+ "type": "string",
+ "javaType": "java.lang.String",
+ "deprecated": false,
+ "autowired": false,
+ "secret": false,
+ "description": "Optional type of the result, as a class name such as
java.lang.String, passed to the language as its resultType option, as in a
route (not used for a predicate)"
+ },
+ "template": {
+ "index": 5,
+ "kind": "option",
"displayName": "Template",
"group": "query",
"label": "query",
@@ -86,7 +100,7 @@
"description": "Template to use for executing simple language function"
},
"variables": {
- "index": 5,
+ "index": 6,
"kind": "option",
"displayName": "Variables",
"group": "query",
diff --git
a/core/camel-console/src/main/java/org/apache/camel/impl/console/EvalLanguageDevConsole.java
b/core/camel-console/src/main/java/org/apache/camel/impl/console/EvalLanguageDevConsole.java
index 3effec949507..4743d45ccb4a 100644
---
a/core/camel-console/src/main/java/org/apache/camel/impl/console/EvalLanguageDevConsole.java
+++
b/core/camel-console/src/main/java/org/apache/camel/impl/console/EvalLanguageDevConsole.java
@@ -21,6 +21,7 @@ import java.util.Map;
import org.apache.camel.Exchange;
import org.apache.camel.Expression;
import org.apache.camel.Predicate;
+import org.apache.camel.spi.Language;
import org.apache.camel.spi.Metadata;
import org.apache.camel.spi.annotations.DevConsole;
import org.apache.camel.support.DefaultExchange;
@@ -58,6 +59,12 @@ public class EvalLanguageDevConsole extends
AbstractDevConsole {
@Metadata(label = "query", description = "Optional exchange variables",
javaType = "java.lang.String")
public static final String VARIABLES = "variables";
+ @Metadata(label = "query",
+ description = "Optional type of the result, as a class name such
as java.lang.String, passed to the language"
+ + " as its resultType option, as in a route (not
used for a predicate)",
+ javaType = "java.lang.String")
+ public static final String RESULT_TYPE = "resultType";
+
public EvalLanguageDevConsole() {
super("camel", "eval-language", "Evaluate Language", "Evaluate
Language and display result");
}
@@ -84,14 +91,18 @@ public class EvalLanguageDevConsole extends
AbstractDevConsole {
}
String out;
- boolean predicate = optionBoolean(options, PREDICATE, false);
- template = resolvePlaceholders(template);
- if (predicate) {
- Predicate pre =
getCamelContext().resolveLanguage(language).createPredicate(template);
- out = pre.matches(dummy) ? "true" : "false";
- } else {
- Expression exp =
getCamelContext().resolveLanguage(language).createExpression(template);
- out = exp.evaluate(dummy, String.class);
+ try {
+ boolean predicate = optionBoolean(options, PREDICATE, false);
+ template = resolvePlaceholders(template);
+ if (predicate) {
+ Predicate pre =
getCamelContext().resolveLanguage(language).createPredicate(template);
+ out = pre.matches(dummy) ? "true" : "false";
+ } else {
+ Expression exp = createExpression(language, template,
options);
+ out = exp.evaluate(dummy, String.class);
+ }
+ } catch (Exception e) {
+ out = "Error: " + e.getMessage();
}
sb.append(String.format("%nEvaluating (%s): %s", language,
template));
sb.append("\n");
@@ -100,6 +111,23 @@ public class EvalLanguageDevConsole extends
AbstractDevConsole {
return sb.toString();
}
+ /**
+ * The expression, with the resultType option of the language when one is
given, as a route passes it: an XPath
+ * function such as string() or count() then gives its value, where the
default result of xpath is nodes.
+ */
+ private Expression createExpression(String language, String template,
Map<String, Object> options) {
+ Language lan = getCamelContext().resolveLanguage(language);
+ String resultType = optionString(options, RESULT_TYPE);
+ if (resultType == null || resultType.isBlank()) {
+ return lan.createExpression(template);
+ }
+ Class<?> type =
getCamelContext().getClassResolver().resolveClass(resultType.strip());
+ if (type == null) {
+ throw new IllegalArgumentException("resultType " + resultType + "
is not a class on the classpath");
+ }
+ return lan.createExpression(template, new Object[] { type });
+ }
+
/**
* Resolves property placeholders in the template first, as the route
loaders do before a language parses the text,
* so that {{myPeriod}} or ${body} > {{myThreshold}} evaluate as they
would in a route (CAMEL-24698).
@@ -144,7 +172,7 @@ public class EvalLanguageDevConsole extends
AbstractDevConsole {
Predicate pre =
getCamelContext().resolveLanguage(language).createPredicate(template);
out = pre.matches(dummy) ? "true" : "false";
} else {
- Expression exp =
getCamelContext().resolveLanguage(language).createExpression(template);
+ Expression exp = createExpression(language, template,
options);
out = exp.evaluate(dummy, String.class);
}
} catch (Exception e) {
diff --git
a/core/camel-console/src/test/java/org/apache/camel/impl/console/EvalLanguageDevConsoleTest.java
b/core/camel-console/src/test/java/org/apache/camel/impl/console/EvalLanguageDevConsoleTest.java
index 9e2c4e881aff..2f7fef177473 100644
---
a/core/camel-console/src/test/java/org/apache/camel/impl/console/EvalLanguageDevConsoleTest.java
+++
b/core/camel-console/src/test/java/org/apache/camel/impl/console/EvalLanguageDevConsoleTest.java
@@ -114,6 +114,57 @@ public class EvalLanguageDevConsoleTest extends
AbstractDevConsoleTest {
Assertions.assertEquals("true", out.getString("result"));
}
+ @Test
+ public void testEvalWithResultType() {
+ DevConsole con = assertConsoleExists("eval-language", "camel");
+
+ // the resultType goes to the language as in a route: the body as an
Integer
+ JsonObject out = callJson(con, Map.of(
+ "language", "simple",
+ "template", "${body}",
+ "body", "0042",
+ "resultType", "java.lang.Integer"));
+ Assertions.assertEquals("success", out.getString("status"));
+ Assertions.assertEquals("42", out.getString("result"));
+
+ // the short name of a java.lang class, as a route resolves it
+ out = callJson(con, Map.of(
+ "language", "simple",
+ "template", "${body}",
+ "body", "0042",
+ "resultType", "Integer"));
+ Assertions.assertEquals("42", out.getString("result"));
+
+ out = callJson(con, Map.of(
+ "language", "simple",
+ "template", "${body}",
+ "body", "0042",
+ "resultType", "com.example.NoSuchType"));
+ Assertions.assertEquals("failed", out.getString("status"));
+ JsonObject exception = out.getMap("exception");
+
Assertions.assertTrue(exception.getString("message").contains("com.example.NoSuchType"));
+
+ String text = callText(con, Map.of(
+ "language", "simple",
+ "template", "${body}",
+ "body", "0042",
+ "resultType", "java.lang.Integer"));
+ Assertions.assertTrue(text.endsWith("\n42"), text);
+ }
+
+ @Test
+ public void testEvalTextWithUnknownResultType() {
+ DevConsole con = assertConsoleExists("eval-language", "camel");
+
+ // the text output reports the error, as the json output does, instead
of throwing
+ String text = callText(con, Map.of(
+ "language", "simple",
+ "template", "${body}",
+ "body", "0042",
+ "resultType", "com.example.NoSuchType"));
+ Assertions.assertTrue(text.contains("com.example.NoSuchType"), text);
+ }
+
@Test
public void testEvalWithBodyAndVariables() {
DevConsole con = assertConsoleExists("eval-language", "camel");
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
index e007e01e0a3f..8278cf1b8668 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -478,7 +478,15 @@ passed as the file name, is answered with the argument the
source goes in.
and returns the value (true/false for a predicate) or the syntax error with
the position where it breaks,
so an agent can check an expression before writing it into a route. Any
language works: one that is not on
the server's own classpath, `jsonpath`, `jq`, `xpath`, `groovy`, has its
component downloaded on first use
- and the answer says so in `downloaded`. A null value is answered with
`nullResult: true` and a note, since the
+ and the answer says so in `downloaded`. The message can carry what an
earlier step of the route sets:
+ `headers` and `variables`, as a JSON object (its values keep their JSON
type, such as the rows of a query for
+ a groovy `headers.rows[0].total`) or as `key=value` lines. `predicate=true`
evaluates a predicate in any
+ language (for simple it is guessed from an operator), and `resultType` is
the `resultType` option of the
+ language, as in a route: `java.lang.String` for an XPath function such as
`string()` or `local-name()`,
+ which the `xpath` language otherwise evaluates as nodes. An XPath that fails
that way
+ (`Can not convert #STRING to a NodeList`) gets a hint with the argument to
pass. An integration before
+ Camel 4.24 ignores `resultType`, which the answer notes.
+ A null value is answered with `nullResult: true` and a note, since the
answer leaves out null fields; an elvis operator without a space on both
sides inside `${...}` (`${header.limit ?:10}`
reads a header named `limit ?:10`), or inside `${...}` for an integration
older than Camel 4.24, gets a note with
the expression rewritten.
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
index 6f6c9c1e2e4f..44fd28832b43 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
@@ -234,7 +234,8 @@ together with the catalog's functions and operators (their
count and names by gr
the matching ones with parameters and examples), and `docPage` serves the
operators, functions, OGNL and
advanced documentation pages. `camel_eval_expression` evaluates an expression
inside the running integration,
like `camel cmd eval`, or locally when no integration is selected, and returns
the value or the parser error,
-so the agent can try an expression before it answers or writes it. Component
lookups get the same treatment for endpoint URIs: every endpoint option
+so the agent can try an expression before it answers or writes it; it takes
the headers, variables, `resultType`
+and predicate mode the expression has in the route. Component lookups get the
same treatment for endpoint URIs: every endpoint option
says whether it is part of the URI path or a query parameter, and the result
spells out the URI rules for
that component (its path options, the `?option=value&option=value` form, the
YAML `uri` plus `parameters`
form, placeholders, `RAW()`, and that component options belong in
`application.properties`). With
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 dfb6d5abaff3..3675eb725d2c 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
@@ -1213,18 +1213,19 @@ public class LocalCliConnector extends ServiceSupport
boolean predicate = root.getBooleanOrDefault("predicate", false);
String template = root.getStringOrDefault("template", "");
String body = root.getStringOrDefault("body", "");
- Map<String, String> headerMap = new LinkedHashMap<>();
+ // a value as it is in the JSON: text, or a number, a list or an
object (the rows of a header for groovy)
+ Map<String, Object> headerMap = new LinkedHashMap<>();
Collection<JsonObject> headers = root.getCollection("headers");
if (headers != null) {
for (JsonObject jo : headers) {
- headerMap.put(jo.getString("key"), jo.getString("value"));
+ headerMap.put(jo.getString("key"), jo.get("value"));
}
}
- Map<String, String> variableMap = new LinkedHashMap<>();
+ Map<String, Object> variableMap = new LinkedHashMap<>();
Collection<JsonObject> variables = root.getCollection("variables");
if (variables != null) {
for (JsonObject jo : variables) {
- variableMap.put(jo.getString("key"),
jo.getString("value"));
+ variableMap.put(jo.getString("key"), jo.get("value"));
}
}
Map<String, Object> params = new HashMap<>();
@@ -1234,6 +1235,10 @@ public class LocalCliConnector extends ServiceSupport
params.put("body", body);
params.put("headers", headerMap);
params.put("variables", variableMap);
+ String resultType = root.getString("resultType");
+ if (resultType != null && !resultType.isBlank()) {
+ params.put("resultType", resultType);
+ }
JsonObject json = (JsonObject) dc.call(DevConsole.MediaType.JSON,
params);
writeOutput(json);
} else {
diff --git
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorEvalActionTest.java
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorEvalActionTest.java
new file mode 100644
index 000000000000..ca7bb3aadbf7
--- /dev/null
+++
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorEvalActionTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+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.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-26400: the eval action passes the resultType of the call to the
eval-language console, and a header or variable
+ * keeps the JSON type of its value (a list of rows), as camel_eval_expression
sends them.
+ */
+class LocalCliConnectorEvalActionTest {
+
+ @Test
+ void theResultTypeGoesToTheConsole() throws Exception {
+ JsonObject request = new JsonObject();
+ request.put("action", "eval");
+ request.put("language", "simple");
+ request.put("template", "${body}");
+ request.put("body", "0042");
+ request.put("resultType", "java.lang.Integer");
+
+ JsonObject answer = dispatch(request);
+
+
assertThat(answer.getString("status")).as(answer.toJson()).isEqualTo("success");
+ assertThat(answer.getString("result")).isEqualTo("42");
+ }
+
+ @Test
+ void aHeaderAndAVariableKeepTheTypeOfTheirValue() throws Exception {
+ JsonArray rows = new JsonArray();
+ JsonObject row = new JsonObject();
+ row.put("total", 3);
+ rows.add(row);
+ JsonObject request = new JsonObject();
+ request.put("action", "eval");
+ request.put("language", "simple");
+ request.put("template", "${header.rows.size()} ${variable.count}");
+ request.put("headers", new JsonArray(List.of(keyValue("rows", rows))));
+ request.put("variables", new JsonArray(List.of(keyValue("count", 7))));
+
+ // as the action file carries it: JSON text
+ JsonObject answer = dispatch((JsonObject)
Jsoner.deserialize(request.toJson()));
+
+
assertThat(answer.getString("status")).as(answer.toJson()).isEqualTo("success");
+ assertThat(answer.getString("result")).isEqualTo("1 7");
+ }
+
+ private static JsonObject dispatch(JsonObject request) throws Exception {
+ try (var 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.start();
+ var connector = new LocalCliConnector(new
DefaultCliConnectorFactory());
+ connector.setCamelContext(context);
+ var answer = new AtomicReference<JsonObject>();
+ assertThat(connector.dispatch(request, answer::set)).isTrue();
+ return answer.get();
+ }
+ }
+
+ private static JsonObject keyValue(String key, Object value) {
+ JsonObject jo = new JsonObject();
+ jo.put("key", key);
+ jo.put("value", value);
+ return jo;
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-core/pom.xml
b/dsl/camel-jbang/camel-jbang-core/pom.xml
index 7b921f8eb8f2..a24cbcd917f4 100644
--- a/dsl/camel-jbang/camel-jbang-core/pom.xml
+++ b/dsl/camel-jbang/camel-jbang-core/pom.xml
@@ -236,6 +236,23 @@
<artifactId>camel-mcp-server</artifactId>
<scope>test</scope>
</dependency>
+ <!-- languages camel_eval_expression evaluates locally with headers,
variables and a resultType; on the
+ classpath of the tests, so they are not downloaded -->
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-xpath</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-groovy</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-jq</artifactId>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-core</artifactId>
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
index 62e249e17336..cbbb5be0abae 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
@@ -78,6 +78,12 @@ public final class AuthoringTools {
+ " half done, e.g. a to: direct:x and the route from direct:x:"
+ " [{\"find\": \"...\", \"replace\": \"...\"}, {\"find\":
\"...\", \"replace\": \"...\"}]";
static final String DIRECTORY_DESC = "Project directory with the source
files (default: the selected integration's)";
+ // the arguments of camel_eval_expression for the message it is evaluated
against, and how (CAMEL-26400)
+ public static final String EVAL_HEADERS_DESC = "Message headers: a JSON
object, or key=value lines";
+ public static final String EVAL_VARIABLES_DESC = "Exchange variables: a
JSON object, or key=value lines";
+ public static final String EVAL_PREDICATE_DESC = "Evaluate as a predicate
(default: guessed for simple, else false)";
+ public static final String EVAL_RESULT_TYPE_DESC
+ = "Result class as resultType in a route, e.g. java.lang.String
for an XPath string() or local-name()";
/** Files listed and read by the file tools; more than that and a
directory is not an integration's sources. */
static final String FILE_PATH_DESC = "File path relative to the directory,
e.g. src/main/resources/camel/foo.camel.yaml";
@@ -397,16 +403,26 @@ public final class AuthoringTools {
.param("language", "string", "simple (default), jsonpath,
xpath, jq", false)
.param("body", "string", "Message body", false)
.param("name", "string", NAME_DESC, false)
+ .param("headers", "string", EVAL_HEADERS_DESC, false)
+ .param("variables", "string", EVAL_VARIABLES_DESC, false)
+ .param("predicate", "boolean", EVAL_PREDICATE_DESC, false)
+ .param("resultType", "string", EVAL_RESULT_TYPE_DESC, false)
.core(true)
.executor((ctx, args) -> {
+ String expression = required(args, "expression");
+ Map<String, Object> headers =
ExpressionEvaluator.parseValues("headers", args.get("headers"));
+ Map<String, Object> variables =
ExpressionEvaluator.parseValues("variables", args.get("variables"));
+ Boolean predicate = booleanArgument("predicate",
args.get("predicate"));
String name = args.get("name");
if (name != null && !name.isBlank()) {
ctx.selectProcess(name);
} else {
ctx.selectSingleProcessIfNone();
}
- return ExpressionEvaluator.evaluate(ctx,
args.get("language"), required(args, "expression"),
- args.get("body")).toJson();
+ return ExpressionEvaluator.evaluate(ctx, new
ExpressionEvaluator.Request(
+ args.get("language"),
+ expression, args.get("body"), headers, variables,
predicate,
+ args.get("resultType"))).toJson();
}));
registry.accept(tool("camel_dependency_for_class",
@@ -2612,6 +2628,21 @@ public final class AuthoringTools {
return value == null || value.isBlank() ? defaultValue :
Boolean.parseBoolean(value.trim());
}
+ /**
+ * A boolean argument that may be left out: null when it is, and refused
when it is not true or false, where
+ * {@link Boolean#valueOf(String)} would take {@code yes} for false
without a word.
+ */
+ static Boolean booleanArgument(String key, String value) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
+ String text = value.strip();
+ if ("true".equalsIgnoreCase(text) || "false".equalsIgnoreCase(text)) {
+ return Boolean.valueOf(text);
+ }
+ throw new ToolExecutionException(key + " is true or false, not " +
text);
+ }
+
static int integer(Map<String, String> args, String key, int defaultValue)
{
String value = args.get(key);
if (value == null || value.isBlank()) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluator.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluator.java
index 2e99d3896939..f2926e9aad39 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluator.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluator.java
@@ -16,14 +16,20 @@
*/
package org.apache.camel.dsl.jbang.core.commands.ai;
+import java.util.Collection;
+import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.Expression;
import org.apache.camel.Predicate;
import org.apache.camel.catalog.LanguageValidationResult;
+import org.apache.camel.dsl.jbang.core.common.ActionClient;
+import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
import org.apache.camel.dsl.jbang.core.common.ScratchContextHelper;
import org.apache.camel.impl.DefaultCamelContext;
import org.apache.camel.main.download.DependencyDownloaderClassLoader;
@@ -31,6 +37,8 @@ import
org.apache.camel.main.download.MavenDependencyDownloader;
import org.apache.camel.spi.Language;
import org.apache.camel.support.DefaultExchange;
import org.apache.camel.tooling.model.LanguageModel;
+import org.apache.camel.util.json.DeserializationException;
+import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
import org.apache.camel.util.json.Jsoner;
@@ -50,9 +58,77 @@ public final class ExpressionEvaluator {
// a path that ends with a filter: $[?(@.sku == 'CAMEL-MUG')]
private static final Pattern FILTER_ENDS_PATH =
Pattern.compile("\\)]\\s*$");
+ // the JDK's XPath asked for nodes, the default result of the xpath
language, by a function that gives a value
+ private static final Pattern XPATH_NOT_NODES = Pattern.compile("Can not
convert #(STRING|BOOLEAN|NUMBER) to a NodeList");
+
private ExpressionEvaluator() {
}
+ /**
+ * What to evaluate, and the message to evaluate it against.
+ *
+ * @param language the language, simple by default
+ * @param expression the expression
+ * @param body the message body, or null for an empty one
+ * @param headers the message headers, or null for none
+ * @param variables the exchange variables, or null for none
+ * @param predicate whether to evaluate it as a predicate; null guesses
it for simple (an operator) and is false
+ * for the other languages
+ * @param resultType the class of the result, such as java.lang.String, as
the resultType of the language in a
+ * route, or null for the language's own (nodes for
xpath)
+ */
+ public record Request(
+ String language, String expression, String body, Map<String,
Object> headers,
+ Map<String, Object> variables, Boolean predicate, String
resultType) {
+
+ public Request(String language, String expression, String body) {
+ this(language, expression, body, null, null, null, null);
+ }
+
+ boolean isPredicate() {
+ return Boolean.TRUE.equals(predicate);
+ }
+ }
+
+ /**
+ * Headers or variables as the tool takes them: a JSON object, whose
values keep their JSON type (so a list of rows
+ * works for a groovy headers.rows[0].total), or key=value lines as
camel_runtime_send takes headers, whose values
+ * are text.
+ *
+ * @param name the argument, headers or variables, for
the error
+ * @param text the argument, or null
+ * @return the values by name, or null when there
are none
+ * @throws ToolExecutionException when the text is neither
+ */
+ public static Map<String, Object> parseValues(String name, String text) {
+ if (text == null || text.isBlank()) {
+ return null;
+ }
+ String trimmed = text.strip();
+ Map<String, Object> answer = new LinkedHashMap<>();
+ if (trimmed.startsWith("{")) {
+ Object json;
+ try {
+ json = Jsoner.deserialize(trimmed);
+ } catch (DeserializationException e) {
+ throw new ToolExecutionException(name + " is not a valid JSON
object: " + e.getMessage());
+ }
+ if (json instanceof Map<?, ?> map) {
+ map.forEach((k, v) -> answer.put(String.valueOf(k), v));
+ return answer.isEmpty() ? null : answer;
+ }
+ }
+ try {
+ for (Object entry : RuntimeHelper.parseKeyValues(trimmed, "A line
of " + name)) {
+ JsonObject jo = (JsonObject) entry;
+ answer.put(jo.getString("key"), jo.getString("value"));
+ }
+ } catch (IllegalArgumentException e) {
+ throw new ToolExecutionException(e.getMessage() + " (or pass a
JSON object)");
+ }
+ return answer.isEmpty() ? null : answer;
+ }
+
/** Whether a simple expression is a predicate: it has a comparison or
logical operator and no ternary or elvis. */
public static boolean looksLikePredicate(String expression) {
if (expression == null || expression.contains(" ? ") ||
expression.contains(" ?: ")) {
@@ -69,26 +145,48 @@ public final class ExpressionEvaluator {
* @return language, expression, predicate, status (ok or
error) and the result or error
*/
public static JsonObject evaluate(ToolContext ctx, String language, String
expression, String body) {
- String lang = language == null || language.isBlank() ? "simple" :
language;
- boolean predicate = "simple".equals(lang) &&
looksLikePredicate(expression);
+ return evaluate(ctx, new Request(language, expression, body));
+ }
+
+ /**
+ * @param ctx the context; its selected process evaluates the
expression when it has one
+ * @param request the expression, and the message to evaluate it against
+ * @return language, expression, predicate, status (ok or error)
and the result or error
+ */
+ public static JsonObject evaluate(ToolContext ctx, Request request) {
+ String lang = request.language() == null ||
request.language().isBlank() ? "simple" : request.language();
+ String expression = request.expression();
+ String body = request.body();
+ boolean predicate = request.predicate() != null
+ ? request.predicate() : "simple".equals(lang) &&
looksLikePredicate(expression);
+ String resultType = request.resultType() == null ||
request.resultType().isBlank()
+ ? null : request.resultType().strip();
JsonObject result = new JsonObject();
result.put("language", lang);
result.put("expression", expression);
if (predicate) {
result.put("predicate", true);
+ } else if (resultType != null) {
+ result.put("resultType", resultType);
}
if (body == null && expression.contains("${body")) {
result.put("note", "evaluated with an empty body; pass body to
evaluate against a value");
}
+ if (predicate && resultType != null) {
+ addNote(result, "resultType is not used for a predicate, which
gives true or false");
+ resultType = null;
+ }
+ Request r = new Request(lang, expression, body, request.headers(),
request.variables(), predicate, resultType);
if (ctx.hasProcess()) {
result.put("evaluatedIn", "the running integration (pid " +
ctx.pid() + ")");
- evaluateInProcess(ctx, lang, expression, body, predicate, result);
+ evaluateInProcess(ctx, r, result);
} else {
result.put("evaluatedIn", "a local scratch context (no integration
selected)");
- evaluateLocally(ctx, lang, expression, body, predicate, result);
+ evaluateLocally(ctx, r, result);
}
addNullAndElvisNotes(ctx, result, lang, expression);
addFilterNote(result, lang, expression);
+ addXPathHint(result, lang, expression);
return result;
}
@@ -107,14 +205,21 @@ public final class ExpressionEvaluator {
result.put("result", null);
result.put("nullResult", true);
}
- String elvis = "simple".equals(lang) && expression != null &&
expression.contains("?:")
- ? SimpleChecks.elvisInsideFunction(expression,
camelVersion(ctx)) : null;
+ String elvis = elvisNote(ctx, lang, expression);
String note = elvis != null ? elvis : isNull ? NULL_NOTE : null;
if (note != null) {
result.put("note", result.containsKey("note") ?
result.getString("note") + "; " + note : note);
}
}
+ /**
+ * What to say about an elvis operator inside ${...} that the Camel of the
evaluation does not read as one, or null.
+ */
+ private static String elvisNote(ToolContext ctx, String lang, String
expression) {
+ return "simple".equals(lang) && expression != null &&
expression.contains("?:")
+ ? SimpleChecks.elvisInsideFunction(expression,
camelVersion(ctx)) : null;
+ }
+
/**
* The Camel version that evaluates: the selected integration's, else the
one the tools are for (null for the CLI's
* own, which evaluates locally).
@@ -133,12 +238,54 @@ public final class ExpressionEvaluator {
}
}
+ /** Adds a note to the result of an evaluation, after the note it has
already. */
+ private static void addNote(JsonObject result, String note) {
+ result.put("note", result.containsKey("note") ?
result.getString("note") + "; " + note : note);
+ }
+
/** Adds the note about a jsonpath filter to the result of an evaluation,
after the note it has already. */
static void addFilterNote(JsonObject result, String lang, String
expression) {
String filter = filterNote(lang, expression,
result.getString("status"));
if (filter != null) {
- result.put("note", result.containsKey("note") ?
result.getString("note") + "; " + filter : filter);
+ addNote(result, filter);
+ }
+ }
+
+ /** Adds what to pass to an XPath function that gives a value instead of
nodes, to an error of an evaluation. */
+ static void addXPathHint(JsonObject result, String lang, String
expression) {
+ String hint = xpathHint(lang, expression, result.getString("error"));
+ if (hint != null) {
+ result.put("hint", hint);
+ }
+ }
+
+ /**
+ * The xpath language evaluates to nodes unless told otherwise, so a
function that gives a value (string(),
+ * local-name(), boolean(), count()) fails with "Can not convert #STRING
to a NodeList": a model of the eval tried
+ * the tool three times with such an XPath, then gave up on it. The hint
says what to pass, here and in the route.
+ */
+ static String xpathHint(String lang, String expression, String error) {
+ if (!"xpath".equals(lang) || error == null) {
+ return null;
+ }
+ Matcher m = XPATH_NOT_NODES.matcher(error);
+ if (!m.find()) {
+ return null;
}
+ String route = "xpath: {expression: " + quoted(expression) + ",
resultType: ";
+ return switch (m.group(1)) {
+ case "STRING" -> "this XPath returns a string, not nodes: pass
resultType java.lang.String (in a route: "
+ + route + "java.lang.String})";
+ case "BOOLEAN" -> "this XPath returns a boolean, not nodes: pass
predicate=true (in a route: use it as the"
+ + " predicate of a filter or a when, or as " +
route + "java.lang.Boolean})";
+ default -> "this XPath returns a number, not nodes: pass
resultType java.lang.Integer, or"
+ + " java.lang.Double for a fraction (in a route: " +
route + "java.lang.Integer})";
+ };
+ }
+
+ /** The text as a quoted YAML value: in single quotes when it holds a
double quote, as the YAML validator quotes. */
+ private static String quoted(String text) {
+ return text.contains("\"") || text.contains("\\") ? "'" +
text.replace("'", "''") + "'" : "\"" + text + "\"";
}
/**
@@ -162,17 +309,49 @@ public final class ExpressionEvaluator {
+ " java.lang.String or java.lang.Integer, gives the one value
when one element matches";
}
- private static void evaluateInProcess(
- ToolContext ctx, String lang, String expression, String body,
boolean predicate, JsonObject result) {
- String raw = ctx.executeAction("eval", root -> {
- root.put("language", lang);
- root.put("predicate", String.valueOf(predicate));
- // as they are: the action file is JSON, and the integration does
not unescape them
- root.put("template", expression);
- if (body != null) {
- root.put("body", body);
+ private static void evaluateInProcess(ToolContext ctx, Request r,
JsonObject result) {
+ // an integration before Camel 4.24 reads the value of a header or
variable as text, and has no resultType
+ String version = ActionClient.versionOf(ctx.pid());
+ boolean before424 = version != null &&
!ActionClient.isAtLeast(version, 4, 24);
+ String ignored = before424 && r.resultType() != null
+ ? "the integration runs Camel " + version + ", which ignores
resultType (taken from Camel 4.24 on)"
+ : null;
+ if (ignored != null) {
+ addNote(result, ignored);
+ }
+ String raw;
+ try {
+ raw = ctx.executeAction("eval", root -> {
+ root.put("language", r.language());
+ root.put("predicate", String.valueOf(r.isPredicate()));
+ // as they are: the action file is JSON, and the integration
does not unescape them
+ root.put("template", r.expression());
+ if (r.body() != null) {
+ root.put("body", r.body());
+ }
+ if (r.headers() != null) {
+ root.put("headers", keyValues(r.headers(), !before424));
+ }
+ if (r.variables() != null) {
+ root.put("variables", keyValues(r.variables(),
!before424));
+ }
+ if (r.resultType() != null) {
+ root.put("resultType", r.resultType());
+ }
+ });
+ } catch (ToolExecutionException e) {
+ // the integration answers an expression that fails as a failed
action, a tool error (CAMEL-25398): what
+ // to pass for an XPath function, that resultType was not used,
and the rewrite of an elvis operator inside
+ // ${...} that an integration before Camel 4.24 refuses, go with
its message
+ String hint = xpathHint(r.language(), r.expression(),
e.getMessage());
+ String elvis = elvisNote(ctx, r.language(), r.expression());
+ if (hint == null && ignored == null && elvis == null) {
+ throw e;
}
- });
+ throw new ToolExecutionException(
+ e.getMessage() + (hint != null ? "; " + hint : "") +
(ignored != null ? "; " + ignored : "")
+ + (elvis != null ? "; " + elvis :
""));
+ }
JsonObject out = null;
try {
out = (JsonObject) Jsoner.deserialize(raw);
@@ -195,26 +374,41 @@ public final class ExpressionEvaluator {
result.put("error", message != null ? Jsoner.unescape(message) :
"evaluation failed");
}
- private static void evaluateLocally(
- ToolContext ctx, String lang, String expression, String body,
boolean predicate, JsonObject result) {
+ /**
+ * Headers or variables as the eval action of the CLI connector reads
them: a key and a value for each. A JSON list
+ * or object goes as JSON text to an integration before Camel 4.24, which
reads every value as text.
+ */
+ private static JsonArray keyValues(Map<String, Object> values, boolean
typed) {
+ JsonArray list = new JsonArray();
+ values.forEach((k, v) -> {
+ JsonObject jo = new JsonObject();
+ jo.put("key", k);
+ jo.put("value", typed || !(v instanceof Map || v instanceof
Collection) ? v : Jsoner.serialize(v));
+ list.add(jo);
+ });
+ return list;
+ }
+
+ private static void evaluateLocally(ToolContext ctx, Request r, JsonObject
result) {
+ String lang = r.language();
ClassLoader tccl = Thread.currentThread().getContextClassLoader();
try {
String gav = languageArtifact(ctx, lang);
// the languages of this process (simple, constant, header, ...)
need no class loader of their own
ClassLoader known = gav != null ? DOWNLOADED.get(gav) : null;
- if (evaluateWith(known, lang, expression, body, predicate,
result)) {
+ if (evaluateWith(known, r, result)) {
if (known != null) {
// downloaded by an earlier call: say so again, so the
answers of two calls read the same
result.put("downloaded", gav);
}
- syntaxCheck(ctx, known, lang, expression, predicate, result);
+ syntaxCheck(ctx, known, lang, r.expression(), r.isPredicate(),
result);
return;
}
// the language is not on this process's classpath: download its
component as camel run does, so a
// jsonpath, jq or xpath expression can be tried before it is
written (CAMEL-24907)
StringBuilder failure = new StringBuilder();
ClassLoader downloaded = download(gav, failure);
- if (downloaded == null || !evaluateWith(downloaded, lang,
expression, body, predicate, result)) {
+ if (downloaded == null || !evaluateWith(downloaded, r, result)) {
result.put("status", "error");
result.put("error", "The language '" + lang + "' is not on the
classpath of this process"
+ (gav != null ? " and " + gav + " could
not be downloaded" : "")
@@ -224,7 +418,7 @@ public final class ExpressionEvaluator {
return;
}
result.put("downloaded", gav);
- syntaxCheck(ctx, downloaded, lang, expression, predicate, result);
+ syntaxCheck(ctx, downloaded, lang, r.expression(),
r.isPredicate(), result);
} finally {
Thread.currentThread().setContextClassLoader(tccl);
}
@@ -234,8 +428,7 @@ public final class ExpressionEvaluator {
* Evaluates with the given class loader (null for this process's own),
and answers whether the language was found;
* the result of the evaluation, or its error, is put in {@code result}.
*/
- private static boolean evaluateWith(
- ClassLoader loader, String lang, String expression, String body,
boolean predicate, JsonObject result) {
+ private static boolean evaluateWith(ClassLoader loader, Request r,
JsonObject result) {
// without the CLI connector, which would make this process, such as
the MCP server, a running integration
try (DefaultCamelContext context =
ScratchContextHelper.withoutCliConnector(new DefaultCamelContext(false))) {
if (loader != null) {
@@ -246,24 +439,33 @@ public final class ExpressionEvaluator {
context.start();
Language language;
try {
- language = context.resolveLanguage(lang);
+ language = context.resolveLanguage(r.language());
} catch (Exception e) {
return false;
}
Exchange exchange = new DefaultExchange(context);
- exchange.getMessage().setBody(body != null ? body : "");
+ exchange.getMessage().setBody(r.body() != null ? r.body() : "");
+ if (r.headers() != null) {
+ r.headers().forEach(exchange.getMessage()::setHeader);
+ }
+ if (r.variables() != null) {
+ r.variables().forEach(exchange::setVariable);
+ }
Object value;
- if (predicate) {
- Predicate p = language.createPredicate(expression);
+ if (r.isPredicate()) {
+ Predicate p = language.createPredicate(r.expression());
p.init(context);
value = p.matches(exchange);
} else {
- Expression e = language.createExpression(expression);
+ // the resultType of a route goes to the language as its first
property, as the reifiers pass it
+ Expression e = r.resultType() != null
+ ? language.createExpression(r.expression(), new
Object[] { resultType(context, r.resultType()) })
+ : language.createExpression(r.expression());
e.init(context);
value = e.evaluate(exchange, Object.class);
}
result.put("status", "ok");
- result.put("result", value != null ? value.toString() : null);
+ result.put("result", text(exchange, value));
} catch (Exception e) {
result.put("status", "error");
result.put("error", rootCause(e));
@@ -271,6 +473,29 @@ public final class ExpressionEvaluator {
return true;
}
+ /** The class of a resultType as a route resolves it, so java.lang.String
works, and String as well. */
+ private static Class<?> resultType(CamelContext context, String name) {
+ Class<?> type = context.getClassResolver().resolveClass(name);
+ if (type == null) {
+ throw new IllegalArgumentException(
+ "resultType " + name + " is not a class on the classpath;
use a class name such as"
+ + " java.lang.String,
java.lang.Integer or java.util.Map");
+ }
+ return type;
+ }
+
+ /**
+ * The value as text, the way the integration answers it: converted by the
context, so an XPath node list shows its
+ * XML instead of the name of its class.
+ */
+ private static String text(Exchange exchange, Object value) {
+ if (value == null) {
+ return null;
+ }
+ String text =
exchange.getContext().getTypeConverter().tryConvertTo(String.class, exchange,
value);
+ return text != null ? text : value.toString();
+ }
+
/**
* The catalog's own check of the text, which names where the syntax
breaks (its index), so an error says more than
* the evaluation's exception; only added when it finds something the
evaluation did not.
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ActionClient.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ActionClient.java
index 06744756dda6..090900d1f096 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ActionClient.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ActionClient.java
@@ -316,7 +316,10 @@ public final class ActionClient {
return request;
}
- private static String versionOf(long pid) {
+ /**
+ * The Camel version of the integration with the given pid, from its
status file, or <tt>null</tt> when unknown.
+ */
+ public static String versionOf(long pid) {
try {
Path f = CommandLineHelper.getCamelDir().resolve(pid +
"-status.json");
JsonObject status = Files.exists(f) ?
Jsoner.deserialize(Files.readString(f), (JsonObject) null) : null;
@@ -332,7 +335,7 @@ public final class ActionClient {
/**
* Whether a Camel version such as <tt>4.24.0-SNAPSHOT</tt> is the given
major.minor or later.
*/
- static boolean isAtLeast(String version, int major, int minor) {
+ public static boolean isAtLeast(String version, int major, int minor) {
Matcher m = VERSION.matcher(version);
if (!m.find()) {
// not a version we know how to read: assume a recent one
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 e7a9d35dd7e0..382f6bfced8e 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
@@ -245,19 +245,31 @@ public final class RuntimeHelper {
* The headers as the send action of the CLI connector reads them: a key
and a value for each header.
*/
static JsonArray parseHeaders(String headers) {
+ return parseKeyValues(headers, "Header");
+ }
+
+ /**
+ * Lines of {@code key=value} as the actions of the CLI connector read
them (the headers of the send action, the
+ * headers and variables of the eval action): a key and a value for each
line.
+ *
+ * @param text the lines
+ * @param what what a line holds, for the error, such
as Header
+ * @throws IllegalArgumentException when a line is not in the {@code
key=value} form
+ */
+ public static JsonArray parseKeyValues(String text, String what) {
JsonArray list = new JsonArray();
- for (String line : headers.split("\\R")) {
- String header = line.strip();
- if (header.isEmpty()) {
+ for (String line : text.split("\\R")) {
+ String entry = line.strip();
+ if (entry.isEmpty()) {
continue;
}
- int pos = header.indexOf('=');
+ int pos = entry.indexOf('=');
if (pos <= 0) {
- throw new IllegalArgumentException("Header must be in
key=value format, was: " + header);
+ throw new IllegalArgumentException(what + " must be in
key=value format, was: " + entry);
}
JsonObject jo = new JsonObject();
- jo.put("key", header.substring(0, pos).strip());
- jo.put("value", header.substring(pos + 1));
+ jo.put("key", entry.substring(0, pos).strip());
+ jo.put("value", entry.substring(pos + 1));
list.add(jo);
}
return list;
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/EvalInTheIntegrationTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/EvalInTheIntegrationTest.java
index 0f4597721b11..540413bf2529 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/EvalInTheIntegrationTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/EvalInTheIntegrationTest.java
@@ -18,6 +18,10 @@ package org.apache.camel.dsl.jbang.core.commands.ai;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import org.apache.camel.console.DevConsole;
@@ -32,6 +36,7 @@ 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 expression tools evaluate in the selected integration: it gets the
expression and the body as they are, as the
@@ -137,6 +142,121 @@ class EvalInTheIntegrationTest {
assertThat(json.getString("result")).isEqualTo("\"a/b\"");
}
+ /** CAMEL-26400: the headers, variables, predicate and resultType of the
call go to the eval action. */
+ @Test
+ void theHeadersVariablesPredicateAndResultTypeGoToTheAction() {
+ JsonObject result = ExpressionEvaluator.evaluate(ctx(), new
ExpressionEvaluator.Request(
+ "simple",
+ "${header.foo}-${variable.bar}", null, Map.of("foo", "x"),
Map.of("bar", "y"), null, null));
+
+ assertThat(result.getString("status")).isEqualTo("ok");
+ assertThat(result.getString("result")).isEqualTo("x-y");
+ JsonObject action = integration.lastAction();
+ Collection<JsonObject> headers = action.getCollection("headers");
+ assertThat(headers).containsExactly(keyValue("foo", "x"));
+ Collection<JsonObject> variables = action.getCollection("variables");
+ assertThat(variables).containsExactly(keyValue("bar", "y"));
+ assertThat(action.getString("predicate")).isEqualTo("false");
+ assertThat(action.get("resultType")).isNull();
+
+ JsonObject typed = ExpressionEvaluator.evaluate(ctx(), new
ExpressionEvaluator.Request(
+ "simple", "${body}",
+ "0042", null, null, null, "java.lang.Integer"));
+
assertThat(integration.lastAction().getString("resultType")).isEqualTo("java.lang.Integer");
+ assertThat(typed.getString("result")).isEqualTo("42");
+ assertThat(typed.get("note")).isNull();
+
+ // any language can be a predicate: the action says so, and the
console answers true or false
+ JsonObject predicate = ExpressionEvaluator.evaluate(ctx(), new
ExpressionEvaluator.Request(
+ "constant",
+ "true", null, null, null, true, null));
+
assertThat(integration.lastAction().getString("predicate")).isEqualTo("true");
+ assertThat(predicate.getString("result")).isEqualTo("true");
+ }
+
+ /** A JSON list or object keeps its type, so a groovy
headers.rows[0].total works in the integration too. */
+ @Test
+ void aJsonValueKeepsItsType() {
+ Map<String, Object> headers =
ExpressionEvaluator.parseValues("headers", "{\"rows\": [{\"total\": 3}]}");
+ ExpressionEvaluator.evaluate(ctx(), new ExpressionEvaluator.Request(
+ "simple", "${header.rows}", null, headers,
+ null, null, null));
+
+ JsonObject header = (JsonObject)
List.copyOf(integration.lastAction().getCollection("headers")).get(0);
+ assertThat(header.get("value")).isInstanceOf(List.class);
+ }
+
+ /**
+ * An integration before Camel 4.24 reads every value of a header as text,
and has no resultType: the list goes as
+ * JSON text, and the answer says that resultType is not used there.
+ */
+ @Test
+ void anIntegrationBefore424GetsTextAndANoteOnTheResultType() throws
Exception {
+ Files.writeString(CommandLineHelper.getCamelDir().resolve(PID +
"-status.json"),
+ "{\"context\": {\"name\": \"orders\", \"version\":
\"4.23.0\"}}");
+ Map<String, Object> headers =
ExpressionEvaluator.parseValues("headers", "{\"rows\": [{\"total\": 3}]}");
+
+ JsonObject result = ExpressionEvaluator.evaluate(ctx(), new
ExpressionEvaluator.Request(
+ "simple", "${body}",
+ "1", headers, null, null, "java.lang.Integer"));
+
+ assertThat(result.getString("note")).contains("the integration runs
Camel 4.23.0, which ignores resultType");
+ JsonObject header = (JsonObject)
List.copyOf(integration.lastAction().getCollection("headers")).get(0);
+ assertThat(header.get("value")).isEqualTo("[{\"total\":3}]");
+ }
+
+ /**
+ * The XPath of a route that fails in the integration gets the same hint
as a local evaluation, with the error the
+ * tool answers (a failed eval action is a tool error).
+ */
+ @Test
+ void anXPathErrorOfTheIntegrationGetsTheHint() throws Exception {
+ try (FakeIntegration failing = new FakeIntegration(
+ PID + 1, root -> Map.of("status", "failed", "exception",
+ Map.of("message", "Invalid xpath: boolean(/x). Reason:
javax.xml.xpath.XPathExpressionException:"
+ + "
com.sun.org.apache.xpath.internal.XPathException: Can not convert #BOOLEAN to a"
+ + " NodeList!")))) {
+ ToolContext ctx = new ToolContext();
+ ctx.selectProcess(PID + 1);
+
+ assertThatThrownBy(() -> ExpressionEvaluator.evaluate(ctx,
"xpath", "boolean(/x)", "<x/>"))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessageStartingWith("Invalid xpath: boolean(/x).")
+ .hasMessageContaining("NodeList!; this XPath returns a
boolean, not nodes: pass predicate=true");
+ assertThat(failing.actions()).hasSize(1);
+ }
+ }
+
+ /**
+ * An integration before Camel 4.24 refuses the elvis operator inside
${...}: the tool error says how to write it
+ * there, as the answer of an evaluation that works does (CAMEL-26401).
+ */
+ @Test
+ void
anElvisInsideThePlaceholderRefusedByAnOlderIntegrationGetsTheRewrite() throws
Exception {
+ Files.writeString(CommandLineHelper.getCamelDir().resolve((PID + 1) +
"-status.json"),
+ "{\"context\":{\"version\":\"4.22.1\"}}");
+ try (FakeIntegration failing = new FakeIntegration(
+ PID + 1, root -> Map.of("status", "failed", "exception",
+ Map.of("message", "${ cannot accept ?: at location
17")))) {
+ ToolContext ctx = new ToolContext();
+ ctx.selectProcess(PID + 1);
+
+ assertThatThrownBy(() -> ExpressionEvaluator.evaluate(ctx,
"simple", "${header.missing ?: 10}", null))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessageStartingWith("${ cannot accept ?: at location
17; ")
+ .hasMessageContaining("needs Camel 4.24 or later")
+ .hasMessageContaining("(here Camel 4.22.1)")
+ .hasMessageEndingWith("write it after the function:
`${header.missing} ?: 10`");
+ }
+ }
+
+ private static JsonObject keyValue(String key, Object value) {
+ JsonObject jo = new JsonObject();
+ jo.put("key", key);
+ jo.put("value", value);
+ return jo;
+ }
+
private static ToolContext ctx() {
ToolContext ctx = new ToolContext();
ctx.selectProcess(PID);
@@ -146,11 +266,25 @@ class EvalInTheIntegrationTest {
// the eval action of the CLI connector: the values of the action as they
are, to the eval-language console
private Object eval(JsonObject root) {
Object predicate = root.get("predicate");
- return console.call(DevConsole.MediaType.JSON, Map.of(
- "language", root.getStringOrDefault("language", "simple"),
- "predicate", predicate != null &&
Boolean.parseBoolean(predicate.toString()),
- "template", root.getStringOrDefault("template", ""),
- "body", root.getStringOrDefault("body", ""),
- "headers", Map.of(), "variables", Map.of()));
+ Map<String, Object> options = new HashMap<>();
+ options.put("language", root.getStringOrDefault("language", "simple"));
+ options.put("predicate", predicate != null &&
Boolean.parseBoolean(predicate.toString()));
+ options.put("template", root.getStringOrDefault("template", ""));
+ options.put("body", root.getStringOrDefault("body", ""));
+ options.put("headers", values(root, "headers"));
+ options.put("variables", values(root, "variables"));
+ if (root.getString("resultType") != null) {
+ options.put("resultType", root.getString("resultType"));
+ }
+ return console.call(DevConsole.MediaType.JSON, options);
+ }
+
+ private static Map<String, Object> values(JsonObject root, String name) {
+ Map<String, Object> values = new LinkedHashMap<>();
+ Collection<JsonObject> list = root.getCollection(name);
+ if (list != null) {
+ list.forEach(jo -> values.put(jo.getString("key"),
jo.get("value")));
+ }
+ return values;
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorRequestTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorRequestTest.java
new file mode 100644
index 000000000000..7d73edda9cd0
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorRequestTest.java
@@ -0,0 +1,209 @@
+/*
+ * 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.dsl.jbang.core.commands.ai;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+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 static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * CAMEL-26400: camel_eval_expression evaluates an expression the way the
route will, with the headers and variables an
+ * earlier step sets, the resultType of the language and as a predicate in any
language. Here in the local scratch
+ * context, with xpath, groovy and jq on the classpath of the tests.
+ */
+class ExpressionEvaluatorRequestTest {
+
+ private static final String XML = "<x><y>1</y><y>2</y></x>";
+
+ @TempDir
+ Path home;
+
+ private Path previousHome;
+
+ // a home of its own: the tool picks the only running integration of the
machine, when there is one
+ @BeforeEach
+ void useOwnHome() {
+ previousHome = CommandLineHelper.getHomeDir();
+ CommandLineHelper.useHomeDir(home.toString());
+ }
+
+ @AfterEach
+ void restoreHome() {
+ CommandLineHelper.useHomeDir(previousHome.toString());
+ }
+
+ @Test
+ void anXPathBooleanIsEvaluatedAsAPredicate() {
+ String expression = "boolean(/*[local-name()='x'])";
+ JsonObject asNodes = eval("xpath", expression, XML, null, null, null,
null);
+ assertThat(asNodes.getString("status")).isEqualTo("error");
+ assertThat(asNodes.getString("error")).contains("#BOOLEAN to a
NodeList");
+ assertThat(asNodes.getString("hint")).startsWith("this XPath returns a
boolean, not nodes: pass predicate=true")
+ .contains("xpath: {expression:
\"boolean(/*[local-name()='x'])\", resultType: java.lang.Boolean}");
+
+ JsonObject predicate = eval("xpath", expression, XML, null, null,
true, null);
+ assertThat(predicate.getString("status")).isEqualTo("ok");
+ assertThat(predicate.getString("result")).isEqualTo("true");
+ assertThat(predicate.getBoolean("predicate")).isTrue();
+ assertThat(predicate.get("hint")).isNull();
+ }
+
+ @Test
+ void anXPathStringFunctionTakesTheResultType() {
+ String expression = "local-name(/*[local-name()='x']/*[1])";
+ JsonObject asNodes = eval("xpath", expression, XML, null, null, null,
null);
+ assertThat(asNodes.getString("status")).isEqualTo("error");
+ assertThat(asNodes.getString("hint")).startsWith("this XPath returns a
string, not nodes: pass resultType"
+ + " java.lang.String")
+ .contains("(in a route: xpath: {expression: \"" + expression +
"\", resultType: java.lang.String})");
+
+ JsonObject typed = eval("xpath", expression, XML, null, null, null,
"java.lang.String");
+ assertThat(typed.getString("status")).isEqualTo("ok");
+ assertThat(typed.getString("result")).isEqualTo("y");
+
assertThat(typed.getString("resultType")).isEqualTo("java.lang.String");
+ // the short name works as in a route, where the class resolver knows
it
+ assertThat(eval("xpath", expression, XML, null, null, null,
"String").getString("result")).isEqualTo("y");
+ }
+
+ @Test
+ void anXPathNumberTakesANumberType() {
+ JsonObject asNodes = eval("xpath", "count(/x/y)", XML, null, null,
null, null);
+ assertThat(asNodes.getString("hint")).startsWith("this XPath returns a
number, not nodes: pass resultType"
+ + "
java.lang.Integer");
+
+ assertThat(eval("xpath", "count(/x/y)", XML, null, null, null,
"java.lang.Integer").getString("result"))
+ .isEqualTo("2");
+ }
+
+ @Test
+ void nodesShowTheirXmlAsTheIntegrationAnswers() {
+ JsonObject nodes = eval("xpath", "/x/y[1]", XML, null, null, null,
null);
+
+ assertThat(nodes.getString("status")).isEqualTo("ok");
+ // not com.sun.org.apache.xml.internal.dtm.ref.DTMNodeList@60b333fd
+ assertThat(nodes.getString("result")).isEqualTo("<y>1</y>");
+ }
+
+ @Test
+ void anUnknownResultTypeSaysWhatToPass() {
+ JsonObject result = eval("xpath", "string(/x/y)", XML, null, null,
null, "Map");
+
+ assertThat(result.getString("status")).isEqualTo("error");
+ assertThat(result.getString("error")).contains("resultType Map is not
a class on the classpath")
+ .contains("java.util.Map");
+ }
+
+ @Test
+ void aPredicateSaysThatItHasNoResultType() {
+ JsonObject result = eval("simple", "${body} == 'a'", "a", null, null,
null, "java.lang.Integer");
+
+ assertThat(result.getString("result")).isEqualTo("true");
+ assertThat(result.get("resultType")).isNull();
+ assertThat(result.getString("note")).contains("resultType is not used
for a predicate");
+ }
+
+ @Test
+ void groovyReadsTheRowsOfAHeader() {
+ Map<String, Object> headers =
ExpressionEvaluator.parseValues("headers",
+ "{\"totalOrdersRows\": [{\"total_orders\": 3}]}");
+ JsonObject result = eval("groovy", "def total =
headers.totalOrdersRows[0].total_orders as long; total + 1",
+ null, headers, null, null, null);
+
+
assertThat(result.getString("status")).as(result.toJson()).isEqualTo("ok");
+ assertThat(result.getString("result")).isEqualTo("4");
+ }
+
+ @Test
+ void jqReadsAVariable() {
+ Map<String, Object> variables =
ExpressionEvaluator.parseValues("variables",
+ Jsoner.serialize(Map.of("requestBodyJson", "{\"products\":
[{\"sku\": \"A\"}, {\"sku\": \"B\"}]}")));
+ JsonObject result = eval("jq", "[ (variable(\"requestBodyJson\") |
fromjson).products[] | .sku ]", "{}",
+ null, variables, null, null);
+
+
assertThat(result.getString("status")).as(result.toJson()).isEqualTo("ok");
+ // as the integration answers it: the JSON of the node, which the type
converter may indent
+ assertThat(result.getString("result").replace(" ",
"")).isEqualTo("[\"A\",\"B\"]");
+ }
+
+ @Test
+ void headersAndVariablesAreKeyValueLines() {
+ Map<String, Object> headers =
ExpressionEvaluator.parseValues("headers", "foo=bar\n qty=3\n");
+ Map<String, Object> variables =
ExpressionEvaluator.parseValues("variables", "greeting=Hi");
+ JsonObject result = eval("simple", "${variable.greeting} ${header.foo}
${header.qty}", null, headers, variables,
+ null, null);
+
+ assertThat(result.getString("result")).isEqualTo("Hi bar 3");
+ assertThat(ExpressionEvaluator.parseValues("headers", " ")).isNull();
+ }
+
+ @Test
+ void headersThatAreNeitherSayWhy() {
+ assertThatThrownBy(() -> ExpressionEvaluator.parseValues("headers",
"foo"))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessage("A line of headers must be in key=value format,
was: foo (or pass a JSON object)");
+ assertThatThrownBy(() -> ExpressionEvaluator.parseValues("variables",
"{\"a\": "))
+
.isInstanceOf(ToolExecutionException.class).hasMessageStartingWith("variables
is not a valid JSON object");
+ }
+
+ /** The tool takes the arguments as text, as the MCP servers, the TUI and
camel ask pass them. */
+ @Test
+ void theToolTakesTheNewArguments() throws Exception {
+ Object answer = ToolRegistry.execute("camel_eval_expression", new
ToolContext(), Map.of(
+ "language", "xpath", "expression", "boolean(/x/y)", "body",
XML, "predicate", "true"));
+ JsonObject predicate = (JsonObject)
Jsoner.deserialize(answer.toString());
+ assertThat(predicate.getString("result")).isEqualTo("true");
+
+ answer = ToolRegistry.execute("camel_eval_expression", new
ToolContext(), Map.of(
+ "expression", "${header.foo}-${variable.bar}", "headers",
"{\"foo\": \"x\"}", "variables", "bar=y"));
+ assertThat(((JsonObject)
Jsoner.deserialize(answer.toString())).getString("result")).isEqualTo("x-y");
+
+ answer = ToolRegistry.execute("camel_eval_expression", new
ToolContext(), Map.of(
+ "language", "xpath", "expression", "string(/x/y[2])", "body",
XML, "resultType", "java.lang.String"));
+ assertThat(((JsonObject)
Jsoner.deserialize(answer.toString())).getString("result")).isEqualTo("2");
+
+ // an explicit false keeps a simple predicate an expression
+ answer = ToolRegistry.execute("camel_eval_expression", new
ToolContext(), Map.of(
+ "expression", "${body} == 'a'", "body", "a", "predicate",
"false"));
+ assertThat(((JsonObject)
Jsoner.deserialize(answer.toString())).get("predicate")).isNull();
+
+
assertThat(ToolRegistry.findTool("camel_eval_expression").params()).extracting(ToolDescriptor.Param::name)
+ .containsAll(List.of("headers", "variables", "predicate",
"resultType"));
+
+ // a predicate that is not true or false is refused, not taken for
false
+ assertThatThrownBy(() -> ToolRegistry.execute("camel_eval_expression",
new ToolContext(), Map.of(
+ "expression", "${body} == 'a'", "body", "a", "predicate",
"yes")))
+
.isInstanceOf(ToolExecutionException.class).hasMessage("predicate is true or
false, not yes");
+ }
+
+ private static JsonObject eval(
+ String language, String expression, String body, Map<String,
Object> headers, Map<String, Object> variables,
+ Boolean predicate, String resultType) {
+ return ExpressionEvaluator.evaluate(new ToolContext(),
+ new ExpressionEvaluator.Request(language, expression, body,
headers, variables, predicate, resultType));
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
index 56392718b63a..d97bfc9affb1 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
@@ -44,6 +44,7 @@ import
org.apache.camel.dsl.jbang.core.commands.ai.ToolExecutionException;
import org.apache.camel.dsl.jbang.core.commands.ai.ToolRegistry;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
import org.jboss.logging.Logger;
/**
@@ -397,9 +398,52 @@ public class AuthoringTools {
@ToolArg(description = "e.g. ${random(1,10)} or ${body} ?:
'none'", required = true) String expression,
@ToolArg(description = "simple (default), jsonpath, xpath, jq",
required = false) String language,
@ToolArg(description = "Message body", required = false) String
body,
- @ToolArg(description = NAME_DESC, required = false) String name) {
+ @ToolArg(description = NAME_DESC, required = false) String name,
+ // Object, so the schema takes any JSON: a client sends a JSON
object, or the text of key=value lines
+ @ToolArg(description =
org.apache.camel.dsl.jbang.core.commands.ai.AuthoringTools.EVAL_HEADERS_DESC,
+ required = false) Object headers,
+ @ToolArg(description =
org.apache.camel.dsl.jbang.core.commands.ai.AuthoringTools.EVAL_VARIABLES_DESC,
+ required = false) Object variables,
+ @ToolArg(description =
org.apache.camel.dsl.jbang.core.commands.ai.AuthoringTools.EVAL_PREDICATE_DESC,
+ required = false) Boolean predicate,
+ @ToolArg(description =
org.apache.camel.dsl.jbang.core.commands.ai.AuthoringTools.EVAL_RESULT_TYPE_DESC,
+ required = false) String resultType) {
return call("camel_eval_expression", args("expression", expression,
"language", language, "body", body,
- "name", name));
+ "name", name, "headers", limited("headers",
jsonText(headers)), "variables",
+ limited("variables", jsonText(variables)), "predicate",
predicate, "resultType", resultType));
+ }
+
+ /**
+ * The text of an argument held to the length limit of the string
arguments: McpSecurityInterceptor checks the
+ * arguments that are strings, and a JSON object is not one.
+ */
+ String limited(String argument, String text) {
+ int max = securityConfig != null
+ ? securityConfig.getMaxArgumentLength() :
McpSecurityConfig.DEFAULT_MAX_ARGUMENT_LENGTH;
+ if (text != null && text.length() > max) {
+ throw new ToolCallException(
+ "Argument '" + argument + "' is " + text.length() + "
characters long as JSON, over the limit of "
+ + max + "; nothing was done. Pass
fewer or shorter values, or raise"
+ + "
camel.mcp.security.max-argument-length");
+ }
+ return text;
+ }
+
+ /**
+ * An argument that is a JSON object or text, as the text the shared tool
reads. The server hands a JSON object over
+ * as a Map of Maps, Lists, strings, numbers and booleans, which is the
JSON of the object for Jsoner.
+ */
+ static String jsonText(Object value) {
+ if (value == null || value instanceof String) {
+ return (String) value;
+ }
+ try {
+ return Jsoner.serialize(value);
+ } catch (IllegalArgumentException e) {
+ // not a value Jsoner knows: its own text, which the shared tool
reads or refuses with the reason
+ LOG.debug("jsonText: Jsoner cannot serialize {}, falling back to
toString()", value.getClass().getSimpleName(), e);
+ return value.toString();
+ }
}
@ToolGuardrails(input = McpArgumentGuardrail.class, output =
McpArgumentGuardrail.class)
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
index 8b4b05ddb024..31a2216ed4b2 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
@@ -135,7 +135,8 @@ class AuthoringToolsTest {
Path previousHome = CommandLineHelper.getHomeDir();
CommandLineHelper.useHomeDir(home.toString());
try {
- assertThatThrownBy(() -> tools.camel_eval_expression("${body}",
null, "camel", "no-such-app-xyz-1"))
+ assertThatThrownBy(
+ () -> tools.camel_eval_expression("${body}", null,
"camel", "no-such-app-xyz-1", null, null, null, null))
.isInstanceOf(ToolCallException.class).hasMessageContaining("no-such-app-xyz-1");
assertThatThrownBy(() -> tools.camel_get_log("no-such-app-xyz-1",
null, null, null, null, null))
.isInstanceOf(ToolCallException.class).hasMessageContaining("no-such-app-xyz-1");
@@ -144,6 +145,54 @@ class AuthoringToolsTest {
}
}
+ /**
+ * CAMEL-26400: the headers, variables, predicate and resultType of
camel_eval_expression reach the shared tool; a
+ * client sends headers as a JSON object (the schema takes any JSON) or as
key=value lines.
+ */
+ @Test
+ void evalTakesHeadersVariablesPredicateAndResultType(@TempDir Path home) {
+ // a home of its own: without a name the tool picks the only running
integration of the machine
+ Path previousHome = CommandLineHelper.getHomeDir();
+ CommandLineHelper.useHomeDir(home.toString());
+ try {
+ JsonObject values =
tools.camel_eval_expression("${header.foo}-${variable.bar}", null, null, null,
+ Map.of("foo", "x"), "bar=y", null, null);
+ assertThat(values.getString("status")).isEqualTo("ok");
+ assertThat(values.getString("result")).isEqualTo("x-y");
+
+ JsonObject typed = tools.camel_eval_expression("${body}", null,
"0042", null, null, null, null,
+ "java.lang.Integer");
+ assertThat(typed.getString("result")).isEqualTo("42");
+
assertThat(typed.getString("resultType")).isEqualTo("java.lang.Integer");
+
+ JsonObject predicate = tools.camel_eval_expression("true",
"constant", null, null, null, null, true, null);
+ assertThat(predicate.getBoolean("predicate")).isTrue();
+ assertThat(predicate.getString("result")).isEqualTo("true");
+
+ assertThatThrownBy(() ->
tools.camel_eval_expression("${header.foo}", null, null, null, "foo", null,
null,
+
null)).isInstanceOf(ToolCallException.class).hasMessageContaining("key=value");
+
+ // a JSON object is not a string argument, which the security
interceptor checks: its JSON is held to the
+ // same length limit
+ Map<String, Object> large = Map.of("foo",
"x".repeat(McpSecurityConfig.DEFAULT_MAX_ARGUMENT_LENGTH));
+ assertThatThrownBy(() ->
tools.camel_eval_expression("${header.foo}", null, null, null, large, null,
null,
+ null)).isInstanceOf(ToolCallException.class)
+ .hasMessageStartingWith("Argument 'headers' is ")
+ .hasMessageContaining(" characters long as JSON, over the
limit of "
+ +
McpSecurityConfig.DEFAULT_MAX_ARGUMENT_LENGTH + "; nothing was done");
+ } finally {
+ CommandLineHelper.useHomeDir(previousHome.toString());
+ }
+ }
+
+ @Test
+ void aJsonObjectArgumentIsPassedOnAsItsJson() {
+ assertThat(AuthoringTools.jsonText(null)).isNull();
+ assertThat(AuthoringTools.jsonText("a=b")).isEqualTo("a=b");
+ assertThat(AuthoringTools.jsonText(Map.of("rows",
List.of(Map.of("total", 3)))))
+ .isEqualTo("{\"rows\":[{\"total\":3}]}");
+ }
+
@Test
void everyArgumentIsRequiredExactlyWhenTheSharedDescriptorSaysSo() {
// the Quarkus server rejects a call that omits an argument declared
required, so an optional argument with a
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java
index 011c540a65b2..16403c755bae 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java
@@ -52,7 +52,10 @@ class AiPanelPromptBudgetTest {
// the camel_component_properties tool and the 38k-character answers a
client spilled to a file
// raised from 5100 when camel_get_errors said that it answers for an
integration that is not running (CAMEL-25784),
// measured ~5119: without it an agent does not ask how the previous run
ended
- static final int CORE_BUDGET_TOKENS = 5_200;
+ // raised from 5200 for the headers, variables, predicate and resultType
arguments of camel_eval_expression
+ // (CAMEL-26400), measured ~5233 (~114 for the four): without them an
XPath function or a header lookup cannot be
+ // checked the way the route runs it
+ static final int CORE_BUDGET_TOKENS = 5_300;
/** Measured ~6.9k tokens for 47 tools. */
// raised from 7500 with tui_write_file and tui_validate_source
// and from 7900 with the shared camel_* set (camel_catalog_find,
camel_run and camel_error_diagnose added)
@@ -72,7 +75,8 @@ class AiPanelPromptBudgetTest {
// raised from 10500 with the core budget for camel_catalog_doc
(CAMEL-25541), measured ~10485
// raised from 10600 for tui_diagram_zoom, measured ~10731 (~168 for the
tool): a model asked to zoom or fold the
// diagram does it as the keys do; full mode only, its schema cut to the
bone
- static final int FULL_BUDGET_TOKENS = 10_750;
+ // raised from 10750 with the core budget for camel_eval_expression
(CAMEL-26400), measured ~10844
+ static final int FULL_BUDGET_TOKENS = 10_900;
/** Measured ~5.5k tokens for 28 tools: the core set (~4.7k) plus every
tool group (CAMEL-24834). */
// the SQL group adds tui_execute_sql and tui_update_row (~385 tokens),
each group one guidance line in the prompt
// (~130 for all three); an integration rarely has all of them, and the
groups only load for the integration that
@@ -81,7 +85,8 @@ class AiPanelPromptBudgetTest {
// (~245 tokens) and its guidance line (~35), for the local model that
otherwise cannot see or call the HTTP API
// it is building
// raised from 5750 with the core budget for camel_catalog_doc
(CAMEL-25541), measured ~5820
- static final int CORE_WITH_GROUPS_BUDGET_TOKENS = 5_900;
+ // raised from 5900 with the core budget for camel_eval_expression
(CAMEL-26400), measured ~6011
+ static final int CORE_WITH_GROUPS_BUDGET_TOKENS = 6_050;
record Prefix(String mode, int tools, long promptChars, long toolChars) {
@@ -186,8 +191,9 @@ class AiPanelPromptBudgetTest {
Prefix prefix = measure(mode + "+semantic", panel);
System.out.println("AI panel semantic prefix: " + prefix);
// core raised from 6100 with the core budget for
camel_catalog_doc (CAMEL-25541), measured ~6170; full
- // raised from 11000 for tui_diagram_zoom, measured ~11068
- assertTrue(prefix.totalTokens() <=
(AiPanel.TOOL_MODE_CORE.equals(mode) ? 6250 : 11100));
+ // raised from 11000 for tui_diagram_zoom, measured ~11068; both
raised by 150 with the core budget for
+ // camel_eval_expression (CAMEL-26400), measured ~6361 and ~11182
+ assertTrue(prefix.totalTokens() <=
(AiPanel.TOOL_MODE_CORE.equals(mode) ? 6400 : 11250));
}
}