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 c42618353434 CAMEL-26411: camel-jbang - AI tools:
camel_eval_expression names the missing body and the unset header or variable
(#29738)
c42618353434 is described below
commit c42618353434307ce62eb315ce9326647c5a0e8e
Author: Federico Mariani <[email protected]>
AuthorDate: Thu Nov 12 21:46:50 2026 +0100
CAMEL-26411: camel-jbang - AI tools: camel_eval_expression names the
missing body and the unset header or variable (#29738)
camel_eval_expression evaluates against a scratch exchange, and some of its
answers sent local models the wrong way. In ExpressionEvaluator:
- With no body the note says that the body is empty text, not null as in a
route whose message has none.
- jsonpath, jq, xpath and xquery with no body now say that the language
reads its JSON or XML from the body, and to pass it as body.
- A Simple expression that is null because it reads a header or variable
that was not passed names it and the headers/variables argument (a name in
brackets or quotes is the whole name). For CamelRedeliveryCounter,
CamelRedeliveryMaxCounter and CamelRedelivered it explains when the error
handler sets them. ${exchangeProperty.X} says that an evaluation has no
exchange properties.
Claude Code on behalf of Federico Mariani.
Co-Authored-By: Claude <[email protected]>
---
.../core/commands/ai/ExpressionEvaluator.java | 103 ++++++++++++++++++---
.../core/commands/ai/EvalInTheIntegrationTest.java | 22 ++++-
.../commands/ai/ExpressionEvaluatorNotesTest.java | 95 +++++++++++++++++++
.../core/commands/ai/ExpressionEvaluatorTest.java | 6 +-
4 files changed, 212 insertions(+), 14 deletions(-)
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 ac2021ee1c3a..2b5eb48b47f8 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
@@ -174,8 +174,9 @@ public final class ExpressionEvaluator {
} 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");
+ String noBody = noBodyNote(lang, expression, body);
+ if (noBody != null) {
+ result.put("note", noBody);
}
if (predicate && resultType != null) {
addNote(result, "resultType is not used for a predicate, which
gives true or false");
@@ -198,19 +199,96 @@ public final class ExpressionEvaluator {
if ("error".equals(result.getString("status"))) {
evaluateOnParsedBody(ctx, r, result, result.getString("error"));
}
- addNullAndElvisNotes(ctx, result, lang, expression);
+ addNullAndElvisNotes(ctx, result, r);
addFilterNote(result, lang, expression);
addXPathHint(result, lang, expression);
return result;
}
- static final String NULL_NOTE = "evaluated to null: a header, property or
variable that is not set, or a function"
- + " name that took in the rest of the
text";
+ static final String NULL_NOTE = "evaluated to null: a header, property or
variable that is not set (an evaluation has"
+ + " only the headers and variables passed
to it), or a function name that took in"
+ + " the rest of the text";
+
+ static final String NO_BODY_NOTE = "no body was passed, so the body is
empty text (\"\"), not null as in a route"
+ + " whose message has none (after a
timer, for example); pass body to evaluate"
+ + " against a value";
/** The languages whose expressions read the fields of a body that is
data, a Map or a List, and not of text. */
private static final Set<String> READS_DATA = Set.of("simple", "groovy",
"mvel", "spel", "js", "javascript",
"python", "java", "joor", "jactl");
+ /** The languages that read their document from the body, and what that
document is. */
+ private static final Map<String, String> BODY_DOCUMENT
+ = Map.of("jsonpath", "JSON", "jq", "JSON", "xpath", "XML",
"xquery", "XML");
+
+ // a header, exchange property or variable that simple reads by name:
${header.foo}, ${headers:foo},
+ // ${header[foo]}, ${headerAs(foo, Integer)}, ${exchangeProperty.foo},
${variable.foo}. A name in brackets or in
+ // quotes is the whole name, as for simple: ${header['kafka.KEY']} reads
the header kafka.KEY, not kafka
+ private static final Pattern READS_BY_NAME = Pattern.compile(
+
"\\$\\{\\s*(?:in\\.)?(header|exchangeProperty|variable)s?(?:[.:\\[]|As\\()\\s*['\"]?"
+ +
"((?<=\\[)[^\\]'\"]+|(?<=['\"])[^'\"]+|[\\w-]+)");
+
+ /** The headers the error handler sets from the first redelivery on. */
+ private static final Set<String> REDELIVERY_HEADERS
+ = Set.of("CamelRedeliveryCounter", "CamelRedeliveryMaxCounter",
"CamelRedelivered");
+
+ /**
+ * What to say when no body is passed and the expression reads it (r24):
the body is then empty text, so ${body} ==
+ * null answered false, and jsonpath failed with "json string can not be
null or empty", which does not name the
+ * body.
+ */
+ static String noBodyNote(String lang, String expression, String body) {
+ if (body != null || expression == null) {
+ return null;
+ }
+ String document = BODY_DOCUMENT.get(lang);
+ if (document != null) {
+ return "no body was passed: " + lang + " reads the " + document +
" of the message body, so this was"
+ + " evaluated on empty text; pass the " + document + " as
body";
+ }
+ return expression.contains("${body") ? NO_BODY_NOTE : null;
+ }
+
+ /**
+ * Why a simple expression evaluated to null when it reads a header, an
exchange property or a variable the
+ * evaluation does not have, else {@link #NULL_NOTE}. r24 to r26
fail-well-error-handling step 2:
+ * ${header.CamelRedeliveryCounter} was null, and the model looked for it
as an exchange property, or in 11 lookups
+ * of the Exchange API, and wrote nothing.
+ */
+ static String nullNote(
+ String lang, String expression, Map<String, Object> headers,
Map<String, Object> variables) {
+ Matcher m = "simple".equals(lang) && expression != null ?
READS_BY_NAME.matcher(expression) : null;
+ while (m != null && m.find()) {
+ String name = m.group(2).strip();
+ boolean redelivery = REDELIVERY_HEADERS.contains(name);
+ if ("exchangeProperty".equals(m.group(1))) {
+ return "evaluated to null: the exchange property " + name + "
is not set: an evaluation has no exchange"
+ + " properties" + (redelivery ? "; " + name + " is a
header: ${header." + name + "}" : "");
+ }
+ if ("variable".equals(m.group(1))) {
+ // a scoped variable, such as ${variable.global:foo}, keeps
the generic note
+ if (!expression.startsWith(":", m.end()) && (variables == null
|| !variables.containsKey(name))) {
+ return "evaluated to null: the variable " + name + " is
not set: an evaluation has only the"
+ + " variables passed in variables";
+ }
+ continue;
+ }
+ // the headers of a message do not tell case
+ if (headers == null ||
headers.keySet().stream().noneMatch(name::equalsIgnoreCase)) {
+ return "evaluated to null: the header " + name + " is not set:
an evaluation has only the headers"
+ + " passed in headers"
+ + (redelivery
+ ? "; the error handler sets it on the message
it redelivers, from the first redelivery"
+ + " on (the redelivered step and onRedelivery
see it), so it is not set on the first"
+ + " attempt; an onException that handles or
continues the exchange, and a dead"
+ + " letter channel, do not see it, because
the error handler removes it before they"
+ + " run"
+ : "");
+ }
+ }
+ return NULL_NOTE;
+ }
+
/**
* What an expression can read that changes from one call to the next: the
clock, a random value, an id, a property,
* a bean, a file, or the context or system of the integration.
@@ -318,14 +396,15 @@ public final class ExpressionEvaluator {
* header named 'offset ?:0' (CAMEL-26401). An elvis operator inside
${...} that the Camel of the evaluation does
* not read as one is the note, also when the value is not null or the
evaluation failed.
*/
- static void addNullAndElvisNotes(ToolContext ctx, JsonObject result,
String lang, String expression) {
+ static void addNullAndElvisNotes(ToolContext ctx, JsonObject result,
Request r) {
boolean isNull = "ok".equals(result.getString("status")) &&
result.get("result") == null;
if (isNull) {
result.put("result", null);
result.put("nullResult", true);
}
- String elvis = elvisNote(ctx, lang, expression);
- String note = elvis != null ? elvis : isNull ? NULL_NOTE : null;
+ String elvis = elvisNote(ctx, r.language(), r.expression());
+ String note
+ = elvis != null ? elvis : isNull ? nullNote(r.language(),
r.expression(), r.headers(), r.variables()) : null;
if (note != null) {
result.put("note", result.containsKey("note") ?
result.getString("note") + "; " + note : note);
}
@@ -461,16 +540,18 @@ public final class ExpressionEvaluator {
} 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
+ // ${...} that an integration before Camel 4.24 refuses, and that
no body was passed, go with its message
String hint = xpathHint(r.language(), r.expression(),
e.getMessage());
String elvis = elvisNote(ctx, r.language(), r.expression());
String filter = filterNote(r.language(), r.expression(), "error");
- if (hint == null && ignored == null && elvis == null && filter ==
null) {
+ String noBody = noBodyNote(r.language(), r.expression(), r.body());
+ if (hint == null && ignored == null && elvis == null && filter ==
null && noBody == null) {
throw e;
}
throw new ToolExecutionException(
e.getMessage() + (hint != null ? "; " + hint : "") +
(ignored != null ? "; " + ignored : "")
- + (elvis != null ? "; " + elvis :
"") + (filter != null ? "; " + filter : ""));
+ + (elvis != null ? "; " + elvis :
"") + (filter != null ? "; " + filter : "")
+ + (noBody != null ? "; " + noBody
: ""));
}
JsonObject out = null;
try {
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 eaf28a9bf9a2..fb1429b3c07b 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
@@ -99,7 +99,27 @@ class EvalInTheIntegrationTest {
assertThat(result.getString("status")).isEqualTo("ok");
assertThat(result.get("result")).isNull();
assertThat(result.getBoolean("nullResult")).isTrue();
-
assertThat(result.getString("note")).isEqualTo(ExpressionEvaluator.NULL_NOTE);
+ assertThat(result.getString("note")).startsWith("evaluated to null:
the header missing is not set");
+ }
+
+ /**
+ * The integration evaluates with empty text as the body when none is
passed, as the local context does: the error
+ * of a language that reads its document from the body names it
(CAMEL-26411).
+ */
+ @Test
+ void aLanguageThatReadsTheBodyFailsWithTheNoteWhenNoneIsPassed() throws
Exception {
+ try (FakeIntegration failing = new FakeIntegration(
+ PID + 1, root -> Map.of("status", "failed", "exception",
+ Map.of("message", "java.lang.IllegalArgumentException:
json string can not be null or empty")))) {
+ ToolContext ctx = new ToolContext();
+ ctx.selectProcess(PID + 1);
+
+ assertThatThrownBy(() -> ExpressionEvaluator.evaluate(ctx,
"jsonpath", "$.lines", null))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessage("java.lang.IllegalArgumentException: json
string can not be null or empty; no body was"
+ + " passed: jsonpath reads the JSON of the
message body, so this was evaluated on empty"
+ + " text; pass the JSON as body");
+ }
}
@Test
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorNotesTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorNotesTest.java
new file mode 100644
index 000000000000..bc069c48630e
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorNotesTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.util.Map;
+
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-26411: the answer of camel_eval_expression says what the evaluation
did not have, so a model does not take it
+ * for what the route will have.
+ */
+class ExpressionEvaluatorNotesTest {
+
+ /** r24: jsonpath with no body answered "json string can not be null or
empty", 3 times. */
+ @Test
+ void aLanguageThatReadsItsDocumentFromTheBodyNamesIt() {
+ JsonObject none = ExpressionEvaluator.evaluate(new ToolContext(),
"xpath", "/order/id", null);
+ assertThat(none.getString("status")).isEqualTo("error");
+ assertThat(none.getString("note")).isEqualTo("no body was passed:
xpath reads the XML of the message body, so"
+ + " this was evaluated on
empty text; pass the XML as body");
+
+ JsonObject body = ExpressionEvaluator.evaluate(new ToolContext(),
"xpath", "/order/id", "<order><id>1</id></order>");
+ assertThat(body.get("note")).isNull();
+
+ assertThat(ExpressionEvaluator.noBodyNote("xquery", "/order/id", null))
+ .isEqualTo("no body was passed: xquery reads the XML of the
message body, so this was evaluated on"
+ + " empty text; pass the XML as body");
+ }
+
+ /**
+ * r24 to r26: ${header.CamelRedeliveryCounter} evaluated to null, then
the model read it as an exchange property,
+ * or made 11 lookups of the Exchange API, and wrote nothing.
+ */
+ @Test
+ void anUnsetHeaderSaysHowToPassItAndWhoSetsIt() {
+ assertThat(ExpressionEvaluator.evaluate(new ToolContext(), "simple",
"${header.CamelRedeliveryCounter}", null)
+ .getString("note")).isEqualTo(
+ "evaluated to null: the header CamelRedeliveryCounter
is not set: an evaluation has only the"
+ + " headers passed in headers;
the error handler sets it on the message"
+ + " it redelivers, from the
first redelivery on (the redelivered step and"
+ + " onRedelivery see it), so it
is not set on the first attempt; an"
+ + " onException that handles or
continues the exchange, and a dead"
+ + " letter channel, do not see
it, because the error handler removes it"
+ + " before they run");
+ assertThat(ExpressionEvaluator.nullNote("simple",
"${exchangeProperty.CamelRedeliveryCounter}", null, null))
+ .isEqualTo("evaluated to null: the exchange property
CamelRedeliveryCounter is not set: an evaluation has"
+ + " no exchange properties; CamelRedeliveryCounter
is a header: ${header.CamelRedeliveryCounter}");
+ // a header that was passed: what is null is something else
+ assertThat(ExpressionEvaluator.nullNote("simple",
"${header.order[missing]}", Map.of("order", Map.of()), null))
+ .isEqualTo(ExpressionEvaluator.NULL_NOTE);
+ }
+
+ /** A name in brackets is the whole name, as for simple: camel-kafka's
headers have a dot in it. */
+ @Test
+ void aHeaderNameInBracketsIsTheWholeName() {
+ String note = "evaluated to null: the header kafka.KEY is not set: an
evaluation has only the headers passed in"
+ + " headers";
+ assertThat(ExpressionEvaluator.nullNote("simple",
"${header['kafka.KEY']}", null, null)).isEqualTo(note);
+ assertThat(ExpressionEvaluator.nullNote("simple",
"${header[kafka.KEY]}", null, null)).isEqualTo(note);
+ assertThat(ExpressionEvaluator.nullNote("simple",
"${header['kafka.KEY']}", Map.of("kafka.KEY", ""), null))
+ .isEqualTo(ExpressionEvaluator.NULL_NOTE);
+ }
+
+ @Test
+ void anUnsetVariableSaysHowToPassIt() {
+ assertThat(ExpressionEvaluator.evaluate(new ToolContext(), "simple",
"${variable.orderId}", null)
+ .getString("note"))
+ .isEqualTo("evaluated to null: the variable orderId is not
set: an evaluation has only the variables"
+ + " passed in variables");
+ // a variable that was passed: what is null is something else
+ assertThat(ExpressionEvaluator.nullNote("simple",
"${variable.order[missing]}", null, Map.of("order", Map.of())))
+ .isEqualTo(ExpressionEvaluator.NULL_NOTE);
+ // a scoped variable keeps the generic note
+ assertThat(ExpressionEvaluator.nullNote("simple",
"${variable.global:orderId}", null, null))
+ .isEqualTo(ExpressionEvaluator.NULL_NOTE);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorTest.java
index 29d365bc7136..207f7fcf1a2c 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorTest.java
@@ -56,7 +56,7 @@ class ExpressionEvaluatorTest {
assertEquals("true", predicate.getString("result"));
JsonObject note = ExpressionEvaluator.evaluate(ctx, "simple",
"${body.length()}", null);
- assertTrue(note.getString("note").contains("empty body"));
+ assertEquals(ExpressionEvaluator.NO_BODY_NOTE, note.getString("note"));
JsonObject error = ExpressionEvaluator.evaluate(ctx, "simple",
"${header.foo ?: x}", null);
assertEquals("error", error.getString("status"), "the fallback is not
quoted");
@@ -85,7 +85,9 @@ class ExpressionEvaluatorTest {
assertTrue(value.containsKey("result"));
assertNull(value.get("result"));
assertTrue(value.getBoolean("nullResult"));
- assertEquals(ExpressionEvaluator.NULL_NOTE, value.getString("note"));
+ assertEquals("evaluated to null: the header missing is not set: an
evaluation has only the headers passed in"
+ + " headers",
+ value.getString("note"));
JsonObject empty = ExpressionEvaluator.evaluate(new ToolContext(),
"simple", "${body}", null);
assertNull(empty.get("nullResult"), "an empty body is a value");