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 c3722335c18e CAMEL-26632: camel-jbang - AI tools and messages:
resource: in a ternary, a header copied to itself, a condition under
otherwise:, variableReceive under parameters:, a Groovy class without its
import, and text after a Simple function (#29230)
c3722335c18e is described below
commit c3722335c18eaa25f438265078649c659621cc57
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Nov 5 16:51:43 2026 +0100
CAMEL-26632: camel-jbang - AI tools and messages: resource: in a ternary, a
header copied to itself, a condition under otherwise:, variableReceive under
parameters:, a Groovy class without its import, and text after a Simple
function (#29230)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../groovy/DefaultGroovyScriptCompiler.java | 2 +-
.../camel/language/groovy/GroovyExpression.java | 11 +-
.../camel/language/groovy/GroovyLanguage.java | 65 +++-
.../groovy/GroovyCompilePreProcessorTest.java | 40 +++
.../groovy/GroovyMissingPropertyHintTest.java | 14 +
.../camel/language/simple/BaseSimpleParser.java | 4 +-
.../camel/language/simple/SimpleSyntaxHints.java | 76 ++++
.../language/simple/SimpleSyntaxHintsTest.java | 27 ++
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 73 +++-
.../dsl/jbang/core/commands/ai/EndpointChecks.java | 51 ++-
.../core/commands/ai/ExpressionEvaluator.java | 10 +-
.../dsl/jbang/core/commands/ai/HeaderChecks.java | 34 ++
.../core/commands/ai/HeaderPropertyMixups.java | 393 +++++++++++++++++++++
.../dsl/jbang/core/commands/ai/LenientRepairs.java | 5 +
.../dsl/jbang/core/commands/ai/SimpleChecks.java | 224 +++++++++++-
.../jbang/core/commands/ai/SourceValidator.java | 9 +-
.../commands/ai/AuthoringToolsLenientTest.java | 67 ++++
.../ai/ExpressionEvaluatorLanguageTest.java | 4 +
.../core/commands/ai/HeaderPropertyMixupsTest.java | 152 +++++++-
.../commands/ai/SourceValidatorEndpointTest.java | 101 ++++++
.../commands/ai/SourceValidatorSimpleTest.java | 217 +++++++++++-
.../camel/dsl/yaml/validator/SchemaHints.java | 49 +++
.../validator/YamlValidatorPropertyHintTest.java | 72 ++++
23 files changed, 1669 insertions(+), 31 deletions(-)
diff --git
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/DefaultGroovyScriptCompiler.java
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/DefaultGroovyScriptCompiler.java
index de92be2c30f0..e1f354b2c455 100644
---
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/DefaultGroovyScriptCompiler.java
+++
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/DefaultGroovyScriptCompiler.java
@@ -365,7 +365,7 @@ public class DefaultGroovyScriptCompiler extends
ServiceSupport
try {
clazz = shell.getClassLoader().parseClass(code);
} catch (CompilationFailedException e) {
- throw GroovyLanguage.compileFailure(e);
+ throw GroovyLanguage.compileFailure(e, shell.getClassLoader());
}
if (clazz != null) {
String name = clazz.getName();
diff --git
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
index d50b70a400c9..5b6a43a70a86 100644
---
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
+++
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
@@ -21,6 +21,7 @@ import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@@ -142,7 +143,13 @@ public class GroovyExpression extends ExpressionSupport {
+ "'), or call it from the route with"
+ " - bean: {ref: " + name + "}";
} else {
- hint = "the script variables are " + SCRIPT_VARIABLES_HINT;
+ // JsonOutput.toJson(body) with no import: a class Groovy
reads as a variable (r27 of the local-model
+ // benchmark wrote such classes without their import)
+ List<String> classes = GroovyLanguage.notImported(name,
script.getClass().getClassLoader());
+ hint = !classes.isEmpty()
+ ? name + " is the class " + classes.get(0) + ", which
the script does not import: add import "
+ + classes.get(0) + " at the top of the script, or
write " + classes.get(0)
+ : "the script variables are " + SCRIPT_VARIABLES_HINT;
}
throw new groovy.lang.MissingPropertyException(
e.getMessageWithoutLocationText() + " (" + hint + ")",
name, e.getType());
@@ -187,7 +194,7 @@ public class GroovyExpression extends ExpressionSupport {
return name != null
? shell.getClassLoader().parseClass(text, name) :
shell.getClassLoader().parseClass(text);
} catch (CompilationFailedException e) {
- throw GroovyLanguage.compileFailure(e);
+ throw GroovyLanguage.compileFailure(e,
shell.getClassLoader());
}
});
c = new CompiledScript(r.context, r.language, generation,
fileName, scriptClass, constructor(scriptClass));
diff --git
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyLanguage.java
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyLanguage.java
index c751a6d6e564..4ce69cb88c73 100644
---
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyLanguage.java
+++
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyLanguage.java
@@ -17,7 +17,9 @@
package org.apache.camel.language.groovy;
import java.io.IOException;
+import java.util.ArrayList;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@@ -193,7 +195,7 @@ public class GroovyLanguage extends TypedLanguageSupport
implements ScriptingLan
try {
return shell.getClassLoader().parseClass(text);
} catch (CompilationFailedException e) {
- throw compileFailure(e);
+ throw compileFailure(e, shell.getClassLoader());
}
});
Script gs = ObjectHelper.newInstance(clazz, Script.class);
@@ -302,16 +304,77 @@ public class GroovyLanguage extends TypedLanguageSupport
implements ScriptingLan
}
}
+ /**
+ * The packages of the classes a script reaches for that can need an
import: JsonSlurper is a class of groovy.json.
+ * A class Groovy finds by itself (java.lang, java.util, java.io,
java.net, groovy.lang, groovy.util) compiles, so a
+ * package here that a Groovy version imports is never asked about.
+ */
+ private static final List<String> NOT_IMPORTED = List.of("groovy.json",
"groovy.xml", "groovy.time",
+ "groovy.transform", "java.time", "java.time.format",
"java.time.temporal", "java.util.concurrent",
+ "java.util.concurrent.atomic", "java.util.function",
"java.util.stream", "java.util.regex", "java.nio.file",
+ "java.nio.charset", "java.text", "java.math", "java.security",
"java.sql", "org.apache.camel");
+
+ /**
+ * The classes of the simple name in the packages a script does not import
by default, that the class loader has:
+ * groovy.json.JsonSlurper for JsonSlurper. Empty when the name is not a
class there.
+ */
+ static List<String> notImported(String simpleName, ClassLoader
classLoader) {
+ List<String> answer = new ArrayList<>();
+ if (simpleName == null || simpleName.isEmpty() ||
!Character.isUpperCase(simpleName.charAt(0))) {
+ return answer;
+ }
+ for (String pkg : NOT_IMPORTED) {
+ try {
+ Class.forName(pkg + "." + simpleName, false, classLoader);
+ answer.add(pkg + "." + simpleName);
+ } catch (ClassNotFoundException | LinkageError e) {
+ // not a class of this package
+ }
+ }
+ return answer;
+ }
+
/**
* A compilation failure whose cause is a class the script imports and the
classpath does not have: the compiler
* names the class and nothing else; the message adds what to do, without
knowing the runtime (CAMEL-24843).
*/
static RuntimeException compileFailure(CompilationFailedException e) {
+ return compileFailure(e, GroovyLanguage.class.getClassLoader());
+ }
+
+ /**
+ * As {@link #compileFailure(CompilationFailedException)}, with the class
loader the script was compiled with: a
+ * class it has is not missing, it needs an import. r27 of the local-model
benchmark wrote new JsonSlurper() with no
+ * import, was told the class is not on the classpath, added a dependency
for it, and read the log 17 times.
+ */
+ static RuntimeException compileFailure(CompilationFailedException e,
ClassLoader classLoader) {
String msg = e.getMessage();
if (msg != null && msg.contains("unable to resolve class")) {
String cls = msg.substring(msg.indexOf("unable to resolve class")
+ "unable to resolve class".length()).trim();
int end = cls.indexOf('\n');
cls = (end > 0 ? cls.substring(0, end) : cls).trim();
+ String simple = cls.substring(cls.lastIndexOf('.') + 1);
+ List<String> classes = notImported(simple, classLoader);
+ classes.remove(cls);
+ if (!classes.isEmpty()) {
+ String fqn = classes.get(0);
+ String others = classes.size() > 1 ? " (or " + String.join(",
", classes.subList(1, classes.size())) + ")" : "";
+ if (cls.contains(".")) {
+ // import groovy.util.JsonSlurper: the class of that name
is in another package
+ return new RuntimeCamelException(
+ "Groovy cannot resolve the class " + cls + ": the
class " + simple + " is " + fqn + others
+ + ". Import it as import
" + fqn + " at the top of the script.",
+ e);
+ }
+ // the source line of the error shows how the class is used
+ String created = msg.contains("new " + simple + "()") ? "new "
+ fqn + "()"
+ : msg.contains("new " + simple + "(") ? "new " + fqn +
"(...)" : fqn;
+ return new RuntimeCamelException(
+ "Groovy cannot resolve the class " + simple + ": it is
" + fqn + others + ", which the script"
+ + " does not import. Add
import " + fqn + " at the top of the script,"
+ + " or write " + created +
".",
+ e);
+ }
return new RuntimeCamelException(
"Groovy cannot resolve the class " + cls + ": it is not on
the classpath. Add the library that"
+ " provides it as a dependency
of the application: a Maven dependency,"
diff --git
a/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyCompilePreProcessorTest.java
b/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyCompilePreProcessorTest.java
index bac1a4febc84..e222347fa0ed 100644
---
a/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyCompilePreProcessorTest.java
+++
b/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyCompilePreProcessorTest.java
@@ -27,6 +27,7 @@ import org.apache.camel.test.junit6.CamelTestSupport;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -66,6 +67,45 @@ public class GroovyCompilePreProcessorTest extends
CamelTestSupport {
assertTrue(seen.get(0).contains("import java.util.Locale"),
seen.get(0));
}
+ /**
+ * r27 connect-service-jms-request-reply step 3: new JsonSlurper() with no
import was told that the class is not on
+ * the classpath; the model added a dependency for it and read the log 17
times. The class is there and needs an
+ * import.
+ */
+ @Test
+ public void testAClassThatNeedsAnImportSaysToImportIt() {
+ GroovyLanguage groovy = (GroovyLanguage)
context.resolveLanguage("groovy");
+ String msg = assertThrows(Exception.class,
+ () -> groovy.evaluate("def stock = new
JsonSlurper().parseText('[1, 2]')\nstock.size()", null,
+ Object.class))
+ .getMessage();
+ assertTrue(msg.startsWith("Groovy cannot resolve the class
JsonSlurper: it is groovy.json.JsonSlurper, which the"
+ + " script does not import. Add import
groovy.json.JsonSlurper at the top of the script,"
+ + " or write new
groovy.json.JsonSlurper()."),
+ msg);
+ assertFalse(msg.contains("not on the classpath"), msg);
+ // a class created with arguments, and a class of the JDK
+ msg = assertThrows(Exception.class,
+ () -> groovy.evaluate("new XmlSlurper(false, false)", null,
Object.class)).getMessage();
+ assertTrue(msg.contains("it is groovy.xml.XmlSlurper, which the script
does not import. Add import"
+ + " groovy.xml.XmlSlurper at the top of the
script, or write new groovy.xml.XmlSlurper(...)."),
+ msg);
+ msg = assertThrows(Exception.class,
+ () -> groovy.evaluate("new SimpleDateFormat('yyyy').format(new
Date())", null, Object.class)).getMessage();
+ assertTrue(msg.contains("it is java.text.SimpleDateFormat, which the
script does not import"), msg);
+ // an import of the class from another package
+ msg = assertThrows(Exception.class,
+ () -> groovy.evaluate("import groovy.util.JsonSlurper\nnew
JsonSlurper()", null, Object.class))
+ .getMessage();
+ assertTrue(msg.startsWith("Groovy cannot resolve the class
groovy.util.JsonSlurper: the class JsonSlurper is"
+ + " groovy.json.JsonSlurper. Import it as
import groovy.json.JsonSlurper at the top of"
+ + " the script."),
+ msg);
+ // with the import it works
+ assertEquals(2, groovy.evaluate("import groovy.json.JsonSlurper\nnew
JsonSlurper().parseText('[1, 2]').size()",
+ null, Integer.class));
+ }
+
@Test
public void testUnresolvedClassSaysWhatToDo() {
GroovyLanguage groovy = (GroovyLanguage)
context.resolveLanguage("groovy");
diff --git
a/components/camel-groovy/src/test/java/org/apache/camel/processor/groovy/GroovyMissingPropertyHintTest.java
b/components/camel-groovy/src/test/java/org/apache/camel/processor/groovy/GroovyMissingPropertyHintTest.java
index a259aab2d58b..77d5d2189ea3 100644
---
a/components/camel-groovy/src/test/java/org/apache/camel/processor/groovy/GroovyMissingPropertyHintTest.java
+++
b/components/camel-groovy/src/test/java/org/apache/camel/processor/groovy/GroovyMissingPropertyHintTest.java
@@ -179,6 +179,19 @@ public class GroovyMissingPropertyHintTest extends
CamelTestSupport {
assertEquals(java.util.Map.of("sku", "A1"), out);
}
+ @Test
+ public void aClassWithNoImportSaysToImportIt() {
+ // JsonOutput.toJson(body) with no import of groovy.json: Groovy reads
the class as a variable
+ Exception e = assertThrows(Exception.class, () ->
template.sendBody("direct:class", Map.of("sku", "A1")));
+ MissingPropertyException cause =
assertInstanceOf(MissingPropertyException.class, e.getCause());
+ assertTrue(cause.getMessage().contains("No such property:
JsonOutput"), cause.getMessage());
+ assertTrue(cause.getMessage().contains("(JsonOutput is the class
groovy.json.JsonOutput, which the script does not"
+ + " import: add import
groovy.json.JsonOutput at the top of the script, or"
+ + " write
groovy.json.JsonOutput)"),
+ cause.getMessage());
+ assertFalse(cause.getMessage().contains("the script variables are"),
cause.getMessage());
+ }
+
@Test
public void messageIsAScriptVariableAsTheHintSays() {
String out = template.requestBodyAndHeader("direct:message", "World",
"name", "Hello", String.class);
@@ -197,6 +210,7 @@ public class GroovyMissingPropertyHintTest extends
CamelTestSupport {
from("direct:customer").transform().groovy("body.customer.customerNo");
from("direct:header").transform().groovy("headers.skuList.find
{ it.sku == headers.sku }");
from("direct:message").transform().groovy("message.getHeader('name') + ' ' +
message.body");
+
from("direct:class").transform().groovy("JsonOutput.toJson(body)");
}
};
}
diff --git
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/BaseSimpleParser.java
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/BaseSimpleParser.java
index 5a7493afc6c9..4450f894acc6 100644
---
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/BaseSimpleParser.java
+++
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/BaseSimpleParser.java
@@ -500,7 +500,9 @@ public abstract class BaseSimpleParser {
} else if (expect == TokenType.whiteSpace &&
token.getType().getType() == TokenType.singleQuote) {
hint = ": a literal that contains a single quote must be
written with double quotes, e.g. \"it's\"";
} else if (expect == TokenType.whiteSpace) {
- hint = ": operators and values must be separated by a space";
+ // ${body.lines}.size(): a method after the closing brace of a
function, rather than a missing space
+ String after = SimpleSyntaxHints.afterFunction(expression,
previousIndex);
+ hint = after != null ? ": " + after : ": operators and values
must be separated by a space";
}
throw new SimpleParserException(
"expected symbol " + expect + " but was " +
token.getType().getType() + hint, previousIndex);
diff --git
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java
index 08e5db23e289..a059985b0ce3 100644
---
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java
+++
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java
@@ -20,6 +20,8 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
/**
* Hints that turn a Simple parser error into a message that says what to
write (CAMEL-24703). Every consumer of the
@@ -172,10 +174,84 @@ public final class SimpleSyntaxHints {
return "Unexpected token " + word + ": text outside ${...} is a
literal, functions are written as ${body}, "
+ "${header.name}; did you mean " + rewrite + "?";
}
+ // ${body.lines}.size() > 0: the method after the closing brace of the
function
+ int start = Math.min(Math.max(index, 0), expression.length());
+ while (start > 0 && !Character.isWhitespace(expression.charAt(start -
1))) {
+ start--;
+ }
+ int close = word.startsWith("${") ? closingBrace(word) : -1;
+ String after = close > 0 ? afterFunction(expression, start + close +
1) : null;
+ if (after != null) {
+ return "Unexpected token " + word + ": " + after;
+ }
return "Unexpected token " + word + ": text outside ${...} must be " +
VALUE_FORMS + ", or an operator ("
+ OPERATORS + ")";
}
+ /** The functions whose value can be read further with a field, a method
or an index, as ${body.lines.size()}. */
+ private static final Pattern READ_FURTHER = Pattern.compile(
+
"(?:in\\.)?(?:body|headers?|exchangeProperty|variables?|exception)(?:[.\\[?].*)?"
+ +
"|(?:mandatoryB|b)odyAs\\(.*|messageAs\\(.*");
+
+ /** A path that reads further: .lines, ?.lines, .size(), [0] or ['sku'],
one after the other. */
+ private static final Pattern PATH = Pattern.compile(
+ "(?:\\??\\.[A-Za-z_]\\w*(?:\\([^()]*\\))?|\\[[^\\]]*])+");
+
+ /**
+ * A method, a field or an index written right after the closing brace of
a function, as in ${body.lines}.size():
+ * the parser reads it as text, not as part of the function (r27 of the
local-model benchmark, 18 calls of the
+ * expression tool with it in one step). The hint writes it inside the
braces: ${body.lines.size()}. Null when the
+ * text at the index does not follow a function whose value can be read
further, or is a filter, which Simple does
+ * not have.
+ *
+ * @param expression the expression
+ * @param index the index of the first character after the closing
brace of the function
+ * @return the hint, or null
+ */
+ public static String afterFunction(String expression, int index) {
+ if (expression == null || index < 3 || index >= expression.length() ||
expression.charAt(index - 1) != '}') {
+ return null;
+ }
+ // the ${ that the brace closes
+ int depth = 0;
+ int open = -1;
+ for (int i = index - 1; i > 0 && open < 0; i--) {
+ char c = expression.charAt(i);
+ if (c == '}') {
+ depth++;
+ } else if (c == '{' && expression.charAt(i - 1) == '$' && --depth
== 0) {
+ open = i - 1;
+ }
+ }
+ String rest = expression.substring(index);
+ Matcher path = PATH.matcher(rest);
+ if (open < 0 || !path.lookingAt() ||
rest.matches("(?s)\\??\\.[?^$!]\\[.*|\\[\\?\\(.*")) {
+ return null;
+ }
+ String inner = expression.substring(open + 2, index - 1);
+ if (!READ_FURTHER.matcher(inner).matches()) {
+ return null;
+ }
+ String written = path.group();
+ String what = written.endsWith(")") ? "method" : written.endsWith("]")
? "index" : "field";
+ return "the " + what + " " + written + " is outside the function, as
text after its closing brace: write it inside"
+ + " the braces, ${" + inner + written + "}";
+ }
+
+ /** The index of the brace that closes the ${ the text starts with, or -1.
*/
+ private static int closingBrace(String text) {
+ int depth = 0;
+ for (int i = 0; i < text.length(); i++) {
+ if (text.startsWith("${", i)) {
+ depth++;
+ i++;
+ } else if (text.charAt(i) == '}' && --depth == 0) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
/** The message when an operator has no usable value next to it. */
public static String unsupportedOperand(String kind, Object operator,
String expression, int index) {
String word = wordAt(expression, index);
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 359e10a9ffb1..3fc2e73bf94f 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
@@ -170,6 +170,33 @@ public class SimpleSyntaxHintsTest extends
ExchangeTestSupport {
assertThat(SimpleSyntaxHints.unknownFunction("jsonpath:$.status")).doesNotContain("${jsonpath:$.status}");
}
+ /**
+ * r27 of the local-model benchmark, contracts-rest-code-first steps 1 and
2: ${body.lines}.size() > 0 was told that
+ * operators and values must be separated by a space, and a model sent it
to the expression tool 18 times. The
+ * method is text after the function; it goes inside the braces.
+ */
+ @Test
+ public void testAMethodAfterTheFunctionGoesInsideIt() {
+ assertThat(predicateError("${body.lines} != null &&
${body.lines}.size() > 0"))
+ .contains("expected symbol whiteSpace but was character: the
method .size() is outside the function, as"
+ + " text after its closing brace: write it inside
the braces, ${body.lines.size()}")
+ .doesNotContain("separated by a space");
+ assertThat(predicateError("${header.foundOrder}.size() > 0"))
+ .contains("Unexpected token ${header.foundOrder}.size(): the
method .size() is outside the function, as"
+ + " text after its closing brace: write it inside
the braces, ${header.foundOrder.size()}");
+ assertThat(predicateError("${body}[0] == 'a'"))
+ .contains("the index [0] is outside the function, as text
after its closing brace: write it inside the"
+ + " braces, ${body[0]}");
+ assertThat(predicateError("${variable.order}.lines.size() > 0"))
+ .contains("the method .lines.size() is outside the
function").contains("${variable.order.lines.size()}");
+ // a filter is not Simple at all, and a function that cannot be read
further keeps the message it had
+ assertThat(predicateError("${body}?.?[orderId == 'A'] !=
null")).doesNotContain("outside the function");
+ assertThat(predicateError("${date:now:yyyy}.x >
0")).doesNotContain("outside the function");
+ // the rewrite is what works
+ exchange.getIn().setBody(Map.of("lines", List.of("a", "b")));
+ assertEquals(true,
context.resolveLanguage("simple").createPredicate("${body.lines.size()} >
0").matches(exchange));
+ }
+
@Test
public void testOperatorAfterOgnlMethod() {
// CAMEL-24921: an OGNL call on the left of the operator is wrapped as
the function it is
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 440aeb9e49b3..0546cb42e9d0 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
@@ -1742,7 +1742,7 @@ public final class AuthoringTools {
} else if (hidden != null) {
// the write repairs the shape itself: the problems to fix
are the ones the shape hid
errors = hidden;
- repairable = r.applied();
+ repairable = markShapeLeft(r);
}
}
if (!errors.isEmpty()) {
@@ -2025,8 +2025,77 @@ public final class AuthoringTools {
return r.left().stream().anyMatch(e ->
written.contains(withoutLine(e))) ? null : r.left();
}
- /** What the write repairs by itself, said with the problems it still
refuses. */
+ /**
+ * A schema problem of the shape of the YAML: a key where none goes, two
EIPs in one step, a node of another type.
+ */
+ private static final Pattern SHAPE_PROBLEM = Pattern.compile(
+ "is not defined in the schema|must have at most \\d+
propert|found, \\w+ expected|starts in column"
+ + "|required
property");
+
+ /** The line a repair or a problem names: "line 101: otherwise: its steps
put under steps:", "Line 102: ...". */
+ private static final Pattern NAMED_LINE = Pattern.compile("^[Ll]ine
(\\d+)\\b");
+
+ /** What a repair says when a problem of the shape is left in the lines it
changed. */
+ static final String SHAPE_LEFT = " (these lines still have errors)";
+
+ /**
+ * The repairs of a refused write, each marked with {@link #SHAPE_LEFT}
when a problem of the shape is left in the
+ * lines it changed: then the YAML shape does need a change there. r27l-4
connect-http-client step 3: the items of
+ * an otherwise: list were conditions, which the repair put under steps:,
where they were still not steps; told that
+ * the shape needed no change from it, the model sent the same lines 16
times.
+ */
+ private static List<String> markShapeLeft(Repaired r) {
+ String[] lines = r.content().split("\n", -1);
+ List<Integer> shapeLines = new ArrayList<>();
+ for (String problem : r.left()) {
+ Matcher m = NAMED_LINE.matcher(problem);
+ if (m.find() && SHAPE_PROBLEM.matcher(problem).find()) {
+ shapeLines.add(Integer.parseInt(m.group(1)));
+ }
+ }
+ List<String> answer = new ArrayList<>();
+ for (String applied : r.applied()) {
+ Matcher m = NAMED_LINE.matcher(applied);
+ boolean left = false;
+ if (m.find()) {
+ int start = Integer.parseInt(m.group(1));
+ int end = blockEnd(lines, start);
+ left = shapeLines.stream().anyMatch(l -> l >= start && l <=
end);
+ }
+ answer.add(left ? applied + SHAPE_LEFT : applied);
+ }
+ return answer;
+ }
+
+ /** The last line (1-based) of the block of a line: the lines after it
that are indented more, or blank. */
+ private static int blockEnd(String[] lines, int line) {
+ if (line < 1 || line > lines.length) {
+ return line;
+ }
+ int indent = YamlLines.countLeadingSpaces(lines[line - 1]);
+ int end = line;
+ for (int i = line; i < lines.length; i++) {
+ if (lines[i].isBlank()) {
+ continue;
+ }
+ if (YamlLines.countLeadingSpaces(lines[i]) <= indent) {
+ break;
+ }
+ end = i + 1;
+ }
+ return end;
+ }
+
+ /**
+ * What the write repairs by itself, said with the problems it still
refuses: not that the shape needs no change
+ * when the lines of a repair still have a problem of their shape.
+ */
static String repairableNote(List<String> repairable) {
+ if (repairable.stream().anyMatch(r -> r.endsWith(SHAPE_LEFT))) {
+ return "The write repairs part of the YAML shape by itself (" +
String.join("; ", repairable) + "), and the"
+ + " lines marked still have errors of their shape: change
them as the errors say. The errors are what"
+ + " the content has with that repaired.";
+ }
return "The YAML shape needs no change from you: the write repairs it
by itself (" + String.join("; ", repairable)
+ "). The errors are what the content has with that repaired.";
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
index 4fb7b8f7769c..f065e545f227 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
@@ -34,6 +34,7 @@ import org.apache.camel.catalog.DefaultCamelCatalog;
import org.apache.camel.catalog.EndpointValidationResult;
import org.apache.camel.catalog.LanguageValidationResult;
import org.apache.camel.catalog.RuntimeProvider;
+import org.apache.camel.tooling.model.EipModel;
import org.apache.camel.util.StringHelper;
import org.apache.camel.util.URISupport;
@@ -396,7 +397,7 @@ final class EndpointChecks {
}
}
if (!result.isSuccess()) {
- collectEndpointErrors(errors, result, scheme, catalog,
lineIdx, optionLineMap);
+ collectEndpointErrors(errors, result, scheme, catalog,
lineIdx, optionLineMap, eipName, fullUri, uriForm);
}
checkRegexOptions(errors, fullUri, lineIdx, optionLineMap);
checkFileLanguageOptions(errors, catalog, fullUri, rawLine,
lineIdx, optionLineMap);
@@ -964,7 +965,7 @@ final class EndpointChecks {
static void collectEndpointErrors(
List<String> errors, EndpointValidationResult result, String
scheme, CamelCatalog catalog,
- int uriLineIdx, Map<String, Integer> optionLineMap) {
+ int uriLineIdx, Map<String, Integer> optionLineMap, String
eipName, String fullUri, boolean uriForm) {
if (result.getUnknown() != null && !"kamelet".equals(scheme)) {
// the options of a kamelet: endpoint are the parameters of its
template, which the catalog does not know
for (String name : result.getUnknown()) {
@@ -977,8 +978,15 @@ final class EndpointChecks {
// (cronService) is a fix that makes it worse; say what the
endpoint takes instead (an entry of
// INVENTED_OPTIONS for it says more, and is the one hint)
String named = known == null && name.equals(scheme) ?
namedAfterComponent(catalog, scheme) : null;
+ // variableReceive under parameters: of a to: is a key of the
to:, which the closest options of the
+ // endpoint do not say
+ String ofTheEip = known == null && named == null
+ ? optionOfTheEip(catalog, eipName, name, scheme,
fullUri, uriForm, optionLineMap.containsKey(name))
+ : null;
if (named != null) {
sb.append(" (").append(named).append(")");
+ } else if (ofTheEip != null) {
+ sb.append(" (").append(ofTheEip).append(")");
} else if (result.getUnknownSuggestions() != null) {
String[] suggestions =
result.getUnknownSuggestions().get(name);
if (suggestions != null && suggestions.length > 0) {
@@ -1035,6 +1043,45 @@ final class EndpointChecks {
}
}
+ /**
+ * The words for an option of the EIP of the endpoint written as an option
of the endpoint: variableReceive under
+ * parameters: of a to: is a key of the to:, next to uri:. r27
connect-service-jms-request-reply step 2: told to
+ * keep the message with variableReceive, a model put it (and pattern
before it) under parameters:, and was told
+ * only "jms: Unknown option 'variableReceive'". The options of the EIP
are the ones the catalog has for it, uri
+ * aside. Null when the EIP has no option of that name.
+ */
+ static String optionOfTheEip(
+ CamelCatalog catalog, String eipName, String name, String scheme,
String fullUri, boolean uriForm,
+ boolean underParameters) {
+ if (eipName == null || "uri".equals(name)) {
+ return null;
+ }
+ String eip = StringHelper.dashToCamelCase(eipName);
+ EipModel model;
+ try {
+ model = catalog.eipModel(eip);
+ } catch (Exception e) {
+ return null;
+ }
+ if (model == null || model.getOptions().stream()
+ .noneMatch(o -> name.equals(o.getName()) &&
"attribute".equals(o.getKind()))) {
+ return null;
+ }
+ if (uriForm) {
+ return name + " is an option of the " + eip + ", not of the " +
scheme + " endpoint: set it on the " + eip
+ + ", not in the uri";
+ }
+ String uri = fullUri.contains("?") ? fullUri.substring(0,
fullUri.indexOf('?')) : fullUri;
+ if (uri.endsWith(":")) {
+ uri = uri.substring(0, uri.length() - 1);
+ }
+ Matcher value = Pattern.compile("[?&]" + Pattern.quote(name) +
"=([^&]*)").matcher(fullUri);
+ String written = value.find() && !value.group(1).isEmpty() ?
value.group(1) : "...";
+ return name + " is a key of the " + eip + ":, next to uri:, not an
option of the " + scheme + " endpoint: "
+ + eip + ": {uri: " + (underParameters ? uri + ", parameters:
{...}" : "\"" + uri + "\"") + ", " + name
+ + ": " + written + "}";
+ }
+
static void addInvalid(
List<String> errors, Map<String, String> invalid, String type,
String scheme, int uriLineIdx,
Map<String, Integer> optionLineMap) {
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 e3c82db128bc..6df292ee6053 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
@@ -289,6 +289,11 @@ public final class ExpressionEvaluator {
* 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 ("simple".equals(lang)) {
+ // a JsonPath or SpEL filter in or after a function, which Simple
does not have: the jsonpath step that does
+ // it, also when the parser only says that a space is missing (r27
rest-code-first step 1)
+ return SimpleChecks.filterInFunction(expression);
+ }
if (!"jsonpath".equals(lang) || expression == null ||
!expression.contains("[?(") || !"ok".equals(status)) {
return null;
}
@@ -340,12 +345,13 @@ public final class ExpressionEvaluator {
// ${...} that an integration before Camel 4.24 refuses, go with
its message
String hint = xpathHint(r.language(), r.expression(),
e.getMessage());
String elvis = elvisNote(ctx, r.language(), r.expression());
- if (hint == null && ignored == null && elvis == null) {
+ String filter = filterNote(r.language(), r.expression(), "error");
+ if (hint == null && ignored == null && elvis == null && filter ==
null) {
throw e;
}
throw new ToolExecutionException(
e.getMessage() + (hint != null ? "; " + hint : "") +
(ignored != null ? "; " + ignored : "")
- + (elvis != null ? "; " + elvis :
""));
+ + (elvis != null ? "; " + elvis :
"") + (filter != null ? "; " + filter : ""));
}
JsonObject out = null;
try {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
index 477e61ddba99..5a4236b37a1b 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
@@ -225,6 +225,39 @@ final class HeaderChecks {
return msgs;
}
+ /** ${body[CamelTimerCounter]} or ${body.CamelTimerCounter}: a field of
the body named as Camel names its own. */
+ private static final Pattern CAMEL_NAME_ON_BODY = Pattern.compile(
+
"\\$\\{body(?:\\??\\.|\\[\\s*['\"]?)(Camel[A-Z][A-Za-z0-9]*)['\"]?\\s*]?");
+
+ /**
+ * An exchange property of a component of the file read as a field of the
body: r27 transform-groovy step 3 wrote
+ * resource:file:order-${body[CamelTimerCounter]}.json, and the body has
no such field, so the route loaded
+ * order-.json. Once per name.
+ */
+ static List<String> propertiesReadOnTheBody(String content, Set<String>
schemes) {
+ List<String> msgs = new ArrayList<>();
+ String[] lines = content.split("\n", -1);
+ Set<String> reported = new HashSet<>();
+ for (int i = 0; i < lines.length; i++) {
+ Matcher m = CAMEL_NAME_ON_BODY.matcher(lines[i]);
+ while (m.find()) {
+ String name = m.group(1);
+ for (String scheme : schemes) {
+ if (EXCHANGE_PROPERTIES.getOrDefault(scheme,
List.of()).contains(name) && reported.add(name)) {
+ msgs.add("Line " + (i + 1) + ": " + m.group() + "}
reads a field of the body, and the body has"
+ + " no field " + name + ": " + name
+ + " is an exchange property of the " + scheme
+ ", write ${exchangeProperty." + name
+ + "}" + (setsExchangeProperties(scheme,
content)
+ ? ""
+ : " (the " + scheme + " sets it only
with " + METADATA_OPTION.get(scheme)
+ + "=true on the endpoint)"));
+ }
+ }
+ }
+ }
+ return msgs;
+ }
+
/**
* The {@link #WRONG_NAMES} the content uses as header names, and the
names written as a constant that Exchange does
* not have, which fail the route when Camel creates it; once each, the
names go into reported.
@@ -290,6 +323,7 @@ final class HeaderChecks {
while (sm.find()) {
schemes.add(sm.group(1));
}
+ msgs.addAll(propertiesReadOnTheBody(content, schemes));
for (String scheme : schemes) {
try {
var model = catalog.componentModel(scheme);
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixups.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixups.java
index 9b49c39627c3..76be04480f4a 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixups.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixups.java
@@ -16,17 +16,25 @@
*/
package org.apache.camel.dsl.jbang.core.commands.ai;
+import java.io.StringReader;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import org.apache.camel.dsl.yaml.validator.ProjectFacts;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
+import org.yaml.snakeyaml.nodes.MappingNode;
+import org.yaml.snakeyaml.nodes.Node;
+import org.yaml.snakeyaml.nodes.NodeTuple;
+import org.yaml.snakeyaml.nodes.ScalarNode;
+import org.yaml.snakeyaml.nodes.SequenceNode;
/**
* A route that keeps a value in one place (a header, an exchange property or
a variable) and reads it from another gets
@@ -37,6 +45,9 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
* Quiet where the name can come from elsewhere: a route that consumes from an
endpoint that can carry headers (only
* timer, scheduler, cron, quartz and file cannot), a path parameter {X} of a
rest or platform-http path, and a name the
* route sets in both forms.
+ * <p/>
+ * In the same routes, a header copied to itself that nothing sets before
({@code setHeader: sku} with
+ * {@code ${header.sku}}) is null too, see {@link #selfCopies(String,
ProjectFacts)}.
*/
final class HeaderPropertyMixups {
@@ -209,6 +220,20 @@ final class HeaderPropertyMixups {
/** The problems, each "Line N: message". */
static List<String> validate(String content) {
+ return validate(content, ProjectFacts.NONE);
+ }
+
+ /**
+ * The problems, each "Line N: message", with the fields of the JSON data
of the project for a header copied to
+ * itself.
+ */
+ static List<String> validate(String content, ProjectFacts facts) {
+ List<String> answer = mixups(content);
+ answer.addAll(selfCopies(content, facts));
+ return answer;
+ }
+
+ private static List<String> mixups(String content) {
List<String> answer = new ArrayList<>();
if (content == null || !content.contains("${")) {
return answer;
@@ -343,4 +368,372 @@ final class HeaderPropertyMixups {
int colon = uri.indexOf(':');
return colon > 0 ? uri.substring(0, colon) : uri;
}
+
+ /** ${header.sku}, ${headers.sku}, ${in.header.sku} or ${header[sku]}: one
header and nothing else. */
+ private static final Pattern ONE_HEADER = Pattern.compile(
+
"\\$\\{(?:in\\.)?headers?(?:\\.([A-Za-z_][\\w-]*)|\\[['\"]?([A-Za-z_][\\w-]*)['\"]?])}");
+
+ /**
+ * The steps that set no header other than the ones they name, and the
EIPs whose steps are walked: any other step
+ * (a to:, an enrich, a process, a bean...) may set a header of any name.
+ */
+ private static final Set<String> QUIET_STEPS = Set.of("log", "setBody",
"transform", "setHeader", "setHeaders",
+ "setProperty", "setVariable", "setVariables", "removeHeader",
"removeHeaders", "removeProperty",
+ "removeProperties", "removeVariable", "unmarshal", "marshal",
"convertBodyTo", "convertHeaderTo",
+ "convertVariableTo", "delay", "throttle", "stop", "filter",
"choice", "split", "loop", "step", "pipeline",
+ "doTry", "idempotentConsumer", "validate", "setExchangePattern");
+
+ /** The languages that run code, which can set a header of any name. */
+ private static final Set<String> SCRIPTS = Set.of("groovy", "js",
"javascript", "python", "java", "joor", "jsh",
+ "mvel", "ognl", "spel", "jactl", "quickjs", "method", "bean",
"csimple", "datasonnet", "wasm");
+
+ /** split: ${body[lines]}, ${body.lines} or the jsonpath $.lines: the
field of the data whose elements it gives. */
+ private static final Pattern SPLIT_FIELD = Pattern.compile(
+
"\\$\\{body(?:\\??\\.|\\[\\s*['\"]?)([A-Za-z_]\\w*)['\"]?\\s*]?}|\\$\\.([A-Za-z_]\\w*)(?:\\[\\*])?");
+
+ /** What the walk of a route knows at a step: the names set before it, and
whether a step may set any header. */
+ private static final class Seen {
+ final Set<String> headers = new LinkedHashSet<>();
+ final Set<String> variables = new LinkedHashSet<>();
+ final Set<String> properties = new LinkedHashSet<>();
+ boolean anyHeader;
+
+ Seen copy() {
+ Seen s = new Seen();
+ s.headers.addAll(headers);
+ s.variables.addAll(variables);
+ s.properties.addAll(properties);
+ s.anyHeader = anyHeader;
+ return s;
+ }
+
+ void merge(Seen inner) {
+ headers.addAll(inner.headers);
+ variables.addAll(inner.variables);
+ properties.addAll(inner.properties);
+ anyHeader |= inner.anyHeader;
+ }
+ }
+
+ /**
+ * A setHeader whose value is one header that nothing set before it: the
line, the name set, the name read, the
+ * field the split it is in gives the elements of, and where the route
keeps the name read instead (a variable or an
+ * exchange property), or null.
+ */
+ private record UnsetCopy(int line, String name, String read, String split,
Kind kept) {
+ }
+
+ /**
+ * A setHeader that copies a header to itself, setHeader sku with
${header.sku}, when nothing sets that header
+ * before it: the route consumes from an endpoint that carries no header
of its own (timer, scheduler, cron, quartz,
+ * file), and no step before it sets the header. The header is null, and
stays null, with no error. r23 to r27
+ * connect-http-client step 2 (10 runs): in a split over the lines of an
order, the HTTP call had no sku, 98 errors
+ * of 404 before the model found ${body[sku]}. Only a header that a later
step of the route reads, as the toD did. A
+ * step that may set a header the check does not see (a to:, a process, a
bean, a script) makes it say nothing after
+ * it. A path parameter of a rest in the file is a header of the route the
rest calls, not of these routes.
+ * <p/>
+ * Only a copy to itself is reported; a copy from another header that
nothing sets (needed with ${header.qty}) is
+ * named in the same message.
+ */
+ static List<String> selfCopies(String content, ProjectFacts facts) {
+ List<String> answer = new ArrayList<>();
+ if (content == null || !content.contains("header")) {
+ return answer;
+ }
+ List<Node> roots = new ArrayList<>();
+ try {
+ for (Node n : new Yaml(new SafeConstructor(new
LoaderOptions())).composeAll(new StringReader(content))) {
+ roots.add(n);
+ }
+ } catch (RuntimeException e) {
+ // not YAML: the schema check says so
+ return answer;
+ }
+ String[] lines = content.split("\n", -1);
+ try {
+ for (Node root : roots) {
+ if (!(root instanceof SequenceNode entries)) {
+ continue;
+ }
+ for (Node entry : entries.getValue()) {
+ MappingNode route = mapping(value(entry, "route"));
+ MappingNode from = mapping(route != null ? value(route,
"from") : value(entry, "from"));
+ String uri = from != null ? scalar(value(from, "uri")) :
null;
+ if (uri == null || !PLAIN_CONSUMERS.contains(scheme(uri)))
{
+ continue;
+ }
+ List<UnsetCopy> copies = new ArrayList<>();
+ walk(value(from, "steps"), new Seen(), null, copies);
+ // only a header that a later step reads: a copy nothing
reads is harmless (a route that worked had
+ // one, with the value read from a variable)
+ int end = from.getEndMark().getLine() + 1;
+ copies.removeIf(c -> !readAfter(lines, c.line(), end,
c.name()));
+ ProjectFacts.JsonFields data = consumedData(uri, from,
facts);
+ for (UnsetCopy copy : copies) {
+ if (copy.name().equals(copy.read())) {
+ answer.add(selfCopyMessage(copy, copies, data));
+ }
+ }
+ }
+ }
+ } catch (RuntimeException e) {
+ // a shape the walk does not know: a check must never fail the
validation
+ }
+ return answer;
+ }
+
+ /** Whether a line after the given one, up to the end line (1-based,
inclusive), reads the header in Simple. */
+ private static boolean readAfter(String[] lines, int line, int end, String
name) {
+ for (int i = line; i < Math.min(end, lines.length); i++) {
+ Matcher m = HEADER_READ.matcher(lines[i]);
+ while (m.find()) {
+ if (name.equals(m.group(1) != null ? m.group(1) : m.group(2)))
{
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /** Walks the steps in order, with what was set before each one,
collecting the copies of unset headers. */
+ private static void walk(Node steps, Seen seen, String split,
List<UnsetCopy> copies) {
+ if (!(steps instanceof SequenceNode list)) {
+ return;
+ }
+ for (Node item : list.getValue()) {
+ if (!(item instanceof MappingNode step) || step.getValue().size()
!= 1) {
+ seen.anyHeader = true;
+ continue;
+ }
+ String eip = scalar(step.getValue().get(0).getKeyNode());
+ Node body = step.getValue().get(0).getValueNode();
+ if (!QUIET_STEPS.contains(eip) || runsCode(body)) {
+ seen.anyHeader = true;
+ continue;
+ }
+ switch (eip) {
+ case "setHeader" -> setHeader(body, seen, split, copies);
+ case "setHeaders" -> namesOf(value(body, "headers"),
seen.headers);
+ case "setVariable" -> name(body, seen.variables);
+ case "setVariables" -> namesOf(value(body, "variables"),
seen.variables);
+ case "setProperty" -> name(body, seen.properties);
+ case "split" -> {
+ // each element starts from what was set before the split
+ Seen inner = seen.copy();
+ walk(value(body, "steps"), inner, split == null ?
splitField(body) : null, copies);
+ seen.merge(inner);
+ }
+ case "loop" -> {
+ // a pass of the loop sees what the passes before it set
+ Seen first = seen.copy();
+ walk(value(body, "steps"), first, split, new
ArrayList<>());
+ Seen inner = seen.copy();
+ inner.merge(first);
+ walk(value(body, "steps"), inner, split, copies);
+ seen.merge(inner);
+ }
+ default -> nested(body, seen, split, copies);
+ }
+ variableReceive(body, seen);
+ }
+ }
+
+ /** The steps of the EIPs that hold some: when and otherwise of a choice,
doCatch and doFinally of a doTry. */
+ private static void nested(Node body, Seen seen, String split,
List<UnsetCopy> copies) {
+ if (!(body instanceof MappingNode map)) {
+ return;
+ }
+ List<Seen> branches = new ArrayList<>();
+ for (NodeTuple t : map.getValue()) {
+ String key = scalar(t.getKeyNode());
+ if ("steps".equals(key)) {
+ Seen inner = seen.copy();
+ walk(t.getValueNode(), inner, split, copies);
+ branches.add(inner);
+ } else if ("when".equals(key) || "doCatch".equals(key)) {
+ if (t.getValueNode() instanceof SequenceNode items) {
+ for (Node branch : items.getValue()) {
+ Seen inner = seen.copy();
+ walk(value(branch, "steps"), inner, split, copies);
+ branches.add(inner);
+ }
+ }
+ } else if ("otherwise".equals(key) || "doFinally".equals(key)) {
+ Seen inner = seen.copy();
+ walk(value(t.getValueNode(), "steps"), inner, split, copies);
+ branches.add(inner);
+ }
+ }
+ branches.forEach(seen::merge);
+ }
+
+ private static void setHeader(Node body, Seen seen, String split,
List<UnsetCopy> copies) {
+ String name = scalar(value(body, "name"));
+ if (name == null) {
+ return;
+ }
+ Node simple = value(body, "simple");
+ if (simple == null && value(body, "expression") instanceof MappingNode
expression) {
+ simple = value(expression, "simple");
+ }
+ if (simple instanceof MappingNode m) {
+ simple = value(m, "expression");
+ }
+ Matcher m = simple instanceof ScalarNode s ?
ONE_HEADER.matcher(s.getValue().trim()) : null;
+ if (m != null && m.matches() && !seen.anyHeader) {
+ String read = m.group(1) != null ? m.group(1) : m.group(2);
+ if (!read.startsWith("Camel") && !seen.headers.contains(read)) {
+ Kind kept = seen.variables.contains(read)
+ ? Kind.VARIABLE : seen.properties.contains(read) ?
Kind.PROPERTY : null;
+ copies.add(new UnsetCopy(simple.getStartMark().getLine() + 1,
name, read, split, kept));
+ }
+ }
+ seen.headers.add(name);
+ }
+
+ /** Whether a step runs code of its own, outside the steps it holds: a
script language can set any header. */
+ private static boolean runsCode(Node node) {
+ if (node instanceof MappingNode map) {
+ for (NodeTuple t : map.getValue()) {
+ String key = scalar(t.getKeyNode());
+ if (SCRIPTS.contains(key)
+ || "simple".equals(key) && scalar(t.getValueNode()) !=
null
+ &&
scalar(t.getValueNode()).contains("setHeader(")) {
+ return true;
+ }
+ if (!"steps".equals(key) && !"when".equals(key) &&
!"otherwise".equals(key) && !"doCatch".equals(key)
+ && !"doFinally".equals(key) &&
runsCode(t.getValueNode())) {
+ return true;
+ }
+ }
+ } else if (node instanceof SequenceNode seq) {
+ for (Node n : seq.getValue()) {
+ if (runsCode(n)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /** The field of the data a split gives the elements of, such as lines, or
null when it is not one. */
+ private static String splitField(Node body) {
+ String text = null;
+ Node expression = value(body, "expression");
+ Node language = expression instanceof MappingNode e ? e : body;
+ for (String key : List.of("simple", "jsonpath")) {
+ Node n = value(language, key);
+ if (n instanceof MappingNode m) {
+ n = value(m, "expression");
+ }
+ if (scalar(n) != null) {
+ text = scalar(n).trim();
+ }
+ }
+ Matcher m = text != null ? SPLIT_FIELD.matcher(text) : null;
+ return m != null && m.matches() ? m.group(1) != null ? m.group(1) :
m.group(2) : null;
+ }
+
+ /** The JSON data of the files the route consumes, as the project facts
have them, or null. */
+ private static ProjectFacts.JsonFields consumedData(String uri,
MappingNode from, ProjectFacts facts) {
+ if (facts == null || !"file".equals(scheme(uri))) {
+ return null;
+ }
+ String directory = uri.length() > "file:".length() ?
uri.substring("file:".length()) : null;
+ if (directory != null && directory.contains("?")) {
+ directory = directory.substring(0, directory.indexOf('?'));
+ }
+ if (directory == null || directory.isBlank()) {
+ directory = scalar(value(value(from, "parameters"),
"directoryName"));
+ }
+ return directory != null ?
facts.jsonFields().get(ProjectFacts.directoryKey(directory)) : null;
+ }
+
+ private static String selfCopyMessage(UnsetCopy copy, List<UnsetCopy>
copies, ProjectFacts.JsonFields data) {
+ String name = copy.name();
+ StringBuilder sb = new StringBuilder("Line
").append(copy.line()).append(": ${header.").append(name)
+ .append("} is null here: no step before this line sets the
header ").append(name)
+ .append(", so setHeader ").append(name).append(" copies null
to itself. ")
+ .append(copy.kept() != null
+ ? "The route keeps " + name + " in " +
copy.kept().what + ": write ${" + copy.kept().read + name
+ + "}"
+ : whereTheValueIs(name, copy.split(), data));
+ // the other headers the same route copies before anything sets them,
such as needed with ${header.qty}
+ for (UnsetCopy other : copies) {
+ if (other != copy && !other.name().equals(other.read())) {
+ sb.append("; the same for
${header.").append(other.read()).append("} on line ").append(other.line())
+ .append(": ").append(other.kept() != null
+ ? "${" + other.kept().read + other.read() + "}"
+ : readInstead(other.read(), other.split(),
data));
+ }
+ }
+ return sb.toString();
+ }
+
+ /** What to write for a value the route has not set as a header: a field
of the split element, or of the data. */
+ private static String whereTheValueIs(String name, String split,
ProjectFacts.JsonFields data) {
+ Set<String> element = split != null && data != null ?
data.elementFields().get(split) : null;
+ if (element != null && element.contains(name)) {
+ return "In the split the body is one of the " + split + " of " +
data.where() + " ("
+ + String.join(", ", new TreeSet<>(element)) + "): write
${body[" + name + "]}";
+ }
+ if (element != null && data.fields().contains(name)) {
+ return name + " is a field of the data that was split, not of one
of the " + split + ": read it before the"
+ + " split, into a variable (setVariable), and write
${variable." + name + "} in the split";
+ }
+ if (split != null) {
+ return "In the split the body is the element: a field of it is
${body[" + name + "]}";
+ }
+ return "Set it from where the value is, such as a field of the body
(${body[" + name + "]}) or a variable"
+ + " (${variable." + name + "})";
+ }
+
+ /** The read to write instead of a header nothing sets, for the tail of
the message. */
+ private static String readInstead(String name, String split,
ProjectFacts.JsonFields data) {
+ Set<String> element = split != null && data != null ?
data.elementFields().get(split) : null;
+ if (element != null && !element.contains(name) &&
data.fields().contains(name)) {
+ return "${variable." + name + "}, set before the split";
+ }
+ return "${body[" + name + "]}";
+ }
+
+ /** A variableReceive of a step: the variable it sets. */
+ private static void variableReceive(Node body, Seen seen) {
+ String v = scalar(value(body, "variableReceive"));
+ if (v != null) {
+ seen.variables.add(v);
+ }
+ }
+
+ private static void name(Node body, Set<String> into) {
+ String n = scalar(value(body, "name"));
+ if (n != null) {
+ into.add(n);
+ }
+ }
+
+ private static void namesOf(Node items, Set<String> into) {
+ if (items instanceof SequenceNode list) {
+ list.getValue().forEach(item -> name(item, into));
+ }
+ }
+
+ private static Node value(Node node, String key) {
+ if (node instanceof MappingNode map) {
+ for (NodeTuple t : map.getValue()) {
+ if (key.equals(scalar(t.getKeyNode()))) {
+ return t.getValueNode();
+ }
+ }
+ }
+ return null;
+ }
+
+ private static MappingNode mapping(Node node) {
+ return node instanceof MappingNode m ? m : null;
+ }
+
+ private static String scalar(Node node) {
+ return node instanceof ScalarNode s ? s.getValue() : null;
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LenientRepairs.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LenientRepairs.java
index dfc653aaef76..ee0bfc41ec8d 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LenientRepairs.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LenientRepairs.java
@@ -187,6 +187,11 @@ final class LenientRepairs {
line, false, "line " + line + ": " + key + ": the value in
the case of the enumeration",
ls -> enumCase(ls, line - 1, key, choices)));
} else if ((m = LIST_FOR_MAP.matcher(error)).find()) {
+ if (error.contains("A second condition is another item of when:"))
{
+ // the items are conditions with their steps, not steps: under
steps: they are still wrong (r27l-4
+ // connect-http-client step 3, refused 19 times in a row with
the repair said to need no change)
+ return answer;
+ }
int line = Integer.parseInt(m.group(1));
String key = m.group(2);
answer.add(new Repair(
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SimpleChecks.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SimpleChecks.java
index 6ac170f4752c..d0151de4feb2 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SimpleChecks.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SimpleChecks.java
@@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.ai;
import java.util.ArrayList;
import java.util.List;
+import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -120,6 +121,15 @@ final class SimpleChecks {
boolean predicate = !isLogMessage && isPredicate(catalog, lines,
i, lineIndent);
checkText(errors, catalog, simpleText, lineNum, predicate,
isLogMessage,
hasAncestorEip(lines, i, lineIndent, "aggregate"));
+ // resolveResource: true loads the resource the result names, so
there a resource: value is right
+ if (!predicate && !isLogMessage
+ && !"true".equals(findSiblingValue(lines, textLine,
countLeadingSpaces(lines[textLine]),
+ "resolveResource"))) {
+ String resource = resourceAsAValue(simpleText);
+ if (resource != null) {
+ errors.add("Line " + lineNum + ": " + resource);
+ }
+ }
}
// ${property.x}: the hint of the parser, with where the route keeps x
return HeaderPropertyMixups.withWhereKept(errors, content);
@@ -181,9 +191,77 @@ final class SimpleChecks {
if (elvis != null) {
errors.add("Line " + lineNum + ": " + elvis);
}
+ String unknown = unknownFunctionWithNested(text, catalog);
+ if (unknown != null) {
+ errors.add("Line " + lineNum + ": " + unknown);
+ }
}
}
+ /** The name a function starts with: size of ${size:${body}}. */
+ private static final Pattern FUNCTION_NAME =
Pattern.compile("[A-Za-z][\\w-]*");
+
+ /**
+ * A function whose name Simple does not have, with a function nested in
it: ${size:${body}}. The parser takes such
+ * a function as a whole only when it evaluates it, so the expression
passes the check and fails on the first
+ * message with "Unknown function: size:${body}" (r27 rest-code-first step
1: 4 errors before ${body.size()}). The
+ * catalog is asked with the nested functions as plain text, and only its
"Unknown function" of that name counts.
+ *
+ * @param text the simple text
+ * @param catalog the catalog that parses it
+ * @return the message, or null
+ */
+ static String unknownFunctionWithNested(String text, CamelCatalog catalog)
{
+ if (text == null || catalog == null || !text.contains("${")) {
+ return null;
+ }
+ for (int open = text.indexOf("${"); open >= 0; open =
text.indexOf("${", open + 2)) {
+ int close = closingBrace(text, open);
+ if (close < 0) {
+ continue;
+ }
+ String inner = text.substring(open + 2, close);
+ int nested = inner.indexOf("${");
+ Matcher name = FUNCTION_NAME.matcher(inner);
+ if (nested <= 0 || !name.lookingAt() || name.end() > nested) {
+ continue;
+ }
+ // ${maskEmail(${header.email})} can call a custom function (a
SimpleFunction in the registry), which the
+ // catalog does not know: only a name that is not followed by ( is
never one
+ if (name.end() < inner.length() && inner.charAt(name.end()) ==
'(') {
+ continue;
+ }
+ // the nested functions as a plain value
+ StringBuilder plain = new StringBuilder();
+ int from = 0;
+ for (int n = inner.indexOf("${"); n >= 0; n = inner.indexOf("${",
from)) {
+ int end = closingBrace(inner, n);
+ if (end < 0) {
+ break;
+ }
+ plain.append(inner, from, n).append('x');
+ from = end + 1;
+ }
+ plain.append(inner.substring(from));
+ String error;
+ try {
+ LanguageValidationResult result =
catalog.validateLanguageExpression(null, "simple", "${" + plain + "}");
+ error = result.isSuccess() ? null : result.getShortError() !=
null ? result.getShortError() : result.getError();
+ } catch (Exception e) {
+ error = null;
+ }
+ if (error == null || !error.startsWith("Unknown function: " +
name.group())) {
+ continue;
+ }
+ String function = text.substring(open, close + 1);
+ String written = name.group() + (name.end() < inner.length() ?
String.valueOf(inner.charAt(name.end())) : "");
+ boolean size = Set.of("size", "count",
"length").contains(name.group());
+ return "Simple has no function " + written + ", so " + function +
" fails when the route runs (Unknown"
+ + " function: " + inner + ")" + (size ? ": the number of
elements of the body is ${body.size()}" : "");
+ }
+ return null;
+ }
+
/** The functions whose text in parentheses is another language:
${jsonpath($[?(@.sku == 'A')])} is right. */
private static final Pattern OTHER_LANGUAGE =
Pattern.compile("(jsonpath|jq|xpath)\\s*\\(");
@@ -215,6 +293,10 @@ final class SimpleChecks {
* <tt>${bodyAs(List).?[orderId == '${header.id}'].size()}</tt> failed
with "ClassNotFoundException: List" and
* "Method with name: ? not found", and <tt>${body[?(@.orderId ==
'${header.id}')].size()}</tt> with "Key
* ?(@.orderId == 'ORD-1001') not found". The message gives the jsonpath
step that does it, as YAML lines.
+ * <p/>
+ * The same for a filter written right after the function,
<tt>${body}?.?[orderId == '${header.id}']</tt> (r27
+ * rest-code-first step 1): text there, which the parser refused in a
predicate with "operators and values must be
+ * separated by a space", and took as literal text in an expression.
*
* @param text the simple text
* @return the message, or null when no function has such a filter
@@ -230,9 +312,18 @@ final class SimpleChecks {
continue;
}
String inner = text.substring(open + 2, close);
- int at = OTHER_LANGUAGE.matcher(inner).lookingAt() ? -1 :
filterIndex(inner);
+ if (OTHER_LANGUAGE.matcher(inner).lookingAt()) {
+ continue;
+ }
+ int at = filterIndex(inner);
if (at >= 0) {
- return filterMessage(inner, at);
+ return filterMessage(inner, at, null);
+ }
+ // a filter right after the closing brace, after a null-safe ?. or
not
+ String after = text.substring(close + 1);
+ after = after.startsWith("?.") ? after.substring(1) : after;
+ if (filterIndex(after) == 0) {
+ return filterMessage(inner + after, inner.length(),
text.substring(open, close + 1));
}
}
return null;
@@ -267,14 +358,21 @@ final class SimpleChecks {
return -1;
}
- /** The message for a filter at the index of the text of a function, with
the jsonpath step that picks it. */
- private static String filterMessage(String inner, int at) {
+ /**
+ * The message for a filter at the index of the text of a function, with
the jsonpath step that picks it; the
+ * function is the one the filter was written after, or null when it is
inside it.
+ */
+ private static String filterMessage(String inner, int at, String function)
{
boolean spel = inner.charAt(at) == '.';
String written = spel ? inner.substring(at, at + 3) + "...]" :
inner.startsWith("@.", at) ? "@." : "[?(...)]";
StringBuilder sb = new StringBuilder("Simple has no ")
.append(spel && inner.charAt(at + 1) == '!' ? "projection" :
"filter").append(": ").append(written)
- .append(spel ? " is SpEL" : " is JsonPath").append(", which
Simple reads as a ")
- .append(spel ? "method" : "key").append(", so it fails at
runtime. ");
+ .append(spel ? " is SpEL" : " is JsonPath");
+ if (function != null) {
+ sb.append(", and after ").append(function).append(" it is text,
not a part of the function. ");
+ } else {
+ sb.append(", which Simple reads as a ").append(spel ? "method" :
"key").append(", so it fails at runtime. ");
+ }
// the path to the List the filter is on, and where it is when not in
the body
String root = "$";
String source = null;
@@ -482,6 +580,16 @@ final class SimpleChecks {
// the expression itself, rewritten: with only an example, models
wrote the same top-level ternary up to 20
// times in a row (a step of the local-model benchmark)
String rewritten = ternaryInsideOneFunction(text, question, colon);
+ if (rewritten != null && rewritten.startsWith("resource:") &&
!text.startsWith("resource:")) {
+ // the resource: of the values went before the function: say why,
or the model writes it inside again (r27
+ // of the local-model benchmark: the body was the text
resource:file:order.json, then a JsonParseException)
+ String scheme = rewritten.substring(0, rewritten.indexOf("${"));
+ return "Simple has no top-level ternary: the ? at index " +
(question + 1)
+ + " and its : are outside ${...}, so they are literal text
and the operator is never evaluated."
+ + " Write the whole ternary inside one function, and " +
scheme + " before it: " + rewritten
+ + " (a function's result is never loaded as a resource:
with " + scheme + " inside the function,"
+ + " the body is the text " +
unquoted(text.substring(question + 3, colon).trim()) + ", not the file)";
+ }
return "Simple has no top-level ternary: the ? at index " + (question
+ 1)
+ " and its : are outside ${...}, so they are literal text and
the operator is never evaluated."
+ (rewritten != null
@@ -501,7 +609,7 @@ final class SimpleChecks {
* order-bad-email.json</tt> is
<tt>resource:file:${exchangeProperty.CamelTimerCounter == 1 ? 'order.json' :
* 'order-bad-email.json'}</tt>, and so is
* <tt>${exchangeProperty.CamelTimerCounter} == 1 ?
resource:file:order.json :
- * resource:file:order-bad-email.json</tt>.
+ * resource:file:order-bad-email.json</tt>, with the values quoted or not.
*/
static String ternaryInsideOneFunction(String text, int question, int
colon) {
// the functions at the top level before the ?, as {start, end} with
end the index of the closing brace
@@ -534,20 +642,110 @@ final class SimpleChecks {
String second = text.substring(colon + 3).trim();
// resource: is read only at the start of the expression: inside the
function, 'resource:file:order.json' is the
// text the body becomes, so a resource: both values start with goes
before the function (the local-model
- // benchmark: the body was the text resource:file:order.json, and the
next step failed to parse it as JSON)
- Matcher scheme = RESOURCE_SCHEME.matcher(first);
- if (prefix.isEmpty() && scheme.lookingAt() &&
second.startsWith(scheme.group())
- && first.length() > scheme.end() && second.length() >
scheme.end()) {
+ // benchmark: the body was the text resource:file:order.json, and the
next step failed to parse it as JSON).
+ // The values may be quoted already, as r27 wrote them:
'resource:file:order.json'
+ String firstText = unquoted(first);
+ String secondText = unquoted(second);
+ Matcher scheme = RESOURCE_SCHEME.matcher(firstText);
+ if (prefix.isEmpty() && scheme.lookingAt() &&
secondText.startsWith(scheme.group())
+ && firstText.length() > scheme.end() && secondText.length() >
scheme.end()) {
prefix = scheme.group();
- first = first.substring(scheme.end());
- second = second.substring(scheme.end());
+ first = firstText.substring(scheme.end());
+ second = secondText.substring(scheme.end());
}
return prefix + "${" + condition + " ? " + quoted(first) + " : " +
quoted(second) + "}";
}
+ /** A value of the ternary without the quotes of a quoted literal:
'order.json' is order.json. */
+ private static String unquoted(String value) {
+ if (value.length() > 1 && (value.startsWith("'") && value.endsWith("'")
+ || value.startsWith("\"") && value.endsWith("\""))) {
+ return value.substring(1, value.length() - 1);
+ }
+ return value;
+ }
+
/** The resource: and its scheme a value starts with, such as
<tt>resource:file:</tt>. */
private static final Pattern RESOURCE_SCHEME =
Pattern.compile("resource:[a-z]+:");
+ /** A quoted literal of a resource: and its scheme, as a whole value:
'resource:file:order.json'. */
+ private static final Pattern QUOTED_RESOURCE =
Pattern.compile("(['\"])(resource:[a-z]+:)(.+)\\1");
+
+ /**
+ * A resource: written as a value a function gives: a value of a ternary
or an elvis inside ${...}. The value is the
+ * text, such as resource:file:order.json, and nothing loads it: resource:
is read only at the start of the
+ * expression. r27 of the local-model benchmark wrote
+ * <tt>${exchangeProperty.CamelTimerCounter == 1 ?
'resource:file:order.json' : 'resource:file:order-bad-email.json'}</tt>,
+ * the write was accepted, and the next step failed to parse that text as
JSON (12 errors in a run). Not said for an
+ * expression that starts with resource:, whose location is loaded, nor
for an elvis after a function, which a route
+ * that worked had as a step whose body the next step replaced.
+ *
+ * @param text the simple text, used as an expression
+ * @return the message, or null when no value is a resource:
+ */
+ static String resourceAsAValue(String text) {
+ if (text == null || text.startsWith("resource:") ||
!text.contains("resource:") || !text.contains("${")) {
+ return null;
+ }
+ // the values of a ternary and of an elvis inside each function, the
nested ones too
+ for (int open = text.indexOf("${"); open >= 0; open =
text.indexOf("${", open + 2)) {
+ int close = closingBrace(text, open);
+ if (close < 0) {
+ continue;
+ }
+ String inner = text.substring(open + 2, close);
+ List<String> values = new ArrayList<>();
+ int question = indexOfTopLevel(inner, " ? ", 0);
+ int colon = question < 0 ? -1 : indexOfTopLevel(inner, " : ",
question + 3);
+ if (colon > 0) {
+ values.add(inner.substring(question + 3, colon).trim());
+ values.add(inner.substring(colon + 3).trim());
+ }
+ int elvis = indexOfTopLevel(inner, " ?: ", 0);
+ if (elvis > 0) {
+ values.add(inner.substring(elvis + 4).trim());
+ }
+ // the condition, for the rewrite: only when the function is the
whole expression
+ String condition = colon > 0 && open == 0 && close ==
text.length() - 1 ? inner.substring(0, question) : null;
+ String message = resourceValueMessage(text.substring(open, close +
1), values, condition);
+ if (message != null) {
+ return message;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * The message for the values of an expression of which one is a quoted
resource:, with resource: before the
+ * function when both values of the ternary of the condition are of the
same scheme; null when none is a resource:.
+ */
+ private static String resourceValueMessage(String written, List<String>
values, String condition) {
+ List<Matcher> resources = new ArrayList<>();
+ for (String value : values) {
+ Matcher m = QUOTED_RESOURCE.matcher(value);
+ if (m.matches()) {
+ resources.add(m);
+ }
+ }
+ if (resources.isEmpty()) {
+ return null;
+ }
+ String scheme = resources.get(0).group(2);
+ StringBuilder sb = new StringBuilder("`").append(written).append("`
gives the text ").append(scheme)
+ .append(resources.get(0).group(3)).append(", not the file: a
function's result is never loaded as a")
+ .append(" resource, only a resource: at the start of the
expression is.");
+ if (condition != null && values.size() == 2 && resources.size() == 2
+ && scheme.equals(resources.get(1).group(2))) {
+ sb.append(" Write ").append(scheme).append(" before the function:
").append(scheme).append("${")
+ .append(condition).append(" ?
").append(quoted(resources.get(0).group(3))).append(" : ")
+ .append(quoted(resources.get(1).group(3))).append("}");
+ } else {
+ sb.append(" Write ").append(scheme).append(" at the start of the
expression, and a function only for the")
+ .append(" part of the name that changes, as in
").append(scheme).append("order-${header.id}.json");
+ }
+ return sb.toString();
+ }
+
/** The index of the brace that closes the function opened at the index,
or -1. */
private static int closingBrace(String text, int open) {
char quote = 0;
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
index 4b74bee533f9..135c7ccf35f1 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
@@ -151,8 +151,8 @@ public final class SourceValidator {
// route: read it, so that a GET operation is known to carry no
body (CAMEL-24844), and with the REST binding
// mode of the properties, whether a Map sent or returned over
REST is written for the route (CAMEL-25762);
// and what a running integration of the file saw at the endpoints
of its routes (CAMEL-25750)
- List<String> msgs = validateCamelYaml(content, catalog,
schemaValidator,
- ValidatorFacts.read(fileName, content, directory));
+ ProjectFacts facts = ValidatorFacts.read(fileName, content,
directory);
+ List<String> msgs = validateCamelYaml(content, catalog,
schemaValidator, facts);
if (name.endsWith(".kamelet.yaml") ||
name.endsWith(".kamelet.yml")) {
// the shape of a Kamelet file, which the schema of a route
file does not know
List<String> shape =
KameletChecks.validateKameletFile(content);
@@ -183,8 +183,9 @@ public final class SourceValidator {
}
}
if (msgs.isEmpty()) {
- // a header read where the route keeps the name in an exchange
property, or the reverse (CAMEL-25516)
- msgs = new ArrayList<>(HeaderPropertyMixups.validate(content));
+ // a header read where the route keeps the name in an exchange
property, or the reverse (CAMEL-25516),
+ // and a header copied to itself that nothing sets
+ msgs = new ArrayList<>(HeaderPropertyMixups.validate(content,
facts));
}
// an option at the column of its EIP: the lines to write, which a
model copies (the words it does not,
// CAMEL-25556)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLenientTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLenientTest.java
index 190087096e95..dadfcf4bc29a 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLenientTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLenientTest.java
@@ -362,6 +362,73 @@ class AuthoringToolsLenientTest {
.contains("the write repairs it by itself");
}
+ /**
+ * r27l-4 connect-http-client step 3: a second condition and an -
otherwise: item written under otherwise:. The
+ * write put them under steps:, where they were still not steps, and said
the shape needed no change; the model sent
+ * the same lines 16 times. They are conditions: no repair, and the answer
says where they go.
+ */
+ @Test
+ void anOtherwiseHoldingASecondConditionIsNotRepaired(@TempDir Path dir) {
+ JsonObject result = write(dir, """
+ - route:
+ from:
+ uri: direct:check
+ steps:
+ - choice:
+ when:
+ - simple:
+ expression: "${header.CamelHttpResponseCode}
!= 200"
+ steps:
+ - log: "not found"
+ otherwise:
+ - simple:
+ expression: "${body[qty]} >=
${variable.needed}"
+ steps:
+ - log: "in stock"
+ - otherwise:
+ steps:
+ - log: "back-order"
+ """);
+
assertThat(result.getString("status")).as(String.valueOf(result)).isEqualTo("invalid");
+ assertThat(result.get("repaired")).isNull();
+ assertThat(result.get("repairable")).isNull();
+ assertThat(result.getString("message")).doesNotContain("needs no
change from you");
+ assertThat(result.get("errors").toString()).contains("Line 12:
otherwise: array found, object expected (otherwise"
+ + " holds only
steps:, the steps for a message that no when"
+ + " matches. A
second condition is another item of when:");
+ }
+
+ /**
+ * r27n-2 contracts-rest-code-first step 1: - otherwise: written at the
column of when:, which the write moves into
+ * the when: list. There it is still not an item of when, so the note must
not say that the shape needs no change.
+ */
+ @Test
+ void aRepairWhoseLinesStillHaveAProblemOfTheirShapeIsSaidSo(@TempDir Path
dir) {
+ JsonObject result = write(dir, """
+ - route:
+ from:
+ uri: direct:get-order
+ steps:
+ - choice:
+ when:
+ - simple: "${body} != null"
+ steps:
+ - log: "found"
+ - otherwise:
+ steps:
+ - log: "not found"
+ """);
+
assertThat(result.getString("status")).as(String.valueOf(result)).isEqualTo("invalid");
+ assertThat(result.get("errors").toString()).contains("otherwise is not
an item of when: it is a key of choice");
+ assertThat(result.get("repairable").toString())
+ .contains("line 10: the list item moved to the column of the
list (these lines still have errors)");
+ assertThat(result.getString("message")).doesNotContain("needs no
change from you")
+ .endsWith("The write repairs part of the YAML shape by itself
(line 10: the list item moved to the column"
+ + " of the list (these lines still have errors)),
and the lines marked still have errors of"
+ + " their shape: change them as the errors say. The
errors are what the content has with that"
+ + " repaired.");
+ }
+
@Test
void aProblemTheWriteCannotRepairKeepsTheAnswerAsItWas(@TempDir Path dir) {
// the case of library: is repaired, the misspelt key is not: both are
said as written, nothing is hidden
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 6388f2f87bad..f2c851c514e7 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
@@ -44,6 +44,10 @@ class ExpressionEvaluatorLanguageTest {
.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();
+ // a SpEL filter in Simple, written after the function (r27
rest-code-first step 1, evaluated twice)
+ assertThat(ExpressionEvaluator.filterNote("simple",
"${body}?.?[orderId == 'ORD-1001']", "error"))
+ .startsWith("Simple has no filter: .?[...] is SpEL, and after
${body} it is text")
+ .contains("jsonpath:\n expression: \"$[?(@.orderId ==
'ORD-1001')]\"");
assertThat(ExpressionEvaluator.filterNote("jsonpath", "$[?(@.sku ==
'X')", "error")).isNull();
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
index 2ba9804ead94..fc358cca485d 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
@@ -16,15 +16,20 @@
*/
package org.apache.camel.dsl.jbang.core.commands.ai;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.List;
import org.apache.camel.catalog.DefaultCamelCatalog;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import static org.assertj.core.api.Assertions.assertThat;
/**
- * CAMEL-25516: a header, exchange property or variable read where the route
keeps the name in another of them.
+ * CAMEL-25516: a header, exchange property or variable read where the route
keeps the name in another of them; and a
+ * header copied to itself that nothing sets.
*/
class HeaderPropertyMixupsTest {
@@ -193,4 +198,149 @@ class HeaderPropertyMixupsTest {
m -> assertThat(m).endsWith(
"Unknown function: property.CamelSplitIndex
(did you mean ${exchangeProperty.CamelSplitIndex}?)"));
}
+
+ /**
+ * r27l-4 connect-http-client step 2 (shortened): in the split over the
lines of an order, sku and needed copied
+ * from headers that nothing sets. The call went to
http://localhost:8080/stock/ with no sku: 98 errors of 404.
+ */
+ private static final String CHECK_STOCK = """
+ - route:
+ id: check-stock
+ from:
+ uri: file
+ parameters:
+ directoryName: orders
+ noop: true
+ steps:
+ - unmarshal:
+ json:
+ library: Jackson
+ - setVariable:
+ name: orderId
+ expression:
+ simple:
+ expression: "${body[orderId]}"
+ - split:
+ expression:
+ simple:
+ expression: "${body[lines]}"
+ steps:
+ - setHeader:
+ name: sku
+ expression:
+ simple:
+ expression: "${header.sku}"
+ - setHeader:
+ name: needed
+ expression:
+ simple:
+ expression: "${header.qty}"
+ - setBody:
+ expression:
+ simple:
+ expression: "${null}"
+ - toD:
+ uri: "http://localhost:8080/stock/${header.sku}"
+ - unmarshal:
+ json:
+ library: Jackson
+ - log:
+ message: "${variable.orderId}: ${header.sku} x
${header.needed}, ${body[qty]} in stock"
+ """;
+
+ /** The rest of connect-http-client, whose path parameter is a header of
the stock service route. */
+ private static final String REST = """
+ - rest:
+ path: /stock
+ get:
+ - path: "/{sku}"
+ to:
+ uri: direct:one-sku
+ """;
+
+ @Test
+ void aHeaderCopiedToItselfThatNothingSetsIsReported() {
+ // the stock service of the same file has the path parameter {sku}: a
header of the route the rest calls only
+ assertThat(HeaderPropertyMixups.validate(CHECK_STOCK +
REST)).singleElement().asString()
+ .startsWith("Line 26: ${header.sku} is null here");
+
assertThat(HeaderPropertyMixups.validate(CHECK_STOCK)).singleElement().isEqualTo(
+ "Line 26: ${header.sku} is null here: no step before this line
sets the header sku, so setHeader sku"
+
+ " copies null to itself. In the split the body is the element: a
field of it"
+
+ " is ${body[sku]}; the same for ${header.qty} on line 31:
${body[qty]}");
+ }
+
+ @Test
+ void theFieldsOfTheDataFilesSayWhatToRead(@TempDir Path dir) throws
IOException {
+ Files.createDirectories(dir.resolve("orders"));
+ Files.writeString(dir.resolve("orders/order-1001.json"), """
+ {"orderId": "ORD-1001", "customer": "C-482", "lines": [{"sku":
"CAMEL-TSHIRT", "qty": 2, "price": 19.9}]}
+ """);
+ List<String> msgs = SourceValidator.validate("http-client.camel.yaml",
CHECK_STOCK, new DefaultCamelCatalog(),
+ null, dir);
+ assertThat(msgs).singleElement().isEqualTo(
+ "Line 26: ${header.sku} is null here: no step before this line
sets the header sku, so setHeader sku"
+ + " copies null to itself.
In the split the body is one of the lines of the"
+ + " files in orders/
(price, qty, sku): write ${body[sku]}; the same for"
+ + " ${header.qty} on line
31: ${body[qty]}");
+ // r23l-3: the order id copied to itself in the split, and no variable
for it: a field of the order, not of a line
+ String orderId = CHECK_STOCK
+ .replace(" - setVariable:\n name: orderId\n
expression:\n simple:\n"
+ + " expression:
\"${body[orderId]}\"\n",
+ "")
+ .replace("name: sku\n expression:\n
simple:\n"
+ + " expression:
\"${header.sku}\"",
+ "name: orderId\n expression:\n
simple:\n"
+
+ " expression: \"${header.orderId}\"")
+ .replace("${variable.orderId}: ", "${header.orderId}: ");
+ assertThat(SourceValidator.validate("http-client.camel.yaml", orderId,
new DefaultCamelCatalog(), null, dir))
+ .singleElement().asString()
+ .startsWith("Line 21: ${header.orderId} is null here: no step
before this line sets the header orderId")
+ .contains("orderId is a field of the data that was split, not
of one of the lines: read it before the"
+ + " split, into a variable (setVariable), and write
${variable.orderId} in the split");
+ }
+
+ @Test
+ void aHeaderCopiedToItselfIsNotReportedWhereItCanBeSet() {
+ String split = " - split:\n";
+ // set before: no copy to itself is left, and a copy from another
header alone is not reported
+ assertThat(HeaderPropertyMixups.validate(CHECK_STOCK.replace(split,
+ " - setHeader:\n name: sku\n
simple: \"${body[lines][0][sku]}\"\n" + split)))
+ .isEmpty();
+ // a step that may set any header before it: a to:, an enrich, a
process, a script
+ for (String step : List.of("- to: direct:prepare", "- enrich:\n
expression:\n constant: direct:prepare",
+ "- process:\n ref: prepare", "- setBody:\n groovy:
\"headers.sku = 'A'; body\"")) {
+
assertThat(HeaderPropertyMixups.validate(CHECK_STOCK.replace(split,
step.indent(8) + split))).as(step)
+ .isEmpty();
+ }
+ // a consumer that can carry the header, or a caller that can set it
+ for (String from : List.of("direct", "seda", "platform-http", "jms",
"kafka")) {
+ assertThat(HeaderPropertyMixups.validate(CHECK_STOCK.replace("uri:
file", "uri: " + from))).as(from)
+ .isEmpty();
+ }
+
+ // r27l-6, a passed step: the order id copied to itself, and the log
reads the variable: the null is never read
+ assertThat(HeaderPropertyMixups.validate(CHECK_STOCK.replace("name:
sku", "name: orderId")
+ .replace("expression: \"${header.sku}\"", "expression:
\"${header.orderId}\""))).isEmpty();
+ // a header of Camel itself, which the consumer sets
+ assertThat(HeaderPropertyMixups.validate(CHECK_STOCK.replace("name:
sku", "name: CamelFileName")
+ .replace("expression: \"${header.sku}\"", "expression:
\"${header.CamelFileName}\""))).isEmpty();
+ }
+
+ @Test
+ void aHeaderCopiedToItselfThatTheRouteKeepsInAVariable() {
+ String copy = " steps:\n - setHeader:\n
name: sku\n";
+ String variable
+ = " - setVariable:\n name: sku\n
simple: \"${body[sku]}\"\n";
+ assertThat(HeaderPropertyMixups.validate(CHECK_STOCK.replace(copy,
+ " steps:\n" + variable + " -
setHeader:\n name: sku\n")))
+ .singleElement().asString()
+ .startsWith("Line 29: ${header.sku} is null here: no step
before this line sets the header sku")
+ .contains("The route keeps sku in a variable: write
${variable.sku}");
+ // a variable set after the copy is not what the copy reads
+ assertThat(HeaderPropertyMixups
+ .validate(CHECK_STOCK.replace(" - setHeader:\n
name: needed\n",
+ variable + " - setHeader:\n
name: needed\n")))
+ .singleElement().asString()
+ .contains("In the split the body is the element: a field of it
is ${body[sku]}");
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
index f66e165a0690..61b5a6f5f4f7 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
@@ -408,6 +408,72 @@ class SourceValidatorEndpointTest {
assertThat(QuickFixes.fixFor(msgs.get(0), " uri:
cron:cron?cron=0/10 * * * * ?")).isNull();
}
+ /**
+ * r27n-5 connect-service-jms-request-reply step 2: told to keep the
message with variableReceive, the model put it
+ * under parameters: of the to:, and pattern before it. Both are keys of
the to:, next to uri:.
+ */
+ @Test
+ void anOptionOfTheEipUnderParametersSaysItIsAKeyOfTheEip() {
+ List<String> msgs = SourceValidator.validateYamlEndpoints("""
+ - route:
+ id: reserve-order
+ from:
+ uri: jms
+ parameters:
+ destinationType: queue
+ destinationName: orders
+ variableReceive: order
+ steps:
+ - to:
+ uri: jms
+ parameters:
+ destinationType: queue
+ destinationName: stock
+ exchangePattern: InOut
+ variableReceive: stockReply
+ - to:
+ uri: jms
+ parameters:
+ destinationName: stock
+ pattern: InOut
+ - to: "jms:queue:stock?variableReceive=stockReply"
+ """, catalog);
+ assertThat(msgs).hasSize(4);
+ assertThat(msgs.get(0)).isEqualTo("Line 8: jms: Unknown option
'variableReceive' (variableReceive is a key of the"
+ + " from:, next to uri:, not an
option of the jms endpoint: from: {uri: jms,"
+ + " parameters: {...},
variableReceive: order})");
+ assertThat(msgs.get(1)).isEqualTo("Line 16: jms: Unknown option
'variableReceive' (variableReceive is a key of"
+ + " the to:, next to uri:, not an
option of the jms endpoint: to: {uri: jms,"
+ + " parameters: {...},
variableReceive: stockReply})");
+ assertThat(msgs.get(2)).isEqualTo("Line 21: jms: Unknown option
'pattern' (pattern is a key of the to:, next to"
+ + " uri:, not an option of the jms
endpoint: to: {uri: jms, parameters: {...},"
+ + " pattern: InOut})");
+ assertThat(msgs.get(3)).isEqualTo("Line 22: jms: Unknown option
'variableReceive' (variableReceive is a key of"
+ + " the to:, next to uri:, not an
option of the jms endpoint: to: {uri:"
+ + " \"jms:queue:stock\",
variableReceive: stockReply})");
+ // the keys where they go, and an option that is neither the
endpoint's nor the EIP's
+ assertThat(SourceValidator.validateYamlEndpoints("""
+ - from:
+ uri: timer:tick
+ steps:
+ - to:
+ uri: jms
+ parameters:
+ destinationName: stock
+ pattern: InOut
+ variableReceive: stockReply
+ - to:
+ uri: jms
+ parameters:
+ destinatonName: stock
+ """, catalog)).anySatisfy(m -> assertThat(m).contains("Unknown
option 'destinatonName'. Did you mean"))
+ .noneMatch(m -> m.contains("is a key of"));
+ // the Java and XML DSLs write the options in the uri
+ assertThat(EndpointChecks.optionOfTheEip(catalog, "to",
"variableReceive", "jms",
+ "jms:queue:stock?variableReceive=stockReply", true, false))
+ .isEqualTo("variableReceive is an option of the to, not of the
jms endpoint: set it on the to, not in the uri");
+ }
+
@Test
void aWildcardInTheFileIncludeOptionIsNamedAsNotARegex() {
List<String> msgs = SourceValidator.validateYamlEndpoints("""
@@ -987,6 +1053,41 @@ class SourceValidatorEndpointTest {
assertThat(msgs).isEmpty();
}
+ /** r27l-4 transform-groovy step 3:
resource:file:order-${body[CamelTimerCounter]}.json loaded order-.json. */
+ @Test
+ void aTimerExchangePropertyReadOnTheBodyIsReported() {
+ List<String> msgs = SourceValidator.validateKnownHeaders("""
+ - from:
+ uri: timer:orders?repeatCount=2&includeMetadata=true
+ steps:
+ - setBody:
+ simple:
"resource:file:order-${body[CamelTimerCounter]}.json"
+ - log: "${body.CamelTimerName}
${body[CamelTimerCounter]}"
+ """, catalog);
+ assertThat(msgs).hasSize(2);
+ assertThat(msgs.get(0)).isEqualTo("Line 5: ${body[CamelTimerCounter]}
reads a field of the body, and the body has"
+ + " no field CamelTimerCounter:
CamelTimerCounter is an exchange property of the"
+ + " timer, write
${exchangeProperty.CamelTimerCounter}");
+ assertThat(msgs.get(1)).startsWith("Line 6: ${body.CamelTimerName}
reads a field of the body");
+ // without includeMetadata the timer does not set it, which the
message says too
+ assertThat(SourceValidator.validateKnownHeaders("""
+ - from:
+ uri: timer:orders?repeatCount=2
+ steps:
+ - setBody:
+ simple:
"resource:file:order-${body.CamelTimerCounter}.json"
+ """, catalog)).singleElement().asString()
+ .endsWith("write ${exchangeProperty.CamelTimerCounter} (the
timer sets it only with includeMetadata=true"
+ + " on the endpoint)");
+ // a field of the body with a name of Camel that no component of the
route sets as an exchange property
+ assertThat(SourceValidator.validateKnownHeaders("""
+ - from:
+ uri: file:orders
+ steps:
+ - log: "${body[CamelTimerCounter]}"
+ """, catalog)).isEmpty();
+ }
+
@Test
void withIncludeMetadataTheHeadersMapStillHasNoSuchHeader() {
List<String> msgs = SourceValidator.validateKnownHeaders("""
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorSimpleTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorSimpleTest.java
index ca1debe94fc6..d45408fd730b 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorSimpleTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorSimpleTest.java
@@ -481,8 +481,12 @@ class SourceValidatorSimpleTest {
private static String rewritten(String text) {
String message = SimpleChecks.topLevelTernary(text);
- assertThat(message).contains("Write the whole ternary inside one
function: ");
- return message.substring(message.indexOf("inside one function: ") +
"inside one function: ".length());
+ assertThat(message).containsPattern("Write the whole ternary inside
one function(, and resource:\\w+: before it)?: ");
+ String start = message.contains(" before it: ") ? " before it: " :
"inside one function: ";
+ String rewrite = message.substring(message.indexOf(start) +
start.length());
+ // the reason a resource: goes before the function follows the rewrite
+ int reason = rewrite.indexOf(" (a function's result is never loaded");
+ return reason > 0 ? rewrite.substring(0, reason) : rewrite;
}
@Test
@@ -514,6 +518,107 @@ class SourceValidatorSimpleTest {
.isEqualTo("${header.a == 1 ? 'resource:file:order.json' :
'none'}");
}
+ /**
+ * r27 transform-groovy step 3: the values quoted, as two runs wrote them.
The hint kept them inside the function,
+ * so the body was the text resource:file:order.json, and the next step
failed with a JsonParseException (12
+ * errors).
+ */
+ @Test
+ void
aResourceInBothQuotedValuesGoesBeforeTheFunctionAndTheMessageSaysWhy() {
+ String message = SimpleChecks.topLevelTernary(
+ "${exchangeProperty.CamelTimerCounter} == 0 ?
'resource:file:order.json' : 'resource:file:order-bad-email.json'");
+ assertThat(message).contains("Write the whole ternary inside one
function, and resource:file: before it:"
+ + "
resource:file:${exchangeProperty.CamelTimerCounter == 0 ? 'order.json' :"
+ + " 'order-bad-email.json'} (a function's
result is never loaded as a resource:"
+ + " with resource:file: inside the
function, the body is the text"
+ + " resource:file:order.json, not the
file)");
+ // r27l-4 wrote the timer's counter as a field of the body, and double
quotes
+ assertThat(rewritten(
+ "${body[CamelTimerCounter]} == 1 ?
\"resource:file:order.json\" : \"resource:file:order-bad-email.json\""))
+ .isEqualTo("resource:file:${body[CamelTimerCounter] == 1 ?
'order.json' : 'order-bad-email.json'}");
+ // the resource: before the condition already: as before, with no
reason to give
+ assertThat(SimpleChecks.topLevelTernary(
+ "resource:file:${exchangeProperty.CamelTimerCounter} == 0 ?
'order.json' : 'order-bad-email.json'"))
+ .endsWith("Write the whole ternary inside one function:"
+ + "
resource:file:${exchangeProperty.CamelTimerCounter == 0 ? 'order.json' :
'order-bad-email.json'}");
+ // quoted values of two schemes stay as they are
+ assertThat(rewritten("${header.a} == 1 ? 'resource:file:order.json' :
'resource:classpath:order.json'"))
+ .isEqualTo("${header.a == 1 ? 'resource:file:order.json' :
'resource:classpath:order.json'}");
+ }
+
+ /**
+ * r27 transform-groovy step 3: the rewrite of the old hint, written as
told, passed the write and failed at
+ * runtime. The value is the text, which nothing loads; the same for the
value of an elvis inside the function.
+ */
+ @Test
+ void aResourceAsTheValueOfAFunctionIsReported() {
+ List<String> msgs = SourceValidator.validateYamlSimple(
+ """
+ - from:
+ uri:
timer:orders?repeatCount=2&includeMetadata=true
+ steps:
+ - setBody:
+ expression:
+ simple:
+ expression:
"${exchangeProperty.CamelTimerCounter == 1 ? 'resource:file:order.json' :
'resource:file:order-bad-email.json'}"
+ - setHeader:
+ name: fileUri
+ simple:
"${exchangeProperty.CamelTimerCounter == 1 ? 'resource:file:order.json' : 'x'}"
+ - setBody:
+ simple: "${header.file ?:
'resource:file:order.json'}"
+ """,
+ catalog);
+ assertThat(msgs).hasSize(3);
+ assertThat(msgs.get(0)).isEqualTo("Line 7:
`${exchangeProperty.CamelTimerCounter == 1 ? 'resource:file:order.json' :"
+ + "
'resource:file:order-bad-email.json'}` gives the text resource:file:order.json,"
+ + " not the file: a function's
result is never loaded as a resource, only a"
+ + " resource: at the start of the
expression is. Write resource:file: before"
+ + " the function:
resource:file:${exchangeProperty.CamelTimerCounter == 1 ?"
+ + " 'order.json' :
'order-bad-email.json'}");
+ assertThat(msgs.get(1)).startsWith("Line 10:
`${exchangeProperty.CamelTimerCounter == 1 ? 'resource:file:order.json'"
+ + " : 'x'}` gives the text
resource:file:order.json, not the file")
+ .endsWith("Write resource:file: at the start of the
expression, and a function only for the part of the"
+ + " name that changes, as in
resource:file:order-${header.id}.json");
+ assertThat(msgs.get(2)).startsWith("Line 12: `${header.file ?:
'resource:file:order.json'}` gives the text"
+ + " resource:file:order.json, not
the file");
+ // the rewrite is accepted
+ assertThat(SourceValidator.validateYamlSimple(
+ """
+ - from:
+ uri:
timer:orders?repeatCount=2&includeMetadata=true
+ steps:
+ - setBody:
+ simple:
"resource:file:${exchangeProperty.CamelTimerCounter == 1 ? 'order.json' :
'order-bad-email.json'}"
+ """,
+ catalog)).isEmpty();
+ }
+
+ @Test
+ void aResourceThatIsLoadedOrNotAValueIsNotReported() {
+ List<String> msgs = SourceValidator.validateYamlSimple(
+ """
+ - from:
+ uri:
timer:orders?repeatCount=2&includeMetadata=true
+ steps:
+ - setBody:
+ simple:
+ expression:
"${exchangeProperty.CamelTimerCounter == 1 ? 'resource:file:order.json' :
'resource:file:b.json'}"
+ resolveResource: true
+ - setBody:
+ simple:
"resource:file:order-${exchangeProperty.CamelTimerCounter}.json"
+ - setBody:
+ simple:
"${header.uri.replace('resource:file:', '')}"
+ - filter:
+ simple: "${header.uri} ==
'resource:file:order.json'"
+ - log:
+ message: "Loading ${header.uri} ?:
'resource:file:order.json'"
+ - setBody:
+ simple: "${body} ?:
'resource:file:order.json'"
+ """,
+ catalog);
+ assertThat(msgs).isEmpty();
+ }
+
@Test
void aResourceLocationWithoutATopLevelTernaryIsAccepted() {
List<String> msgs = SourceValidator.validateYamlSimple(
@@ -772,6 +877,114 @@ class SourceValidatorSimpleTest {
.contains(".^[...] is
SpEL").endsWith(PICK_BY_ID.substring(PICK_BY_ID.indexOf(" expression")));
}
+ /**
+ * r27 rest-code-first step 1 (r27n-4): the filter written after the
function. In a predicate the parser said only
+ * that operators and values must be separated by a space; in an
expression it was text after the body.
+ */
+ @Test
+ void aFilterWrittenAfterTheFunctionIsReportedWithTheJsonPathStep() {
+ List<String> msgs = SourceValidator.validateYamlSimple("""
+ - from:
+ uri: direct:get-order
+ steps:
+ - setHeader:
+ name: foundOrder
+ expression:
+ simple:
+ expression: "${body}?.?[orderId ==
'${header.id}']"
+ - choice:
+ when:
+ - simple: "${header.id} != null &&
${body}?.?[orderId == '${header.id}'] != null"
+ steps:
+ - setBody:
+ simple: "${body}[?(@.orderId ==
'${header.id}')]"
+ """, catalog);
+ String pick = " To pick the element whose orderId is '${header.id}',
set the body with jsonpath before this step;"
+ + " the body is then that element, or null when none or
several match (test that with ${body} =="
+ + " null):\n" + PICK_BY_ID;
+ assertThat(msgs).hasSize(3);
+ assertThat(msgs.get(0)).isEqualTo("Line 8: Simple has no filter:
.?[...] is SpEL, and after ${body} it is text, not"
+ + " a part of the function." + pick);
+ assertThat(msgs.get(1)).isEqualTo("Line 11: Simple has no filter:
.?[...] is SpEL, and after ${body} it is text,"
+ + " not a part of the function." +
pick);
+ assertThat(msgs.get(2)).isEqualTo("Line 14: Simple has no filter:
[?(...)] is JsonPath, and after ${body} it is"
+ + " text, not a part of the
function." + pick);
+ // from a header, as r27n-4 kept the list in one
+ assertThat(SimpleChecks.filterInFunction("${header.orders}?.?[orderId
== '${header.id}']"))
+ .contains("after ${header.orders} it is text").endsWith("
source: header:orders\n"
+ + "
resultType: java.util.Map");
+ // text after a function that is no filter
+ for (String text : List.of("${header.name}.json",
"order-${header.id}.csv", "${body}?.x", "${body} [?]")) {
+ assertThat(SimpleChecks.filterInFunction(text)).as(text).isNull();
+ }
+ }
+
+ /**
+ * r27 rest-code-first step 1 (r27n-7): ${size:${body}} passed the check
and failed at runtime with "Unknown
+ * function: size:${body}", 4 errors before ${body.size()}. A nested
function makes the parser read the outer one
+ * only when it runs.
+ */
+ @Test
+ void anUnknownFunctionWithANestedFunctionIsReported() {
+ List<String> msgs = SourceValidator.validateYamlSimple("""
+ - from:
+ uri: direct:get-order
+ steps:
+ - choice:
+ when:
+ - simple: "${body} == null || ${size:${body}} == 0"
+ steps:
+ - setBody:
+ simple: "${foo:${header.id}}"
+ """, catalog);
+ assertThat(msgs).hasSize(2);
+ assertThat(msgs.get(0)).isEqualTo("Line 6: Simple has no function
size:, so ${size:${body}} fails when the route"
+ + " runs (Unknown function:
size:${body}): the number of elements of the body"
+ + " is ${body.size()}");
+ assertThat(msgs.get(1)).isEqualTo("Line 9: Simple has no function
foo:, so ${foo:${header.id}} fails when the"
+ + " route runs (Unknown function:
foo:${header.id})");
+ // functions that take a nested function
+ assertThat(SourceValidator.validateYamlSimple("""
+ - from:
+ uri: direct:get-order
+ steps:
+ - setBody:
+ simple: "${header.${header.name}}
${variable.${header.key}} ${bean:${header.bean}?method=find}"
+ - setBody:
+ simple: "${date:header.${header.field}:yyyy}
${properties:${header.key}} ${size(${body})}"
+ - setBody:
+ simple: "${body[${header.index}]}
${exchangeProperty.${header.p}}"
+ """, catalog)).isEmpty();
+ // a custom function (a SimpleFunction bean, as in the simple-advanced
documentation) is not in the catalog
+ assertThat(SourceValidator.validateYamlSimple("""
+ - from:
+ uri: direct:contact
+ steps:
+ - setBody:
+ simple: "Contact:
${maskEmail(${header.customerEmail})}"
+ """, catalog)).isEmpty();
+ }
+
+ /**
+ * r27 rest-code-first step 2 (r27n-6): the method after the function,
told only that a space is missing; the parser
+ * says now that it goes inside the braces, and the write check shows that.
+ */
+ @Test
+ void aMethodAfterTheFunctionSaysItGoesInside() {
+ List<String> msgs = SourceValidator.validateYamlSimple("""
+ - from:
+ uri: direct:list-orders
+ steps:
+ - filter:
+ simple: "${body.lines} != null &&
${body.lines}.size() > 0"
+ steps:
+ - log: "${body}"
+ """, catalog);
+ assertThat(msgs).singleElement().asString().startsWith("Line 5: Simple
syntax error:")
+ .contains("the method .size() is outside the function, as text
after its closing brace: write it inside"
+ + " the braces, ${body.lines.size()}");
+ }
+
@Test
void whatIsNotAFilterOfAnotherLanguageIsNotReported() {
// the jsonpath function of Simple, null-safe navigation, an index,
and the syntax in a literal or outside ${...}
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
index 55637a4f9bc4..1a0aec4ed387 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
@@ -635,6 +635,11 @@ final class SchemaHints {
+ " steps: [...])"
: "")
+ "; several of them are several - " + m.name() + ":
items"),
+ // otherwise: [- simple: ..., steps: [...], - otherwise: ...]: a
second condition and its own otherwise
+ // written under otherwise:, whose steps: they are not (r27
connect-http-client step 3: told that the write
+ // repairs the shape by itself, a model sent the same lines 16
times)
+ append("type", ".*/otherwise", m -> m.message().contains("object
expected") && heldCondition(m) != null,
+ SchemaHints::conditionsUnderOtherwise),
// otherwise: [- log: ...]: the block is a map whose steps: holds
the list
append("type", ".*/(otherwise|doTry|doFinally|doCatch/\\d+)", m ->
m.message().contains("object expected"),
m -> {
@@ -699,6 +704,45 @@ final class SchemaHints {
append("type", null, m -> m.message().contains("array expected"),
m -> "write it as a list: each item starts with \"- \""));
+ /**
+ * The key of the condition of the first item of an otherwise: list that
is a condition with steps, as a when: item
+ * is (simple, expression...), or otherwise for an - otherwise: item; null
when no item is one: the items are steps.
+ */
+ static String heldCondition(Match m) {
+ JsonNode instance = m.error().getInstanceNode();
+ if (instance == null || !instance.isArray()) {
+ return null;
+ }
+ for (JsonNode item : instance) {
+ if (!item.isObject()) {
+ continue;
+ }
+ if (item.has("otherwise") || item.has("when")) {
+ return "otherwise";
+ }
+ if (item.has("steps")) {
+ for (String key : (Iterable<String>) item::fieldNames) {
+ if ("expression".equals(key) ||
m.validator().languageKeys().contains(key)) {
+ return key;
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ /** What to write for an otherwise: list that holds a second condition, or
an - otherwise: item of its own. */
+ static String conditionsUnderOtherwise(Match m) {
+ String key = heldCondition(m);
+ String condition = "otherwise".equals(key) ? "simple" : key;
+ String when = "expression".equals(condition)
+ ? "{expression: {simple: {expression: \"...\"}}, steps: [...]}"
+ : "{" + condition + ": \"...\", steps: [...]}";
+ return "otherwise holds only steps:, the steps for a message that no
when matches. A second condition is another"
+ + " item of when:, with its " + condition + ": and its steps:,
and the steps of an - otherwise: item go"
+ + " under otherwise: steps:, as in choice: {when: [" + when +
", " + when + "], otherwise: {steps: [...]}}";
+ }
+
//
-------------------------------------------------------------------------------------------------------------
// property hints
//
-------------------------------------------------------------------------------------------------------------
@@ -853,6 +897,11 @@ final class SchemaHints {
return "the endpoint of " + m.name() + " is an
expression: write " + m.name()
+ ": {expression: {constant: {expression: \"" +
uri + "\"}}}";
}),
+ // when: [..., - otherwise: {steps: [...]}]: otherwise is a key of
choice, not an item of its when: list (r27
+ // connect-http-client step 3)
+ unknownProperty(".*/when/\\d+", m ->
m.unknown().equals("otherwise"),
+ m -> "otherwise is not an item of when: it is a key of
choice, next to when: and at its column, and"
+ + " it holds only steps: choice: {when: [...],
otherwise: {steps: [...]}}"),
// choice: {steps: [...]}: the steps of a choice are under each
when: and under otherwise: (r26
// contracts-openapi-server step 4: told that choice owns a steps:
list, a model wrote it 6 times)
unknownProperty(".*/choice", m -> m.unknown().equals("steps"),
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java
index 08736e81cdaf..d1ef342bdf44 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java
@@ -841,6 +841,78 @@ public class YamlValidatorPropertyHintTest {
.contains("otherwise holds its EIPs under steps: otherwise:
{steps: [- log: \"...\"]}"));
}
+ /**
+ * r27l-4 connect-http-client step 3: a second condition and an -
otherwise: item written under otherwise:. Told
+ * that otherwise holds its EIPs under steps:, a model sent the same lines
16 times.
+ */
+ @Test
+ public void testOtherwiseHoldingASecondConditionSaysWhen() throws
Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - choice:
+ when:
+ - simple:
+ expression: "${header.CamelHttpResponseCode}
!= 200"
+ steps:
+ - log: "a"
+ otherwise:
+ - simple:
+ expression: "${body[qty]} >=
${variable.needed}"
+ steps:
+ - log: "b"
+ - otherwise:
+ steps:
+ - log: "c"
+ """);
+ assertThat(errors).anySatisfy(e ->
assertThat(e.getMessage()).contains("array found, object expected").endsWith(
+ "(otherwise holds only steps:, the steps for a message that no
when matches."
+
+ " A second condition is another item
of when:, with its simple: and its steps:,"
+
+ " and the steps of an - otherwise:
item go under otherwise: steps:, as in choice:"
+
+ " {when: [{simple: \"...\", steps:
[...]}, {simple: \"...\", steps: [...]}],"
+
+ " otherwise: {steps: [...]}})"));
+ // with expression: and the language under it, as the when items are
written in the canonical form
+ errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - choice:
+ when:
+ - expression:
+ simple:
+ expression: "${body} != null"
+ steps:
+ - log: "a"
+ otherwise:
+ - expression:
+ simple:
+ expression: "${body} == 'x'"
+ steps:
+ - log: "b"
+ """);
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage())
+ .contains("with its expression: and its steps:")
+ .contains("{expression: {simple: {expression: \"...\"}},
steps: [...]}"));
+ // an - otherwise: item in the when: list
+ errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - choice:
+ when:
+ - simple: "${body} != null"
+ steps:
+ - log: "a"
+ - otherwise:
+ steps:
+ - log: "c"
+ """);
+ assertThat(errors).anySatisfy(e ->
assertThat(e.getMessage()).contains("property 'otherwise' is not defined")
+ .contains("otherwise is not an item of when: it is a key of
choice, next to when: and at its column, and"
+ + " it holds only steps: choice: {when: [...],
otherwise: {steps: [...]}}"));
+ }
+
@Test
public void testStepDirectlyUnderOtherwiseOrWhenSaysSteps() throws
Exception {
List<Error> errors = validator.validate("""