This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25900 in repository https://gitbox.apache.org/repos/asf/camel.git
commit df5effaa9afeba77a030eff616281bb3f4600666 Author: Claus Ibsen <[email protected]> AuthorDate: Wed Oct 21 07:43:50 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 - 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. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m --- .../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 = """
