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 539ad74a4a98 CAMEL-24907: camel-jbang - camel_eval_expression
evaluates any language, not only the ones on its own classpath (#26753)
539ad74a4a98 is described below
commit 539ad74a4a98333d61a4cbaa99f3ba194818c15e
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Sep 23 11:22:12 2026 +0200
CAMEL-24907: camel-jbang - camel_eval_expression evaluates any language,
not only the ones on its own classpath (#26753)
The component of a language such as jsonpath, jq or xpath is downloaded on
first use, as camel run downloads what a route needs, and the answer names it
in downloaded. A failing expression also carries the catalog's syntax error and
the position where it breaks.
---
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 5 +
.../modules/ROOT/pages/camel-jbang-mcp.adoc | 6 +-
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 7 +-
.../core/commands/ai/ExpressionEvaluator.java | 145 +++++++++++++++++++--
.../ai/ExpressionEvaluatorLanguageTest.java | 70 ++++++++++
.../jbang/core/commands/mcp/AuthoringTools.java | 5 +-
6 files changed, 221 insertions(+), 17 deletions(-)
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index f92b5daa8346..c91514cf01c6 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -900,6 +900,11 @@ and what to put there, and validates and reloads the
result as a write does. A m
change one step corrupts the lines it did not mean to touch, so this is the
tool for a change to an existing file;
`camel_write_file` writes a new one.
+`camel_eval_expression` evaluates any language, not only the ones on the
server's own classpath: the component of
+a language such as `jsonpath`, `jq` or `xpath` is downloaded on first use, as
`camel run` downloads what a route
+needs, and the answer names it in `downloaded`. A failing expression also
carries the catalog's syntax error and
+the position where it breaks.
+
The `camel_run` tool, when no files are named, starts the project with `camel
run --source-dir=.` instead of
listing the directory's files: the directory is watched, so a file added
afterwards (a bean file, a Java class
under `src/main/java`) is part of the app and reloaded in dev mode, and a
`restart` starts the same way. Naming
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 8cf2d973f870..7dfeda56011f 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -365,8 +365,10 @@ project `directory` as an argument, the runtime tools take
the integration `name
| `camel_eval_expression`
| Evaluates an expression (simple by default) in the running integration, or
locally when none is named,
- and returns the value (true/false for a predicate) or the syntax error, so
an agent can check a simple
- expression before writing it into a route.
+ 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`.
| `camel_dependency_for_class`
| Which Maven dependency provides a class, and how to declare it. Local first:
the known dependencies
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 36adb55dd749..478fbcd3062c 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
@@ -307,9 +307,10 @@ public final class AuthoringTools {
}));
registry.accept(tool("camel_eval_expression",
- "Evaluates an expression: in the running integration when
there is one, else locally. Returns the "
- + "value (true/false for
a predicate) or the syntax error, so check simple "
- + "before answering or
writing it.")
+ "Evaluates an expression: in the running integration when
there is one, else locally, in any "
+ + "language (jsonpath,
jq, xpath, groovy: its component is downloaded when "
+ + "needed). Returns the
value (true/false for a predicate) or the syntax "
+ + "error, so check an
expression before answering or writing it.")
.param("expression", "string", "e.g. ${random(1,10)} or
${body} ?: 'none'", true)
.param("language", "string", "simple (default), jsonpath,
xpath, jq", false)
.param("body", "string", "Message body", false)
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 3a532da5be1c..ec4e5800740e 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.Map;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
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.impl.DefaultCamelContext;
+import org.apache.camel.main.download.DependencyDownloaderClassLoader;
+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.JsonObject;
import org.apache.camel.util.json.Jsoner;
@@ -75,7 +81,7 @@ public final class ExpressionEvaluator {
evaluateInProcess(ctx, lang, expression, body, predicate, result);
} else {
result.put("evaluatedIn", "a local scratch context (no integration
selected)");
- evaluateLocally(lang, expression, body, predicate, result);
+ evaluateLocally(ctx, lang, expression, body, predicate, result);
}
return result;
}
@@ -113,16 +119,58 @@ public final class ExpressionEvaluator {
}
private static void evaluateLocally(
- String lang, String expression, String body, boolean predicate,
JsonObject result) {
+ ToolContext ctx, String lang, String expression, String body,
boolean predicate, JsonObject result) {
+ 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 (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);
+ 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)) {
+ 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" : "")
+ + (failure.isEmpty() ? "" : " (" + failure
+ ")")
+ + "; select a running integration that has
it (camel_run, then its name) and the"
+ + " expression is evaluated there");
+ return;
+ }
+ result.put("downloaded", gav);
+ syntaxCheck(ctx, downloaded, lang, expression, predicate, result);
+ } finally {
+ Thread.currentThread().setContextClassLoader(tccl);
+ }
+ }
+
+ /**
+ * 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) {
try (DefaultCamelContext context = new DefaultCamelContext(false)) {
+ if (loader != null) {
+ // before the start: the language resolver reads the class
loader as the context comes up
+ context.setApplicationContextClassLoader(loader);
+ Thread.currentThread().setContextClassLoader(loader);
+ }
context.start();
Language language;
try {
language = context.resolveLanguage(lang);
} catch (Exception e) {
- result.put("status", "error");
- result.put("error", "Unknown language '" + lang + "': " +
e.getMessage());
- return;
+ return false;
}
Exchange exchange = new DefaultExchange(context);
exchange.getMessage().setBody(body != null ? body : "");
@@ -140,12 +188,89 @@ public final class ExpressionEvaluator {
result.put("result", value != null ? value.toString() : null);
} catch (Exception e) {
result.put("status", "error");
- Throwable cause = e;
- while (cause.getCause() != null && cause.getCause() != cause) {
- cause = cause.getCause();
+ result.put("error", rootCause(e));
+ }
+ return true;
+ }
+
+ /**
+ * 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.
+ */
+ private static void syntaxCheck(
+ ToolContext ctx, ClassLoader loader, String lang, String
expression, boolean predicate, JsonObject result) {
+ if ("ok".equals(result.getString("status"))) {
+ return;
+ }
+ try {
+ LanguageValidationResult check = predicate
+ ? ctx.catalog().validateLanguagePredicate(loader, lang,
expression)
+ : ctx.catalog().validateLanguageExpression(loader, lang,
expression);
+ if (!check.isSuccess()) {
+ String error = check.getShortError() != null ?
check.getShortError() : check.getError();
+ if (error != null) {
+ result.put("syntaxError", error);
+ if (check.getIndex() >= 0) {
+ result.put("syntaxErrorAt", check.getIndex());
+ }
+ }
}
- String message = cause.getMessage();
- result.put("error", message != null ? message : cause.toString());
+ } catch (Exception e) {
+ // the catalog cannot check this language here; the evaluation's
own error stands
+ }
+ }
+
+ private static String rootCause(Throwable e) {
+ Throwable cause = e;
+ while (cause.getCause() != null && cause.getCause() != cause) {
+ cause = cause.getCause();
+ }
+ return cause.getMessage() != null ? cause.getMessage() :
cause.toString();
+ }
+
+ /** The groupId:artifactId:version of a language, from the catalog, or
null when the catalog does not know it. */
+ private static String languageArtifact(ToolContext ctx, String lang) {
+ try {
+ LanguageModel model = ctx.catalog().languageModel(lang);
+ if (model != null && model.getArtifactId() != null) {
+ return model.getGroupId() + ":" + model.getArtifactId() + ":"
+ model.getVersion();
+ }
+ } catch (Exception e) {
+ // the catalog does not know it
}
+ return null;
}
+
+ /**
+ * Downloads the component of a language and keeps its class loader, so
the next call does not download again.
+ * Returns null when there is nothing to download (an unknown name) or the
download fails, appending the reason to
+ * {@code failure}.
+ */
+ private static ClassLoader download(String gav, StringBuilder failure) {
+ if (gav == null) {
+ return null;
+ }
+ String[] parts = gav.split(":");
+ // computeIfAbsent so two evaluations of the same language do not
download it twice and leak a class loader;
+ // a failed download stores nothing, so the next call tries again
+ return DOWNLOADED.computeIfAbsent(gav, k -> {
+ try {
+ DependencyDownloaderClassLoader cl
+ = new
DependencyDownloaderClassLoader(ExpressionEvaluator.class.getClassLoader());
+ try (MavenDependencyDownloader downloader = new
MavenDependencyDownloader()) {
+ downloader.setClassLoader(cl);
+ downloader.start();
+ downloader.downloadDependency(parts[0], parts[1],
parts[2]);
+ }
+ return cl;
+ } catch (Exception e) {
+ // say why, so the answer names the cause (offline, wrong
repository, unknown artifact)
+ failure.append(e.getMessage() != null ? e.getMessage() :
e.getClass().getSimpleName());
+ return null;
+ }
+ });
+ }
+
+ /** The class loaders of the languages downloaded so far, so the next call
does not download again. */
+ private static final Map<String, ClassLoader> DOWNLOADED = new
ConcurrentHashMap<>();
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorLanguageTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorLanguageTest.java
new file mode 100644
index 000000000000..076e5ae24910
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorLanguageTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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 org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24907: a language that is not on this process's classpath is
downloaded, so jsonpath, jq and the others can be
+ * evaluated before an integration runs. The download needs a network or a
filled local repository, so the test that
+ * downloads runs with -Dcamel.test.download=true.
+ */
+class ExpressionEvaluatorLanguageTest {
+
+ private static final String STOCK =
"[{\"sku\":\"CAMEL-MUG\",\"qty\":42},{\"sku\":\"CAMEL-CAP\",\"qty\":0}]";
+
+ @Test
+ void aLanguageThatCannotBeLoadedSaysWhatToDo() {
+ JsonObject result = ExpressionEvaluator.evaluate(new ToolContext(),
"no-such-language", "x", null);
+
+ assertThat(result.getString("status")).isEqualTo("error");
+ assertThat(result.getString("error")).contains("is not on the
classpath of this process")
+ .contains("select a running integration that has it");
+ }
+
+ @Test
+ void theLanguagesOfThisProcessAreEvaluatedAsBefore() {
+ JsonObject result = ExpressionEvaluator.evaluate(new ToolContext(),
"simple", "${body}", STOCK);
+
+ assertThat(result.getString("status")).isEqualTo("ok");
+ assertThat(result.getString("result")).isEqualTo(STOCK);
+ assertThat(result.get("downloaded")).isNull();
+ }
+
+ @Test
+ void aBrokenSimpleExpressionCarriesTheSyntaxErrorWithItsPosition() {
+ JsonObject result = ExpressionEvaluator.evaluate(new ToolContext(),
"simple", "${body", null);
+
+ assertThat(result.getString("status")).isEqualTo("error");
+ assertThat(result.getString("syntaxError")).isNotBlank();
+ }
+
+ @Test
+ @EnabledIfSystemProperty(named = "camel.test.download", matches = "true")
+ void jsonpathIsDownloadedAndEvaluated() {
+ JsonObject result = ExpressionEvaluator.evaluate(new ToolContext(),
"jsonpath",
+ "$[?(@.sku == 'CAMEL-MUG')]", STOCK);
+
+ assertThat(result.getString("downloaded")).contains("camel-jsonpath");
+ assertThat(result.getString("status")).isEqualTo("ok");
+ assertThat(result.getString("result")).contains("CAMEL-MUG");
+ }
+}
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 d666d660d8a0..2a11c2405768 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
@@ -212,8 +212,9 @@ public class AuthoringTools {
}
@Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint
= false, openWorldHint = false),
- description = "Evaluates an expression: in the running integration
when there is one, else locally. "
- + "Returns the value (true/false for a predicate) or
the syntax error, so check simple before "
+ description = "Evaluates an expression: in the running integration
when there is one, else locally, in any "
+ + "language (jsonpath, jq, xpath, groovy: its
component is downloaded when needed). Returns "
+ + "the value (true/false for a predicate) or the
syntax error, so check an expression before "
+ "answering or writing it.")
public JsonObject camel_eval_expression(
@ToolArg(description = "e.g. ${random(1,10)} or ${body} ?:
'none'", required = true) String expression,