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 2eaf9e9c1ca2 CAMEL-26690: camel-jbang - AI tools: the column message
gives a place that works, misread messages say what is wrong, and the
errorHandler hint gives its kind (#29325)
2eaf9e9c1ca2 is described below
commit 2eaf9e9c1ca22a1b20a8e2de329d056e9537da07
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 6 16:32:10 2026 +0100
CAMEL-26690: camel-jbang - AI tools: the column message gives a place that
works, misread messages say what is wrong, and the errorHandler hint gives its
kind (#29325)
* CAMEL-26690: camel-jbang - AI tools: a key written in the column of the
dashes of a list gets the one place the schema takes, with the lines to write
The message offered two columns: a key of the list item, or a key next to
the key
that holds the list. In r28 of the local-model benchmark
(fail-well-error-handling
step 2) both gave invalid Camel: the steps: belonged to a when item further
up, and
the model sent the same lines 16 times. Each place, and the end of each
step and key
of the item above the key, is now judged by the schema against the content
without
the key; when one place fits, the message says it and gives the lines to
write. When
the schema cannot judge (the key sits under otherwise: with a list, or the
lines do
not parse), the two columns are said as before.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016Y4dDi5iQNgtHrDdz2NpwF
* CAMEL-26690: camel-jbang - AI tools: messages that misread what a model
wrote say what is wrong, and steps: or otherwise: in the column of the dashes
of a list is repaired
Three refusal loops of r28 of the local-model benchmark answered a
construct the
message did not name:
- the two characters \n of a line break escaped twice, after a closing
quote, read as
a concatenation (contracts-rest-code-first step 1, 15 identical writes):
the message
says what they are and gives the lines to write;
- - route: in column 3 after a top-level onException was told to line up
with the
steps of the onException (connect-sftp-transfer step 2, 12 identical
writes): an
entry of the file is named against the list of the entries, which the
item-column
repair then moves;
- a marshal with variableReceive before a wireTap of the body was told to
add a
marshal (route-enrich-and-dedupe step 3, 11 refusals): the message names
the
variable and variableSend.
steps: and otherwise: in the column of the dashes of a list are repaired
when the
validator finds the one place the schema takes them.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016Y4dDi5iQNgtHrDdz2NpwF
* CAMEL-26690: camel-jbang - AI tools: the errorHandler hint gives a
handler with its kind to write
The hint for an errorHandler written under from: copied the handler as
written as the
text to write, also when it had no kind: in r28 of the local-model benchmark
(connect-service-jms-request-reply step 4) errorHandler: {redeliveryPolicy:
...}, which
the model wrote and was refused for next, with no hint. The options of a
kind are now
put under the kind the schema takes them in (defaultErrorHandler, or
deadLetterChannel
with a deadLetterUri, also when the uri was written as the value of the
kind), and an
option of a kind written straight under errorHandler: gets the same form.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016Y4dDi5iQNgtHrDdz2NpwF
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../dsl/jbang/core/commands/ai/LenientRepairs.java | 54 ++-
.../commands/ai/AuthoringToolsLenientTest.java | 127 +++++++
.../camel/dsl/yaml/validator/SchemaHints.java | 59 +++-
.../camel/dsl/yaml/validator/TextBodyFlow.java | 38 ++-
.../camel/dsl/yaml/validator/YamlValidator.java | 373 +++++++++++++++++++--
.../camel/dsl/yaml/validator/TextBodyFlowTest.java | 44 +++
.../validator/YamlValidatorPropertyHintTest.java | 298 +++++++++++++++-
7 files changed, 957 insertions(+), 36 deletions(-)
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 ee0bfc41ec8d..efb0cc650253 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
@@ -29,10 +29,11 @@ import java.util.regex.Pattern;
/**
* CAMEL-25532: the YAML shapes a model writes for a Camel route that have one
reading, repaired before a write is
* refused for them: an option at the column of its EIP, a plain value where
an expression goes, a list where a map goes
- * (and the other way round), a list item or a key one column off, an unquoted
placeholder, an enum value in another
- * case, and the names models use for an option that has another name (from
the benchmark traces). Each repair is driven
- * by the validator's message for that shape and changes only the lines it
names; a name is never guessed by similarity.
- * The caller validates the result again, and keeps it only when no new
problem is left.
+ * (and the other way round), a list item or a key one column off, steps: or
otherwise: in the column of the dashes of a
+ * list where the schema takes it in one place, an unquoted placeholder, an
enum value in another case, and the names
+ * models use for an option that has another name (from the benchmark traces).
Each repair is driven by the validator's
+ * message for that shape and changes only the lines it names; a name is never
guessed by similarity. The caller
+ * validates the result again, and keeps it only when no new problem is left.
*/
final class LenientRepairs {
@@ -87,6 +88,18 @@ final class LenientRepairs {
private static final Pattern KEY_COLUMN = Pattern.compile(
"^line (\\d+): (\\S+) starts in column (\\d+), but the keys of the
mapping that starts at line \\d+ are in"
+ " column
(\\d+)");
+ /**
+ * steps: or otherwise: in the column of the dashes of a list, which the
validator places where the schema takes it,
+ * when one place does, with the lines to write (what it is there, the
column, the line after which it goes).
+ */
+ private static final Pattern KEY_IN_DASH_COLUMN = Pattern.compile(
+ "^line (\\d+): (steps|otherwise) starts in column \\d+, the column
of the - of the list item at line \\d+, so"
+ + " it
is part of neither that item nor the key that"
+ + "
holds the list\\. It is (.+?): (?:write it in"
+ + "
column (\\d+)|it goes in column (\\d+), after"
+ + " line
(\\d+))\\. Write lines? (\\d+)(?:-(\\d+))?"
+ + "
as:\\n",
+ Pattern.DOTALL);
/** How many columns a list item or key may be off to be moved: a slip,
not another structure. */
private static final int MAX_SHIFT = 2;
@@ -141,6 +154,20 @@ final class LenientRepairs {
private static List<Repair> repairsFor(String error, List<String> lines) {
List<Repair> answer = new ArrayList<>();
Matcher m;
+ if ((m = KEY_IN_DASH_COLUMN.matcher(error)).find()) {
+ // the place of steps: and otherwise: is fixed by the schema, and
the validator found the one place it
+ // takes: r28 of the local-model benchmark,
fail-well-error-handling step 2 sent the same lines 16 times
+ int line = Integer.parseInt(m.group(1));
+ int first = Integer.parseInt(m.group(7));
+ int last = m.group(8) != null ? Integer.parseInt(m.group(8)) :
first;
+ List<String> written =
List.of(error.substring(m.end()).split("\n", -1));
+ String column = m.group(4) != null ? m.group(4) : m.group(5) + "
after line " + m.group(6);
+ answer.add(new Repair(
+ line, m.group(6) != null, "line " + line + ": " +
m.group(2) + ": moved to column " + column + " ("
+ + m.group(3) + ")",
+ ls -> replaceLines(ls, first - 1, last, written)));
+ return answer;
+ }
if (error.contains("lines up with") && error.startsWith("Line ")) {
int line = leadingLine(error);
Matcher o = OPTION_AT_EIP.matcher(error);
@@ -560,6 +587,25 @@ final class LenientRepairs {
return true;
}
+ /**
+ * The lines the validator says to write in place of the lines it names,
when they are those lines, moved or with
+ * another indentation: the same lines without their indentation, in
another order at most.
+ */
+ private static boolean replaceLines(List<String> lines, int from, int to,
List<String> written) {
+ if (from < 0 || to > lines.size() || from >= to || written.size() !=
to - from) {
+ return false;
+ }
+ List<String> before = lines.subList(from,
to).stream().map(String::strip).sorted().toList();
+ List<String> after =
written.stream().map(String::strip).sorted().toList();
+ if (!before.equals(after)) {
+ return false;
+ }
+ for (int i = 0; i < written.size(); i++) {
+ lines.set(from + i, written.get(i));
+ }
+ return true;
+ }
+
/** A list item or a key one column off, moved with its lines to the
column the validator names. */
private static boolean shiftBlock(List<String> lines, int index, int
delta) {
if (!valid(lines, index) || delta == 0) {
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 dadfcf4bc29a..e8cc8d6d9d41 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,133 @@ class AuthoringToolsLenientTest {
.contains("the write repairs it by itself");
}
+ /**
+ * r28l-4 fail-well-error-handling step 2: the steps: of the when of a
choice nested in otherwise:, written after
+ * that otherwise:, in the column of the - of the choice. Told two
columns, both wrong Camel, the model sent the
+ * same lines 16 times. The schema takes the steps: in one place, the when
item further up, so the write puts them
+ * there.
+ */
+ @Test
+ void
stepsInTheColumnOfTheDashesOfAListAreWrittenWhereTheSchemaTakesThem(@TempDir
Path dir) throws IOException {
+ JsonObject result = write(dir, """
+ - route:
+ from:
+ uri: direct:charge
+ steps:
+ - choice:
+ when:
+ - expression:
+ simple:
+ expression: "${body[orderId]} ==
'ORD-1003'"
+ steps:
+ - throwException:
+ exceptionType:
java.lang.IllegalStateException
+ message: "card declined"
+ otherwise:
+ steps:
+ - choice:
+ when:
+ - expression:
+ simple:
+ expression: "${body[orderId]} ==
'ORD-1002'"
+ otherwise:
+ steps:
+ - log:
+ message: "Order ${body[orderId]}
charged successfully"
+ steps:
+ - throwException:
+ exceptionType: java.net.ConnectException
+ message: "payment provider did not
answer"
+ """);
+ assertRepairedAndValid(dir, result, "line 25: steps: moved to column
25 after line 20 (a key of the item at line"
+ + " 18, as expression is)");
+ assertThat(Files.readString(dir.resolve(FILE))).contains("""
+ - expression:
+ simple:
+ expression: "${body[orderId]} ==
'ORD-1002'"
+ steps:
+ - throwException:
+ exceptionType:
java.net.ConnectException
+ message: "payment provider did
not answer"
+ otherwise:
+ """);
+ }
+
+ /** r28n-1 contracts-rest-code-first step 1: otherwise: in the column of
the dashes of the when items. */
+ @Test
+ void
anOtherwiseInTheColumnOfTheDashesOfTheWhenItemsIsWrittenAsAKeyOfTheChoice(@TempDir
Path dir)
+ throws IOException {
+ JsonObject result = write(dir, """
+ - route:
+ from:
+ uri: direct:get-order
+ steps:
+ - choice:
+ when:
+ - simple: "${body} != null"
+ steps:
+ - log: "found"
+ otherwise:
+ steps:
+ - log: "not found"
+ """);
+ assertRepairedAndValid(dir, result, "line 10: otherwise: moved to
column 13 (a key next to when (line 6))");
+ }
+
+ /**
+ * Under otherwise: with a list the schema checks nothing, so no place is
said, and the write repairs nothing: the
+ * columns are said as before.
+ */
+ @Test
+ void
aKeyInTheColumnOfTheDashesWhosePlaceTheSchemaCannotJudgeIsNotRepaired(@TempDir
Path dir) {
+ JsonObject result = write(dir, """
+ - route:
+ from:
+ uri: direct:start
+ steps:
+ - choice:
+ when:
+ - simple: "${body} == 'a'"
+ steps:
+ - log: "a"
+ otherwise:
+ - choice:
+ when:
+ - simple: "${body} == 'b'"
+ steps:
+ - log: "b"
+ """);
+
assertThat(result.getString("status")).as(String.valueOf(result)).isEqualTo("invalid");
+ assertThat(result.get("repaired")).isNull();
+ assertThat(result.get("errors").toString()).contains("Write it in
column 23 to make it a key of the item, as"
+ + " simple is, or
in column 19 to make it a key next to when");
+ }
+
+ /**
+ * r28n-6 connect-sftp-transfer step 2: - route: in column 3 after a
top-level onException was told to line up with
+ * the steps of the onException, 12 times. Named as an entry of the file,
it is two columns off the entries, which
+ * the write repairs.
+ */
+ @Test
+ void aRouteTwoColumnsOffTheEntriesOfTheFileIsRepaired(@TempDir Path dir)
throws IOException {
+ JsonObject result = write(dir, """
+ - onException:
+ exception:
+ - java.lang.Exception
+ steps:
+ - log:
+ message: "Batch failed: ${exception.message}"
+ - route:
+ id: invoice-batches
+ from:
+ uri: timer:tick
+ steps:
+ - log: "tick"
+ """);
+ assertRepairedAndValid(dir, result, "line 7: the list item moved to
the column of the list");
+ assertThat(Files.readString(dir.resolve(FILE))).contains("\n- route:\n
id: invoice-batches\n");
+ }
+
/**
* 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
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 1a0aec4ed387..1d2151a98b46 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
@@ -30,6 +30,8 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import com.networknt.schema.Error;
/**
@@ -340,9 +342,14 @@ final class SchemaHints {
*/
static String errorHandlerUnderFrom(Match m) {
JsonNode from = m.error().getInstanceNode();
- String handler = errorHandlerText(from != null ?
from.get("errorHandler") : null);
+ JsonNode written = from != null ? from.get("errorHandler") : null;
+ String handler = errorHandlerText(written, m.validator());
String example = "route: {errorHandler: " + handler + ", from: {uri:
..., steps: [...]}}";
- return "errorHandler: is a key of the route, not of from:. For this
route only, "
+ // the handler as written, copied as the text to write, had no kind
(r28 of the local-model benchmark,
+ // connect-service-jms-request-reply step 4): the model wrote it so,
and was refused for it next
+ String kind = written != null && written.isObject() &&
!handler.equals(errorHandlerText(written))
+ ? ", and it takes the kind of handler as its key, with the
options under the kind" : "";
+ return "errorHandler: is a key of the route, not of from:" + kind + ".
For this route only, "
+ (m.location().endsWith("/route/from")
? "write it under the route at the same level as from:,
as in " + example
: "write the route as - route: with errorHandler: at
the same level as from:, as in - " + example)
@@ -351,10 +358,52 @@ final class SchemaHints {
/** An error handler as one line to copy, {noErrorHandler: {}}; its kind
only when it is long. */
static String errorHandlerText(JsonNode handler) {
+ return errorHandlerText(handler, null);
+ }
+
+ /**
+ * As {@link #errorHandlerText(JsonNode)}; with the validator, a handler
whose options are not under its kind gets
+ * the form the schema takes: {redeliveryPolicy: {...}} is
{defaultErrorHandler: {redeliveryPolicy: {...}}}, with a
+ * deadLetterUri the kind is deadLetterChannel, and {deadLetterChannel:
"jms:queue:dead", redeliveryPolicy: {...}}
+ * is {deadLetterChannel: {deadLetterUri: "jms:queue:dead",
redeliveryPolicy: {...}}}. The kind alone, {kind:
+ * {...}}, when the options are not all options of the kind.
+ */
+ static String errorHandlerText(JsonNode handler, YamlValidator validator) {
if (handler == null || !handler.isObject() || handler.isEmpty()) {
return handler != null && handler.isTextual() &&
ROUTE_ERROR_HANDLER_KINDS.contains(handler.asText())
? "{" + handler.asText() + ": {}}" : "{noErrorHandler:
{}}";
}
+ List<String> kinds = new ArrayList<>();
+ handler.fieldNames().forEachRemaining(k -> {
+ if (ROUTE_ERROR_HANDLER_KINDS.contains(k)) {
+ kinds.add(k);
+ }
+ });
+ boolean asTheSchemaTakesIt = kinds.size() == 1 && handler.size() == 1
&& handler.get(kinds.get(0)).isObject();
+ if (validator != null && kinds.size() <= 1 && !asTheSchemaTakesIt &&
!kinds.contains("refErrorHandler")) {
+ String kind = !kinds.isEmpty() ? kinds.get(0)
+ : handler.has("deadLetterUri") ? "deadLetterChannel" :
"defaultErrorHandler";
+ JsonNode value = handler.get(kind);
+ ObjectNode options = value != null && value.isObject()
+ ? ((ObjectNode) value).deepCopy() :
JsonNodeFactory.instance.objectNode();
+ if (value != null && value.isTextual() &&
"deadLetterChannel".equals(kind)) {
+ // the kind with its uri as the value
+ options.set("deadLetterUri", value);
+ }
+ handler.properties().forEach(e -> {
+ if (!kind.equals(e.getKey())) {
+ options.set(e.getKey(), e.getValue());
+ }
+ });
+ List<String> keys = new ArrayList<>();
+ options.fieldNames().forEachRemaining(keys::add);
+ if (!validator.errorHandlerOptions(kind).containsAll(keys)) {
+ return "{" + kind + ": {...}}";
+ }
+ ObjectNode wrapped = JsonNodeFactory.instance.objectNode();
+ wrapped.set(kind, options);
+ handler = wrapped;
+ }
String kind = handler.fieldNames().next();
if (handler.size() == 1 && ROUTE_ERROR_HANDLER_KINDS.contains(kind) &&
!"refErrorHandler".equals(kind)
&& !handler.get(kind).isObject()) {
@@ -801,6 +850,12 @@ final class SchemaHints {
m -> "errorHandler: has the kind of handler as its key,
not a " + m.unknown() + " property:"
+ " errorHandler: {noErrorHandler: {}},
{deadLetterChannel: {deadLetterUri: \"...\"}} or"
+ " {defaultErrorHandler: {...}}"),
+ // errorHandler: {redeliveryPolicy: ...}: an option of a kind of
handler written without the kind (r28 of the
+ // local-model benchmark, connect-service-jms-request-reply step 4)
+ unknownProperty(".*/errorHandler", m ->
m.validator().errorHandlerOptions("defaultErrorHandler")
+ .contains(m.unknown()) ||
m.validator().errorHandlerOptions("deadLetterChannel").contains(m.unknown()),
+ m -> "errorHandler takes the kind of handler as its key,
and " + m.unknown() + " is an option of the"
+ + " kind: write errorHandler: " +
errorHandlerText(m.error().getInstanceNode(), m.validator())),
append("type", ".*/errorHandler/noErrorHandler", m ->
m.message().contains("object expected"),
m -> "noErrorHandler takes no options: write
noErrorHandler: {}"),
// onException: {java.lang.Exception: ...}: the class is a list
item under exception:
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
index fba891b1649f..2eb6d79f8d70 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
@@ -576,7 +576,8 @@ final class TextBodyFlow {
Need need = needsText(name, value, w);
Parsed element = "split".equals(name) ?
splitElement(value, parsed, w.bodyTypes()) : null;
if (need != null) {
- w.errors().add(error(here, w.routeId(), need, parsed));
+ w.errors().add(error(here, w.routeId(), need, parsed,
+ SENDS_THE_BODY.contains(name) ?
marshalledInto(parsed, w) : null, name));
if (!"wireTap".equals(name)) {
// the step fails at runtime, so what comes after
it is not run with this body: one report.
// A wire tap fails on its own thread, and the
route goes on
@@ -2183,6 +2184,21 @@ final class TextBodyFlow {
}
private static Error error(NodePath at, String routeId, Need need, Parsed
parsed) {
+ return error(at, routeId, need, parsed, null, null);
+ }
+
+ /**
+ * As {@link #error(NodePath, String, Need, Parsed)}, with the variable a
marshal of the data wrote its text into,
+ * and the step, which can send that variable instead of the body.
+ */
+ private static Error error(NodePath at, String routeId, Need need, Parsed
parsed, String variable, String step) {
+ // a marshal with variableReceive: told to add marshal: json before
the step, a local model sent the same
+ // lines 11 times (r28 of the local-model benchmark,
route-enrich-and-dedupe step 3)
+ String fix = variable != null
+ ? "the marshal wrote the text into the variable " + variable +
" (variableReceive: " + variable
+ + "), and the body is still the data: send that variable
with variableSend: " + variable
+ + " on this " + step + ", or leave out variableReceive so
that the marshal writes the body"
+ : fix(need, parsed);
return Error.builder()
.keyword("type")
.instanceLocation(at)
@@ -2190,10 +2206,28 @@ final class TextBodyFlow {
.format(new MessageFormat("{0}"))
.arguments((routeId != null ? "route " + routeId + ": " : "")
+ need.what()
+ ", but the body here is parsed data (a Map or a
List), not text - " + parsed.origin()
- + ": " + fix(need, parsed))
+ + ": " + fix)
.build();
}
+ /**
+ * The one variable that holds the text a marshal wrote of data in the
format of the body, which a marshal with
+ * variableReceive leaves as it was; null when there is none, or several.
+ */
+ private static String marshalledInto(Parsed parsed, Walk w) {
+ String answer = null;
+ for (Map.Entry<String, Body> e : w.variables().entrySet()) {
+ if (e.getValue() instanceof Text text && text.marshalled()
+ && (parsed.format() == null ||
parsed.format().equals(text.format()))) {
+ if (answer != null) {
+ return null;
+ }
+ answer = e.getKey();
+ }
+ }
+ return answer;
+ }
+
/**
* What to write for parsed data where text is needed. The request of an
operation is rarely the data as it is, such
* as one line of an order that the request needs the id of the order for:
told to marshal, a model sends the data
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
index 9f91299994ef..458134a52bcb 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
@@ -30,6 +30,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -56,7 +57,11 @@ import org.apache.camel.tooling.model.ComponentModel;
import org.apache.camel.tooling.model.EipModel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.snakeyaml.engine.v2.nodes.MappingNode;
import org.snakeyaml.engine.v2.nodes.Node;
+import org.snakeyaml.engine.v2.nodes.NodeTuple;
+import org.snakeyaml.engine.v2.nodes.ScalarNode;
+import org.snakeyaml.engine.v2.nodes.SequenceNode;
/**
* YAML DSL validator that tooling can use to validate Camel source files if
they can be parsed and are valid according
@@ -242,17 +247,70 @@ public class YamlValidator {
answer.addAll(errors);
return answer;
} catch (Exception e) {
- return routesOnly ? List.of() : List.of(parseError(e, content));
+ return routesOnly ? List.of() : List.of(parseError(e, content,
this));
+ }
+ }
+
+ /**
+ * What the schema says of a content that parses: its tree, its problems,
each as its location and message, with the
+ * keys given twice, and the locations under which it checks nothing (a
node of another type, a key it does not
+ * know). What the places a key can go are judged by.
+ */
+ record Shape(JsonNode tree, Set<String> problems, List<String> unchecked) {
+ }
+
+ /** What the schema says of a content, or null when it does not parse. */
+ private Shape shapeOf(String content) {
+ try {
+ JsonNode tree = mapper.readTree(content);
+ if (tree == null) {
+ return null;
+ }
+ Set<String> problems = new HashSet<>();
+ List<String> unchecked = new ArrayList<>();
+ Schema against = isKamelet(tree) ? kameletSchema : schema;
+ for (Error e : filterOneOfNoise(new
ArrayList<>(against.validate(tree)))) {
+ if (isRuntimeAcceptedScalar(e)) {
+ continue;
+ }
+ String at = String.valueOf(e.getInstanceLocation());
+ problems.add(at + " " + e.getMessage());
+ if ("additionalProperties".equals(e.getKeyword()) &&
e.getProperty() != null) {
+ unchecked.add(at + "/" + e.getProperty());
+ } else if ("type".equals(e.getKeyword()) ||
"oneOf".equals(e.getKeyword())
+ || "anyOf".equals(e.getKeyword())) {
+ unchecked.add(at);
+ }
+ }
+ // the tree keeps the last of two equal keys: the YAML nodes have
both (without the lines, which a move
+ // changes)
+ DuplicateKeys.find(YamlPointerLines.root(content))
+ .forEach(d -> problems.add("twice: " +
d.replaceAll("\\d+", "")));
+ return new Shape(tree, problems, unchecked);
+ } catch (Exception e) {
+ return null;
}
}
private static final Pattern LINE_COLUMN = Pattern.compile("line:? (\\d+),
column:? (\\d+)");
+ /** The two characters \n before a key or a list item, as a line break
escaped twice writes them. */
+ private static final Pattern ESCAPED_LINE_BREAK =
Pattern.compile("\\\\n(?=\\s*(?:-\\s|[A-Za-z_][\\w.-]*:))");
+
/**
* A YAML parse error whose line is a line of text at column 1 after the
routes (an explanation appended to the
* file, or a markdown fence) says so; the parser's "while scanning a
simple key" does not.
*/
static Error parseError(Exception e, String content) {
+ return parseError(e, content, null);
+ }
+
+ /**
+ * As {@link #parseError(Exception, String)}, with the validator whose
schema the messages read: the places a key
+ * written in the column of the dashes of a list can go, and the entries
of a file. Null to say them by the columns
+ * only.
+ */
+ static Error parseError(Exception e, String content, YamlValidator
validator) {
Error plain = parseError(e);
String msg = e.getMessage();
if (msg == null || content == null) {
@@ -299,11 +357,29 @@ public class YamlValidator {
Matcher q =
Pattern.compile(":\\s*(\"(?:[^\"\\\\]|\\\\.)*\"|'[^']*')\\s*\\S").matcher(t);
if (q.find()) {
String key = t.trim().contains(":") ?
t.trim().substring(0, t.trim().indexOf(':')) : "the value";
- String tail = t.substring(t.indexOf(q.group(1)) +
q.group(1).length()).strip();
- // the value was corrected twice: it ends with two quotes,
not with a concatenation (CAMEL-24906)
- String message = "\"".equals(tail)
- ? "line " + last + ": the value of " + key + "
ends with two double quotes; remove the"
- + " extra one and write the line as " +
t.strip().substring(0, t.strip().length() - 1)
+ int valueEnd = t.indexOf(q.group(1)) + q.group(1).length();
+ String tail = t.substring(valueEnd).strip();
+ // the two characters \n of a line break escaped twice,
before the next key: read as a
+ // concatenation, a local model sent the same lines 15
times (r28 of the local-model benchmark,
+ // contracts-rest-code-first step 1)
+ List<String> parts = new
ArrayList<>(List.of(ESCAPED_LINE_BREAK.split(t.substring(valueEnd), -1)));
+ parts.set(0, t.substring(0, valueEnd) + parts.get(0));
+ boolean escaped = tail.startsWith("\\n") && parts.size() >
1;
+ String message = escaped &&
ESCAPED_LINE_BREAK.matcher(t.substring(0, valueEnd)).find()
+ // a whole block on one line, its quotes escaped
too
+ ? "line " + last + " has the two characters \\n
where line breaks belong (and \\\" where"
+ + " quotes belong): YAML reads them as text, so
the line is one value. Write each part on"
+ + " a line of its own, with real line breaks and
quotes"
+ : escaped
+ ? "line " + last + ": the value of " + key
+ " is followed by \\n, the two characters"
+ + " \\ and n: YAML reads them as text,
not as a line break, so what comes after them"
+ + " is not a key. Write line " + last +
" as these " + parts.size() + " lines:\n"
+ + String.join("\n",
parts.stream().map(String::stripTrailing).toList())
+ // the value was corrected twice: it ends with two
quotes, not with a concatenation
+ // (CAMEL-24906)
+ : "\"".equals(tail)
+ ? "line " + last + ": the value of " + key
+ " ends with two double quotes; remove the"
+ + " extra one and write the line as " +
t.strip().substring(0, t.strip().length() - 1)
// a line copied with the colon of the next key:
expression: "$[?(@.id == 'a')]": (r24)
: ":".equals(tail)
? "line " + last + ": the value of " + key
+ " ends with a : after its closing quote;"
@@ -328,7 +404,7 @@ public class YamlValidator {
return unclosed;
}
}
- Error marked = indentationError(msg, lines);
+ Error marked = indentationError(msg, lines, validator);
return marked != null ? marked : plain;
}
// errorHandler: at column 1 in a file of - items is YAML, not prose:
the list item marker is missing
@@ -406,6 +482,13 @@ public class YamlValidator {
* the messages this does not know, so the raw one is still reported.
*/
static Error indentationError(String msg, String[] lines) {
+ return indentationError(msg, lines, null);
+ }
+
+ /**
+ * As {@link #indentationError(String, String[])}, with the validator
whose schema the messages read, or null.
+ */
+ static Error indentationError(String msg, String[] lines, YamlValidator
validator) {
List<Mark> marks = marks(msg, lines);
if (marks.isEmpty()) {
return null;
@@ -414,7 +497,7 @@ public class YamlValidator {
// the stray item is named '-', or '<block sequence start>' when the
list it broke uses bare dashes
if (msg.contains("expected <block end>, but found '-'")
|| msg.contains("expected <block end>, but found '<block
sequence start>'")) {
- return listItemColumn(problem, lines);
+ return listItemColumn(problem, lines, validator != null ?
validator.entriesOnly() : Set.of());
}
if (msg.contains("expected <block end>, but found '<block mapping
start>'")) {
Error bare = bareLineAbove(problem.line(), lines);
@@ -425,7 +508,7 @@ public class YamlValidator {
}
// a key in the column of the dashes of a list: '?' is the parser's
name for a key
if (msg.contains("expected <block end>, but found '?'")) {
- return marks.size() > 1 ? keyInListColumn(marks.get(0), problem,
lines) : null;
+ return marks.size() > 1 ? keyInListColumn(marks.get(0), problem,
lines, validator) : null;
}
if (msg.contains("mapping values are not allowed here")) {
return mappingValue(problem, lines);
@@ -485,12 +568,18 @@ public class YamlValidator {
/**
* A list item in a column of its own: the parser only says that it
expected the end of what it was reading. Name
* the list the item belongs to, which is the shallowest list still open
below the item's column, or the deepest one
- * above it when the item is the over-indented one.
+ * above it when the item is the over-indented one; for an entry of the
file (- route:), the list of the entries.
+ *
+ * @param entries the keys of the entries of a file that are nowhere else
an item of a list (route, rest...)
*/
- private static Error listItemColumn(Mark problem, String[] lines) {
+ private static Error listItemColumn(Mark problem, String[] lines,
Set<String> entries) {
Mark deeper = null;
Mark shallower = null;
+ Mark top = null;
for (Mark open : openLists(problem, lines)) {
+ if (open.column() == 1) {
+ top = open;
+ }
if (open.column() > problem.column() && (deeper == null ||
open.column() < deeper.column())) {
deeper = open;
} else if (open.column() < problem.column() && (shallower == null
|| open.column() > shallower.column())) {
@@ -498,11 +587,19 @@ public class YamlValidator {
}
}
Mark list = deeper != null ? deeper : shallower;
+ // - route: in column 3 after the steps of a top-level onException was
told to line up with those steps, and a
+ // local model sent the same lines 12 times (r28 of the local-model
benchmark, connect-sftp-transfer step 2)
+ String key = keyName(lines[problem.line() - 1].strip().substring(1));
+ boolean entry = top != null && problem.column() > 1 &&
entries.contains(key);
+ if (entry) {
+ list = top;
+ }
// the two lines as written: with the columns alone, a local model
sent the same indentation again up to 18 times
String belongs = list == null
? ""
: ", but the list that starts at line " + list.line() +
quotedLine(lines, list.line())
- + " has its items in column " + list.column();
+ + " has its items in column " + list.column()
+ + (entry ? " (- " + key + ": is an entry of the file, as the
items of that list are)" : "");
return hint("line " + problem.line() + quotedLine(lines,
problem.line()) + ": this list item starts in column "
+ problem.column() + belongs + "; every item of a list
must start in the same column");
}
@@ -558,9 +655,10 @@ public class YamlValidator {
/**
* A key in the column of the dashes of the list above it, such as steps:
under - simple: of a when: list, or
* onFallback: under the - to: of the steps: of a circuitBreaker: it is
part of neither the list item nor the
- * mapping that holds the list. Names both columns, one of which was meant.
+ * mapping that holds the list. With the schema, the one place whose
result it takes, with the lines to write; else
+ * the two columns, one of which was meant.
*/
- private static Error keyInListColumn(Mark item, Mark problem, String[]
lines) {
+ private static Error keyInListColumn(Mark item, Mark problem, String[]
lines, YamlValidator validator) {
String itemLine = lines[item.line() - 1];
int dash = item.column() - 1;
if (dash < 0 || dash >= itemLine.length() || itemLine.charAt(dash) !=
'-') {
@@ -569,10 +667,11 @@ public class YamlValidator {
String afterDash = itemLine.substring(dash + 1);
int itemKeyColumn = dash + 2 + afterDash.length() -
afterDash.stripLeading().length();
String key = keyName(lines[problem.line() - 1]);
- String answer = "line " + problem.line() + ": " + key + " starts in
column " + problem.column()
- + ", the column of the - of the list item at line " +
item.line() + ", so it is part of neither"
- + " that item nor the key that holds the list. Write
it in column " + itemKeyColumn
- + " to make it a key of the item, as " +
keyName(afterDash) + " is";
+ String start = "line " + problem.line() + ": " + key + " starts in
column " + problem.column()
+ + ", the column of the - of the list item at line " +
item.line() + ", so it is part of neither"
+ + " that item nor the key that holds the list.";
+ List<Place> places = new ArrayList<>();
+ places.add(new Place(itemKeyColumn, 0, "a key of the item, as " +
keyName(afterDash) + " is"));
// the key whose value the list is: the nearest line above that is
indented less than the dashes
for (int i = item.line() - 2; i >= 0; i--) {
String stripped = lines[i].stripLeading();
@@ -587,11 +686,204 @@ public class YamlValidator {
stripped = rest.stripLeading();
column += 1 + rest.length() - stripped.length();
}
- return hint(answer + ", or in column " + column + " to make it
a key next to " + keyName(stripped)
- + " (line " + (i + 1) + ")");
+ places.add(new Place(column, 0, "a key next to " +
keyName(stripped) + " (line " + (i + 1) + ")"));
+ break;
}
}
- return hint(answer);
+ List<Place> fit = validator != null ? fittingPlaces(item, problem,
lines, places, validator::shapeOf) : null;
+ if (fit == null) {
+ // the places by their columns, when the schema cannot judge them
+ fit = places;
+ } else if (fit.size() == 1) {
+ // a model sent the same lines 16 times when told two columns,
both wrong Camel (r28 of the local-model
+ // benchmark, fail-well-error-handling step 2: the steps belonged
to a when item further up)
+ Place p = fit.get(0);
+ String written = linesAt(lines, problem.line() - 1, p);
+ return hint(start + " It is " + p.what() + ": "
+ + (p.after() == 0
+ ? "write it in column " + p.column()
+ : "it goes in column " + p.column() + ", after
line " + p.after())
+ + "." + (written != null ? written : ""));
+ } else if (fit.isEmpty()) {
+ // no column is right for the key: the problem is the key itself,
such as then: for steps:, which the
+ // schema says once the YAML parses
+ fit = places;
+ }
+ StringBuilder sb = new StringBuilder(start);
+ for (int i = 0; i < fit.size(); i++) {
+ Place p = fit.get(i);
+ sb.append(i == 0 ? " Write it in column " : ", or in column
").append(p.column())
+ .append(p.after() > 0 ? " after line " + p.after() :
"").append(" to make it ").append(p.what());
+ }
+ return hint(sb.toString());
+ }
+
+ /**
+ * A place for a key written in the column of the dashes of a list: the
column, the line after which its lines go (0
+ * where they are), and what the key is there.
+ */
+ private record Place(int column, int after, String what) {
+ }
+
+ /** The most lines a key in the column of the dashes of a list is written
with, for a place found for it. */
+ private static final int MAX_PLACED_LINES = 40;
+
+ /** The most places tried for a key in the column of the dashes of a list.
*/
+ private static final int MAX_PLACES = 40;
+
+ /**
+ * The places of a key written in the column of the dashes of a list whose
result the schema takes as it takes the
+ * content without the key, but for what is in the lines of the key: of
the places by the columns, and after each
+ * step and key of the item above it, in the column of its keys (steps: of
a when item further up, written after the
+ * otherwise: of a choice in that item). Two places that give the same
YAML tree are one. A place under a node the
+ * schema checks nothing of in the content without the key (otherwise:
with a list, a key it does not know) is not
+ * judged. Null when the content without the key does not parse, or no
place can be judged.
+ */
+ private static List<Place> fittingPlaces(
+ Mark item, Mark problem, String[] lines, List<Place> byColumn,
Function<String, Shape> shape) {
+ int keyIndex = problem.line() - 1;
+ int end = blockEnd(lines, keyIndex);
+ List<String> without = new ArrayList<>(List.of(lines));
+ without.subList(keyIndex, end).clear();
+ Shape baseline = shape.apply(String.join("\n", without));
+ if (baseline == null || end - keyIndex > MAX_PLACED_LINES) {
+ return null;
+ }
+ List<Place> places = new ArrayList<>(byColumn);
+ for (int i = item.line(); i < keyIndex && places.size() < MAX_PLACES;
i++) {
+ String stripped = lines[i].stripLeading();
+ int indent = lines[i].length() - stripped.length();
+ String name = keyOf(stripped);
+ int after = blockEnd(lines, i);
+ if (name == null || stripped.startsWith("#") || after > keyIndex) {
+ continue;
+ }
+ if (stripped.startsWith("- ")) {
+ String rest = stripped.substring(1);
+ int column = indent + 2 + rest.length() -
rest.stripLeading().length();
+ places.add(new Place(column, after, "a key of the item at line
" + (i + 1) + ", as " + name + " is"));
+ } else {
+ places.add(new Place(indent + 1, after, "a key next to " +
name + " (line " + (i + 1) + ")"));
+ }
+ }
+ List<Place> answer = new ArrayList<>();
+ List<JsonNode> trees = new ArrayList<>();
+ boolean judged = false;
+ for (Place p : places) {
+ List<String> moved = placed(lines, keyIndex, end, p);
+ String content = moved != null ? String.join("\n", moved) : null;
+ Shape placed = content != null ? shape.apply(content) : null;
+ String at = placed != null
+ ? keyPointer(YamlPointerLines.root(content), p.after() ==
0 ? keyIndex + 1 : p.after() + 1) : null;
+ if (at == null || baseline.unchecked().stream().anyMatch(u ->
at.equals(u) || at.startsWith(u + "/"))) {
+ continue;
+ }
+ judged = true;
+ // what the lines of the key have in them they have at any place:
only the problems they make there count
+ boolean fits = placed.problems().stream()
+ .allMatch(e -> baseline.problems().contains(e) ||
e.startsWith(at + "/"));
+ if (fits && !trees.contains(placed.tree())) {
+ answer.add(p);
+ trees.add(placed.tree());
+ }
+ }
+ return judged ? answer : null;
+ }
+
+ /** The JSON pointer of the key on the line (1-based) of the YAML, or null
when no key starts there. */
+ private static String keyPointer(Node node, int line) {
+ return keyPointer(node, line, "");
+ }
+
+ private static String keyPointer(Node node, int line, String at) {
+ if (node instanceof MappingNode map) {
+ for (NodeTuple t : map.getValue()) {
+ String key = t.getKeyNode() instanceof ScalarNode s ?
s.getValue() : null;
+ if (key == null) {
+ continue;
+ }
+ String here = at + "/" + key.replace("~", "~0").replace("/",
"~1");
+ if (t.getKeyNode().getStartMark().map(m -> m.getLine() +
1).orElse(0) == line) {
+ return here;
+ }
+ String found = keyPointer(t.getValueNode(), line, here);
+ if (found != null) {
+ return found;
+ }
+ }
+ } else if (node instanceof SequenceNode seq) {
+ for (int i = 0; i < seq.getValue().size(); i++) {
+ String found = keyPointer(seq.getValue().get(i), line, at +
"/" + i);
+ if (found != null) {
+ return found;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * The end (exclusive) of the block of a line: the lines below it that are
indented more, without the blank lines at
+ * its end.
+ */
+ private static int blockEnd(String[] lines, int start) {
+ int indent = lines[start].length() -
lines[start].stripLeading().length();
+ int end = start + 1;
+ int last = start + 1;
+ while (end < lines.length) {
+ String l = lines[end];
+ if (!l.isBlank() && l.length() - l.stripLeading().length() <=
indent) {
+ break;
+ }
+ end++;
+ if (!l.isBlank()) {
+ last = end;
+ }
+ }
+ return last;
+ }
+
+ /** The lines with the block of the key at its place; null when its lines
cannot be moved to that column. */
+ private static List<String> placed(String[] lines, int keyIndex, int end,
Place p) {
+ int delta = p.column() - 1 - (lines[keyIndex].length() -
lines[keyIndex].stripLeading().length());
+ List<String> block = new ArrayList<>();
+ for (int i = keyIndex; i < end; i++) {
+ String l = lines[i];
+ if (l.isBlank()) {
+ block.add(l);
+ } else if (delta >= 0) {
+ block.add(" ".repeat(delta) + l);
+ } else if (l.length() - l.stripLeading().length() >= -delta) {
+ block.add(l.substring(-delta));
+ } else {
+ return null;
+ }
+ }
+ List<String> answer = new ArrayList<>(List.of(lines));
+ answer.subList(keyIndex, end).clear();
+ answer.addAll(p.after() == 0 ? keyIndex : p.after(), block);
+ return answer;
+ }
+
+ /**
+ * " Write lines 49-56 as:" and the lines with the block of the key at its
place, from the first line that changes;
+ * null when there are too many to copy.
+ */
+ private static String linesAt(String[] lines, int keyIndex, Place p) {
+ int end = blockEnd(lines, keyIndex);
+ List<String> moved = placed(lines, keyIndex, end, p);
+ int first = p.after() == 0 ? keyIndex : p.after();
+ if (moved == null || end - first > MAX_PLACED_LINES) {
+ return null;
+ }
+ return (end == first + 1 ? " Write line " + end : " Write lines " +
(first + 1) + "-" + end) + " as:\n"
+ + String.join("\n", moved.subList(first, end));
+ }
+
+ /** The key of a line of a mapping or of a list item (log of - log: x), or
null for a line that has none. */
+ private static String keyOf(String stripped) {
+ String text = stripped.startsWith("- ") ?
stripped.substring(2).stripLeading() : stripped;
+ return KEY_LINE.matcher(text).find() ? keyName(text) : null;
}
/**
@@ -2112,6 +2404,17 @@ public class YamlValidator {
return topLevelEntries;
}
+ /**
+ * The keys of the file's entries that are nowhere else an item of a list
(route, rest, beans...): not onException
+ * or intercept, which are items of a routeConfiguration too; from the
schema.
+ */
+ Set<String> entriesOnly() {
+ Set<String> answer = new LinkedHashSet<>(topLevelEntries);
+
model.at("/items/definitions/org.apache.camel.model.RouteConfigurationDefinition/properties").fieldNames()
+ .forEachRemaining(answer::remove);
+ return answer;
+ }
+
/** The language keys of an expression (simple, constant, groovy...), from
the schema. */
Set<String> languageKeys() {
return languageKeys;
@@ -2140,6 +2443,34 @@ public class YamlValidator {
return answer;
}
+ /**
+ * The options of a kind of error handler (deadLetterChannel:
deadLetterUri, redeliveryPolicy...), from the schema;
+ * empty for a kind it does not have.
+ */
+ Set<String> errorHandlerOptions(String kind) {
+ Set<String> answer = new LinkedHashSet<>();
+ if (model != null) {
+
collectKindOptions(model.at("/items/definitions/org.apache.camel.model.ErrorHandlerDefinition"),
kind, answer,
+ 0);
+ }
+ return answer;
+ }
+
+ /** The kinds are branches of the definition of errorHandler: {required:
[kind], properties: {kind: {$ref}}}. */
+ private void collectKindOptions(JsonNode node, String kind, Set<String>
answer, int depth) {
+ if (node == null || node.isMissingNode() || depth > 4) {
+ return;
+ }
+ JsonNode ref = node.path("properties").path(kind).path("$ref");
+ if (ref.isTextual() && ref.asText().startsWith("#")) {
+
model.at(ref.asText().substring(1)).path("properties").fieldNames().forEachRemaining(answer::add);
+ return;
+ }
+ for (String composition : new String[] { "anyOf", "oneOf", "allOf" }) {
+ node.path(composition).forEach(n -> collectKindOptions(n, kind,
answer, depth + 1));
+ }
+ }
+
/** The properties of redeliveryPolicy (maximumRedeliveries,
redeliveryDelay...), from the schema. */
Set<String> redeliveryPolicyProperties() {
return redeliveryPolicyProperties;
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
index 83bba9a48cc1..d65c4124895a 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
@@ -3044,6 +3044,50 @@ public class TextBodyFlowTest {
+ " need, such as for a
file name or a log, before the marshal"));
}
+ /**
+ * r28 of the local-model benchmark, route-enrich-and-dedupe step 3: the
marshal wrote the JSON text into a variable
+ * (variableReceive), so the body was still the order Map at the wireTap.
Told to add marshal: json before the step,
+ * which it had, the model sent the same lines 11 times: the variable is
what to send.
+ */
+ @Test
+ public void testAMarshalIntoAVariableBeforeAWireTapOfTheBody() {
+ String yaml = """
+ - route:
+ id: incoming-orders
+ from:
+ uri: file:orders
+ steps:
+ - unmarshal:
+ json:
+ library: Jackson
+ - setVariable:
+ name: orderId
+ expression:
+ simple:
+ expression: "${body[orderId]}"
+ - marshal:
+ json:
+ library: Jackson
+ variableReceive: orderJson
+ - wireTap:
+ uri: "file:audit?fileName=${variable.orderId}.json"
+ """;
+ assertThat(lines(yaml)).singleElement().asString()
+ .startsWith("Line 19: ")
+ .endsWith("route incoming-orders: wireTap: file needs the body
as text, bytes, a stream or a file, but"
+ + " the body here is parsed data (a Map or a List),
not text - unmarshal: json turns the text"
+ + " into data: the marshal wrote the text into the
variable orderJson (variableReceive:"
+ + " orderJson), and the body is still the data: send
that variable with variableSend: orderJson"
+ + " on this wireTap, or leave out variableReceive so
that the marshal writes the body");
+ // the variable sent: valid
+ assertThat(lines(yaml.replace("""
+ uri: "file:audit?fileName=${variable.orderId}.json"
+ """, """
+ uri: "file:audit?fileName=${variable.orderId}.json"
+ variableSend: orderJson
+ """))).isEmpty();
+ }
+
@Test
public void testAWireTapOrAnEnrichOfDataToAFile() {
// what the benchmark wrote (r22): the order Map is tapped to a file
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 d1ef342bdf44..91dff4e36b13 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
@@ -16,6 +16,7 @@
*/
package org.apache.camel.dsl.yaml.validator;
+import java.util.ArrayList;
import java.util.List;
import com.networknt.schema.Error;
@@ -109,6 +110,77 @@ public class YamlValidatorPropertyHintTest {
}
}
+ /**
+ * r28 of the local-model benchmark, connect-service-jms-request-reply
step 4: errorHandler: {redeliveryPolicy: ...}
+ * under from:, with no kind of handler. The hint copied it as the text to
write, and the model was refused for it
+ * next ("errorHandler: property 'redeliveryPolicy' is not defined", which
had no hint). Both say the form with the
+ * kind, which the schema takes.
+ */
+ @Test
+ public void testAnErrorHandlerWithoutItsKindGetsTheKind() throws Exception
{
+ List<Error> errors = validator.validate("""
+ - route:
+ id: reserve-order
+ from:
+ uri: direct:orders
+ errorHandler:
+ redeliveryPolicy:
+ maximumRedeliveries: 3
+ redeliveryDelay: 1000
+ useExponentialBackOff: false
+ steps:
+ - log: "reserving"
+ """);
+ String handler = "{defaultErrorHandler: {redeliveryPolicy:
{maximumRedeliveries: 3, redeliveryDelay: 1000,"
+ + " useExponentialBackOff: false}}}";
+
assertThat(errors).singleElement().extracting(Error::getMessage).asString()
+ .endsWith("(errorHandler: is a key of the route, not of from:,
and it takes the kind of handler as its"
+ + " key, with the options under the kind. For this
route only, write it under the route at"
+ + " the same level as from:, as in route:
{errorHandler: " + handler + ", from: {uri: ...,"
+ + " steps: [...]}}. For every route, write it as a
top-level item instead: - errorHandler: "
+ + handler + ")");
+ // the model's next write, the handler under the route as the old hint
gave it
+ String route = """
+ - route:
+ id: reserve-order
+ errorHandler:
+ redeliveryPolicy:
+ maximumRedeliveries: 3
+ redeliveryDelay: 1000
+ useExponentialBackOff: false
+ from:
+ uri: direct:orders
+ steps:
+ - log: "reserving"
+ """;
+
assertThat(validator.validate(route)).singleElement().extracting(Error::getMessage).asString()
+ .endsWith("(errorHandler takes the kind of handler as its key,
and redeliveryPolicy is an option of the"
+ + " kind: write errorHandler: " + handler + ")");
+ // what is said to write is valid
+ assertThat(validator.validate(route.replace("""
+ errorHandler:
+ redeliveryPolicy:
+ maximumRedeliveries: 3
+ redeliveryDelay: 1000
+ useExponentialBackOff: false
+ """, " errorHandler: " + handler + "\n"))).isEmpty();
+ // the dead letter queue as the value of its kind, the options next to
it (r25n-4 step 3)
+ assertThat(validator.validate("""
+ - route:
+ errorHandler:
+ deadLetterChannel: "jms:queue:orders.dead"
+ redeliveryPolicy:
+ maximumRedeliveries: 3
+ from:
+ uri: direct:orders
+ steps:
+ - log: "reserving"
+ """)).extracting(Error::getMessage)
+ .anySatisfy(m -> assertThat(m).endsWith("write errorHandler:
{deadLetterChannel: {deadLetterUri:"
+ + "
\"jms:queue:orders.dead\", redeliveryPolicy:"
+ + "
{maximumRedeliveries: 3}}})"));
+ }
+
/**
* An errorHandler under from: gets both of its places: under the route
for that route, a top-level item for every
* route. Told only "a top-level entry", r21 and r25 of the local-model
benchmark moved a noErrorHandler meant for
@@ -1194,6 +1266,93 @@ public class YamlValidatorPropertyHintTest {
.contains("no concatenation");
}
+ /**
+ * r28 of the local-model benchmark, contracts-rest-code-first step 1: a
line break escaped twice, so the line holds
+ * the two characters \n before the next key. Told it was a concatenation,
the model sent the same lines 15 times.
+ */
+ @Test
+ public void testTheTwoCharactersOfALineBreakAfterAQuoteAreNamed() throws
Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: direct:get-order
+ steps:
+ - setBody:
+ expression:
+ jsonpath:
+ expression: "$[?(@.orderId ==
'${header.id}')]"\\n resultType: java.util.Map
+ """);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).getMessage()).isEqualTo(
+ "line 7: the value of expression is followed by \\n, the two
characters \\ and n: YAML reads them as text,"
+ + " not as a line
break, so what comes after them is not a key."
+ + " Write line 7 as
these 2 lines:\n"
+ + "
expression: \"$[?(@.orderId == '${header.id}')]\"\n"
+ + "
resultType: java.util.Map");
+ // a whole block on one line, its quotes escaped too (r23l-2
transform-groovy step 1)
+ errors = validator.validate(
+ """
+ - from:
+ uri: direct:check
+ steps:
+ - choice:\\n when:\\n - simple:
\\"${body} != null\\"\\n steps:\\n - log: "ok"\\n -
otherwise:
+ """);
+
assertThat(errors).singleElement().extracting(Error::getMessage).asString()
+ .startsWith("line 4 has the two characters \\n where line
breaks belong (and \\\" where quotes belong)");
+ // a value that continues after its quote: as before
+ errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - log:
+ message: "Order " + "${body}"
+ """);
+
assertThat(errors).singleElement().extracting(Error::getMessage).asString()
+ .contains("continues after its closing quote");
+ }
+
+ /**
+ * r28 of the local-model benchmark, connect-sftp-transfer step 2: -
route: in column 3 after a top-level
+ * onException was told to line up with the steps of the onException, and
the model sent the same lines 12 times. It
+ * is an entry of the file, so it belongs to the list of the entries, in
column 1.
+ */
+ @Test
+ public void testAnEntryOfTheFileInAColumnOfItsOwnBelongsToTheEntries()
throws Exception {
+ List<Error> errors = validator.validate("""
+ - onException:
+ exception:
+ - java.lang.Exception
+ steps:
+ - log:
+ message: "Batch failed: ${exception.message}"
+ - route:
+ id: invoice-batches
+ from:
+ uri: timer:tick
+ steps:
+ - log: "tick"
+ """);
+
assertThat(errors).singleElement().extracting(Error::getMessage).asString()
+ .isEqualTo("line 7 (\" - route:\"): this list item starts in
column 3, but the list that starts at line"
+ + " 1 (\"- onException:\") has its items in column
1 (- route: is an entry of the file, as the"
+ + " items of that list are); every item of a list
must start in the same column");
+ // an onException can be an item of a routeConfiguration too: the list
is named by the columns, as before
+ errors = validator.validate("""
+ - routeConfiguration:
+ onException:
+ - onException:
+ exception:
+ - java.lang.Exception
+ steps:
+ - log: "failed"
+ - onException:
+ exception:
+ - java.io.IOException
+ """);
+
assertThat(errors).singleElement().extracting(Error::getMessage).asString()
+ .contains("this list item starts in column 9, but the list
that starts at line")
+ .doesNotContain("entry of the file");
+ }
+
@Test
public void testALineWithNoKeyIsNamed() throws Exception {
// r24 ai-ticket-classifier step 2: a setHeader with the bare line
directory (meant as name: directory), told 15
@@ -2065,10 +2224,11 @@ public class YamlValidatorPropertyHintTest {
/**
* A key in the column of the dashes of a list gets "expected <block end>,
but found '?'": steps: under the -
- * simple: of a when:, meant for the item.
+ * simple: of a when:, meant for the item. Of the two columns, only the
one of the item gives valid Camel (choice
+ * has no steps: of its own): it is the one said, with the lines to write.
*/
@Test
- void aKeyInTheColumnOfTheDashesOfAListNamesBothColumns() throws Exception {
+ void aKeyInTheColumnOfTheDashesOfAListGetsTheColumnTheSchemaTakes() throws
Exception {
List<Error> errors = validator.validate("""
- route:
from:
@@ -2083,9 +2243,10 @@ public class YamlValidatorPropertyHintTest {
assertThat(errors).hasSize(1);
assertThat(errors.get(0).getMessage())
.isEqualTo("line 8: steps starts in column 15, the column of
the - of the list item at line 7, so it is"
- + " part of neither that item nor the key that
holds the list. Write it in column 17 to make"
- + " it a key of the item, as simple is, or in
column 13 to make it a key next to when"
- + " (line 6)");
+ + " part of neither that item nor the key that
holds the list. It is a key of the item, as"
+ + " simple is: write it in column 17. Write lines
8-9 as:\n"
+ + " steps:\n"
+ + " - log: \"a\"");
}
/** onFallback: under the - to: of the steps: of a circuitBreaker, meant
for the circuit breaker. */
@@ -2106,7 +2267,130 @@ public class YamlValidatorPropertyHintTest {
""");
assertThat(errors).hasSize(1);
assertThat(errors.get(0).getMessage())
- .startsWith("line 9: onFallback starts in column 15, the
column of the - of the list item at line 7")
- .endsWith("as to is, or in column 13 to make it a key next to
steps (line 6)");
+ .isEqualTo("line 9: onFallback starts in column 15, the column
of the - of the list item at line 7, so"
+ + " it is part of neither that item nor the key
that holds the list. It is a key next to steps"
+ + " (line 6): write it in column 13. Write lines
9-11 as:\n"
+ + " onFallback:\n"
+ + " steps:\n"
+ + " - log: \"fallback\"");
+ }
+
+ /**
+ * r28 of the local-model benchmark, fail-well-error-handling step 2: the
steps: of the when of a choice nested in
+ * otherwise:, written after that otherwise:, in the column of the - of
the choice. The two columns of the old
+ * message both give invalid Camel (a second key of the - choice: item, a
second steps: of otherwise:), and the
+ * model sent the same lines 16 times. The steps belong to the when item
further up: the lines to write move them
+ * there.
+ */
+ @Test
+ void aKeyInTheColumnOfTheDashesOfAListGoesToTheItemAboveItThatTakesIt()
throws Exception {
+ String route = """
+ - route:
+ from:
+ uri: direct:charge
+ steps:
+ - choice:
+ when:
+ - expression:
+ simple:
+ expression: "${body[orderId]} ==
'ORD-1003'"
+ steps:
+ - throwException:
+ exceptionType:
java.lang.IllegalStateException
+ message: "card declined"
+ otherwise:
+ steps:
+ - choice:
+ when:
+ - expression:
+ simple:
+ expression: "${body[orderId]} ==
'ORD-1002'"
+ otherwise:
+ steps:
+ - log:
+ message: "Order ${body[orderId]}
charged successfully"
+ steps:
+ - throwException:
+ exceptionType: java.net.ConnectException
+ message: "payment provider did not
answer"
+ """;
+ List<Error> errors = validator.validate(route);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).getMessage())
+ .isEqualTo("line 25: steps starts in column 17, the column of
the - of the list item at line 16, so it"
+ + " is part of neither that item nor the key that
holds the list. It is a key of the item at"
+ + " line 18, as expression is: it goes in column
25, after line 20. Write lines 21-28 as:\n"
+ + " steps:\n"
+ + " - throwException:\n"
+ + " exceptionType:
java.net.ConnectException\n"
+ + " message: \"payment
provider did not answer\"\n"
+ + " otherwise:\n"
+ + " steps:\n"
+ + " - log:\n"
+ + " message: \"Order
${body[orderId]} charged successfully\"");
+ // the lines to write give valid Camel
+ String message = errors.get(0).getMessage();
+ List<String> lines = new ArrayList<>(List.of(route.split("\n", -1)));
+ lines.subList(20, 28).clear();
+ lines.addAll(20, List.of(message.substring(message.indexOf(" as:\n") +
5).split("\n")));
+ assertThat(validator.validate(String.join("\n", lines))).isEmpty();
+ }
+
+ /** otherwise: in the column of the dashes of the when items is a key of
the choice. */
+ @Test
+ void anOtherwiseInTheColumnOfTheDashesOfTheWhenItemsIsAKeyOfTheChoice()
throws Exception {
+ List<Error> errors = validator.validate("""
+ - route:
+ from:
+ uri: direct:start
+ steps:
+ - choice:
+ when:
+ - simple: "${body} == 'a'"
+ steps:
+ - log: "a"
+ otherwise:
+ steps:
+ - log: "other"
+ """);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).getMessage())
+ .isEqualTo("line 10: otherwise starts in column 15, the column
of the - of the list item at line 7, so"
+ + " it is part of neither that item nor the key
that holds the list. It is a key next to when"
+ + " (line 6): write it in column 13. Write lines
10-12 as:\n"
+ + " otherwise:\n"
+ + " steps:\n"
+ + " - log: \"other\"");
+ }
+
+ /**
+ * Under otherwise: with a list, which the schema checks nothing in, no
place can be judged: the columns are said as
+ * before (r24l-6 connect-http-client step 3).
+ */
+ @Test
+ void aKeyWhosePlaceTheSchemaCannotJudgeGetsBothColumns() throws Exception {
+ List<Error> errors = validator.validate("""
+ - route:
+ from:
+ uri: direct:start
+ steps:
+ - choice:
+ when:
+ - simple: "${body} == 'a'"
+ steps:
+ - log: "a"
+ otherwise:
+ - choice:
+ when:
+ - simple: "${body} == 'b'"
+ steps:
+ - log: "b"
+ """);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).getMessage())
+ .isEqualTo("line 14: steps starts in column 21, the column of
the - of the list item at line 13, so it"
+ + " is part of neither that item nor the key that
holds the list. Write it in column 23 to make"
+ + " it a key of the item, as simple is, or in
column 19 to make it a key next to when (line"
+ + " 12)");
}
}