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 a04357c4f4da CAMEL-25900: camel-jbang - AI tools: lead to the jsonpath
filter with resultType java.util.Map for the one element of a list (#28210)
a04357c4f4da is described below
commit a04357c4f4dabe35a3723ca807971734ab53faf9
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Oct 21 21:57:00 2026 +0200
CAMEL-25900: camel-jbang - AI tools: lead to the jsonpath filter with
resultType java.util.Map for the one element of a list (#28210)
- The source check reports an index after a jsonpath filter (an empty list)
and && or || outside a filter (no
JsonPath), and names the recipe: a filter with resultType: java.util.Map
gives the element, or null.
- camel_eval_expression of a jsonpath filter notes that a filter gives a
list also for one match, and that a route
gets the one element with resultType: java.util.Map.
- camel_catalog_doc jsonpath answers the recipe in usage;
camel_catalog_find of CamelHttpStatusCode (and similar)
names CamelHttpResponseCode.
- camel-bean: the hint for an OGNL read on a List leads with the jsonpath
recipe; [0] comes after it.
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../apache/camel/language/bean/BeanExpression.java | 7 +--
.../language/simple/SimpleSyntaxHintsTest.java | 12 +++--
.../dsl/jbang/core/commands/ai/CatalogDocs.java | 19 +++++++
.../core/commands/ai/ExpressionEvaluator.java | 16 ++++++
.../dsl/jbang/core/commands/ai/JsonPathChecks.java | 61 +++++++++++++++++++++-
.../jbang/core/commands/ai/CatalogDocsTest.java | 17 ++++++
.../ai/ExpressionEvaluatorLanguageTest.java | 10 ++++
.../jbang/core/commands/ai/JsonPathChecksTest.java | 54 +++++++++++++++++++
8 files changed, 187 insertions(+), 9 deletions(-)
diff --git
a/components/camel-bean/src/main/java/org/apache/camel/language/bean/BeanExpression.java
b/components/camel-bean/src/main/java/org/apache/camel/language/bean/BeanExpression.java
index d91e7ef5c00c..1a5e368d6f26 100644
---
a/components/camel-bean/src/main/java/org/apache/camel/language/bean/BeanExpression.java
+++
b/components/camel-bean/src/main/java/org/apache/camel/language/bean/BeanExpression.java
@@ -568,10 +568,11 @@ public class BeanExpression implements Expression,
Predicate {
String type = bean instanceof List ? "a List" : bean instanceof
Object[] ? "an array" : "a Collection";
boolean field = methodName != null &&
FIELD_NAME.matcher(methodName).matches();
String name = field ? methodName : "field";
+ // the lookup by a field first: given [0] first, models wrote
${body[0].orderId} and took the first element
return " (" + type + " has no " + (field ? "method or key " +
methodName : "such method")
- + ": pick an element first, such as [0]." + name + ", or the
element whose " + name
- + " has a given value with jsonpath, such as jsonpath:
\"$[?(@." + name + " == '${header." + name
- + "}')]\" with resultType java.util.Map, or split it)";
+ + ": to pick the element whose " + name + " has a given value,
use jsonpath: \"$[?(@." + name
+ + " == '${header." + name + "}')]\" with resultType
java.util.Map (the element, or null when none"
+ + " matches); [0]." + name + " reads the first element; or
split it)";
}
private static final Pattern FIELD_NAME =
Pattern.compile("[A-Za-z_][A-Za-z0-9_]*");
diff --git
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
index 3bf213142d2b..fcc8ac4567aa 100644
---
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
+++
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
@@ -242,10 +242,11 @@ public class SimpleSyntaxHintsTest extends
ExchangeTestSupport {
Exception e = assertThrows(Exception.class,
() ->
context.resolveLanguage("simple").createExpression("${body.orderId}").evaluate(exchange,
String.class));
- assertThat(e.getMessage()).contains("(a List has no method or key
orderId: pick an element first, such as"
- + " [0].orderId, or the element
whose orderId has a given value with jsonpath,"
- + " such as jsonpath:
\"$[?(@.orderId == '${header.orderId}')]\" with"
- + " resultType java.util.Map, or
split it)");
+ // the lookup by a field first: given [0] first, models took the first
element
+ assertThat(e.getMessage()).contains("(a List has no method or key
orderId: to pick the element whose orderId"
+ + " has a given value, use
jsonpath: \"$[?(@.orderId == '${header.orderId}')]\""
+ + " with resultType java.util.Map
(the element, or null when none matches);"
+ + " [0].orderId reads the first
element; or split it)");
}
@Test
@@ -254,7 +255,8 @@ public class SimpleSyntaxHintsTest extends
ExchangeTestSupport {
Exception e = assertThrows(Exception.class,
() ->
context.resolveLanguage("simple").createExpression("${body.find(o -> o.orderId
== 1)}")
.evaluate(exchange, String.class));
- assertThat(e.getMessage()).contains("(a List has no such method: pick
an element first, such as [0].field");
+ assertThat(e.getMessage()).contains("(a List has no such method: to
pick the element whose field has a given"
+ + " value, use jsonpath");
}
@Test
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java
index f780f0426006..c8fee5255776 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java
@@ -1089,6 +1089,12 @@ public final class CatalogDocs {
} else {
result.put("message", "Best match first; camel_catalog_doc gives
the options of one");
}
+ // a header name models write for one that has another name, such as
CamelHttpStatusCode
+ String wrong = HeaderChecks.WRONG_NAMES.get(term.trim());
+ if (wrong != null) {
+ result.put("header", "Camel has no header " + term.trim() + ": " +
wrong);
+ result.put("message", result.getString("message") + "; " +
result.getString("header"));
+ }
return result;
}
@@ -1679,6 +1685,15 @@ public final class CatalogDocs {
return answer;
}
+ /**
+ * How to pick the one element of a JSON list by a field: the answer of
camel_catalog_doc jsonpath says it first.
+ */
+ static final String JSONPATH_ONE_ELEMENT = "to pick the one element of a
list by a field, write a filter with"
+ + " resultType: java.util.Map,
such as expression: \"$[?(@.orderId =="
+ + " '${header.id}')]\" and
resultType: java.util.Map: the element when one"
+ + " matches, null when none
does (an index after the filter gives an empty"
+ + " list); camel_catalog_sample
jsonpath shows it in a route";
+
/** How many options of a component are listed in full when no filter is
given (CAMEL-25541). */
static final int DEFAULT_OPTIONS_LIMIT = 20;
@@ -2322,6 +2337,10 @@ public final class CatalogDocs {
if ("simple".equals(model.getName()) ||
"csimple".equals(model.getName())) {
result.put("syntax", SIMPLE_SYNTAX);
}
+ if ("jsonpath".equals(model.getName())) {
+ // the one element of a list by a field, which models wrote as
$[?(...)][0], an empty list
+ result.put("usage", JSONPATH_ONE_ELEMENT);
+ }
JsonObject script = scriptVariables(model.getName());
if (script != null) {
// the variables the script sees, and how to reach the Camel API
from them
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 13b177e255db..39739bf833b2 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
@@ -83,9 +83,25 @@ public final class ExpressionEvaluator {
result.put("evaluatedIn", "a local scratch context (no integration
selected)");
evaluateLocally(ctx, lang, expression, body, predicate, result);
}
+ String filter = filterNote(lang, expression,
result.getString("status"));
+ if (filter != null) {
+ result.put("note", result.containsKey("note") ?
result.getString("note") + "; " + filter : filter);
+ }
return result;
}
+ /**
+ * A jsonpath filter gives a list, also for one match: models picked the
element with an index after the filter,
+ * which gives an empty list, or a made-up syntax. In a route, resultType
java.util.Map gives the one element.
+ */
+ static String filterNote(String lang, String expression, String status) {
+ if (!"jsonpath".equals(lang) || expression == null ||
!expression.contains("[?(") || !"ok".equals(status)) {
+ return null;
+ }
+ return "a filter gives a list, also for one match; in a route,
resultType: java.util.Map gives the one element"
+ + " it matches (null when none does), and an index after the
filter gives an empty list";
+ }
+
private static void evaluateInProcess(
ToolContext ctx, String lang, String expression, String body,
boolean predicate, JsonObject result) {
String raw = ctx.executeAction("eval", root -> {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/JsonPathChecks.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/JsonPathChecks.java
index d12a0becc80a..5db46d434043 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/JsonPathChecks.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/JsonPathChecks.java
@@ -33,6 +33,10 @@ import static
org.apache.camel.dsl.jbang.core.commands.ai.YamlLines.extractYamlV
public final class JsonPathChecks {
private static final Pattern COMPARISON = Pattern.compile("
(==|!=|<=|>=|<|>|=~|in|nin|size|empty) ");
+ // an index after a filter: $[?(@.orderId == 'ORD-1')][0]
+ private static final Pattern INDEX_AFTER_FILTER =
Pattern.compile("\\[\\?\\(.*\\)]\\s*\\[\\s*-?\\d+\\s*]");
+ private static final String ONE_ELEMENT = "set resultType: java.util.Map
(the element when one matches, null when"
+ + " none does)";
private JsonPathChecks() {
}
@@ -67,7 +71,15 @@ public final class JsonPathChecks {
break;
}
}
- if (text == null || text.isEmpty() || text.contains("[?(") ||
!COMPARISON.matcher(text).find()) {
+ if (text == null || text.isEmpty()) {
+ continue;
+ }
+ String filter = filterProblem(text);
+ if (filter != null) {
+ errors.add("Line " + lineNum + ": " + filter);
+ continue;
+ }
+ if (text.contains("[?(") || !COMPARISON.matcher(text).find()) {
continue;
}
if (SimpleChecks.isPredicate(catalog, lines, i,
countLeadingSpaces(line))) {
@@ -86,4 +98,51 @@ public final class JsonPathChecks {
}
return errors;
}
+
+ /**
+ * What is wrong around the filter of a path that should give the one
element it matches. Jayway applies an index
+ * after a filter to each element the filter matched, so $[?(@.orderId ==
'ORD-1')][0] gives an empty list; and
+ * && or || outside the filter is no JsonPath (500 in a REST
route). The one element comes with resultType
+ * java.util.Map: a single match is the element, and no match null.
+ */
+ static String filterProblem(String text) {
+ if (!text.contains("[?(")) {
+ return null;
+ }
+ if (INDEX_AFTER_FILTER.matcher(text).find()) {
+ return "an index after a filter gives an empty list in JsonPath
([?(...)][0] applies the index to each element"
+ + " the filter matched): to get the one element, leave the
index out and " + ONE_ELEMENT;
+ }
+ String outside = outsideFilters(text);
+ if (outside.contains("&&") || outside.contains("||")) {
+ return "&& and || work inside a filter only, such as $[?(@.status
== 'paid' && @.qty > 0)]; to test whether"
+ + " a filter matched, " + ONE_ELEMENT + ", and check
${body} == null in a choice";
+ }
+ return null;
+ }
+
+ // the text with each filter [?(...)] taken out
+ private static String outsideFilters(String text) {
+ StringBuilder sb = new StringBuilder();
+ int i = 0;
+ while (i < text.length()) {
+ if (text.startsWith("[?(", i)) {
+ int depth = 0;
+ int j = i + 2;
+ for (; j < text.length(); j++) {
+ char c = text.charAt(j);
+ if (c == '(') {
+ depth++;
+ } else if (c == ')' && --depth == 0) {
+ break;
+ }
+ }
+ // past the closing )]
+ i = Math.min(text.length(), j + 2);
+ } else {
+ sb.append(text.charAt(i++));
+ }
+ }
+ return sb.toString();
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocsTest.java
index ae686c0d8080..3e1b624b3443 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocsTest.java
@@ -268,6 +268,23 @@ class CatalogDocsTest {
}
}
+ /** The one element of a JSON list by a field: models wrote $[?(...)][0],
an empty list (the r20 REST runs). */
+ @Test
+ void theJsonPathAnswerSaysHowToPickOneElement() throws Exception {
+ JsonObject doc = catalogDoc(Map.of("name", "jsonpath"));
+ assertTrue(doc.getString("usage").contains("resultType:
java.util.Map"), doc.getString("usage"));
+ assertTrue(doc.getString("usage").contains("an index after the filter
gives an empty list"), doc.getString("usage"));
+ }
+
+ @Test
+ void aWrongHeaderNameIsAnsweredWithTheRightOne() {
+ org.apache.camel.catalog.CamelCatalog catalog = new
org.apache.camel.catalog.DefaultCamelCatalog();
+ JsonObject result = CatalogDocs.find(catalog, "CamelHttpStatusCode",
null, 10);
+ assertTrue(result.getString("header").startsWith("Camel has no header
CamelHttpStatusCode"), result.toJson());
+ assertTrue(result.getString("header").contains("CamelHttpResponseCode
(Exchange.HTTP_RESPONSE_CODE)"));
+ assertNull(CatalogDocs.find(catalog, "CamelHttpResponseCode", null,
10).get("header"));
+ }
+
@Test
void anInterfaceNameFindsTheBuiltInBeans() {
org.apache.camel.catalog.CamelCatalog catalog = new
org.apache.camel.catalog.DefaultCamelCatalog();
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
index 076e5ae24910..0fd7655f1100 100644
---
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
@@ -31,6 +31,16 @@ class ExpressionEvaluatorLanguageTest {
private static final String STOCK =
"[{\"sku\":\"CAMEL-MUG\",\"qty\":42},{\"sku\":\"CAMEL-CAP\",\"qty\":0}]";
+ /** A jsonpath filter gives a list: the answer says how a route gets the
one element (r20 REST runs). */
+ @Test
+ void aJsonPathFilterSaysHowToGetTheOneElement() {
+ assertThat(ExpressionEvaluator.filterNote("jsonpath", "$[?(@.sku ==
'CAMEL-MUG')]", "ok"))
+ .contains("resultType: java.util.Map gives the one
element").contains("an index after the filter");
+ assertThat(ExpressionEvaluator.filterNote("jsonpath", "$.sku",
"ok")).isNull();
+ assertThat(ExpressionEvaluator.filterNote("simple", "${body}",
"ok")).isNull();
+ assertThat(ExpressionEvaluator.filterNote("jsonpath", "$[?(@.sku ==
'X')", "error")).isNull();
+ }
+
@Test
void aLanguageThatCannotBeLoadedSaysWhatToDo() {
JsonObject result = ExpressionEvaluator.evaluate(new ToolContext(),
"no-such-language", "x", null);
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/JsonPathChecksTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/JsonPathChecksTest.java
index 165b0d1cc2cc..ffe399a499e1 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/JsonPathChecksTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/JsonPathChecksTest.java
@@ -55,6 +55,60 @@ class JsonPathChecksTest {
.contains("read as $[?(@.status == 'paid')]");
}
+ /** The one order of a list by its id (the REST example of the r20 runs):
an index after the filter gives []. */
+ @Test
+ void anIndexAfterAFilterGivesAnEmptyList() {
+ String yaml = """
+ - route:
+ from:
+ uri: direct:get-order
+ steps:
+ - setBody:
+ expression:
+ jsonpath:
+ expression: "$[?(@.orderId ==
'${header.id}')][0]"
+ """;
+ List<String> errors = JsonPathChecks.validateYamlJsonPath(yaml,
catalog);
+ assertThat(errors).singleElement().satisfies(e -> assertThat(e)
+ .startsWith("Line 8: an index after a filter gives an empty
list in JsonPath")
+ .contains("leave the index out and set resultType:
java.util.Map"));
+ }
+
+ @Test
+ void andOutsideAFilterIsNoJsonPath() {
+ String yaml = """
+ - route:
+ from:
+ uri: direct:get-order
+ steps:
+ - choice:
+ when:
+ - expression:
+ jsonpath:
+ expression: "$[?(@.orderId ==
'${header.id}')] && size() > 0"
+ """;
+ List<String> errors = JsonPathChecks.validateYamlJsonPath(yaml,
catalog);
+ assertThat(errors).singleElement().satisfies(e -> assertThat(e)
+ .startsWith("Line 9: && and || work inside a filter only")
+ .contains("set resultType: java.util.Map").contains("check
${body} == null"));
+ }
+
+ @Test
+ void theOneElementRecipeIsFine() {
+ String yaml = """
+ - route:
+ from:
+ uri: direct:get-order
+ steps:
+ - setBody:
+ expression:
+ jsonpath:
+ expression: "$[?(@.orderId == '${header.id}'
&& @.status == 'paid')]"
+ resultType: java.util.Map
+ """;
+ assertThat(JsonPathChecks.validateYamlJsonPath(yaml,
catalog)).isEmpty();
+ }
+
@Test
void nestedPathHasNoSimpleForm() {
String yaml = """