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 a28d23675afe CAMEL-24844: the validator reports a field read on a body 
that is still text (#27367)
a28d23675afe is described below

commit a28d23675afeaaf151f967f102ceba693e165164
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Oct 5 10:38:13 2026 +0200

    CAMEL-24844: the validator reports a field read on a body that is still 
text (#27367)
    
    * CAMEL-24844: the validator reports a field read on a body that is still 
text
    
    A Groovy or simple field read (body.find { ... }, ${body[x]}) after the 
file consumer, a setBody with constant,
    a marshal or a direct: route that returns text is reported with the 
unmarshal step that parses it. The to-eip
    page read ${body.templateName} from a file consumer; it reads a header now.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-24844: an unclosed body['x in a Groovy script is reported, not a 
StringIndexOutOfBoundsException
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../org/apache/camel/catalog/docs/to-eip.adoc      |  20 +-
 .../src/main/docs/modules/eips/pages/to-eip.adoc   |  20 +-
 .../dsl/jbang/core/commands/ai/eip-samples.json    |   4 +-
 .../camel/dsl/yaml/validator/TextBodyFlow.java     | 478 +++++++++++++++++++++
 .../camel/dsl/yaml/validator/YamlValidator.java    |   2 +
 .../camel/dsl/yaml/validator/TextBodyFlowTest.java | 275 ++++++++++++
 6 files changed, 777 insertions(+), 22 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/to-eip.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/to-eip.adoc
index 3869fed116a7..2ec2080a6001 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/to-eip.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/to-eip.adoc
@@ -109,7 +109,7 @@ Java::
 [source,java]
 ----
 from("file:messages/foo")
-    .to("freemarker://templateHome/${body.templateName}.ftl")
+    .to("freemarker://templateHome/${header.templateName}.ftl")
     .to("jms:queue:foo");
 ----
 
@@ -119,7 +119,7 @@ XML::
 ----
 <route>
     <from uri="file:messages/foo"/>
-    <to uri="freemarker://templateHome/${body.templateName}.ftl"/>
+    <to uri="freemarker://templateHome/${header.templateName}.ftl"/>
     <to uri="jms:queue:foo"/>
 </route>
 ----
@@ -134,7 +134,7 @@ YAML::
       uri: file:messages/foo
       steps:
         - to:
-            uri: "freemarker://templateHome/${body.templateName}.ftl"
+            uri: "freemarker://templateHome/${header.templateName}.ftl"
         - to:
             uri: jms:queue:foo
 ----
@@ -164,7 +164,7 @@ Use `toD` for dynamic URIs
 [source,java]
 ----
 from("file:messages/foo")
-    .toD("freemarker://templateHome/${body.templateName}.ftl")
+    .toD("freemarker://templateHome/${header.templateName}.ftl")
     .to("jms:queue:foo");
 ----
 
@@ -176,7 +176,7 @@ Use `<toD>` for dynamic URIs
 ----
 <route>
     <from uri="file:messages/foo"/>
-    <toD uri="freemarker://templateHome/${body.templateName}.ftl"/>
+    <toD uri="freemarker://templateHome/${header.templateName}.ftl"/>
     <to uri="jms:queue:foo"/>
 </route>
 ----
@@ -192,7 +192,7 @@ Use `- toD:` for dynamic URIs
       uri: file:messages/foo
       steps:
         - toD:
-            uri: "freemarker://templateHome/${body.templateName}.ftl"
+            uri: "freemarker://templateHome/${header.templateName}.ftl"
         - to:
             uri: jms:queue:foo
 ----
@@ -202,7 +202,7 @@ When using recipient list:
 
 [source,java]
 ----
-.recipientList(simple("freemarker://templateHome/${body.templateName}.ftl"))
+.recipientList(simple("freemarker://templateHome/${header.templateName}.ftl"))
 ----
 
 [tabs]
@@ -214,7 +214,7 @@ Use `recipientList` for more flexible and dynamic URIs
 [source,java]
 ----
 from("file:messages/foo")
-    
.recipientList(simple("freemarker://templateHome/${body.templateName}.ftl"))
+    
.recipientList(simple("freemarker://templateHome/${header.templateName}.ftl"))
     .to("jms:queue:foo");
 ----
 
@@ -227,7 +227,7 @@ Use `<recipientList>` for more flexible and dynamic URIs
 <route>
     <from uri="file:messages/foo"/>
     <recipientList>
-        <simple>freemarker://templateHome/${body.templateName}.ftl</simple>
+        <simple>freemarker://templateHome/${header.templateName}.ftl</simple>
     </recipientList>
     <to uri="jms:queue:foo"/>
 </route>
@@ -246,7 +246,7 @@ Use `- recipientList:` for more flexible and dynamic URIs
         - recipientList:
             expression:
               simple:
-                expression: 
"freemarker://templateHome/${body.templateName}.ftl"
+                expression: 
"freemarker://templateHome/${header.templateName}.ftl"
         - to:
             uri: jms:queue:foo
 ----
diff --git 
a/core/camel-core-engine/src/main/docs/modules/eips/pages/to-eip.adoc 
b/core/camel-core-engine/src/main/docs/modules/eips/pages/to-eip.adoc
index 3869fed116a7..2ec2080a6001 100644
--- a/core/camel-core-engine/src/main/docs/modules/eips/pages/to-eip.adoc
+++ b/core/camel-core-engine/src/main/docs/modules/eips/pages/to-eip.adoc
@@ -109,7 +109,7 @@ Java::
 [source,java]
 ----
 from("file:messages/foo")
-    .to("freemarker://templateHome/${body.templateName}.ftl")
+    .to("freemarker://templateHome/${header.templateName}.ftl")
     .to("jms:queue:foo");
 ----
 
@@ -119,7 +119,7 @@ XML::
 ----
 <route>
     <from uri="file:messages/foo"/>
-    <to uri="freemarker://templateHome/${body.templateName}.ftl"/>
+    <to uri="freemarker://templateHome/${header.templateName}.ftl"/>
     <to uri="jms:queue:foo"/>
 </route>
 ----
@@ -134,7 +134,7 @@ YAML::
       uri: file:messages/foo
       steps:
         - to:
-            uri: "freemarker://templateHome/${body.templateName}.ftl"
+            uri: "freemarker://templateHome/${header.templateName}.ftl"
         - to:
             uri: jms:queue:foo
 ----
@@ -164,7 +164,7 @@ Use `toD` for dynamic URIs
 [source,java]
 ----
 from("file:messages/foo")
-    .toD("freemarker://templateHome/${body.templateName}.ftl")
+    .toD("freemarker://templateHome/${header.templateName}.ftl")
     .to("jms:queue:foo");
 ----
 
@@ -176,7 +176,7 @@ Use `<toD>` for dynamic URIs
 ----
 <route>
     <from uri="file:messages/foo"/>
-    <toD uri="freemarker://templateHome/${body.templateName}.ftl"/>
+    <toD uri="freemarker://templateHome/${header.templateName}.ftl"/>
     <to uri="jms:queue:foo"/>
 </route>
 ----
@@ -192,7 +192,7 @@ Use `- toD:` for dynamic URIs
       uri: file:messages/foo
       steps:
         - toD:
-            uri: "freemarker://templateHome/${body.templateName}.ftl"
+            uri: "freemarker://templateHome/${header.templateName}.ftl"
         - to:
             uri: jms:queue:foo
 ----
@@ -202,7 +202,7 @@ When using recipient list:
 
 [source,java]
 ----
-.recipientList(simple("freemarker://templateHome/${body.templateName}.ftl"))
+.recipientList(simple("freemarker://templateHome/${header.templateName}.ftl"))
 ----
 
 [tabs]
@@ -214,7 +214,7 @@ Use `recipientList` for more flexible and dynamic URIs
 [source,java]
 ----
 from("file:messages/foo")
-    
.recipientList(simple("freemarker://templateHome/${body.templateName}.ftl"))
+    
.recipientList(simple("freemarker://templateHome/${header.templateName}.ftl"))
     .to("jms:queue:foo");
 ----
 
@@ -227,7 +227,7 @@ Use `<recipientList>` for more flexible and dynamic URIs
 <route>
     <from uri="file:messages/foo"/>
     <recipientList>
-        <simple>freemarker://templateHome/${body.templateName}.ftl</simple>
+        <simple>freemarker://templateHome/${header.templateName}.ftl</simple>
     </recipientList>
     <to uri="jms:queue:foo"/>
 </route>
@@ -246,7 +246,7 @@ Use `- recipientList:` for more flexible and dynamic URIs
         - recipientList:
             expression:
               simple:
-                expression: 
"freemarker://templateHome/${body.templateName}.ftl"
+                expression: 
"freemarker://templateHome/${header.templateName}.ftl"
         - to:
             uri: jms:queue:foo
 ----
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json
 
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json
index b4e84fed68ba..3da0a58cc9b2 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json
@@ -1494,11 +1494,11 @@
     },
     {
       "source": "to-eip.adoc",
-      "yaml": "- route:\n    from:\n      uri: file:messages\/foo\n      
steps:\n        - toD:\n            uri: 
\"freemarker:\/\/templateHome\/${body.templateName}.ftl\"\n        - to:\n      
      uri: jms:queue:foo\n"
+      "yaml": "- route:\n    from:\n      uri: file:messages\/foo\n      
steps:\n        - toD:\n            uri: 
\"freemarker:\/\/templateHome\/${header.templateName}.ftl\"\n        - to:\n    
        uri: jms:queue:foo\n"
     },
     {
       "source": "to-eip.adoc",
-      "yaml": "- route:\n    from:\n      uri: file:messages\/foo\n      
steps:\n        - recipientList:\n            expression:\n              
simple:\n                expression: 
\"freemarker:\/\/templateHome\/${body.templateName}.ftl\"\n        - to:\n      
      uri: jms:queue:foo\n"
+      "yaml": "- route:\n    from:\n      uri: file:messages\/foo\n      
steps:\n        - recipientList:\n            expression:\n              
simple:\n                expression: 
\"freemarker:\/\/templateHome\/${header.templateName}.ftl\"\n        - to:\n    
        uri: jms:queue:foo\n"
     }
   ],
   "toD": [
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
new file mode 100644
index 000000000000..1a90314cfb73
--- /dev/null
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
@@ -0,0 +1,478 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.yaml.validator;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.networknt.schema.Error;
+import com.networknt.schema.path.NodePath;
+import org.apache.camel.util.MimeTypeHelper;
+
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.endpointOf;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.normalize;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.scheme;
+
+/**
+ * A field read on a body that is still text.
+ * <p/>
+ * Groovy {@code body.find { it.sku == ... }} or simple {@code ${body[sku]}} 
reads the fields of parsed data: a Map or a
+ * List. When the step before left the payload as text - a {@code setBody} 
with {@code constant}, a {@code marshal}, the
+ * file consumer, or a {@code direct:} route that ends with one of those - the 
read fails at runtime, Groovy with "No
+ * such property: sku for class: java.lang.Byte". The walk follows the steps 
of each route in order, and into the
+ * {@code direct:} and {@code seda:} routes it calls, and reports only where 
it is certain the body is text: anything it
+ * does not know makes it say nothing (CAMEL-24844).
+ */
+final class TextBodyFlow {
+
+    /** The Groovy methods that iterate a collection with a closure; on text 
they iterate the characters or bytes. */
+    private static final Set<String> ITERATES = Set.of("find", "findAll", 
"each", "eachWithIndex", "collect",
+            "collectEntries", "any", "every", "grep", "count", "sum", 
"findResult", "groupBy", "sort", "max", "min");
+
+    /** The methods of a Map, which text does not have. */
+    private static final Set<String> MAP_METHODS = Set.of("get", "getAt", 
"containsKey", "keySet", "values",
+            "entrySet");
+
+    /** What can be read from text without parsing it: a property of String, 
or a Groovy property of text. */
+    private static final Set<String> TEXT_PROPERTIES = Set.of("bytes", "text", 
"class", "empty", "blank", "length",
+            "size", "toString", "trim", "lines", "chars", "hashCode");
+
+    /** What the file consumer's GenericFile has besides: ${body.fileName} 
reads the file, not its content. */
+    private static final Set<String> FILE_PROPERTIES = Set.of("file", 
"fileName", "fileNameOnly", "fileLength",
+            "lastModified", "charset", "extendedAttributes", "body", "parent", 
"absoluteFilePath", "relativeFilePath",
+            "absolute", "endpointPath", "directory", 
"copyFromAbsoluteFilePath", "lastOffsetValue", "fileSeparator",
+            "binding");
+
+    /** body.x, body?.x, message.body.x, followed by what comes after the 
name. */
+    private static final Pattern GROOVY_PROPERTY = 
Pattern.compile("(?<![\\w$])body\\s*\\??\\.\\s*([A-Za-z_]\\w*)\\s*([{(])?");
+
+    /** body['x'] or body["x"]. */
+    private static final Pattern GROOVY_KEY = 
Pattern.compile("(?<![\\w$])body\\s*\\[\\s*['\"]");
+
+    /** ${body.x} or ${body?.x}, not a method call. */
+    private static final Pattern SIMPLE_PROPERTY = 
Pattern.compile("\\$\\{body\\??\\.([A-Za-z_]\\w*)(?!\\w|\\s*\\()");
+
+    /** ${body[x]} where x is a key, not an index. */
+    private static final Pattern SIMPLE_KEY = 
Pattern.compile("\\$\\{body\\[\\s*['\"]?[A-Za-z_]");
+
+    /** Steps whose own steps run on the same body, so a read inside them sees 
the body at the step. */
+    private static final Set<String> SAME_BODY_BLOCKS = Set.of("choice", 
"when", "otherwise", "filter", "doTry",
+            "doCatch", "doFinally");
+
+    /** Endpoints that leave the body as it is. */
+    private static final Set<String> KEEPS_THE_BODY = Set.of("log", "mock");
+
+    private TextBodyFlow() {
+    }
+
+    /** What is known about the body at a step: text, and where it came from, 
or nothing (null). */
+    record Text(String origin, String format, boolean file) {
+
+        Text(String origin, String format) {
+            this(origin, format, false);
+        }
+
+        /** The names that can be read from this body without parsing it. */
+        Set<String> readable() {
+            if (!file) {
+                return TEXT_PROPERTIES;
+            }
+            Set<String> answer = new HashSet<>(TEXT_PROPERTIES);
+            answer.addAll(FILE_PROPERTIES);
+            return answer;
+        }
+    }
+
+    static void check(JsonNode target, NodePath path, List<Error> errors) {
+        if (target == null || !target.isArray()) {
+            return;
+        }
+        Map<String, JsonNode> byFrom = new HashMap<>();
+        for (JsonNode entry : target) {
+            JsonNode from = from(entry);
+            String uri = from != null ? endpointOf(from) : null;
+            if (uri != null) {
+                byFrom.put(normalize(uri), entry);
+            }
+        }
+        for (int i = 0; i < target.size(); i++) {
+            JsonNode entry = target.get(i);
+            JsonNode from = from(entry);
+            if (from == null) {
+                continue;
+            }
+            NodePath at = path.append(i);
+            JsonNode steps = from.get("steps");
+            if (entry.has("route")) {
+                at = at.append("route");
+                if (steps == null) {
+                    steps = entry.get("route").get("steps");
+                    at = at.append("steps");
+                } else {
+                    at = at.append("from").append("steps");
+                }
+            } else {
+                at = at.append("from").append("steps");
+            }
+            String id = entry.has("route") && entry.get("route").has("id") ? 
entry.get("route").get("id").asText() : null;
+            walk(steps, consumerText(from), at, id, byFrom, errors, new 
HashSet<>());
+        }
+    }
+
+    private static JsonNode from(JsonNode entry) {
+        if (entry == null || !entry.isObject()) {
+            return null;
+        }
+        JsonNode route = entry.get("route");
+        return route != null ? route.get("from") : entry.get("from");
+    }
+
+    /** The body a consumer starts with: the file consumer reads a file, the 
text as it is; others are not known. */
+    private static Text consumerText(JsonNode from) {
+        String uri = endpointOf(from);
+        if (!"file".equals(scheme(uri))) {
+            return null;
+        }
+        JsonNode parameters = from.get("parameters");
+        String format = null;
+        for (String key : new String[] { "include", "antInclude", "fileName" 
}) {
+            if (format == null && parameters != null && parameters.isObject() 
&& parameters.has(key)) {
+                // .*\.csv and *.csv end with the extension, as a file name 
does
+                format = formatOf(parameters.get(key).asText().replace("\\", 
""));
+            }
+        }
+        return new Text("the file consumer gives it", format, true);
+    }
+
+    /**
+     * Walks the steps in order with what is known about the body, reports the 
reads that need parsed data where it is
+     * text, and answers what is known at the end. A report is only made when 
errors is not null: the routes a step
+     * calls are walked for their outcome alone, and report from their own 
walk.
+     */
+    private static Text walk(
+            JsonNode steps, Text text, NodePath at, String routeId, 
Map<String, JsonNode> byFrom,
+            List<Error> errors, Set<String> seen) {
+        if (steps == null || !steps.isArray()) {
+            return text;
+        }
+        for (int j = 0; j < steps.size(); j++) {
+            JsonNode step = steps.get(j);
+            if (!step.isObject() || !step.fieldNames().hasNext()) {
+                text = null;
+                continue;
+            }
+            String name = step.fieldNames().next();
+            JsonNode value = step.get(name);
+            NodePath here = at.append(j).append(name);
+            if (text != null && errors != null) {
+                if (SAME_BODY_BLOCKS.contains(name)) {
+                    walkBlock(value, text, here, routeId, byFrom, errors, 
seen);
+                } else {
+                    String read = readIn(value, text.readable());
+                    if (read != null) {
+                        errors.add(error(here, routeId, read, text));
+                    }
+                }
+            }
+            text = after(name, value, step, text, byFrom, seen);
+        }
+        return text;
+    }
+
+    /**
+     * A choice, filter or doTry: its predicates read the body at the step, 
and so do its steps until one changes it.
+     */
+    private static void walkBlock(
+            JsonNode node, Text text, NodePath at, String routeId, Map<String, 
JsonNode> byFrom,
+            List<Error> errors, Set<String> seen) {
+        if (node == null) {
+            return;
+        }
+        if (node.isArray()) {
+            for (int i = 0; i < node.size(); i++) {
+                walkBlock(node.get(i), text, at.append(i), routeId, byFrom, 
errors, seen);
+            }
+            return;
+        }
+        if (!node.isObject()) {
+            return;
+        }
+        for (var it = node.fieldNames(); it.hasNext();) {
+            String key = it.next();
+            JsonNode child = node.get(key);
+            if ("steps".equals(key)) {
+                walk(child, text, at.append(key), routeId, byFrom, errors, 
seen);
+            } else if (SAME_BODY_BLOCKS.contains(key) || child.isArray()) {
+                walkBlock(child, text, at.append(key), routeId, byFrom, 
errors, seen);
+            } else {
+                String read = readEntry(key, child, text.readable());
+                if (read != null) {
+                    errors.add(error(at.append(key), routeId, read, text));
+                }
+            }
+        }
+    }
+
+    /** What is known about the body after a step. */
+    private static Text after(
+            String name, JsonNode value, JsonNode step, Text text, Map<String, 
JsonNode> byFrom, Set<String> seen) {
+        switch (name) {
+            case "setBody", "transform" -> {
+                return setBodyText(value);
+            }
+            case "marshal" -> {
+                String format = value != null && value.isObject() && 
value.fieldNames().hasNext()
+                        ? value.fieldNames().next() : null;
+                return new Text(
+                        "marshal" + (format != null ? ": " + format : "")
+                                + " writes data as text (marshal turns data 
into text, unmarshal turns text into data)",
+                        format);
+            }
+            case "convertBodyTo" -> {
+                String type = value != null && value.isObject() && 
value.has("type") ? value.get("type").asText()
+                        : value != null && value.isTextual() ? value.asText() 
: "";
+                return "String".equals(type) || 
"java.lang.String".equals(type) || "byte[]".equals(type)
+                        ? new Text("convertBodyTo " + type + " makes it text", 
text != null ? text.format() : null)
+                        : null;
+            }
+            case "to", "toD" -> {
+                String uri = endpointOf(value);
+                String scheme = scheme(uri);
+                if (scheme != null && KEEPS_THE_BODY.contains(scheme)) {
+                    return text;
+                }
+                JsonNode called = uri != null ? byFrom.get(normalize(uri)) : 
null;
+                if (called == null || !"to".equals(name) || 
!seen.add(normalize(uri))) {
+                    return null;
+                }
+                JsonNode from = from(called);
+                JsonNode steps = from.has("steps") ? from.get("steps")
+                        : called.has("route") ? 
called.get("route").get("steps") : null;
+                Text end = walk(steps, text, new 
NodePath(com.networknt.schema.path.PathType.JSON_POINTER), null, byFrom,
+                        null, seen);
+                seen.remove(normalize(uri));
+                return end == null
+                        ? null
+                        : new Text(
+                                "to: " + normalize(uri) + " returns it as text 
(" + end.origin() + ")", end.format(),
+                                end.file());
+            }
+            case "setHeader", "setHeaders", "setProperty", "setVariable", 
"removeHeader", "removeHeaders",
+                    "removeProperty", "removeProperties", "log", "wireTap", 
"delay", "throttle", "stop" -> {
+                return text;
+            }
+            default -> {
+                return null;
+            }
+        }
+    }
+
+    /**
+     * The body after a setBody: text for a constant, and for a simple 
template that is more than one ${...}; not known
+     * for any other language, which may return data.
+     */
+    private static Text setBodyText(JsonNode value) {
+        JsonNode node = value != null && value.has("expression") ? 
value.get("expression") : value;
+        if (node == null || !node.isObject() || !node.fieldNames().hasNext()) {
+            return null;
+        }
+        String language = node.fieldNames().next();
+        String expression = expressionText(node.get(language));
+        if (expression == null) {
+            return null;
+        }
+        if ("constant".equals(language)) {
+            String origin = expression.startsWith("resource:")
+                    ? "setBody with constant: " + expression + " loads the 
file as text"
+                    : "setBody with constant sets text";
+            return new Text(origin, formatOf(expression));
+        }
+        if ("simple".equals(language)) {
+            String trimmed = expression.trim();
+            boolean single = trimmed.startsWith("${") && trimmed.endsWith("}") 
&& trimmed.indexOf("${", 2) < 0;
+            return single ? null : new Text("setBody with a simple template 
sets text", formatOf(trimmed));
+        }
+        return null;
+    }
+
+    /** The text of an expression, short form or with expression: inside. */
+    private static String expressionText(JsonNode node) {
+        if (node == null) {
+            return null;
+        }
+        if (node.isValueNode()) {
+            return node.asText();
+        }
+        JsonNode expression = node.get("expression");
+        return expression != null && expression.isValueNode() ? 
expression.asText() : null;
+    }
+
+    /** A Groovy or simple expression in this node, not in nested steps, that 
reads fields; what it reads, or null. */
+    private static String readIn(JsonNode node, Set<String> readable) {
+        if (node == null) {
+            return null;
+        }
+        if (node.isArray()) {
+            for (JsonNode child : node) {
+                String found = readIn(child, readable);
+                if (found != null) {
+                    return found;
+                }
+            }
+            return null;
+        }
+        if (!node.isObject()) {
+            return null;
+        }
+        for (var it = node.fieldNames(); it.hasNext();) {
+            String key = it.next();
+            if ("steps".equals(key)) {
+                continue;
+            }
+            String found = readEntry(key, node.get(key), readable);
+            if (found != null) {
+                return found;
+            }
+        }
+        return null;
+    }
+
+    /** One entry of a node: a Groovy or simple expression that reads fields, 
or anything nested that does. */
+    private static String readEntry(String key, JsonNode child, Set<String> 
readable) {
+        if ("groovy".equals(key)) {
+            String found = groovyRead(expressionText(child), readable);
+            return found != null ? "groovy reads fields of the body (" + found 
+ ")" : null;
+        } else if ("simple".equals(key)) {
+            String found = simpleRead(expressionText(child), readable);
+            return found != null ? "simple reads a field of the body (" + 
found + ")" : null;
+        }
+        return readIn(child, readable);
+    }
+
+    static String groovyRead(String script, Set<String> readable) {
+        if (script == null) {
+            return null;
+        }
+        Matcher m = GROOVY_PROPERTY.matcher(script);
+        while (m.find()) {
+            String name = m.group(1);
+            String next = m.group(2);
+            if ("{".equals(next) && ITERATES.contains(name)
+                    || "(".equals(next) && MAP_METHODS.contains(name)
+                    || next == null && !readable.contains(name)) {
+                return m.group().trim() + ("{".equals(next) ? " ... }" : "");
+            }
+        }
+        m = GROOVY_KEY.matcher(script);
+        if (m.find()) {
+            int end = script.indexOf(']', m.start());
+            return end > 0 ? script.substring(m.start(), end + 1) : m.group();
+        }
+        return null;
+    }
+
+    static String simpleRead(String template, Set<String> readable) {
+        if (template == null) {
+            return null;
+        }
+        Matcher m = SIMPLE_PROPERTY.matcher(template);
+        while (m.find()) {
+            if (!readable.contains(m.group(1))) {
+                return m.group() + "}";
+            }
+        }
+        m = SIMPLE_KEY.matcher(template);
+        if (m.find()) {
+            int end = template.indexOf('}', m.start());
+            return end > 0 ? template.substring(m.start(), end + 1) : 
m.group();
+        }
+        return null;
+    }
+
+    /**
+     * The data format a name points at: a data format such as json, a file 
name or resource (by its extension, the way
+     * {@link MimeTypeHelper} maps it to a content type), or the first 
character of the text.
+     */
+    static String formatOf(String hint) {
+        if (hint == null) {
+            return null;
+        }
+        String t = hint.trim();
+        String format = formatOfMimeType(t);
+        if (format == null && t.indexOf('.') >= 0 && t.indexOf(' ') < 0) {
+            format = formatOfMimeType(MimeTypeHelper.probeMimeType(t));
+        }
+        if (format == null) {
+            if (t.startsWith("{") || t.startsWith("[")) {
+                format = "json";
+            } else if (t.startsWith("<")) {
+                format = "jacksonXml";
+            }
+        }
+        return format;
+    }
+
+    /** The data format of a content type, or of a data format name: json, 
csv, yaml or jacksonXml. */
+    private static String formatOfMimeType(String type) {
+        if (type == null) {
+            return null;
+        }
+        String s = type.toLowerCase(Locale.ROOT);
+        if (s.equals("json") || s.endsWith("/json") || s.endsWith("+json")) {
+            return "json";
+        } else if (s.equals("csv") || s.endsWith("/csv") || 
s.endsWith("/tab-separated-values")) {
+            return "csv";
+        } else if (s.equals("yaml") || s.endsWith("/yaml") || 
s.endsWith("/x-yaml")) {
+            return "yaml";
+        } else if (s.equals("jacksonxml") || s.endsWith("/xml") || 
s.endsWith("+xml")) {
+            return "jacksonXml";
+        } else if (s.equals("ical") || s.endsWith("/calendar")) {
+            return "ical";
+        }
+        return null;
+    }
+
+    private static Error error(NodePath at, String routeId, String read, Text 
text) {
+        String format = text.format();
+        String fix;
+        if (format == null) {
+            fix = "unmarshal it first with the data format of the payload 
(json, jacksonXml, csv, ...) to read its fields";
+        } else if (text.origin().startsWith("marshal")) {
+            fix = "to read its fields, unmarshal: " + format + " is the step, 
not marshal";
+        } else {
+            fix = "add unmarshal: " + format + " before this step to read its 
fields";
+        }
+        return Error.builder()
+                .keyword("type")
+                .instanceLocation(at)
+                .messageKey("type")
+                .format(new java.text.MessageFormat("{0}"))
+                .arguments((routeId != null ? "route " + routeId + ": " : "") 
+ read
+                           + (text.file()
+                                   ? ", but the body here is the file as it 
was read (a GenericFile), not parsed data - "
+                                   : ", but the body here is still text, not 
parsed data - ")
+                           + text.origin() + ": " + fix)
+                .build();
+    }
+}
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 85c19f190020..e42290470f92 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
@@ -730,6 +730,8 @@ public class YamlValidator {
             checkDynamicUri(camelOnly, new NodePath(PathType.JSON_POINTER), 
errors);
             // where the body comes from, across the routes of the file 
(CAMEL-24844)
             BodyTypeFlow.check(camelOnly, new NodePath(PathType.JSON_POINTER), 
errors, bodylessEndpoints);
+            // a field read on a body that is still text (CAMEL-24844)
+            TextBodyFlow.check(camelOnly, new NodePath(PathType.JSON_POINTER), 
errors);
         }
         if (canonical) {
             checkOneOfCardinality(camelOnly, new 
NodePath(PathType.JSON_POINTER), errors);
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
new file mode 100644
index 000000000000..f2b65a35919f
--- /dev/null
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
@@ -0,0 +1,275 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.yaml.validator;
+
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24844: a Groovy or simple field read on a body that is still text - 
the file as loaded, the output of a
+ * marshal, or what a direct: route returns - is reported with the unmarshal 
step that parses it.
+ */
+public class TextBodyFlowTest {
+
+    private static YamlValidator validator;
+
+    @BeforeAll
+    public static void setup() throws Exception {
+        validator = new YamlValidator();
+        validator.init();
+    }
+
+    @Test
+    public void testMarshalInsteadOfUnmarshal() {
+        // what the benchmark wrote: the file is loaded, then marshalled, then 
read with Groovy
+        String yaml = """
+                - route:
+                    id: lookup
+                    from:
+                      uri: direct
+                      parameters:
+                        name: lookup
+                      steps:
+                        - setBody:
+                            expression:
+                              constant:
+                                expression: resource:file:stock.json
+                        - marshal:
+                            json:
+                              library: Jackson
+                        - setHeader:
+                            name: sku
+                            expression:
+                              simple: ${header.sku}
+                        - setBody:
+                            expression:
+                              groovy: body.find { it.sku == headers.sku }
+                """;
+        List<String> lines = lines(yaml);
+        assertThat(lines).singleElement().satisfies(m -> {
+            assertThat(m).startsWith("Line 20: ");
+            assertThat(m).contains("groovy reads fields of the body (body.find 
{ ... })");
+            assertThat(m).contains("marshal: json writes data as text");
+            assertThat(m).contains("unmarshal: json is the step, not marshal");
+        });
+    }
+
+    @Test
+    public void testTheCalledRouteReturnsText() {
+        String yaml = """
+                - route:
+                    id: lookup
+                    from:
+                      uri: direct:lookup
+                      steps:
+                        - setBody:
+                            expression:
+                              constant:
+                                expression: resource:file:stock.json
+                - route:
+                    id: getStock
+                    from:
+                      uri: direct:getStock
+                      steps:
+                        - to:
+                            uri: direct:lookup
+                        - setHeader:
+                            name: item
+                            expression:
+                              groovy: body.find { it.sku == headers.sku }
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> {
+            assertThat(m).startsWith("Line 18: ");
+            assertThat(m).contains("route getStock: groovy reads fields of the 
body");
+            assertThat(m).contains("to: direct:lookup returns it as text");
+            assertThat(m).contains("add unmarshal: json before this step");
+        });
+    }
+
+    @Test
+    public void testSimpleKeyOnACsvFile() {
+        String yaml = """
+                - route:
+                    from:
+                      uri: file:inbox
+                      parameters:
+                        include: ".*\\\\.csv"
+                      steps:
+                        - choice:
+                            when:
+                              - simple: "${body[status]} == 'NEW'"
+                                steps:
+                                  - to: mock:new
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> {
+            assertThat(m).contains("simple reads a field of the body 
(${body[status]})");
+            assertThat(m).contains("the body here is the file as it was read 
(a GenericFile), not parsed data");
+            assertThat(m).contains("add unmarshal: csv");
+        });
+    }
+
+    @Test
+    public void testTheFormatFromTheFileExtension() {
+        // the extension maps to a content type as MimeTypeHelper knows it: 
.tsv is tab-separated values, read with csv
+        
assertThat(TextBodyFlow.formatOf("resource:file:orders.tsv")).isEqualTo("csv");
+        assertThat(TextBodyFlow.formatOf("orders.yml")).isEqualTo("yaml");
+        
assertThat(TextBodyFlow.formatOf("catalog.xml")).isEqualTo("jacksonXml");
+        assertThat(TextBodyFlow.formatOf("events.ics")).isEqualTo("ical");
+        assertThat(TextBodyFlow.formatOf("notes.txt")).isNull();
+        assertThat(TextBodyFlow.formatOf("{\"sku\": 
\"A1\"}")).isEqualTo("json");
+    }
+
+    @Test
+    public void testAnUnclosedKeyIsStillReported() {
+        assertThat(TextBodyFlow.groovyRead("body['sku", 
java.util.Set.of())).isEqualTo("body['");
+        assertThat(TextBodyFlow.groovyRead("body['sku']", 
java.util.Set.of())).isEqualTo("body['sku']");
+        assertThat(TextBodyFlow.simpleRead("${body[sku", 
java.util.Set.of())).isEqualTo("${body[s");
+    }
+
+    @Test
+    public void testUnknownFormatNamesTheChoices() {
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:start
+                      steps:
+                        - setBody:
+                            constant: "sku=A1"
+                        - setBody:
+                            groovy: "body.sku"
+                """;
+        assertThat(lines(yaml)).singleElement()
+                .satisfies(m -> assertThat(m).contains("the data format of the 
payload (json, jacksonXml, csv, ...)"));
+    }
+
+    @Test
+    public void testUnmarshalFirstIsFine() {
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:lookup
+                      steps:
+                        - setBody:
+                            constant: resource:file:stock.json
+                        - unmarshal:
+                            json: {}
+                        - setBody:
+                            groovy: body.find { it.sku == headers.sku }
+                        - setHeader:
+                            name: found
+                            simple: ${body[sku]}
+                """;
+        assertThat(lines(yaml)).isEmpty();
+    }
+
+    @Test
+    public void testTextOperationsAreFine() {
+        // jsonpath reads JSON text; length, toUpperCase, a regex find and an 
index all work on text
+        String yaml = """
+                - route:
+                    from:
+                      uri: file:inbox
+                      steps:
+                        - setHeader:
+                            name: sku
+                            jsonpath: $.sku
+                        - setHeader:
+                            name: size
+                            groovy: body.length() + body.toUpperCase().size() 
+ body.find(/A\\d/).size()
+                        - setHeader:
+                            name: first
+                            simple: ${body[0]} ${body.length()}
+                        - log: ${body}
+                """;
+        assertThat(lines(yaml)).isEmpty();
+    }
+
+    @Test
+    public void testTheFileItselfIsReadable() {
+        // the file consumer's body is a GenericFile: its own properties are 
not fields of the content
+        String yaml = """
+                - route:
+                    from:
+                      uri: file:inbox
+                      steps:
+                        - setBody:
+                            simple: ${body.file}
+                - route:
+                    from:
+                      uri: file:outbox
+                      steps:
+                        - log: ${body.fileName} has ${body.fileLength} bytes
+                        - setHeader:
+                            name: name
+                            groovy: body.fileNameOnly
+                """;
+        assertThat(lines(yaml)).isEmpty();
+    }
+
+    @Test
+    public void testUnknownBodySaysNothing() {
+        // a consumer of its own, a bean, an unknown route, or a choice that 
may set the body: nothing is certain
+        String yaml = """
+                - route:
+                    from:
+                      uri: platform-http:/orders
+                      steps:
+                        - setBody:
+                            groovy: body.find { it.sku == 'A1' }
+                - route:
+                    from:
+                      uri: direct:a
+                      steps:
+                        - setBody:
+                            constant: resource:file:stock.json
+                        - bean:
+                            ref: parser
+                        - setBody:
+                            groovy: body.items
+                - route:
+                    from:
+                      uri: direct:b
+                      steps:
+                        - setBody:
+                            constant: resource:file:stock.json
+                        - to: direct:elsewhere
+                        - setBody:
+                            groovy: body.items
+                - route:
+                    from:
+                      uri: direct:c
+                      steps:
+                        - setBody:
+                            simple: ${header.payload}
+                        - setBody:
+                            groovy: body.items
+                """;
+        assertThat(lines(yaml)).isEmpty();
+    }
+
+    private static List<String> lines(String yaml) {
+        try {
+            return YamlValidator.describeAll(yaml, validator.validate(yaml));
+        } catch (Exception e) {
+            throw new AssertionError("Failed to validate:\n" + yaml, e);
+        }
+    }
+}

Reply via email to