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 d5e5d907c981 CAMEL-26413: camel-jbang - AI tools: an emptied body, an
option of a to: that is not evaluated, a variable for a value of the route, and
a line with no key (#28723)
d5e5d907c981 is described below
commit d5e5d907c9812687962e1bbf3ab78cb04191cf48
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Oct 30 18:49:35 2026 +0100
CAMEL-26413: camel-jbang - AI tools: an emptied body, an option of a to:
that is not evaluated, a variable for a value of the route, and a line with no
key (#28723)
Four more gaps from the r24 run of the local-model benchmark:
- A body set to null or empty text: a field read on it is reported, and
empty text sent to an http call with no httpMethod (which makes it a POST) is
reported with the advice to use ${null} or httpMethod: GET.
- An expression in an option of a to: that has a per-message header (such
as the key of kafka) is reported, with the advice to set that header, as the
options of a to: are fixed when the route starts.
- The header/property mix-up message also offers setVariable and
${variable.x}.
- A bare line with no key gets its own message instead of the wrong-column
one.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../core/commands/ai/HeaderPropertyMixups.java | 10 +-
.../core/commands/ai/HeaderPropertyMixupsTest.java | 9 +-
.../camel/dsl/yaml/validator/TextBodyFlow.java | 90 ++++++++++-
.../camel/dsl/yaml/validator/YamlValidator.java | 178 +++++++++++++++++++++
.../camel/dsl/yaml/validator/TextBodyFlowTest.java | 89 +++++++++++
.../validator/YamlValidatorDynamicUriTest.java | 108 +++++++++++++
.../validator/YamlValidatorPropertyHintTest.java | 35 ++++
7 files changed, 511 insertions(+), 8 deletions(-)
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 bb19dd0f680f..21a1708eb698 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
@@ -148,9 +148,17 @@ final class HeaderPropertyMixups {
break;
}
}
+ // r24 run-order-generator step 1: asked to set a variable, a
model set an exchange property in 3 runs of
+ // 7, and this answer kept it there; a variable is what Camel
keeps a value of the route in
answer.add("Line " + line + ": " + token + " reads " + kind.what +
" " + name + ", but the route keeps " + name
+ " in " + kept.what + " (the " +
kind.what.substring(kind.what.indexOf(' ') + 1)
- + " is null here): write ${" + kept.read + name + "}");
+ + " is null here): write ${" + kept.read + name + "}"
+ + (kept != Kind.PROPERTY ? ""
+ : kind == Kind.VARIABLE
+ ? ", or set it with setVariable instead
of setProperty, which is what Camel uses"
+ + " for a value of the route"
+ : ", or keep it in a variable instead
(setVariable, read with ${variable." + name
+ + "}), which is what Camel uses for a value
of the route"));
}
}
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 2619cc423d1b..4f17149d7619 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
@@ -40,7 +40,9 @@ class HeaderPropertyMixupsTest {
assertThat(msgs).singleElement().isEqualTo("Line 10: ${header.sku}
reads a header sku, but the route keeps sku in"
+ " an exchange property
(the header is null here): write"
- + "
${exchangeProperty.sku}");
+ + "
${exchangeProperty.sku}, or keep it in a variable instead"
+ + " (setVariable, read with
${variable.sku}), which is what Camel"
+ + " uses for a value of the
route");
}
@Test
@@ -58,8 +60,11 @@ class HeaderPropertyMixupsTest {
.singleElement().asString()
.startsWith("Line 10: ${header.sku} reads a header sku, but
the route keeps sku in a variable")
.endsWith("write ${variable.sku}");
+ // r24 run-order-generator step 1: a variable read, an exchange
property set
assertThat(HeaderPropertyMixups.validate(route("file:orders",
"setProperty", "${variable.sku}")))
- .singleElement().asString().endsWith("write
${exchangeProperty.sku}");
+ .singleElement().asString().contains("write
${exchangeProperty.sku}")
+ .endsWith("or set it with setVariable instead of setProperty,
which is what Camel uses for a value of"
+ + " the route");
assertThat(HeaderPropertyMixups.validate(route("file:orders",
"setVariable", "${variable.sku}"))).isEmpty();
}
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 8af810156954..a5bba63c028c 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
@@ -202,22 +202,35 @@ final class TextBodyFlow {
}
}
+ /** How a setBody emptied the body: to null (${null}), or to empty text
(constant ""). */
+ enum Emptied {
+ TO_NULL,
+ TO_EMPTY_TEXT
+ }
+
/**
* Text: the payload as it was read or written; marshalled when a marshal
wrote it from parsed data; the resource it
- * was loaded from (file:orders.json) when a setBody with constant:
resource: loaded it.
+ * was loaded from (file:orders.json) when a setBody with constant:
resource: loaded it; emptied when a setBody set
+ * it to null or to empty text, which has no field to read.
*/
- record Text(String origin, String format, boolean file, boolean
marshalled, String resource) implements Body {
+ record Text(String origin, String format, boolean file, boolean
marshalled, String resource, Emptied emptied)
+ implements
+ Body {
Text(String origin, String format) {
- this(origin, format, false, false, null);
+ this(origin, format, false, false, null, null);
}
Text(String origin, String format, boolean file) {
- this(origin, format, file, false, null);
+ this(origin, format, file, false, null, null);
}
Text(String origin, String format, boolean file, boolean marshalled) {
- this(origin, format, file, marshalled, null);
+ this(origin, format, file, marshalled, null, null);
+ }
+
+ Text(String origin, String format, boolean file, boolean marshalled,
String resource) {
+ this(origin, format, file, marshalled, resource, null);
}
/** The names that can be read from this body without parsing it. */
@@ -438,6 +451,7 @@ final class TextBodyFlow {
String name = step.fieldNames().next();
JsonNode value = step.get(name);
NodePath here = at.append(j).append(name);
+ boolean splitWalked = false;
if (w.errors() != null && parsesData(name, value, body, here, w)) {
// the unmarshal fails at runtime, so what comes after it is
not run: one report
body = null;
@@ -453,6 +467,11 @@ final class TextBodyFlow {
}
if (read != null) {
w.errors().add(error(here, w.routeId(), read, text));
+ } else if (text.emptied() == Emptied.TO_EMPTY_TEXT) {
+ String post = postOfEmptyText(name, value, w);
+ if (post != null) {
+ w.errors().add(emptyPostError(here, w.routeId(),
post, text));
+ }
}
} else if (body instanceof Parsed parsed && parsed.list()
&& (listRead(value) != null || templateRead(name,
value, LIST_PROPERTIES) != null)) {
@@ -475,9 +494,15 @@ final class TextBodyFlow {
}
} else if (element != null) {
walk(value.get("steps"), element,
here.append("steps"), w.branch());
+ splitWalked = true;
}
}
}
+ if ("split".equals(name) && !splitWalked && w.errors() != null &&
value != null && value.isObject()) {
+ // an element that is not known: what the steps of the split
set themselves still is, such as the
+ // empty text a GET is then sent with (r24 connect-http-client)
+ walk(value.get("steps"), null, here.append("steps"),
w.branch());
+ }
if (w.errors() != null) {
readsOfVariables(name, value, here, w);
}
@@ -691,6 +716,16 @@ final class TextBodyFlow {
if (expression == null) {
return null;
}
+ // r24 connect-http-client: a setBody ${null} before the split of the
lines, and a constant "" before a GET
+ if (("constant".equals(language) && expression.isEmpty())
+ || ("simple".equals(language) && expression.trim().isEmpty()))
{
+ return new Text(
+ "setBody with " + language + " \"\" sets empty text",
null, false, false, null,
+ Emptied.TO_EMPTY_TEXT);
+ }
+ if ("simple".equals(language) && "${null}".equals(expression.trim())) {
+ return new Text("setBody with ${null} empties it", null, false,
false, null, Emptied.TO_NULL);
+ }
if ("constant".equals(language)) {
String origin = expression.startsWith("resource:")
? "setBody with constant: " + expression + " loads the
file as text"
@@ -1613,6 +1648,19 @@ final class TextBodyFlow {
}
private static Error error(NodePath at, String routeId, String read, Text
text) {
+ if (text.emptied() != null) {
+ return Error.builder()
+ .keyword("type")
+ .instanceLocation(at)
+ .messageKey("type")
+ .format(new MessageFormat("{0}"))
+ .arguments((routeId != null ? "route " + routeId + ": " :
"") + read + ", but the body here is "
+ + (text.emptied() == Emptied.TO_NULL ? "null" :
"empty text") + " - " + text.origin()
+ + ": empty the body later, right before the
step that needs no body (such as an http"
+ + " GET, and inside the split when the call is
made for each element), so the fields"
+ + " are read before that")
+ .build();
+ }
String format = text.format();
String fix;
if (text.marshalled()) {
@@ -1647,6 +1695,38 @@ final class TextBodyFlow {
.build();
}
+ /**
+ * An http call that sends empty text as a POST: the http producer sends a
POST for any body that is not null, and
+ * empty text is a body (HttpMethodHelper). r24 connect-http-client: a
setBody with constant "" before a GET of the
+ * stock service gave 87 times 405 Method Not Allowed. Null when the step
is no such call.
+ */
+ private static String postOfEmptyText(String name, JsonNode value, Walk w)
{
+ if (!w.post() || !SENDS_THE_BODY.contains(name) ||
w.bodyTypes().needsNoBody(name, value)) {
+ return null;
+ }
+ String uri = sendUri(name, value);
+ String scheme = scheme(uri);
+ if (!"http".equals(scheme) && !"https".equals(scheme)) {
+ return null;
+ }
+ return sendsTheBody(uri, "enrich".equals(name) ? endpointNode(uri) :
value)
+ ? name + ": " + scheme + " sends the body as the request, and
empty text is still a body, so the call"
+ + " is a POST"
+ : null;
+ }
+
+ private static Error emptyPostError(NodePath at, String routeId, String
post, Text text) {
+ return Error.builder()
+ .keyword("type")
+ .instanceLocation(at)
+ .messageKey("type")
+ .format(new MessageFormat("{0}"))
+ .arguments((routeId != null ? "route " + routeId + ": " : "")
+ post + " - " + text.origin()
+ + ": set the body to null for a call with no body
(setBody with simple: ${null}), or set"
+ + " httpMethod: GET")
+ .build();
+ }
+
/** The REST binding writes json and xml: offer it where it applies. */
private static String restBinding(Need need, Parsed parsed) {
if (!need.rest()) {
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 73a0cc60c997..339c9102b09c 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
@@ -417,6 +417,10 @@ public class YamlValidator {
return listItemColumn(problem, lines);
}
if (msg.contains("expected <block end>, but found '<block mapping
start>'")) {
+ Error bare = bareLineAbove(problem.line(), lines);
+ if (bare != null) {
+ return bare;
+ }
return marks.size() > 1 ? mappingKeyColumn(marks.get(0), problem,
lines) : null;
}
// a key in the column of the dashes of a list: '?' is the parser's
name for a key
@@ -597,6 +601,10 @@ public class YamlValidator {
return null;
}
if (colon == text.indexOf(':')) {
+ Error bare = bareLineAbove(problem.line(), lines);
+ if (bare != null) {
+ return bare;
+ }
String stripped = text.stripLeading();
int indent = text.length() - stripped.length();
Mark mapping = enclosingMapping(problem.line(), indent, lines);
@@ -612,6 +620,48 @@ public class YamlValidator {
+ "): a colon followed by a space starts a new key, so the
value must be quoted");
}
+ /** The EIPs whose first key is name:, the name of what they set or
remove. */
+ private static final Set<String> NAMED_EIPS = Set.of("setHeader",
"setProperty", "setVariable", "removeHeader",
+ "removeProperty", "removeVariable");
+
+ private static final Pattern BARE_WORD =
Pattern.compile("[A-Za-z_][\\w.-]*");
+
+ /**
+ * A bare word on the line above the key the parser stopped at, such as
directory under - setHeader:, meant as name:
+ * directory: with no colon it is not a key, and the parser reads it with
the line below as one text. The column of
+ * the key below then looked wrong, and a local model was told so 15 times
(r24 ai-ticket-classifier step 2). Null
+ * when the line above is anything else.
+ */
+ private static Error bareLineAbove(int line, String[] lines) {
+ if (line < 2 || line > lines.length) {
+ return null;
+ }
+ String key = lines[line - 1];
+ int keyIndent = key.length() - key.stripLeading().length();
+ for (int i = line - 2; i >= 0; i--) {
+ String stripped = lines[i].strip();
+ if (stripped.isEmpty() || stripped.startsWith("#")) {
+ continue;
+ }
+ int indent = lines[i].length() - lines[i].stripLeading().length();
+ if (indent != keyIndent || !BARE_WORD.matcher(stripped).matches())
{
+ return null;
+ }
+ String owner = null;
+ for (int j = i - 1; j >= 0 && owner == null; j--) {
+ String above = lines[j].stripLeading();
+ if (!above.isEmpty() && !above.startsWith("#") &&
lines[j].length() - above.length() < indent) {
+ owner = keyName(above.startsWith("- ") ?
above.substring(2) : above);
+ }
+ }
+ String fix = owner != null && NAMED_EIPS.contains(owner) ? "name:
" + stripped : stripped + ": <value>";
+ return hint("line " + (i + 1) + ": " + stripped + " has no colon,
so it is not a key, and the parser reads"
+ + " it together with the line below as one text; every
line of "
+ + (owner != null ? owner : "a mapping") + " is a key
with its value - here write " + fix);
+ }
+ return null;
+ }
+
/** The mapping an over-indented key was meant to join: the first key of
the nearest shallower run of keys. */
private static Mark enclosingMapping(int line, int indent, String[] lines)
{
for (int i = line - 2; i >= 0; i--) {
@@ -1281,12 +1331,140 @@ public class YamlValidator {
+ " is fixed when the route starts, so
it is sent as text: write toD: to build"
+ " the uri for each message")
.build());
+ } else {
+ String option = staticOptionWithExpression(uri, value);
+ if (option != null) {
+ errors.add(Error.builder()
+ .keyword("type")
+ .instanceLocation(at)
+ .messageKey("type")
+ .format(new MessageFormat("{0}"))
+ .arguments("to: " + option)
+ .build());
+ }
}
}
checkDynamicUri(value, path.append(name), errors);
}
}
+ /**
+ * An option of a to: with a simple expression that the component does not
evaluate, said with the header to set
+ * instead; null when there is none. The options of a to: are fixed when
the route starts: r24
+ * connect-service-kafka-orders step 3 wrote key: "${header.orderId}" on a
to: kafka, and every record got that text
+ * as its key. Only an option with a header of its own for each message
(CamelKafkaKey for the key of kafka) is
+ * reported: some components evaluate their options themselves (openfga,
the method of bean), and the catalog marks
+ * only some of them (the fileName of file), or says so in the description.
+ */
+ private String staticOptionWithExpression(String uri, JsonNode to) {
+ if (uri == null) {
+ return null;
+ }
+ int colon = uri.indexOf(':');
+ int question = uri.indexOf('?');
+ String component = colon > 0 && (question < 0 || colon < question) ?
uri.substring(0, colon)
+ : question > 0 ? uri.substring(0, question) : uri;
+ Map<String, String> options = new LinkedHashMap<>();
+ if (question > 0) {
+ for (String pair : uri.substring(question + 1).split("&")) {
+ int eq = pair.indexOf('=');
+ if (eq > 0) {
+ options.put(pair.substring(0, eq), pair.substring(eq + 1));
+ }
+ }
+ }
+ JsonNode parameters = to != null && to.isObject() ?
to.get("parameters") : null;
+ if (parameters != null && parameters.isObject()) {
+ for (var it = parameters.fieldNames(); it.hasNext();) {
+ String name = it.next();
+ if (parameters.get(name).isValueNode()) {
+ options.put(name, parameters.get(name).asText());
+ }
+ }
+ }
+ ComponentModel model;
+ try {
+ model = catalog().componentModel(component);
+ } catch (Exception e) {
+ LOG.debug("Cannot read the catalog model of component {}: its
options are left alone", component, e);
+ return null;
+ }
+ if (model == null) {
+ return null;
+ }
+ Set<String> prefixes = headerPrefixes(component, model);
+ for (Map.Entry<String, String> option : options.entrySet()) {
+ String text = option.getValue();
+ int start = text.indexOf("${");
+ if (start < 0 || start >= 2 && text.startsWith(":#", start - 2)) {
+ continue;
+ }
+ var known = model.getEndpointParameterOptions().stream()
+ .filter(o -> o.getName().equals(option.getKey()))
+ .findFirst();
+ String description = known.map(o ->
String.valueOf(o.getDescription()).toLowerCase(Locale.ROOT)).orElse("");
+ if (known.isEmpty() || known.get().isSupportSimpleExpression() ||
description.contains("expression")
+ || description.contains("simple")) {
+ continue;
+ }
+ // the header the producer reads for each message, such as
CamelKafkaKey for the key of kafka, or
+ // CamelAwsSqsDelaySeconds for the delaySeconds of aws2-sqs (a
header only the consumer sets is left alone)
+ String header = model.getEndpointHeaders().stream()
+ .filter(h -> !"consumer".equals(h.getLabel()))
+ .map(BaseOptionModel::getName)
+ .filter(h -> h.equalsIgnoreCase(component + "." +
option.getKey())
+ || prefixes.stream().anyMatch(p ->
h.equalsIgnoreCase(p + option.getKey())))
+ .findFirst()
+ .orElse(null);
+ if (header == null) {
+ continue;
+ }
+ String expression = text.indexOf('}', start) > 0 ?
text.substring(start, text.indexOf('}', start) + 1) : text;
+ return "the option " + option.getKey() + " holds an expression ("
+ expression + "), but the options of"
+ + " a to: are fixed when the route starts, so it is sent as
the text it is - remove the option and"
+ + " set the header " + header + " before this step
(setHeader with simple: " + expression
+ + "), which the producer reads for each message";
+ }
+ return null;
+ }
+
+ /**
+ * How the headers of a component start: Camel and the scheme with no
dashes, with and without its digits
+ * (CamelInfluxDB2 for influxdb2, CamelAwsSqs for aws2-sqs), and the start
that all its Camel headers share
+ * (CamelKubernetes for kubernetes-pods, CamelOpenAI for llm).
+ */
+ static Set<String> headerPrefixes(String component, ComponentModel model) {
+ Set<String> prefixes = new LinkedHashSet<>();
+ prefixes.add("Camel" + component.replace("-", ""));
+ prefixes.add("Camel" + component.replaceAll("[^A-Za-z]", ""));
+ List<String> names = model.getEndpointHeaders().stream()
+ .map(BaseOptionModel::getName)
+ .filter(n -> n.startsWith("Camel"))
+ .toList();
+ if (names.size() > 1) {
+ String shared = names.get(0);
+ for (String name : names) {
+ int i = 0;
+ while (i < shared.length() && i < name.length() &&
shared.charAt(i) == name.charAt(i)) {
+ i++;
+ }
+ shared = shared.substring(0, i);
+ }
+ // back to where a word starts in every header: CamelAwsS3 and not
CamelAwsS3B when they all start with B
+ while (shared.length() > "Camel".length() && !atWordStart(names,
shared.length())) {
+ shared = shared.substring(0, shared.length() - 1);
+ }
+ if (shared.length() > "Camel".length()) {
+ prefixes.add(shared);
+ }
+ }
+ return prefixes;
+ }
+
+ private static boolean atWordStart(List<String> names, int index) {
+ return names.stream().allMatch(n -> n.length() == index ||
!Character.isLowerCase(n.charAt(index)));
+ }
+
/**
* The first simple expression in the path of the uri (what comes before
the options), or null when there is none.
*/
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 97dc90060d63..f7be71e5a827 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
@@ -2371,6 +2371,95 @@ public class TextBodyFlowTest {
assertThat(lines(getOrder(CHOICE).replace("orders.json",
"order.json"), ORDERS_ARRAY)).isEmpty();
}
+ @Test
+ public void testAFieldReadAfterASetBodyThatEmptiedIt() {
+ // r24 connect-http-client step 1: the body set to null before the
split, so the split had nothing to split
+ String yaml = """
+ - route:
+ id: check-stock
+ from:
+ uri: file:orders?noop=true
+ steps:
+ - unmarshal:
+ json: {}
+ - setBody:
+ simple: "${null}"
+ - split:
+ simple: "${body[lines]}"
+ steps:
+ - toD:
+ uri: http://localhost:8080/stock/CAMEL-MUG
+ - log: "Stock answer: ${body}"
+ """;
+ assertThat(lines(yaml)).singleElement().satisfies(m -> {
+ assertThat(m).contains("route check-stock: simple reads a field of
the body (${body[lines]})");
+ assertThat(m).contains("but the body here is null - setBody with
${null} empties it");
+ assertThat(m).contains("inside the split when the call is made for
each element");
+ });
+ // emptied inside the split, right before the call: right
+ assertThat(lines(yaml.replace("""
+ - setBody:
+ simple: "${null}"
+ - split:
+ simple: "${body[lines]}"
+ steps:
+ """, """
+ - split:
+ simple: "${body[lines]}"
+ steps:
+ - setBody:
+ simple: "${null}"
+ """))).isEmpty();
+ // empty text has no fields either
+ assertThat(lines(yaml.replace("simple: \"${null}\"", "constant:
\"\""))).singleElement()
+ .satisfies(m -> assertThat(m).contains("but the body here is
empty text - setBody with constant"));
+ }
+
+ @Test
+ public void testEmptyTextBeforeAnHttpCallIsAPost() {
+ // r24 connect-http-client: a setBody with constant "" before the GET
of the stock service: 405 Method Not
+ // Allowed 87 times, as the http producer sends a POST for any body
that is not null
+ String yaml = """
+ - route:
+ id: check-stock
+ from:
+ uri: file:orders?noop=true
+ steps:
+ - unmarshal:
+ json: {}
+ - split:
+ simple: "${body[lines]}"
+ steps:
+ - setBody:
+ constant: ""
+ - to:
+ uri: http://localhost:8080/stock/CAMEL-MUG
+ - log: "Stock answer: ${body}"
+ """;
+ assertThat(lines(yaml)).singleElement().satisfies(m -> {
+ assertThat(m).contains("route check-stock: to: http sends the body
as the request, and empty text is still"
+ + " a body, so the call is a POST");
+ assertThat(m).contains("set the body to null for a call with no
body (setBody with simple: ${null})");
+ assertThat(m).contains("or set httpMethod: GET");
+ });
+ // null, a GET, and a route that sets the http method itself
+ assertThat(lines(yaml.replace("constant: \"\"", "simple:
\"${null}\""))).isEmpty();
+ assertThat(lines(yaml.replace("uri:
http://localhost:8080/stock/CAMEL-MUG",
+ "uri: http://localhost:8080/stock/CAMEL-MUG\n
parameters:\n"
+
+ " httpMethod: GET")))
+ .isEmpty();
+ assertThat(lines(yaml.replace("""
+ - setBody:
+ constant: ""
+ """, """
+ - setHeader:
+ name: CamelHttpMethod
+ constant: GET
+ - setBody:
+ constant: ""
+ """))).isEmpty();
+ }
+
private static List<String> lines(String yaml, ProjectFacts facts) {
try {
return YamlValidator.describeAll(yaml, validator.validate(yaml,
facts));
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorDynamicUriTest.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorDynamicUriTest.java
index 370f1bac1e11..d40abed245db 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorDynamicUriTest.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorDynamicUriTest.java
@@ -177,6 +177,114 @@ public class YamlValidatorDynamicUriTest {
}
}
+ @Test
+ public void testAnExpressionInAnOptionThatIsNotEvaluated() {
+ // r24 connect-service-kafka-orders step 3: every record got the text
${header.orderId} as its key
+ String yaml = """
+ - route:
+ from:
+ uri: direct:start
+ steps:
+ - to:
+ uri: kafka
+ parameters:
+ topic: orders
+ key: "${header.orderId}"
+ """;
+ for (YamlValidator validator : List.of(classic, canonical)) {
+ assertThat(validate(validator, yaml)).singleElement().satisfies(e
-> assertThat(e.getMessage())
+ .contains("to: the option key holds an expression
(${header.orderId})")
+ .contains("remove the option and set the header
CamelKafkaKey before this step"));
+ }
+ // in the uri as well
+ assertHint(yaml.replace("""
+ uri: kafka
+ parameters:
+ topic: orders
+ key: "${header.orderId}"
+ """, """
+ uri: "kafka:orders?key=${header.orderId}"
+ """), "the option key holds an expression", "CamelKafkaKey");
+ }
+
+ @Test
+ public void testTheHeaderOfAComponentWithADashInItsName() {
+ // the headers of aws2-sqs start with CamelAwsSqs: the scheme with no
dash and no digit
+ assertHint("""
+ - route:
+ from:
+ uri: direct:start
+ steps:
+ - to:
+ uri: aws2-sqs:orders
+ parameters:
+ delaySeconds: "${header.delay}"
+ """, "the option delaySeconds holds an expression", "set the
header CamelAwsSqsDelaySeconds");
+ // the headers of the kubernetes components all start with
CamelKubernetes
+ assertHint("""
+ - route:
+ from:
+ uri: direct:start
+ steps:
+ - to:
+ uri: kubernetes-pods:http://localhost:8080
+ parameters:
+ operation: "${header.op}"
+ """, "the option operation holds an expression", "set the
header CamelKubernetesOperation");
+ }
+
+ @Test
+ public void testAnOptionTheProducerEvaluates() {
+ // the fileName of file takes an expression, toD evaluates its options
for each message, and the method of
+ // bean binds its parameters for each call
+ assertNoOptionHint("""
+ - route:
+ from:
+ uri: direct:start
+ steps:
+ - to:
+ uri:
"bean:userService?method=findById(${header.id})"
+ """);
+ assertNoOptionHint("""
+ - route:
+ from:
+ uri: direct:start
+ steps:
+ - to:
+ uri: file:out
+ parameters:
+ fileName: "${header.orderId}.json"
+ """);
+ assertNoOptionHint("""
+ - route:
+ from:
+ uri: direct:start
+ steps:
+ - toD:
+ uri: kafka
+ parameters:
+ topic: orders
+ key: "${header.orderId}"
+ """);
+ // CamelSpringRabbitmqRoutingKey is a header the consumer sets, the
producer does not read it
+ assertNoOptionHint("""
+ - route:
+ from:
+ uri: direct:start
+ steps:
+ - to:
+ uri: spring-rabbitmq:orders
+ parameters:
+ routingKey: "${header.kind}"
+ """);
+ }
+
+ private void assertNoOptionHint(String yaml) {
+ for (YamlValidator validator : List.of(classic, canonical)) {
+ assertThat(validate(validator, yaml)).noneMatch(e ->
e.getMessage().contains("holds an expression"));
+ }
+ }
+
private void assertNoHint(String yaml) {
for (YamlValidator validator : List.of(classic, canonical)) {
assertThat(validate(validator, yaml))
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 6c32c385cbd8..eeb4cd002634 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
@@ -974,6 +974,41 @@ public class YamlValidatorPropertyHintTest {
.contains("no concatenation");
}
+ @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
+ // times that the key below it was in the wrong column
+ String yaml = """
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - choice:
+ when:
+ - simple: "${header.kind} == 'shipping'"
+ steps:
+ - setHeader:
+ directory
+ simple: "tickets-out/shipping"
+ """;
+ List<Error> errors = validator.validate(yaml);
+ assertThat(errors).singleElement().satisfies(e ->
assertThat(e.getMessage())
+ .startsWith("line 10: directory has no colon, so it is not a
key")
+ .endsWith("every line of setHeader is a key with its value -
here write name: directory")
+ .doesNotContain("every key of a mapping must start in the same
column"));
+ // the same at the top of the steps
+ errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - setHeader:
+ CamelFileName
+ simple: "${header.id}.json"
+ """);
+ assertThat(errors).singleElement().satisfies(e ->
assertThat(e.getMessage())
+ .startsWith("line 5: CamelFileName has no
colon").endsWith("here write name: CamelFileName"));
+ }
+
@Test
public void testAColonAfterTheClosingQuoteIsNamed() throws Exception {
// r24 rest-code-first step 1: the jsonpath line copied with the colon
of a key after it, told 5 times that a