Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -568,11 +568,18 @@ static String listHint(Object bean, String methodName) {
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")
+ ": 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)";
return " (" + type + " has no " + (field ? "method or key " + methodName : "such method") + ": "
+ pickElementHint(name, "[0]." + name) + ")";
}

/**
* How to pick an element of a list by a field, and then the first element: given [0] first, models wrote
* ${body[0].orderId} and took the first element.
*/
private static String pickElementHint(String name, String first) {
return "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 or several match); " + first
+ " reads the first element; or split it";
}

private static final Pattern FIELD_NAME = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*");
Expand Down Expand Up @@ -787,7 +794,8 @@ static String noKeysHint(Object value, String key) {
}
if (value instanceof Collection || value instanceof Object[]) {
String type = value instanceof List ? "a List" : value instanceof Object[] ? "an array" : "a Collection";
return " (" + type + " has no keys: pick an element first, such as [0][" + key + "], or split it)";
String name = FIELD_NAME.matcher(key).matches() ? key : "field";
return " (" + type + " has no keys: " + pickElementHint(name, "[0][" + key + "]") + ")";
}
return "";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,8 @@ public void testOgnlDotOnAListSaysHowToGetToAnElement() {
// 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)");
+ " with resultType java.util.Map (the element, or null when none or several"
+ " match); [0].orderId reads the first element; or split it)");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,11 @@ public void testOGNLKeyOnAListSaysToPickAnElement() {
() -> assertExpression("${body[qty]}", null));

IndexOutOfBoundsException cause = assertIsInstanceOf(IndexOutOfBoundsException.class, e.getCause());
assertTrue(cause.getMessage().contains("a List has no keys: pick an element first, such as [0][qty]"),
// the lookup by a field first, as for ${body.qty}
assertTrue(cause.getMessage().contains("a List has no keys: to pick the element whose qty has a given value, use"
+ " jsonpath: \"$[?(@.qty == '${header.qty}')]\" with resultType"
+ " java.util.Map (the element, or null when none or several match);"
+ " [0][qty] reads the first element; or split it)"),
cause.getMessage());
}

Expand All @@ -563,7 +567,11 @@ public void testOGNLKeyOnAnArraySaysToPickAnElement() {
() -> assertExpression("${body[qty]}", null));

IndexOutOfBoundsException cause = assertIsInstanceOf(IndexOutOfBoundsException.class, e.getCause());
assertTrue(cause.getMessage().contains("an array has no keys: pick an element first, such as [0][qty]"),
// the lookup by a field first, as for ${body.qty}
assertTrue(cause.getMessage().contains("an array has no keys: to pick the element whose qty has a given value, use"
+ " jsonpath: \"$[?(@.qty == '${header.qty}')]\" with resultType"
+ " java.util.Map (the element, or null when none or several match);"
+ " [0][qty] reads the first element; or split it)"),
cause.getMessage());
// [0] picks the element the hint points to
assertExpression("${body[0][qty]}", 42);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1691,8 +1691,8 @@ static String usage(ComponentModel model) {
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";
+ " matches, null when none or several do (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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ public final class ExpressionEvaluator {
"\\s(==|=~|!=|!=~|>|>=|<|<=|~~|!~~|contains|!contains|regex|!regex|in|!in|is|!is|range|!range"
+ "|startsWith|!startsWith|endsWith|!endsWith|equals|!equals|&&|\\|\\|)\\s");

// a path that ends with a filter: $[?(@.sku == 'CAMEL-MUG')]
private static final Pattern FILTER_ENDS_PATH = Pattern.compile("\\)]\\s*$");

private ExpressionEvaluator() {
}

Expand Down Expand Up @@ -83,23 +86,37 @@ public static JsonObject evaluate(ToolContext ctx, String language, String expre
result.put("evaluatedIn", "a local scratch context (no integration selected)");
evaluateLocally(ctx, lang, expression, body, predicate, result);
}
addFilterNote(result, lang, expression);
return result;
}

/** 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);
}
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.
* which gives an empty list, or a made-up syntax. In a route, resultType java.util.Map gives the one element when
* the path ends with the filter, and the type of the value gives the one value of a path that goes on after it.
*/
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";
String problem = JsonPathChecks.filterProblem(expression, false);
if (problem != null) {
return problem;
}
if (FILTER_ENDS_PATH.matcher(expression).find()) {
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 or several do)";
}
return "a filter gives a list, also for one match; in a route, a resultType that is not a list, such as"
+ " java.lang.String or java.lang.Integer, gives the one value when one element matches";
}

private static void evaluateInProcess(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.camel.catalog.CamelCatalog;
Expand All @@ -34,9 +35,12 @@ 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 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)";
+ " none or several do)";
// in a when or filter a filter is the condition: an empty list is false (ObjectHelper.evaluateValuePredicate)
private static final String FILTER_IS_THE_CONDITION = "in a when or filter, the filter alone is the condition (true"
+ " when an element matches)";

private JsonPathChecks() {
}
Expand Down Expand Up @@ -74,7 +78,7 @@ public static List<String> validateYamlJsonPath(String content, CamelCatalog cat
if (text == null || text.isEmpty()) {
continue;
}
String filter = filterProblem(text);
String filter = filterProblem(text, SimpleChecks.isPredicate(catalog, lines, i, countLeadingSpaces(line)));
if (filter != null) {
errors.add("Line " + lineNum + ": " + filter);
continue;
Expand All @@ -100,23 +104,43 @@ public static List<String> validateYamlJsonPath(String content, CamelCatalog cat
}

/**
* 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
* &amp;&amp; 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.
* What is wrong around the filter of a path. Jayway applies an index after a filter to each element the filter
* matched, so $[?(@.orderId == 'ORD-1')][0] gives an empty list; and &amp;&amp; or || outside the filter is no
* JsonPath (500 in a REST route). As a value, the one element comes with resultType java.util.Map, and a field of
* it with the type of the field; in a when or filter, the filter alone is the condition.
*
* @param text the jsonpath expression
* @param predicate whether it is the condition of a when or filter
*/
static String filterProblem(String text) {
static String filterProblem(String text, boolean predicate) {
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;
Matcher index = INDEX_AFTER_FILTER.matcher(text);
if (index.find()) {
String problem = "an index after a filter gives an empty list in JsonPath ([?(...)][0] applies the index to"
+ " each element the filter matched): ";
if (predicate) {
return problem + "leave the index out: " + FILTER_IS_THE_CONDITION;
}
String rest = text.substring(index.end()).strip();
if (rest.isEmpty()) {
return problem + "to get the one element, leave the index out and " + ONE_ELEMENT;
}
// $[?(...)][0].qty: the path without the index gives the qty of each match, a single match unwrapped
return problem + "to get a value of the one element, leave the index out, as in "
+ text.substring(0, index.start()) + index.group(1) + rest
+ ", and set resultType to the type of that value, such as java.lang.Integer for a number (the"
+ " value when one element matches)";
}
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";
String problem = "&& and || work inside a filter only, such as $[?(@.status == 'paid' && @.qty > 0)]";
if (predicate) {
return problem + ": " + FILTER_IS_THE_CONDITION + ", so move the other conditions inside it";
}
return problem + "; to test whether an element matches, use the filter as the condition: "
+ FILTER_IS_THE_CONDITION;
}
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ void theJsonPathAnswerSaysHowToPickOneElement() throws Exception {

@Test
void aWrongHeaderNameIsAnsweredWithTheRightOne() {
org.apache.camel.catalog.CamelCatalog catalog = new org.apache.camel.catalog.DefaultCamelCatalog();
CamelCatalog catalog = new 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)"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,34 @@ class ExpressionEvaluatorLanguageTest {
@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");
.contains("resultType: java.util.Map gives the one element it matches (null when none or several do)");
// a path that goes on after the filter: a Map would convert 42 to null
assertThat(ExpressionEvaluator.filterNote("jsonpath", "$[?(@.sku == 'CAMEL-MUG')].qty", "ok"))
.contains("such as java.lang.String or java.lang.Integer, gives the one value").doesNotContain("Map");
assertThat(ExpressionEvaluator.filterNote("jsonpath", "$[?(@.sku == 'CAMEL-MUG')][0].qty", "ok"))
.startsWith("an index after a filter gives an empty list")
.contains("leave the index out, as in $[?(@.sku == 'CAMEL-MUG')].qty");
assertThat(ExpressionEvaluator.filterNote("jsonpath", "$.sku", "ok")).isNull();
assertThat(ExpressionEvaluator.filterNote("simple", "${body}", "ok")).isNull();
assertThat(ExpressionEvaluator.filterNote("jsonpath", "$[?(@.sku == 'X')", "error")).isNull();
}

/** The note goes after the one the evaluation has already, as evaluate adds it for any language it runs. */
@Test
void theFilterNoteIsAddedToTheResult() {
JsonObject result = new JsonObject();
result.put("status", "ok");
result.put("note", "evaluated with an empty body; pass body to evaluate against a value");
ExpressionEvaluator.addFilterNote(result, "jsonpath", "$[?(@.sku == 'CAMEL-MUG')]");
assertThat(result.getString("note")).startsWith("evaluated with an empty body; pass body to evaluate against a"
+ " value; a filter gives a list, also for one match");

JsonObject failed = new JsonObject();
failed.put("status", "error");
ExpressionEvaluator.addFilterNote(failed, "jsonpath", "$[?(@.sku == 'CAMEL-MUG')]");
assertThat(failed.get("note")).isNull();
}

@Test
void aLanguageThatCannotBeLoadedSaysWhatToDo() {
JsonObject result = ExpressionEvaluator.evaluate(new ToolContext(), "no-such-language", "x", null);
Expand Down
Loading
Loading