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

Reply via email to