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 abca5fbead82 CAMEL-25928: Body type checks - field reads in log 
messages and endpoint uris, wireTap and enrich, variables, unmarshal of parsed 
data, and runtime hints (#28500)
abca5fbead82 is described below

commit abca5fbead82fb19e8966220865be06a7ded4db4
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Oct 26 20:39:20 2026 +0100

    CAMEL-25928: Body type checks - field reads in log messages and endpoint 
uris, wireTap and enrich, variables, unmarshal of parsed data, and runtime 
hints (#28500)
    
    * CAMEL-25928: camel-yaml-dsl-validator - field reads in log messages and 
endpoint uris, wireTap and enrich, variables, and an unmarshal of parsed data
    
    Gaps the r22 benchmark found in the body checks:
    
    - A field read is also looked for in the simple templates a step evaluates: 
a log message, the uri of a toD or a
      wireTap, and an endpoint option such as the fileName of file.
    - A wireTap and an enrich that send parsed data to a file are reported, as 
a to is. wireTap gets an eipIn in the
      catalog (from producerIn, any for variableSend).
    - An unmarshal of a body or a variable that is parsed data already is 
reported.
    - What variableReceive or a setVariable of the body puts in a variable is 
followed, and a field read on a variable that
      holds text or the file as read is reported (pollEnrich of a file with 
variableReceive, then ${variable.customer[name]}).
    - The "no body" check also fires when an unmarshal, a marshal or a 
convertBodyTo is the first step to read the body.
    - A field read after a marshal of parsed data says to read the field before 
the marshal, or to marshal into a
      variable, instead of "unmarshal is the step, not marshal"; a file write 
of parsed data says to read the fields that
      are still needed before the marshal.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
    
    * CAMEL-25928: camel-support, camel-bean, camel-groovy - runtime hints for 
an unmarshal of data, a field read after a marshal, and a file in a variable
    
    - An unmarshal of a Map or a List says the body (or the variable) is data 
already, instead of the bare type converter
      error.
    - A field read on text that a marshal wrote from parsed data says to read 
the field before the marshal, as the message
      history tells, instead of "unmarshal it first".
    - A key read on the payload of the body gets one hint, from what the body 
is, not a second one that disagrees.
    - ${variable.customer.name} on a variable that holds the file as read says 
to unmarshal it in place, as the [name]
      form already did.
    - The Groovy hint "still text ... unmarshal it first" is given only when 
the body is text: on parsed data it says the
      value read is a text value of the data.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
    
    * CAMEL-25928: the body type hints keep a field for later in a variable, 
not a header
    
    A header is sent on with the message and becomes part of what the endpoint 
gets, so a field kept for a later step
    (a file name, a log) goes into a variable: setVariable with name ticketId, 
and ${variable.ticketId} where it is used.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
    
    * CAMEL-25928: camel-groovy - the hint for JSON text in a header keeps the 
parsed data in a variable, not a header
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
    
    * CAMEL-25928: address review feedback
    
    - TextBodyFlow does not look at the text of a block when no variable is 
known: clearing an empty map does nothing.
    - The javadoc of Walk.called() says why the called route shares the 
variables, where a branch gets a copy.
    - MAY_SET_VARIABLES says what the "ariable" substring matches.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
    
    * CAMEL-25928: tell a marshal of data by the body it was reached with, not 
by loading its class
    
    The message history keeps the canonical class name of the body, and the
    class of a nested type (the Map of Map.of, the List of List.of or
    Arrays.asList) cannot be loaded back from it, so a marshal of such data
    got the "unmarshal it first" hint. The history entry now records whether
    the body was a Map or a collection as it is captured.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
    
    * CAMEL-25928: name a JDK internal class of the body by its interface in 
the hints
    
    The Map of Map.of is java.util.ImmutableCollections.Map1, and the List of
    Arrays.asList is java.util.Arrays.ArrayList: their names say nothing to the
    reader. The hints now call such a body "a Map" or "a List", and the list of
    types other messages reached a step with leaves them out, as its kind says
    what they are.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
    
    ---------
    
    Signed-off-by: Claus Ibsen <[email protected]>
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../org/apache/camel/catalog/models/wireTap.json   |   1 +
 .../apache/camel/language/bean/BeanExpression.java |  23 +-
 .../camel/language/groovy/GroovyExpression.java    |  17 +-
 .../groovy/GroovyMissingPropertyHintTest.java      |  30 +-
 .../META-INF/org/apache/camel/model/wireTap.json   |   1 +
 .../org/apache/camel/model/WireTapDefinition.java  |   6 +-
 .../language/simple/SimpleSyntaxHintsTest.java     |  20 +-
 .../apache/camel/language/simple/SimpleTest.java   |   4 +-
 .../apache/camel/processor/EipBodyTypesTest.java   |   9 +-
 .../camel/processor/UnmarshalProcessorTest.java    |  44 ++
 .../camel/support/BodyFactsOtherBodiesTest.java    |  18 +
 .../org/apache/camel/support/BodyFactsTest.java    |  57 ++-
 .../java/org/apache/camel/support/BodyFacts.java   |  99 ++++-
 .../camel/support/DefaultMessageHistory.java       |  14 +
 .../support/processor/UnmarshalProcessor.java      |  31 +-
 .../user-manual/modules/ROOT/pages/body-types.adoc |   9 +
 .../camel/dsl/yaml/validator/BodyTypeFlow.java     |   7 +-
 .../camel/dsl/yaml/validator/CatalogBodyTypes.java |   7 +
 .../camel/dsl/yaml/validator/TextBodyFlow.java     | 493 +++++++++++++++++++--
 .../camel/dsl/yaml/validator/BodyTypeFlowTest.java |  37 ++
 .../dsl/yaml/validator/OneOfAlternativesTest.java  |   2 +-
 .../camel/dsl/yaml/validator/TextBodyFlowTest.java | 250 +++++++++++
 22 files changed, 1121 insertions(+), 58 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/wireTap.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/wireTap.json
index d848b685c8fb..f9f1970290b3 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/wireTap.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/wireTap.json
@@ -34,6 +34,7 @@
     "CamelToEndpoint": { "index": 0, "kind": "exchangeProperty", 
"displayName": "To Endpoint", "label": "producer", "required": false, 
"javaType": "String", "deprecated": false, "autowired": false, "secret": false, 
"description": "Endpoint URI where this Exchange is being sent to" }
   },
   "bodyTypes": {
+    "eipIn": { "byOption": [ { "option": "variableSend", "value": "*", 
"carriers": [ "any" ] } ], "from": "producerIn", "description": "The copy of 
the message goes to the producer of the endpoint. With variableSend the 
variable is sent, and the body is not read" },
     "eipOut": { "carriers": [ "unchanged" ], "description": "Sends a copy of 
the message to the endpoint: the body of the route stays as it was" }
   }
 }
diff --git 
a/components/camel-bean/src/main/java/org/apache/camel/language/bean/BeanExpression.java
 
b/components/camel-bean/src/main/java/org/apache/camel/language/bean/BeanExpression.java
index 1e51db17bef9..8a83f96c09f8 100644
--- 
a/components/camel-bean/src/main/java/org/apache/camel/language/bean/BeanExpression.java
+++ 
b/components/camel-bean/src/main/java/org/apache/camel/language/bean/BeanExpression.java
@@ -571,11 +571,15 @@ public class BeanExpression implements Expression, 
Predicate {
             Object bean = beanOf(holder, exchange);
             String body = bodyHint(exchange, bean);
             String list = body == null ? listHint(bean, methodName) : null;
+            // ${variable.customer.name} on a variable that holds the file as 
read
+            String file = body == null && (bean instanceof WrappedFile<?> || 
bean instanceof File)
+                    ? noKeysHint(bean, methodName) : null;
             throw new RuntimeBeanExpressionException(
                     exchange, describeBean(holder, beanName, exchange),
                     body != null
                             ? method + " (" + body + ")"
-                            : list != null ? method + list : keyHint(holder, 
exchange, methodName, method),
+                            : list != null ? method + list
+                            : file != null ? method + file : keyHint(holder, 
exchange, methodName, method),
                     resultExchange.getException());
         }
         return newResult;
@@ -692,8 +696,12 @@ public class BeanExpression implements Expression, 
Predicate {
         }
         if (key != null) {
             Object bean = holder.getBean(exchange);
+            // the payload of the message body is told by bodyHint below, from 
what the message history knows
+            Object value = result != null ? result : bean;
+            boolean payloadBody = bean != null && bean == 
exchange.getMessage().getBody() && value == bean
+                    && isPayload(value);
             try {
-                return lookupResult(resultExchange, key, result, nullSafe, 
ognlPath, bean);
+                return lookupResult(resultExchange, key, result, nullSafe, 
ognlPath, bean, !payloadBody);
             } catch (IndexOutOfBoundsException e) {
                 // ${body[status]} on the file as read, or on a stream: the 
same exception, saying what the body is
                 // and the step that parses it (CAMEL-25749)
@@ -730,8 +738,15 @@ public class BeanExpression implements Expression, 
Predicate {
         }
     }
 
+    /** Text, bytes, a stream or the file as read: what an unmarshal turns 
into data. */
+    private static boolean isPayload(Object value) {
+        return value instanceof WrappedFile<?> || value instanceof File || 
value instanceof StreamCache
+                || value instanceof InputStream || value instanceof byte[] || 
value instanceof String;
+    }
+
     private static Object lookupResult(
-            Exchange exchange, String key, Object result, boolean nullSafe, 
String ognlPath, Object bean) {
+            Exchange exchange, String key, Object result, boolean nullSafe, 
String ognlPath, Object bean,
+            boolean hint) {
         StringHelper.notEmpty(key, "key", "in Simple language ognl path: " + 
ognlPath);
 
         final TypeConverter typeConverter = 
exchange.getContext().getTypeConverter();
@@ -786,7 +801,7 @@ public class BeanExpression implements Expression, 
Predicate {
             throw new IndexOutOfBoundsException(
                     "Key: " + key + " not found in bean: " + bean + " of type: 
"
                                                 + 
ObjectHelper.classCanonicalName(bean) + " using OGNL path [" + ognlPath
-                                                + "]" + noKeysHint(result != 
null ? result : bean, key));
+                                                + "]" + (hint ? 
noKeysHint(result != null ? result : bean, key) : ""));
         } else {
             // null safe so we can return null
             return null;
diff --git 
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
 
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
index 1631050895c6..40248accd3ce 100644
--- 
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
+++ 
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
@@ -34,6 +34,7 @@ import org.apache.camel.Message;
 import org.apache.camel.RuntimeCamelException;
 import org.apache.camel.attachment.AttachmentMessage;
 import org.apache.camel.attachment.DefaultAttachmentMessage;
+import org.apache.camel.spi.BodyTypeRegistry;
 import org.apache.camel.support.BodyFacts;
 import org.apache.camel.support.ExchangeHelper;
 import org.apache.camel.support.ExpressionSupport;
@@ -107,6 +108,10 @@ public class GroovyExpression extends ExpressionSupport {
             // (CAMEL-25749); null for an object, where the name read is wrong
             String payload = !onText && body != null && e.getType() == 
body.getClass() && !(body instanceof Map)
                     ? BodyFacts.hint(exchange, BodyFacts.Need.DATA) : null;
+            // customer.customerNo on parsed data, where customer holds the 
text C-482: the text is a value of the
+            // data, and the body needs no unmarshal
+            BodyTypeRegistry.Kind kind = BodyFacts.kindOf(body);
+            boolean bodyData = kind == BodyTypeRegistry.Kind.DATA || kind == 
BodyTypeRegistry.Kind.COLLECTION;
             String hint;
             if (bodyText) {
                 // what the body is, the step that set it, and the unmarshal 
that parses it (CAMEL-25749)
@@ -116,9 +121,17 @@ public class GroovyExpression extends ExpressionSupport {
                 hint = BodyFacts.describe(exchange) + ": " + payload;
             } else if (textHeader != null) {
                 String ref = isIdentifier(textHeader) ? "headers." + 
textHeader : "headers['" + textHeader + "']";
+                String variable
+                        = isIdentifier(textHeader) ? "variables." + textHeader 
: "variables['" + textHeader + "']";
+                // the parsed data is kept in a variable, not a header: a 
header is sent on with the message
                 hint = "header '" + textHeader + "' is still JSON text, not 
parsed data: parse it in the script with"
-                       + " new groovy.json.JsonSlurper().parseText(" + ref + 
"), or set the header from the body after"
-                       + " unmarshal: json";
+                       + " new groovy.json.JsonSlurper().parseText(" + ref + 
"), or keep the parsed data in a variable"
+                       + " (setVariable with simple ${body} after unmarshal: 
json) and read " + variable;
+            } else if (onText && bodyData && !bean) {
+                String keys = BodyFacts.hint(exchange, BodyFacts.Need.DATA);
+                hint = "the value read here is text (a " + 
e.getType().getSimpleName() + ") in the body, not an object"
+                       + " with fields: " + BodyFacts.describe(exchange) + ", 
which is data already, so no unmarshal is"
+                       + " needed; check the name read, and the value it is 
read on" + (keys != null ? " (" + keys + ")" : "");
             } else if (onText && !bean) {
                 hint = "the value read here is still text (a " + 
e.getType().getSimpleName()
                        + " comes from iterating text), not parsed data: 
unmarshal it first (unmarshal: json for JSON),"
diff --git 
a/components/camel-groovy/src/test/java/org/apache/camel/processor/groovy/GroovyMissingPropertyHintTest.java
 
b/components/camel-groovy/src/test/java/org/apache/camel/processor/groovy/GroovyMissingPropertyHintTest.java
index b24bbd5b4d71..a259aab2d58b 100644
--- 
a/components/camel-groovy/src/test/java/org/apache/camel/processor/groovy/GroovyMissingPropertyHintTest.java
+++ 
b/components/camel-groovy/src/test/java/org/apache/camel/processor/groovy/GroovyMissingPropertyHintTest.java
@@ -18,6 +18,8 @@ package org.apache.camel.processor.groovy;
 
 import java.io.ByteArrayInputStream;
 import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
 
 import groovy.lang.MissingPropertyException;
 import org.apache.camel.Exchange;
@@ -28,6 +30,7 @@ import org.apache.camel.test.junit6.CamelTestSupport;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -135,16 +138,38 @@ public class GroovyMissingPropertyHintTest extends 
CamelTestSupport {
         assertTrue(cause.getMessage().contains("header 'skuList' is still JSON 
text, not parsed data"), cause.getMessage());
         assertTrue(cause.getMessage().contains("new 
groovy.json.JsonSlurper().parseText(headers.skuList)"),
                 cause.getMessage());
+        // the parsed data goes into a variable, not back into a header
+        assertTrue(cause.getMessage().contains("or keep the parsed data in a 
variable (setVariable with simple ${body}"
+                                               + " after unmarshal: json) and 
read variables.skuList"),
+                cause.getMessage());
     }
 
     @Test
     public void fieldReadOnTextElsewhereSaysItIsText() {
-        // a list of plain text values: an element is text, not data with 
fields
+        // a list of plain text values: an element is text, not data with 
fields, and the body needs no unmarshal
         Exception e = assertThrows(Exception.class,
                 () -> template.sendBodyAndHeader("direct:field", 
java.util.List.of("A1", "B2"), "sku", "A1"));
         MissingPropertyException cause = 
assertInstanceOf(MissingPropertyException.class, e.getCause());
-        assertTrue(cause.getMessage().contains("the value read here is still 
text (a String comes from iterating text)"),
+        assertTrue(cause.getMessage().contains("the value read here is text (a 
String) in the body, not an object with"
+                                               + " fields: the body is a 
List"),
+                cause.getMessage());
+        assertTrue(cause.getMessage().contains("which is data already, so no 
unmarshal is needed"), cause.getMessage());
+        assertFalse(cause.getMessage().contains("unmarshal it first"), 
cause.getMessage());
+    }
+
+    @Test
+    public void fieldReadOnATextFieldOfParsedDataSaysTheBodyIsParsed() {
+        // what the benchmark wrote (r22): customer holds the customer number, 
and customer.customerNo reads it
+        Exception e = assertThrows(Exception.class,
+                () -> template.sendBody("direct:customer", new 
LinkedHashMap<>(Map.of("customer", "C-482"))));
+        MissingPropertyException cause = 
assertInstanceOf(MissingPropertyException.class, e.getCause());
+        assertTrue(cause.getMessage().contains("No such property: customerNo 
for class: java.lang.String"),
+                cause.getMessage());
+        assertTrue(cause.getMessage().contains("the value read here is text (a 
String) in the body, not an object with"
+                                               + " fields: the body is a Map 
(LinkedHashMap), which is data already"),
                 cause.getMessage());
+        assertTrue(cause.getMessage().contains("(its keys are: customer)"), 
cause.getMessage());
+        assertFalse(cause.getMessage().contains("unmarshal it first"), 
cause.getMessage());
     }
 
     @Test
@@ -169,6 +194,7 @@ public class GroovyMissingPropertyHintTest extends 
CamelTestSupport {
                 
from("direct:unknown").transform().groovy("nosuch.toUpperCase()");
                 from("direct:field").transform().groovy("body.find { it.sku == 
headers.sku }");
                 from("direct:read").transform().groovy("body.sku");
+                
from("direct:customer").transform().groovy("body.customer.customerNo");
                 from("direct:header").transform().groovy("headers.skuList.find 
{ it.sku == headers.sku }");
                 
from("direct:message").transform().groovy("message.getHeader('name') + ' ' + 
message.body");
             }
diff --git 
a/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/wireTap.json
 
b/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/wireTap.json
index d848b685c8fb..f9f1970290b3 100644
--- 
a/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/wireTap.json
+++ 
b/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/wireTap.json
@@ -34,6 +34,7 @@
     "CamelToEndpoint": { "index": 0, "kind": "exchangeProperty", 
"displayName": "To Endpoint", "label": "producer", "required": false, 
"javaType": "String", "deprecated": false, "autowired": false, "secret": false, 
"description": "Endpoint URI where this Exchange is being sent to" }
   },
   "bodyTypes": {
+    "eipIn": { "byOption": [ { "option": "variableSend", "value": "*", 
"carriers": [ "any" ] } ], "from": "producerIn", "description": "The copy of 
the message goes to the producer of the endpoint. With variableSend the 
variable is sent, and the body is not read" },
     "eipOut": { "carriers": [ "unchanged" ], "description": "Sends a copy of 
the message to the endpoint: the body of the route stays as it was" }
   }
 }
diff --git 
a/core/camel-core-model/src/main/java/org/apache/camel/model/WireTapDefinition.java
 
b/core/camel-core-model/src/main/java/org/apache/camel/model/WireTapDefinition.java
index a05df428954a..9051b88a2207 100644
--- 
a/core/camel-core-model/src/main/java/org/apache/camel/model/WireTapDefinition.java
+++ 
b/core/camel-core-model/src/main/java/org/apache/camel/model/WireTapDefinition.java
@@ -41,7 +41,11 @@ import org.apache.camel.spi.Metadata;
                         + " The tapped message is sent asynchronously in a 
separate thread")
 @XmlRootElement(name = "wireTap")
 @XmlAccessorType(XmlAccessType.FIELD)
-@BodyTypes(eipOut = @BodyType(value = Carrier.UNCHANGED,
+@BodyTypes(eipIn = @BodyType(from = "producerIn",
+                             byOption = @BodyType.When(option = 
"variableSend", value = "*", carriers = Carrier.ANY),
+                             description = "The copy of the message goes to 
the producer of the endpoint. With variableSend"
+                                           + " the variable is sent, and the 
body is not read"),
+           eipOut = @BodyType(value = Carrier.UNCHANGED,
                               description = "Sends a copy of the message to 
the endpoint: the body of the route stays as it"
                                             + " was"))
 public class WireTapDefinition<Type extends ProcessorDefinition<Type>> extends 
ToDynamicDefinition
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
index e8c6096db880..412106724618 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
@@ -243,7 +243,7 @@ public class SimpleSyntaxHintsTest extends 
ExchangeTestSupport {
         exchange.getIn().setBody(List.of("A1", "A2").iterator());
         Exception e = assertThrows(Exception.class,
                 () -> 
context.resolveLanguage("simple").createExpression("${body.size()}").evaluate(exchange,
 String.class));
-        assertThat(e.getMessage()).contains("the body is an iterator (")
+        assertThat(e.getMessage()).contains("the body is an iterator")
                 .contains("it has no size and can be read once: split it, or 
have the producer give a List");
     }
 
@@ -324,6 +324,24 @@ public class SimpleSyntaxHintsTest extends 
ExchangeTestSupport {
                 .contains("unmarshal: {json: {}, variableSend: customer, 
variableReceive: customer}");
     }
 
+    @Test
+    public void testMethodOfAVariableThatHoldsAFileSaysToUnmarshalIt() {
+        // what the benchmark wrote (r22): ${variable.customer.name} reads the 
polled file, not the customer
+        GenericFile<File> file = new GenericFile<>();
+        file.setFile(new File("customers/C-482.json"));
+        file.setFileName("C-482.json");
+        exchange.setVariable("customer", file);
+        Exception e = assertThrows(Exception.class,
+                () -> 
context.resolveLanguage("simple").createExpression("${variable.customer.name}").evaluate(exchange,
+                        String.class));
+        assertThat(e.getMessage()).contains("(the value is the file as read (a 
GenericFile), not its data: unmarshal it"
+                                            + " first")
+                .contains("unmarshal: {json: {}, variableSend: customer, 
variableReceive: customer}");
+        // what the file has is read as it is
+        assertEquals("C-482.json", 
context.resolveLanguage("simple").createExpression("${variable.customer.fileName}")
+                .evaluate(exchange, String.class));
+    }
+
     @Test
     public void testOgnlOnAnObjectSaysNothingAboutTheBody() {
         // a name an object does not have is a wrong name, not a body of the 
wrong type
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleTest.java
index 1ec074ef84cc..d4d38598eaf4 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleTest.java
@@ -531,8 +531,10 @@ public class SimpleTest extends LanguageTestSupport {
 
         IndexOutOfBoundsException cause = 
assertIsInstanceOf(IndexOutOfBoundsException.class, e.getCause());
         assertTrue(cause.getMessage().startsWith("Key: qty not found in bean: 
"), cause.getMessage());
-        assertTrue(cause.getMessage().contains("the value is the message body 
as a stream, not a Map: unmarshal it first"),
+        // what the body is says it once: no second hint about the value
+        assertTrue(cause.getMessage().contains("(the body is a stream 
(ByteArrayInputStream): unmarshal it first"),
                 cause.getMessage());
+        assertFalse(cause.getMessage().contains("the value is the message body 
as a stream"), cause.getMessage());
     }
 
     @Test
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/EipBodyTypesTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/EipBodyTypesTest.java
index 755b972bd2da..1260409fbefd 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/processor/EipBodyTypesTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/EipBodyTypesTest.java
@@ -41,6 +41,7 @@ import org.apache.camel.Route;
 import org.apache.camel.StreamCache;
 import org.apache.camel.builder.AggregationStrategies;
 import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
 import org.apache.camel.language.constant.ConstantLanguage;
 import org.apache.camel.language.simple.SimpleLanguage;
 import org.apache.camel.model.AggregateDefinition;
@@ -309,7 +310,7 @@ public class EipBodyTypesTest extends ContextTestSupport {
     @Test
     public void variableSendSendsTheVariable() throws Exception {
         for (Class<?> model : List.of(ToDefinition.class, 
ToDynamicDefinition.class, EnrichDefinition.class,
-                MarshalDefinition.class, UnmarshalDefinition.class)) {
+                WireTapDefinition.class, MarshalDefinition.class, 
UnmarshalDefinition.class)) {
             assertEquals(Carrier.ANY, anyValueOf(bodyTypes(model).eipIn(), 
"variableSend", model), model.getSimpleName());
         }
         Exchange out = run(r -> r.setVariable("v", 
constant("sent")).toV("language:simple:${body}", "v", null), new Order());
@@ -318,6 +319,12 @@ public class EipBodyTypesTest extends ContextTestSupport {
         out = run(r -> r.setVariable("v", 
constant("sent")).enrich().constant("language:simple:${body}").variableSend("v"),
                 new Order());
         assertEquals("sent", out.getMessage().getBody());
+        // the wire tap sends its copy to the producer of the endpoint, and 
with variableSend the variable
+        assertEquals("producerIn", 
bodyTypes(WireTapDefinition.class).eipIn().from());
+        MockEndpoint tap = getMockEndpoint("mock:tap");
+        tap.expectedBodiesReceived("sent");
+        run(r -> r.setVariable("v", 
constant("sent")).wireTap("mock:tap").variableSend("v"), new Order());
+        tap.assertIsSatisfied();
     }
 
     @Test
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/UnmarshalProcessorTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/UnmarshalProcessorTest.java
index 57c4c9d906c1..5e1ef36396e3 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/processor/UnmarshalProcessorTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/UnmarshalProcessorTest.java
@@ -18,6 +18,10 @@ package org.apache.camel.processor;
 
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
 
 import org.apache.camel.CamelContext;
 import org.apache.camel.Exchange;
@@ -135,6 +139,46 @@ public class UnmarshalProcessorTest extends TestSupport {
                 exchange.getException().getMessage());
     }
 
+    @Test
+    public void testParsedDataSaysItIsDataAlready() throws Exception {
+        // what the benchmark wrote (r22): the order was unmarshalled, and is 
unmarshalled again
+        Exchange exchange = createExchangeWithBody(new DefaultCamelContext(), 
new LinkedHashMap<>(Map.of("orderId", "1")));
+        Processor processor = new UnmarshalProcessor(new MyDataFormat(new 
Object()));
+
+        processor.process(exchange);
+
+        NoTypeConversionAvailableException e
+                = assertInstanceOf(NoTypeConversionAvailableException.class, 
exchange.getException());
+        assertEquals("Cannot unmarshal using MyDataFormat: the body is a Map 
(LinkedHashMap), which is data already, and"
+                     + " the data format reads text: read its fields as they 
are, and leave this unmarshal out; to parse the"
+                     + " text in a variable, unmarshal it with variableSend 
and variableReceive set to its name",
+                e.getMessage());
+        assertEquals(InputStream.class, e.getToType());
+        assertInstanceOf(NoTypeConversionAvailableException.class, 
e.getCause());
+    }
+
+    @Test
+    public void testParsedDataInAVariableSaysItIsDataAlready() throws 
Exception {
+        Exchange exchange = createExchangeWithBody(new DefaultCamelContext(), 
"body");
+        exchange.setVariable("order", new ArrayList<>(List.of("A1")));
+        UnmarshalProcessor processor = new UnmarshalProcessor(new 
MyDataFormat(new Object()));
+        processor.setVariableSend("order");
+
+        processor.process(exchange);
+
+        assertEquals("Cannot unmarshal using MyDataFormat: the variable order 
holds data (ArrayList) already, and the data"
+                     + " format reads text: read its fields as they are, such 
as ${variable.order[name]}, and leave this"
+                     + " unmarshal out",
+                exchange.getException().getMessage());
+
+        // the List of List.of goes by its interface, not by the class the JDK 
keeps to itself
+        exchange = createExchangeWithBody(new DefaultCamelContext(), "body");
+        exchange.setVariable("order", List.of("A1"));
+        processor.process(exchange);
+        assertTrue(exchange.getException().getMessage().contains("the variable 
order holds data (List) already"),
+                exchange.getException().getMessage());
+    }
+
     @Test
     public void testBodyThatCannotBeConvertedKeepsTheConverterMessage() throws 
Exception {
         Exchange exchange = createExchangeWithBody(new DefaultCamelContext(), 
new Object());
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/support/BodyFactsOtherBodiesTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/support/BodyFactsOtherBodiesTest.java
index 35c12bde0e4f..aac4fd1423d5 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/support/BodyFactsOtherBodiesTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/support/BodyFactsOtherBodiesTest.java
@@ -54,6 +54,22 @@ public class BodyFactsOtherBodiesTest extends 
ContextTestSupport {
                 message);
     }
 
+    @Test
+    public void testAClassTheJdkKeepsToItselfIsLeftOutOfTheTypes() {
+        // the Map of Map.of is java.util.ImmutableCollections.Map1, a name 
that says nothing to the reader
+        Exchange parsed = template.request("direct:start", e -> {
+            e.getMessage().setHeader("immutable", true);
+            e.getMessage().setBody("{\"qty\": 2}");
+        });
+        assertNull(parsed.getException());
+
+        Exchange text = template.request("direct:start", e -> 
e.getMessage().setBody("{\"qty\": 2}"));
+        assertNotNull(text.getException());
+        String message = text.getException().getMessage();
+        assertTrue(message.contains("other messages reached this step with 
data, so the body here depends on the path"),
+                message);
+    }
+
     @Test
     public void testNothingMoreWhenEveryMessageBroughtTheSameKind() {
         template.request("direct:start", e -> 
e.getMessage().setBody("{\"qty\": 2}"));
@@ -76,6 +92,8 @@ public class BodyFactsOtherBodiesTest extends 
ContextTestSupport {
                         .choice()
                         .when(header("parsed"))
                         .process(e -> e.getMessage().setBody(new 
LinkedHashMap<>(Map.of("qty", 2))))
+                        .when(header("immutable"))
+                        .process(e -> e.getMessage().setBody(Map.of("qty", 2)))
                         .end()
                         .setHeader("qty", simple("${body.qty}")).id("readQty")
                         .to("mock:result");
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/support/BodyFactsTest.java 
b/core/camel-core/src/test/java/org/apache/camel/support/BodyFactsTest.java
index 35b821925785..8ed358e3a299 100644
--- a/core/camel-core/src/test/java/org/apache/camel/support/BodyFactsTest.java
+++ b/core/camel-core/src/test/java/org/apache/camel/support/BodyFactsTest.java
@@ -17,12 +17,19 @@
 package org.apache.camel.support;
 
 import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Scanner;
 import java.util.concurrent.CopyOnWriteArrayList;
 
 import org.apache.camel.CamelContext;
@@ -30,7 +37,9 @@ import org.apache.camel.ContextTestSupport;
 import org.apache.camel.Exchange;
 import org.apache.camel.builder.RouteBuilder;
 import org.apache.camel.spi.BodyTypeRegistry.Kind;
+import org.apache.camel.spi.DataFormat;
 import org.apache.camel.support.BodyFacts.Need;
+import org.apache.camel.support.service.ServiceSupport;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -59,6 +68,8 @@ public class BodyFactsTest extends ContextTestSupport {
                 from("direct:unchanged").process(e -> 
seen.add(BodyFacts.describe(e)));
                 from("direct:setBy").setBody(constant(new 
LinkedHashMap<>(Map.of("sku", "A1")))).log("next")
                         .process(e -> seen.add(BodyFacts.describe(e)));
+                from("direct:marshalled").marshal(new TextDataFormat())
+                        .process(e -> seen.add(BodyFacts.describe(e) + ": " + 
BodyFacts.hint(e, Need.DATA)));
             }
         };
     }
@@ -76,6 +87,29 @@ public class BodyFactsTest extends ContextTestSupport {
         assertTrue(seen.get(0).startsWith("the body is a Map (LinkedHashMap), 
set by setBody"), seen.get(0));
     }
 
+    @Test
+    public void testAFieldReadAfterAMarshalOfData() {
+        // what the benchmark wrote (r22): the ticket is marshalled for a 
file, and a field is read after it
+        template.sendBody("direct:marshalled", new 
LinkedHashMap<>(Map.of("ticketId", "T-1")));
+        assertEquals(1, seen.size());
+        assertTrue(seen.get(0).contains(", set by marshal"), seen.get(0));
+        assertTrue(seen.get(0).contains("wrote it from data (LinkedHashMap): 
read the field before the marshal, for"
+                                        + " example into a variable with 
setVariable, or keep the data in the body and"
+                                        + " marshal into a variable"),
+                seen.get(0));
+        // nested classes, whose canonical names cannot be loaded back as 
classes
+        seen.clear();
+        template.sendBody("direct:marshalled", Map.of("ticketId", "T-1"));
+        assertTrue(seen.get(0).contains("wrote it from data (Map): read the 
field before the marshal"), seen.get(0));
+        seen.clear();
+        template.sendBody("direct:marshalled", List.of("T-1", "T-2"));
+        assertTrue(seen.get(0).contains("wrote it from data (List): read the 
field before the marshal"), seen.get(0));
+        // a marshal of text: unmarshal was meant
+        seen.clear();
+        template.sendBody("direct:marshalled", "{\"ticketId\": \"T-1\"}");
+        assertTrue(seen.get(0).contains("unmarshal it first"), seen.get(0));
+    }
+
     @Test
     public void testTheBodyInPlainWords() {
         assertEquals("null", BodyFacts.words(null));
@@ -85,7 +119,14 @@ public class BodyFactsTest extends ContextTestSupport {
         assertEquals("a stream (ByteArrayInputStream)", BodyFacts.words(new 
ByteArrayInputStream(new byte[0])));
         assertEquals("a Map (LinkedHashMap)", BodyFacts.words(new 
LinkedHashMap<>()));
         assertEquals("a List (ArrayList)", BodyFacts.words(new ArrayList<>()));
-        assertTrue(BodyFacts.words(List.of(1).iterator()).startsWith("an 
iterator ("));
+        // a class the JDK keeps to itself goes by the interface it implements
+        assertEquals("a Map", BodyFacts.words(Map.of("sku", "A1")));
+        assertEquals("a List", BodyFacts.words(List.of("A1")));
+        assertEquals("a List", BodyFacts.words(Arrays.asList("A1")));
+        assertEquals("a collection (Set)", 
BodyFacts.words(Collections.unmodifiableSet(new HashSet<>())));
+        assertEquals("a Map", BodyFacts.words(Collections.unmodifiableMap(new 
HashMap<>())));
+        assertEquals("an iterator", BodyFacts.words(List.of(1).iterator()));
+        assertEquals("an iterator (Scanner)", BodyFacts.words(new 
Scanner("A1")));
         assertEquals("an object of class java.lang.Object", 
BodyFacts.words(new Object()));
         // without the message history the step is not known
         assertEquals("the body is a Map (LinkedHashMap)", 
BodyFacts.describe(exchange(new LinkedHashMap<>())));
@@ -191,6 +232,20 @@ public class BodyFactsTest extends ContextTestSupport {
         assertNull(BodyFacts.dataFormatOf(null));
     }
 
+    /** Writes the body as its text. */
+    private static final class TextDataFormat extends ServiceSupport 
implements DataFormat {
+
+        @Override
+        public void marshal(Exchange exchange, Object graph, OutputStream 
stream) throws Exception {
+            
stream.write(String.valueOf(graph).getBytes(StandardCharsets.UTF_8));
+        }
+
+        @Override
+        public Object unmarshal(Exchange exchange, InputStream stream) {
+            throw new UnsupportedOperationException();
+        }
+    }
+
     private Exchange exchange(Object body) {
         Exchange answer = new DefaultExchange(context);
         answer.getMessage().setBody(body);
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/BodyFacts.java 
b/core/camel-support/src/main/java/org/apache/camel/support/BodyFacts.java
index ef8584393b57..7c884e32deba 100644
--- a/core/camel-support/src/main/java/org/apache/camel/support/BodyFacts.java
+++ b/core/camel-support/src/main/java/org/apache/camel/support/BodyFacts.java
@@ -19,6 +19,7 @@ package org.apache.camel.support;
 import java.io.File;
 import java.io.InputStream;
 import java.io.Reader;
+import java.lang.reflect.Modifier;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Path;
 import java.util.Collection;
@@ -26,6 +27,7 @@ import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
 import java.util.StringJoiner;
 import java.util.stream.BaseStream;
 import java.util.stream.Collectors;
@@ -72,6 +74,12 @@ import org.apache.camel.util.URISupport;
  * </tr>
  * <tr>
  * <td>{@link Need#DATA}</td>
+ * <td>text, bytes or a stream a marshal wrote from a Map or a List, as the 
message history says</td>
+ * <td>read the field before the marshal, or marshal into a variable: an 
unmarshal would undo the marshal a later step
+ * needs</td>
+ * </tr>
+ * <tr>
+ * <td>{@link Need#DATA}</td>
  * <td>null, or empty text or bytes</td>
  * <td>as for {@link Need#A_BODY}</td>
  * </tr>
@@ -214,7 +222,8 @@ public final class BodyFacts {
             return readTheDataFirst();
         }
         if (isPayload(body)) {
-            return unmarshalFirst(exchange, body);
+            String marshal = marshalledData(exchange, body);
+            return marshal != null ? marshal : unmarshalFirst(exchange, body);
         }
         if (body instanceof Map<?, ?> map) {
             if (map.isEmpty()) {
@@ -277,6 +286,36 @@ public final class BodyFacts {
         };
     }
 
+    /**
+     * When a marshal wrote the body from a Map or a List, as the message 
history says: the marshal is wanted, for a
+     * later step that needs text (a file write), and the field is read after 
it, too late; an unmarshal would undo it.
+     * Null when the history does not say so, as for a marshal of text, where 
an unmarshal was meant.
+     */
+    private static String marshalledData(Exchange exchange, Object body) {
+        List<MessageHistory> history = 
exchange.getProperty(ExchangePropertyKey.MESSAGE_HISTORY, List.class);
+        if (history == null || history.isEmpty()) {
+            return null;
+        }
+        String now = ObjectHelper.classCanonicalName(body);
+        for (int i = history.size() - 1; i >= 0; i--) {
+            MessageHistory step = history.get(i);
+            String type = step.getBodyType();
+            if (type == null || type.equals(now)) {
+                continue;
+            }
+            // the step that set the body, and the body it was reached with
+            if (step.getNode() == null || 
!"marshal".equals(step.getNode().getShortName())
+                    || !(step instanceof DefaultMessageHistory dmh) || 
dmh.getBodyDataType() == null) {
+                return null;
+            }
+            return "the " + step(step.getNode()) + " wrote it from data (" + 
dmh.getBodyDataType()
+                   + "): read the field before the marshal, for example into a 
variable with setVariable, or keep the data"
+                   + " in the body and marshal into a variable (marshal with 
variableReceive, and send that variable with"
+                   + " variableSend)";
+        }
+        return null;
+    }
+
     private static String readTheDataFirst() {
         return "read the data first - setBody with constant: 
resource:file:data.json for a known file, or poll:,"
                + " pollEnrich or a from: consumer for one that is not (a route 
reached with direct: has the body of its"
@@ -302,17 +341,22 @@ public final class BodyFacts {
         } else if (body instanceof InputStream || body instanceof Reader) {
             return "a stream (" + type + ")";
         } else if (body instanceof Map<?, ?>) {
-            return "a Map (" + type + ")";
+            return withType("a Map", type, "Map");
         } else if (body instanceof List<?>) {
-            return "a List (" + type + ")";
+            return withType("a List", type, "List");
         } else if (body instanceof Collection<?>) {
-            return "a collection (" + type + ")";
+            return withType("a collection", type, "Collection");
         } else if (isIterator(body)) {
-            return "an iterator (" + type + ")";
+            return withType("an iterator", type, "Iterator");
         }
         return "an object of class " + body.getClass().getName();
     }
 
+    /** The words with the class, unless the class is only the interface the 
words name already: a Map, for Map.of. */
+    private static String withType(String words, String type, String plain) {
+        return type.equals(plain) ? words : words + " (" + type + ")";
+    }
+
     /**
      * The step that set the body, from the message history: the last node 
reached with a body of another type, which
      * ran on it and left this one. When every node was reached with this 
type, the consumer gave it. Empty when the
@@ -385,8 +429,22 @@ public final class BodyFacts {
         if (kind == BodyTypeRegistry.Kind.NULL || types.isEmpty()) {
             return words;
         }
-        return words + " (" + types.stream().map(t -> 
StringHelper.afterLast(t, ".", t)).collect(Collectors.joining(", "))
-               + ")";
+        String names = types.stream().filter(t -> !isJdkInternal(t)).map(t -> 
StringHelper.afterLast(t, ".", t))
+                .distinct().collect(Collectors.joining(", "));
+        return names.isEmpty() ? words : words + " (" + names + ")";
+    }
+
+    /**
+     * Whether a canonical class name is a class nested in a JDK class, such 
as java.util.ImmutableCollections.Map1 of
+     * Map.of: its name says nothing to the reader, and the kind says what it 
is.
+     */
+    private static boolean isJdkInternal(String type) {
+        if (!type.startsWith("java.")) {
+            return false;
+        }
+        String outer = StringHelper.beforeLast(type, ".", "");
+        String outerName = StringHelper.afterLast(outer, ".", outer);
+        return !outerName.isEmpty() && 
Character.isUpperCase(outerName.charAt(0));
     }
 
     /** A step as people find it: unmarshal at orders.yaml:9, or the step and 
its label when there is no location. */
@@ -448,9 +506,30 @@ public final class BodyFacts {
         return body == null || body instanceof String s && s.isBlank() || body 
instanceof byte[] b && b.length == 0;
     }
 
-    private static String typeName(Object body) {
-        String name = body.getClass().getSimpleName();
-        return name.isEmpty() ? body.getClass().getName() : name;
+    /**
+     * The class of a body for a message: its simple name, or for a class the 
JDK keeps to itself (the Map of Map.of,
+     * the List of Arrays.asList) the interface it implements, as its own name 
says nothing to the reader.
+     *
+     * @param  body the body, not null
+     * @return      the name, such as LinkedHashMap, or Map for the Map of 
Map.of
+     */
+    public static String typeName(Object body) {
+        Class<?> type = body.getClass();
+        if (!Modifier.isPublic(type.getModifiers()) && 
type.getName().startsWith("java.")) {
+            if (body instanceof Map<?, ?>) {
+                return "Map";
+            } else if (body instanceof List<?>) {
+                return "List";
+            } else if (body instanceof Set<?>) {
+                return "Set";
+            } else if (body instanceof Collection<?>) {
+                return "Collection";
+            } else if (body instanceof Iterator<?>) {
+                return "Iterator";
+            }
+        }
+        String name = type.getSimpleName();
+        return name.isEmpty() ? type.getName() : name;
     }
 
     /** The first character of text that is not blank, or -1: only text in 
memory is looked at, never a stream. */
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/DefaultMessageHistory.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/DefaultMessageHistory.java
index 385e761b395d..4a8e595c2243 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/DefaultMessageHistory.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/DefaultMessageHistory.java
@@ -16,6 +16,9 @@
  */
 package org.apache.camel.support;
 
+import java.util.Collection;
+import java.util.Map;
+
 import org.apache.camel.CamelContext;
 import org.apache.camel.Exchange;
 import org.apache.camel.Message;
@@ -39,6 +42,7 @@ public class DefaultMessageHistory implements MessageHistory {
     private boolean debugSkipOver;
     private long elapsed;
     private String bodyType;
+    private String bodyDataType;
     private long bodySize = -1;
 
     public DefaultMessageHistory(String routeId, NamedNode node) {
@@ -66,6 +70,15 @@ public class DefaultMessageHistory implements MessageHistory 
{
         this.bodyType = bodyType;
     }
 
+    /**
+     * The class of the body, as {@link BodyFacts#typeName(Object)} names it, 
when it was a Map or a collection as this
+     * node was reached: data, which a marshal writes as text. Null for any 
other body. Kept as the body is captured, as
+     * the class of a nested type (such as the Map of {@code Map.of}) cannot 
be loaded back from its canonical name.
+     */
+    public String getBodyDataType() {
+        return bodyDataType;
+    }
+
     @Override
     public long getBodySize() {
         return bodySize;
@@ -101,6 +114,7 @@ public class DefaultMessageHistory implements 
MessageHistory {
         Message current = exchange.getMessage();
         Object body = current.getBody();
         bodyType = body != null ? ObjectHelper.classCanonicalName(body) : 
"null";
+        bodyDataType = body instanceof Map<?, ?> || body instanceof 
Collection<?> ? BodyFacts.typeName(body) : null;
         if (registry != null && routeId != null && nodeId != null) {
             registry.addBodyType(routeId, nodeId, bodyType, 
BodyFacts.kindOf(body));
         }
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/processor/UnmarshalProcessor.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/processor/UnmarshalProcessor.java
index 056c8fb13bf0..1da209be9f39 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/processor/UnmarshalProcessor.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/processor/UnmarshalProcessor.java
@@ -17,7 +17,9 @@
 package org.apache.camel.support.processor;
 
 import java.io.InputStream;
+import java.util.Collection;
 import java.util.Iterator;
+import java.util.Map;
 
 import org.apache.camel.AsyncCallback;
 import org.apache.camel.CamelContext;
@@ -124,7 +126,7 @@ public class UnmarshalProcessor extends 
AsyncProcessorSupport
         } catch (Exception e) {
             // remove OUT message, as an exception occurred
             exchange.setOut(null);
-            exchange.setException(body == null ? nullBodyException(exchange, 
e) : e);
+            exchange.setException(body == null ? nullBodyException(exchange, 
e) : parsedDataException(exchange, body, e));
         } finally {
             // The Iterator will close the stream itself
             if (!(result instanceof Iterator)) {
@@ -158,6 +160,33 @@ public class UnmarshalProcessor extends 
AsyncProcessorSupport
         return answer;
     }
 
+    /**
+     * A Map or a List fails in the type converter too, as the data format 
reads text: "No type converter available to
+     * convert from type: java.util.LinkedHashMap to the required type: 
java.io.InputStream". Say what it means here:
+     * the body, or the variable, is data already, and a variable is 
unmarshalled with variableSend and variableReceive.
+     * The exception type stays the same, so an onException for it still 
matches.
+     */
+    private Exception parsedDataException(Exchange exchange, Object body, 
Exception e) {
+        if (!(e instanceof NoTypeConversionAvailableException ntc) || !(body 
instanceof Map) && !(body instanceof Collection)) {
+            return e;
+        }
+        String name
+                = dataFormat instanceof DataFormatName dfn ? 
dfn.getDataFormatName() : dataFormat.getClass().getSimpleName();
+        String message = variableSend != null
+                ? "Cannot unmarshal using " + name + ": the variable " + 
variableSend + " holds data ("
+                  + BodyFacts.typeName(body)
+                  + ") already, and the data format reads text: read its 
fields as they are, such as"
+                  + " ${variable." + variableSend + "[name]}, and leave this 
unmarshal out"
+                : "Cannot unmarshal using " + name + ": " + 
BodyFacts.describe(exchange)
+                  + ", which is data already, and the data format reads text: 
read its fields as they are, and leave"
+                  + " this unmarshal out; to parse the text in a variable, 
unmarshal it with variableSend and"
+                  + " variableReceive set to its name";
+        NoTypeConversionAvailableException answer
+                = new NoTypeConversionAvailableException(message, body, 
ntc.getToType());
+        answer.initCause(e);
+        return answer;
+    }
+
     @Override
     public String toString() {
         return "Unmarshal[" + dataFormat + "]";
diff --git a/docs/user-manual/modules/ROOT/pages/body-types.adoc 
b/docs/user-manual/modules/ROOT/pages/body-types.adoc
index 53b77f8c6824..7a6eb96450d2 100644
--- a/docs/user-manual/modules/ROOT/pages/body-types.adoc
+++ b/docs/user-manual/modules/ROOT/pages/body-types.adoc
@@ -289,6 +289,15 @@ that cannot take the body it gets (see 
xref:camel-jbang-validation.adoc[Validati
 know the body, such as after an HTTP consumer, it says nothing, unless a 
running integration has shown what the body
 is.
 
+A field read is looked for in the simple and Groovy expressions of a step, and 
in the simple templates it evaluates:
+a log message, the uri of a `toD` or a `wireTap`, and an endpoint option such 
as the `fileName` of `file`. A `wireTap`
+and an `enrich` send the body to their endpoint, as a `to` does. What a step 
puts in a variable is followed as well:
+after a `pollEnrich` of a file with `variableReceive: customer`, 
`+${variable.customer[name]}+` reads the file, not the
+customer, and the validator says to unmarshal the variable in place, with 
`variableSend` and `variableReceive` set to
+its name. An `unmarshal` of a body or a variable that is parsed data already 
is reported too, and so is a field read
+after a `marshal` of parsed data, with the hint to read the field into a 
variable before the marshal (not into a
+header, which is sent on with the message).
+
 The EIPs come from the catalog as well: their `eipOut` says which ones leave 
the body as it was (`log`, `setHeader`,
 `threads`, a `split` without an aggregation strategy), and their `eipSteps` 
whose steps start with the body at the EIP
 (`choice`, `filter`, `loop`), not with another body (`aggregate`, `split`) or 
one the validator cannot know (`doCatch`).
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlow.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlow.java
index a3824b0512db..c993c40c8b28 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlow.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlow.java
@@ -209,8 +209,11 @@ final class BodyTypeFlow {
             }
             if (reader != null) {
                 // the step both reads and produces, as setBody with a 
jsonpath expression does, or a choice with a
-                // branch that sets the body: which comes first cannot be told 
from the tree, so say nothing
-                return "setBody".equals(firstName(step)) ? reader : null;
+                // branch that sets the body: which comes first cannot be told 
from the tree, so say nothing. Not so
+                // for a setBody, which evaluates its expression first, nor 
for an unmarshal, a marshal or a
+                // convertBodyTo, which read the body to make the new one
+                String first = firstName(step);
+                return "setBody".equals(first) || first.equals(reader) ? 
reader : null;
             }
             if (produces) {
                 // a branch of a choice, a doTry or a split may set the body: 
from here on nothing is certain
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/CatalogBodyTypes.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/CatalogBodyTypes.java
index 122595a70cf0..6a218b047cc9 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/CatalogBodyTypes.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/CatalogBodyTypes.java
@@ -184,6 +184,13 @@ final class CatalogBodyTypes {
                 ? answer(model.getBodyType(BodyTypeModel.UNMARSHAL_OUT), 
values(options), Set.of()) : null;
     }
 
+    /** The bodies an unmarshal with a data format of the YAML DSL (json, 
csv...) and its options can read. */
+    Answer unmarshalIn(String key, JsonNode options) {
+        DataFormatModel model = dataFormat(key, options);
+        return model != null
+                ? answer(model.getBodyType(BodyTypeModel.UNMARSHAL_IN), 
values(options), Set.of()) : null;
+    }
+
     /** The bodies the data type transformer of a transformDataType can 
transform. */
     Answer transformerIn(JsonNode step) {
         return transformerSide(step, BodyTypeModel.TRANSFORMER_IN);
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 0dd20624d29a..daa436a79447 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
@@ -17,6 +17,7 @@
 package org.apache.camel.dsl.yaml.validator;
 
 import java.text.MessageFormat;
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -28,6 +29,8 @@ import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.networknt.schema.Error;
 import com.networknt.schema.path.NodePath;
 import com.networknt.schema.path.PathType;
@@ -60,6 +63,11 @@ import static 
org.apache.camel.dsl.yaml.validator.RouteGraph.scheme;
  * does not say, or after a to: it cannot follow - what a running integration 
of the routes saw there fills the gap,
  * when all the bodies it saw were of one kind (CAMEL-25750): the stream cache 
of an HTTP request, the List a sql call
  * returns.
+ * <p/>
+ * A field is read in a simple or Groovy expression, and in the simple 
template of a log message, of the uri of a toD or
+ * a wireTap, and of an endpoint option such as the fileName of file. What a 
step puts in a variable (variableReceive,
+ * or a setVariable of the body) is followed the same way, and a field read on 
a variable that holds text or the file as
+ * read is reported too. So is an unmarshal of data that is parsed already, 
which fails as a to: file of a Map does.
  */
 final class TextBodyFlow {
 
@@ -90,6 +98,16 @@ final class TextBodyFlow {
     /** ${body.x} or ${body?.x}, not a method call. */
     private static final Pattern SIMPLE_PROPERTY = 
Pattern.compile("\\$\\{body\\??\\.([A-Za-z_]\\w*)(?!\\w|\\s*\\()");
 
+    /**
+     * ${variable.x.y}, ${variables.x?.y} or ${variable.x[y]}: a field read on 
the variable x, not a method call, nor an
+     * index.
+     */
+    private static final Pattern SIMPLE_VARIABLE = Pattern.compile(
+            
"\\$\\{variables?[.:]([A-Za-z_]\\w*)(\\??\\.([A-Za-z_]\\w*)(?!\\w|\\s*\\()|\\[\\s*['\"]?[A-Za-z_])");
+
+    /** The name of the field a read such as ${body[x]} or ${body.x} reads. */
+    private static final Pattern FIELD_READ = 
Pattern.compile("\\$\\{body(?:\\??\\.|\\[\\s*['\"]?)([A-Za-z_]\\w*)");
+
     /** ${body[x]} where x is a key, not an index. */
     private static final Pattern SIMPLE_KEY = 
Pattern.compile("\\$\\{body\\[\\s*['\"]?[A-Za-z_]");
 
@@ -134,6 +152,31 @@ final class TextBodyFlow {
     /** The REST binding modes that write a Map or a List for the route. */
     private static final Set<String> BINDS = Set.of("auto", "json", "xml", 
"json_xml");
 
+    /** The steps that send the body to an endpoint, and can need it as text. 
*/
+    private static final Set<String> SENDS_THE_BODY = Set.of("to", "toD", 
"wireTap", "enrich");
+
+    /** The steps whose uri, and whose endpoint options, are simple templates 
read for each message. */
+    private static final Set<String> TEMPLATE_URIS = Set.of("to", "toD", 
"wireTap");
+
+    /** The steps that may set any variable, which the walk then forgets. */
+    private static final Set<String> SETS_VARIABLES = Set.of("bean", 
"process", "script", "setVariables",
+            "removeVariables", "convertVariableTo", "kamelet", "claimCheck");
+
+    /** The components whose endpoint is a route, which may set variables of 
the message. */
+    private static final Set<String> ROUTES = Set.of("direct", "seda", 
"direct-vm", "vm", "kamelet");
+
+    /**
+     * What a block that may set variables has in it: a step that does, or a 
call of a route. "ariable" is one substring
+     * for setVariable, removeVariable, variableReceive, ${variable.x} and the 
like, whether the v is upper or lower
+     * case.
+     */
+    private static final List<String> MAY_SET_VARIABLES = List.of("ariable", 
"\"bean\"", "\"process\"", "\"script\"",
+            "direct", "seda", "kamelet");
+
+    /** The options of an unmarshal or a marshal: the other key is the data 
format. */
+    private static final Set<String> DATA_FORMAT_OPTIONS = Set.of("id", 
"description", "disabled", "note",
+            "variableSend", "variableReceive", "allowNullBody");
+
     private TextBodyFlow() {
     }
 
@@ -149,11 +192,15 @@ final class TextBodyFlow {
     record Parsed(String origin, String format) implements Body {
     }
 
-    /** Text: the payload as it was read or written. */
-    record Text(String origin, String format, boolean file) implements Body {
+    /** Text: the payload as it was read or written; marshalled when a marshal 
wrote it from parsed data. */
+    record Text(String origin, String format, boolean file, boolean 
marshalled) implements Body {
 
         Text(String origin, String format) {
-            this(origin, format, false);
+            this(origin, format, false, false);
+        }
+
+        Text(String origin, String format, boolean file) {
+            this(origin, format, file, false);
         }
 
         /** The names that can be read from this body without parsing it. */
@@ -171,16 +218,28 @@ final class TextBodyFlow {
      * What a walk knows besides the body: the route, the routes of the file, 
where reports go (null for a route a step
      * calls, which reports from its own walk), whether an http producer sends 
the body with POST, the verbs of the
      * OpenAPI operations a rest-openapi call can be judged by (empty when a 
REST binding may write the body), with the
-     * fields their JSON requests need, and the body types of the catalog.
+     * fields their JSON requests need, the body types of the catalog, and 
what is known about the variables of the
+     * message.
      */
     private record Walk(String routeId, Map<String, JsonNode> byFrom, 
List<Error> errors, Set<String> seen, boolean post,
             Map<String, Map<String, String>> operations, Map<String, 
Map<String, List<String>>> requestFields,
             Map<String, ProjectFacts.Observed> observed, CatalogBodyTypes 
bodyTypes,
-            Map<String, ProjectFacts.KameletDataTypes> kamelets) {
+            Map<String, ProjectFacts.KameletDataTypes> kamelets, Map<String, 
Body> variables) {
 
-        /** The walk of a route a step calls: for its outcome alone. */
+        /**
+         * The walk of a route a step calls: for its outcome alone, and the 
variables of the message it changes. The
+         * called route runs on the same exchange, so it shares the variables, 
where a branch gets a copy.
+         */
         Walk called() {
-            return new Walk(null, byFrom, null, seen, false, operations, 
requestFields, observed, bodyTypes, kamelets);
+            return new Walk(
+                    null, byFrom, null, seen, false, operations, 
requestFields, observed, bodyTypes, kamelets, variables);
+        }
+
+        /** The walk of the steps of a block: what a branch puts in a variable 
stays in the branch. */
+        Walk branch() {
+            return new Walk(
+                    routeId, byFrom, errors, seen, post, operations, 
requestFields, observed, bodyTypes, kamelets,
+                    new HashMap<>(variables));
         }
     }
 
@@ -236,9 +295,15 @@ final class TextBodyFlow {
                 // what the consumer gave in a running integration of the route
                 start = observed(observed, ProjectFacts.consumerKey(uri), 
"from: " + normalize(uri) + " gave");
             }
+            Map<String, Body> variables = new HashMap<>();
+            String received = variable ? 
fixedName(from.get("variableReceive")) : null;
+            if (received != null && !"kamelet".equals(consumer)) {
+                // the message the consumer receives is in the variable
+                put(variables, received, consumerBody(uri, from, bodyTypes, 
"from: " + normalize(uri) + " gives it"));
+            }
             Body end = walk(steps, start, at, new Walk(
                     id, byFrom, errors, new HashSet<>(), post, operations,
-                    requestFields, observed, bodyTypes, kamelets));
+                    requestFields, observed, bodyTypes, kamelets, variables));
             // the body at the end of the route of a consumer that replies 
with text (platform-http), or of the route of
             // a REST call, is the reply
             boolean rest = uri != null && restReplies.contains(normalize(uri));
@@ -270,11 +335,17 @@ final class TextBodyFlow {
      * paho-mqtt5), or data (a row of sql); null when it is not known, or 
neither text nor data.
      */
     private static Body consumerBody(String uri, JsonNode from, 
CatalogBodyTypes bodyTypes) {
+        return consumerBody(uri, from, bodyTypes, "the " + scheme(uri) + " 
consumer gives it");
+    }
+
+    /**
+     * The body a consumer gives, as the catalog says, with where it comes 
from in words: pollEnrich: file:in gives it.
+     */
+    private static Body consumerBody(String uri, JsonNode from, 
CatalogBodyTypes bodyTypes, String consumer) {
         CatalogBodyTypes.Answer out = bodyTypes.consumerOut(uri, from);
         if (out == null) {
             return null;
         }
-        String consumer = "the " + scheme(uri) + " consumer gives it";
         if (out.payload()) {
             // the file as read has the format of the files it reads
             return out.file()
@@ -284,19 +355,36 @@ final class TextBodyFlow {
         return out.data() ? new Parsed(consumer + " as " + out.inWords(), 
out.dataFormat()) : null;
     }
 
-    /** The format of the files a file consumer reads, by the extension it 
includes. */
+    /** The format of the files a file consumer reads, by the extension it 
includes, in its parameters or its uri. */
     private static String fileFormat(JsonNode from) {
         JsonNode parameters = from.get("parameters");
+        String uri = endpointOf(from);
         String format = null;
         for (String key : new String[] { "include", "antInclude", "fileName" 
}) {
-            if (format == null && parameters != null && parameters.isObject() 
&& parameters.has(key)) {
+            String value = parameters != null && parameters.isObject() && 
parameters.has(key)
+                    ? parameters.get(key).asText() : queryValue(uri, key);
+            if (format == null && value != null) {
                 // .*\.csv and *.csv end with the extension, as a file name 
does
-                format = formatOf(parameters.get(key).asText().replace("\\", 
""));
+                format = formatOf(value.replace("\\", ""));
             }
         }
         return format;
     }
 
+    /** The value of an option in the query of an endpoint uri, or null. */
+    private static String queryValue(String uri, String key) {
+        int q = uri != null ? uri.indexOf('?') : -1;
+        if (q < 0) {
+            return null;
+        }
+        for (String pair : uri.substring(q + 1).split("&")) {
+            if (pair.startsWith(key + "=")) {
+                return pair.substring(key.length() + 1);
+            }
+        }
+        return null;
+    }
+
     /**
      * The body a producer or a data type transformer leaves as the catalog 
says it: text (the response of the http
      * producer) or data (the List of a mongodb findAll); null when it is not 
known, or neither text nor data.
@@ -325,16 +413,25 @@ final class TextBodyFlow {
             JsonNode step = steps.get(j);
             if (!step.isObject() || !step.fieldNames().hasNext()) {
                 body = null;
+                w.variables().clear();
                 continue;
             }
             String name = step.fieldNames().next();
             JsonNode value = step.get(name);
             NodePath here = at.append(j).append(name);
+            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;
+                continue;
+            }
             if (body != null && w.errors() != null) {
                 if (w.bodyTypes().stepsHaveTheBody(name, value)) {
-                    walkBlock(value, body, here, w);
+                    walkBlock(value, body, here, w.branch());
                 } else if (body instanceof Text text) {
                     String read = readIn(value, text.readable());
+                    if (read == null) {
+                        read = templateRead(name, value, text.readable());
+                    }
                     if (read != null) {
                         w.errors().add(error(here, w.routeId(), read, text));
                     }
@@ -343,14 +440,21 @@ final class TextBodyFlow {
                     Parsed element = "split".equals(name) ? 
splitElement(value, parsed, w.bodyTypes()) : null;
                     if (need != null) {
                         w.errors().add(error(here, w.routeId(), need, parsed));
-                        // the step fails at runtime, so what comes after it 
is not run with this body: one report
-                        body = null;
-                        continue;
+                        if (!"wireTap".equals(name)) {
+                            // the step fails at runtime, so what comes after 
it is not run with this body: one report.
+                            // A wire tap fails on its own thread, and the 
route goes on
+                            body = null;
+                            continue;
+                        }
                     } else if (element != null) {
-                        walk(value.get("steps"), element, 
here.append("steps"), w);
+                        walk(value.get("steps"), element, 
here.append("steps"), w.branch());
                     }
                 }
             }
+            if (w.errors() != null) {
+                readsOfVariables(name, value, here, w);
+            }
+            remember(name, value, body, w);
             body = after(name, value, body, w);
         }
         return body;
@@ -414,12 +518,7 @@ final class TextBodyFlow {
                 return unmarshalled(value, w.bodyTypes());
             }
             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);
+                return marshalled(value, body);
             }
             case "convertBodyTo" -> {
                 String type = value != null && value.isObject() && 
value.has("type") ? value.get("type").asText()
@@ -565,14 +664,318 @@ final class TextBodyFlow {
      * iterator).
      */
     private static Parsed unmarshalled(JsonNode value, CatalogBodyTypes 
bodyTypes) {
-        if (value == null || !value.isObject() || 
!value.fieldNames().hasNext()) {
+        String format = dataFormatOf(value);
+        if (format == null) {
             return null;
         }
-        String format = value.fieldNames().next();
         CatalogBodyTypes.Answer out = bodyTypes.unmarshalOut(format, 
value.get(format));
         return out != null && out.data() ? new Parsed("unmarshal: " + format + 
" turns the text into data", format) : null;
     }
 
+    /** The text a marshal writes; marshalled when the body it writes is known 
to be parsed data. */
+    private static Text marshalled(JsonNode value, Body body) {
+        String format = dataFormatOf(value);
+        return new Text(
+                "marshal" + (format != null ? ": " + format : "")
+                        + " writes data as text (marshal turns data into text, 
unmarshal turns text into data)",
+                format, false, body instanceof Parsed);
+    }
+
+    /** The data format of an unmarshal or a marshal: the key that is not one 
of its options, such as json. */
+    private static String dataFormatOf(JsonNode value) {
+        if (value == null || !value.isObject()) {
+            return null;
+        }
+        for (var it = value.fieldNames(); it.hasNext();) {
+            String key = it.next();
+            if (!DATA_FORMAT_OPTIONS.contains(key)) {
+                return key;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * An unmarshal of data that is parsed already: the body after an 
unmarshal, or a variable that holds data
+     * (variableSend). The data format reads text, and a Map is no text: "No 
type converter available to convert from
+     * type: java.util.LinkedHashMap to the required type: 
java.io.InputStream". Reports it, and answers whether it did.
+     */
+    private static boolean parsesData(String name, JsonNode value, Body body, 
NodePath at, Walk w) {
+        String format = "unmarshal".equals(name) ? dataFormatOf(value) : null;
+        if (format == null) {
+            return false;
+        }
+        String variable = fixedName(value.get("variableSend"));
+        Body input = variable != null ? w.variables().get(variable) : 
value.has("variableSend") ? null : body;
+        CatalogBodyTypes.Answer in = input instanceof Parsed ? 
w.bodyTypes().unmarshalIn(format, value.get(format)) : null;
+        if (in == null || !in.onlyPayload()) {
+            return false;
+        }
+        String held = variable != null ? "the variable " + variable + " holds" 
: "the body here is";
+        String fix = variable != null
+                ? "the variable is data already, so leave this unmarshal out, 
and read its fields as they are, such as"
+                  + " ${variable." + variable + "[name]}"
+                : "the body is data already, so leave this unmarshal out, and 
read its fields as they are; to parse text"
+                  + " in a variable, unmarshal it with variableSend and 
variableReceive set to its name";
+        w.errors().add(Error.builder()
+                .keyword("type")
+                .instanceLocation(at)
+                .messageKey("type")
+                .format(new MessageFormat("{0}"))
+                .arguments((w.routeId() != null ? "route " + w.routeId() + ": 
" : "") + "unmarshal: " + format
+                           + " parses text (" + in.inWords() + "), but " + held
+                           + " parsed data (a Map or a List), not text - " + 
input.origin() + ": " + fix)
+                .build());
+        return true;
+    }
+
+    /**
+     * Remembers what a step puts in a variable: the result of a step with 
variableReceive, which is what the step would
+     * leave in the body, and the body or a constant a setVariable sets. A 
step that may set any variable (a bean, a
+     * call of a route the walk does not follow, a block with such a step) 
makes the walk forget them all, so that it
+     * only reports a variable it is certain of.
+     */
+    private static void remember(String name, JsonNode value, Body body, Walk 
w) {
+        Map<String, Body> variables = w.variables();
+        String uri = sendUri(name, value);
+        boolean callsARoute = uri != null && ROUTES.contains(scheme(uri))
+                && !("to".equals(name) && !value.has("variableReceive") && 
w.byFrom().containsKey(normalize(uri)));
+        // nothing to forget when no variable is known, and the text of a 
block is then not looked at
+        if (!variables.isEmpty() && (SETS_VARIABLES.contains(name) || 
callsARoute
+                || RouteGraph.SENDS.contains(name) && uri == null || 
mayChangeVariables(value))) {
+            variables.clear();
+        }
+        String receive = value != null && value.isObject() ? 
fixedName(value.get("variableReceive")) : null;
+        if (receive != null) {
+            put(variables, receive, received(name, value, uri, body, w));
+        } else if (value != null && value.isObject() && 
value.has("variableReceive")) {
+            // a variable named by an expression
+            variables.clear();
+        } else if ("setVariable".equals(name) || 
"removeVariable".equals(name)) {
+            JsonNode n = value != null && value.isObject() ? value.get("name") 
: value;
+            String variable = fixedName(n);
+            if (variable == null) {
+                variables.clear();
+            } else if ("setVariable".equals(name)) {
+                put(variables, variable, setVariableBody(value, body));
+            } else {
+                variables.remove(variable);
+            }
+        }
+    }
+
+    /** Whether the steps of a block may set a variable: a step that does, or 
a call of a route. */
+    private static boolean mayChangeVariables(JsonNode value) {
+        if (value == null || !value.isContainerNode()) {
+            return false;
+        }
+        String text = value.toString();
+        return text.contains("\"steps\"") && 
MAY_SET_VARIABLES.stream().anyMatch(text::contains);
+    }
+
+    /** What a step with variableReceive puts in the variable: what it would 
leave in the body without it. */
+    private static Body received(String name, JsonNode value, String uri, Body 
body, Walk w) {
+        return switch (name) {
+            case "pollEnrich", "poll" -> uri != null
+                    ? consumerBody(uri, endpointNode(uri), w.bodyTypes(), name 
+ ": " + normalize(uri) + " gives it")
+                    : null;
+            case "to", "toD", "enrich" -> uri != null
+                    ? producerBody(w.bodyTypes().producerOut(uri, 
"enrich".equals(name) ? endpointNode(uri) : value),
+                            name + ": " + normalize(uri) + " returns it")
+                    : null;
+            case "unmarshal" -> unmarshalled(value, w.bodyTypes());
+            case "marshal" -> marshalled(value, body);
+            default -> null;
+        };
+    }
+
+    /** What a setVariable sets: the body for simple ${body}, text for a 
constant or a simple template. */
+    private static Body setVariableBody(JsonNode value, Body body) {
+        JsonNode node = value.has("expression") ? value.get("expression") : 
value;
+        String language = null;
+        for (var it = node.fieldNames(); it.hasNext() && language == null;) {
+            String key = it.next();
+            if (!"name".equals(key) && !"id".equals(key) && 
!"description".equals(key) && !"disabled".equals(key)
+                    && !"note".equals(key)) {
+                language = key;
+            }
+        }
+        String expression = language != null ? 
expressionText(node.get(language)) : null;
+        if (expression == null) {
+            return null;
+        }
+        if ("simple".equals(language) && "${body}".equals(expression.trim())) {
+            return body;
+        }
+        ObjectNode setBody = JsonNodeFactory.instance.objectNode();
+        setBody.set(language, node.get(language));
+        Text text = setBodyText(setBody);
+        return text != null ? new Text(text.origin().replace("setBody", 
"setVariable"), text.format()) : null;
+    }
+
+    /** Puts what is known about a variable, or forgets it when nothing is. */
+    private static void put(Map<String, Body> variables, String name, Body 
body) {
+        if (body != null) {
+            variables.put(name, body);
+        } else {
+            variables.remove(name);
+        }
+    }
+
+    /** The name of a variable as the route sets it, or null for none, or a 
name only known when the route runs. */
+    private static String fixedName(JsonNode node) {
+        String name = node != null && node.isValueNode() ? node.asText() : 
null;
+        return name == null || name.isBlank() || name.contains("${") || 
name.contains("{{") ? null : name;
+    }
+
+    /**
+     * The endpoint a step sends to or polls from: the uri of a to, toD, 
wireTap or poll, or the expression of an enrich
+     * or a pollEnrich (a constant, or a simple template, whose component is 
known); null for any other step.
+     */
+    private static String sendUri(String name, JsonNode value) {
+        if (!RouteGraph.SENDS.contains(name) && !"poll".equals(name)) {
+            return null;
+        }
+        if (!"enrich".equals(name) && !"pollEnrich".equals(name)) {
+            return endpointOf(value);
+        }
+        JsonNode node = value != null && value.isObject() && 
value.has("expression") ? value.get("expression") : value;
+        if (node == null || node.isTextual()) {
+            return node != null ? node.asText() : null;
+        }
+        for (String language : new String[] { "constant", "simple" }) {
+            String uri = node.isObject() ? expressionText(node.get(language)) 
: null;
+            if (uri != null && scheme(uri) != null && 
!scheme(uri).contains("$")) {
+                return uri.trim();
+            }
+        }
+        return null;
+    }
+
+    /** A node of an endpoint with only its uri, as an enrich names it in an 
expression. */
+    private static JsonNode endpointNode(String uri) {
+        return JsonNodeFactory.instance.objectNode().put("uri", uri);
+    }
+
+    /** A simple template of a step: where it is, in words, and its text. */
+    private record Template(String where, String text) {
+    }
+
+    /**
+     * The simple templates a step evaluates for each message besides its 
expressions: the message of a log, and the uri
+     * and the endpoint options of a to, toD or wireTap, which toD and wireTap 
evaluate, and a component an option of
+     * such as the fileName of file.
+     */
+    private static List<Template> templates(String name, JsonNode value) {
+        List<Template> answer = new ArrayList<>();
+        if (value == null) {
+            return answer;
+        }
+        if ("log".equals(name)) {
+            JsonNode message = value.isObject() ? value.get("message") : value;
+            if (message != null && message.isValueNode()) {
+                answer.add(new Template("the log message", message.asText()));
+            }
+        } else if (TEMPLATE_URIS.contains(name)) {
+            JsonNode uri = value.isObject() ? value.get("uri") : value;
+            if (uri != null && uri.isValueNode()) {
+                answer.add(new Template("the uri of " + name, uri.asText()));
+            }
+            JsonNode parameters = value.isObject() ? value.get("parameters") : 
null;
+            if (parameters != null && parameters.isObject()) {
+                for (var it = parameters.fieldNames(); it.hasNext();) {
+                    String option = it.next();
+                    if (parameters.get(option).isValueNode()) {
+                        answer.add(new Template("the " + option + " of " + 
name, parameters.get(option).asText()));
+                    }
+                }
+            }
+        }
+        return answer;
+    }
+
+    /** A simple read of a field of the body in the templates of a step: what 
reads it, or null. */
+    private static String templateRead(String name, JsonNode value, 
Set<String> readable) {
+        for (Template template : templates(name, value)) {
+            String found = simpleRead(template.text(), readable);
+            if (found != null) {
+                return template.where() + " reads a field of the body (" + 
found + ")";
+            }
+        }
+        return null;
+    }
+
+    /** The simple expressions of a node, not of its nested steps. */
+    private static void simpleExpressions(JsonNode node, List<String> answer) {
+        if (node == null || !node.isContainerNode()) {
+            return;
+        }
+        if (node.isArray()) {
+            node.forEach(child -> simpleExpressions(child, answer));
+            return;
+        }
+        for (var it = node.fieldNames(); it.hasNext();) {
+            String key = it.next();
+            if ("simple".equals(key)) {
+                String text = expressionText(node.get(key));
+                if (text != null) {
+                    answer.add(text);
+                }
+            } else if (!"steps".equals(key)) {
+                simpleExpressions(node.get(key), answer);
+            }
+        }
+    }
+
+    /**
+     * Reports a field read on a variable that holds text or the file as read, 
as ${variable.customer[name]} after a
+     * pollEnrich of a file with variableReceive: customer reads the 
GenericFile, not the customer.
+     */
+    private static void readsOfVariables(String name, JsonNode value, NodePath 
at, Walk w) {
+        if (w.variables().isEmpty()) {
+            return;
+        }
+        List<String> texts = new ArrayList<>();
+        simpleExpressions(value, texts);
+        templates(name, value).forEach(t -> texts.add(t.text()));
+        for (String text : texts) {
+            Matcher m = SIMPLE_VARIABLE.matcher(text);
+            while (m.find()) {
+                String variable = m.group(1);
+                if (!(w.variables().get(variable) instanceof Text held)
+                        || m.group(3) != null && 
held.readable().contains(m.group(3))) {
+                    continue;
+                }
+                int end = text.indexOf('}', m.start());
+                String read = end > 0 ? text.substring(m.start(), end + 1) : 
m.group();
+                w.errors().add(variableError(at, w.routeId(), variable, read, 
held));
+                return;
+            }
+        }
+    }
+
+    private static Error variableError(NodePath at, String routeId, String 
variable, String read, Text text) {
+        String format = text.format() != null ? text.format() : "json";
+        String unmarshal = "unmarshal: {" + format + ": {}, variableSend: " + 
variable + ", variableReceive: " + variable
+                           + "}";
+        String fix = text.format() != null
+                ? "unmarshal it in place before this step, with " + unmarshal 
+ ", to read its fields"
+                : "unmarshal it in place before this step, with the data 
format of the payload, such as " + unmarshal
+                  + ", to read its fields";
+        return Error.builder()
+                .keyword("type")
+                .instanceLocation(at)
+                .messageKey("type")
+                .format(new MessageFormat("{0}"))
+                .arguments((routeId != null ? "route " + routeId + ": " : "") 
+ "simple reads a field of the variable "
+                           + variable + " (" + read + ")"
+                           + (text.file()
+                                   ? ", but the variable holds the file as it 
was read (a GenericFile), not parsed data - "
+                                   : ", but the variable holds text, not 
parsed data - ")
+                           + text.origin() + ": " + fix)
+                .build();
+    }
+
     /**
      * A Groovy setBody whose result is data again: one collection method on 
the body and nothing after it, such as
      * body.findAll { ... }, or body.find { it.sku == ... }, which picks an 
element whose fields it reads. The call, or
@@ -775,11 +1178,11 @@ final class TextBodyFlow {
                             "before this step")
                     : null;
         }
-        if (!"to".equals(name) && !"toD".equals(name) || 
w.bodyTypes().needsNoBody(name, value)) {
+        if (!SENDS_THE_BODY.contains(name) || w.bodyTypes().needsNoBody(name, 
value)) {
             // with variableSend the variable is sent, not the body
             return null;
         }
-        String uri = endpointOf(value);
+        String uri = sendUri(name, value);
         String scheme = scheme(uri);
         if (scheme == null) {
             return null;
@@ -799,19 +1202,42 @@ final class TextBodyFlow {
             }
             return null;
         }
-        CatalogBodyTypes.Answer in = w.bodyTypes().producerIn(uri, value);
+        JsonNode endpoint = "enrich".equals(name) ? endpointNode(uri) : value;
+        CatalogBodyTypes.Answer in = w.bodyTypes().producerIn(uri, endpoint);
         if (in == null || !in.onlyPayload()) {
             return null;
         }
         if ("http".equals(scheme) || "https".equals(scheme)) {
-            return w.post() && sendsTheBody(uri, value)
+            return w.post() && sendsTheBody(uri, endpoint)
                     ? new Need(
                             name + ": " + scheme
                                + " sends the body as the request (a message 
with a body is sent as a POST)",
                             "before this step, or set httpMethod: GET when the 
call sends no body")
                     : null;
         }
-        return new Need(name + ": " + scheme + " needs the body as " + 
in.inWords(), "before this step");
+        return new Need(
+                name + ": " + scheme + " needs the body as " + in.inWords(),
+                "before this step" + beforeTheMarshal(name, value));
+    }
+
+    /**
+     * What to read before the marshal a step that needs text gets: the fields 
this step reads in its uri or options (a
+     * file name), or else the fields the steps after it read (a log), which 
are text after the marshal. Into a
+     * variable, not a header: a header is sent on with the message, and 
becomes part of what the endpoint gets.
+     */
+    private static String beforeTheMarshal(String name, JsonNode value) {
+        for (Template template : templates(name, value)) {
+            String found = simpleRead(template.text(), TEXT_PROPERTIES);
+            if (found != null) {
+                Matcher field = FIELD_READ.matcher(found);
+                String variable = field.find() ? field.group(1) : "field";
+                return " - and " + template.where() + " reads " + found + ", 
which is text after the marshal: read it"
+                       + " before the marshal into a variable (setVariable 
with name " + variable + " and simple "
+                       + found + ") and use ${variable." + variable + "} here";
+            }
+        }
+        return " (read the fields you still need, such as for a file name or a 
log, before the marshal, for example into"
+               + " a variable with setVariable)";
     }
 
     /**
@@ -1060,7 +1486,12 @@ final class TextBodyFlow {
     private static Error error(NodePath at, String routeId, String read, Text 
text) {
         String format = text.format();
         String fix;
-        if (format == null) {
+        if (text.marshalled()) {
+            // the marshal is wanted, for a step after it that needs text: the 
field is read too late
+            fix = "read the field before the marshal, for example into a 
variable with setVariable, or keep the data in"
+                  + " the body and marshal into a variable (marshal with 
variableReceive, and send that variable with"
+                  + " variableSend)";
+        } else 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";
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlowTest.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlowTest.java
index e9f96d300d2b..045684d9ff46 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlowTest.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlowTest.java
@@ -346,4 +346,41 @@ public class BodyTypeFlowTest {
                 """;
         assertThat(messages(marshal)).noneMatch(m -> m.contains("works on the 
message body"));
     }
+
+    @Test
+    public void testAnUnmarshalAsTheFirstStepOfAGet() {
+        // what the benchmark wrote (r22): the lookup route of a GET starts 
with an unmarshal, which reads the body
+        // before it makes the new one, and a GET carries none
+        String yaml = """
+                - rest:
+                    get:
+                      - path: /contracts/{id}
+                        to: direct:lookup
+                - route:
+                    id: lookup
+                    from:
+                      uri: direct:lookup
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - setBody:
+                            simple: ${body[name]}
+                """;
+        assertThat(messages(yaml)).anyMatch(m -> m.contains("route lookup: 
unmarshal works on the message body")
+                && m.contains("the message reaching this route has none"));
+        // the same for a marshal and a convertBodyTo of a timer route
+        for (String step : List.of("marshal: {json: {}}", "convertBodyTo: 
{type: String}")) {
+            String timer = """
+                    - route:
+                        id: tick
+                        from:
+                          uri: timer:tick
+                          steps:
+                            - %s
+                    """.formatted(step);
+            assertThat(messages(timer)).as(step).anyMatch(m -> 
m.contains("works on the message body"));
+        }
+        // a POST carries a body
+        assertThat(messages(yaml.replace("get:", "post:"))).noneMatch(m -> 
m.contains("works on the message body"));
+    }
 }
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/OneOfAlternativesTest.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/OneOfAlternativesTest.java
index 0004656cc589..4816b411559d 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/OneOfAlternativesTest.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/OneOfAlternativesTest.java
@@ -65,7 +65,7 @@ class OneOfAlternativesTest {
                 - route:
                     id: two
                     from:
-                      uri: timer:tick
+                      uri: direct:two
                       steps:
                         - unmarshal:
                             json:
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 8debd489f14a..1be98b6e9b99 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
@@ -2582,4 +2582,254 @@ public class TextBodyFlowTest {
         assertThat(lines(events, KAMELETS)).singleElement()
                 .satisfies(m -> assertThat(m).contains("to: file needs the 
body as text"));
     }
+
+    @Test
+    public void testAFieldReadInTheUriOfAToDAfterAMarshal() {
+        // what the benchmark wrote (r22): the ticket is marshalled for the 
file, and the file name reads a field of it
+        String yaml = """
+                - route:
+                    id: classify
+                    from:
+                      uri: file:tickets
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - marshal:
+                            json: {}
+                        - toD:
+                            uri: 
file:classified?fileName=${body[ticketId]}.json
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> {
+            assertThat(m).startsWith("Line 11: ");
+            assertThat(m).contains("route classify: the uri of toD reads a 
field of the body (${body[ticketId]})");
+            assertThat(m).contains("marshal: json writes data as text");
+            // the marshal is wanted here: not "unmarshal is the step"
+            assertThat(m).contains("read the field before the marshal, for 
example into a variable with setVariable");
+            assertThat(m).contains("marshal with variableReceive");
+            assertThat(m).doesNotContain("is the step, not marshal");
+        });
+        // the same read in a log message, and in an option of the endpoint
+        assertThat(lines(yaml.replace("""
+                        - toD:
+                            uri: 
file:classified?fileName=${body[ticketId]}.json
+                """, """
+                        - log:
+                            message: "ticket ${body[ticketId]} classified"
+                """))).singleElement().satisfies(m -> assertThat(m)
+                .contains("the log message reads a field of the body 
(${body[ticketId]})"));
+        assertThat(lines(yaml.replace("""
+                        - toD:
+                            uri: 
file:classified?fileName=${body[ticketId]}.json
+                """, """
+                        - to:
+                            uri: file:classified
+                            parameters:
+                              fileName: ${body[ticketId]}.json
+                """))).singleElement().satisfies(m -> assertThat(m)
+                .contains("the fileName of to reads a field of the body 
(${body[ticketId]})"));
+        // the body itself, and a field of the data before the marshal, are 
fine
+        assertThat(lines(yaml.replace("${body[ticketId]}", 
"${body}"))).isEmpty();
+        assertThat(lines(yaml.replace("""
+                        - marshal:
+                            json: {}
+                        - toD:
+                """, """
+                        - log: "ticket ${body[ticketId]}"
+                        - setVariable:
+                            name: ticketId
+                            simple: ${body[ticketId]}
+                        - marshal:
+                            json: {}
+                        - toD:
+                """).replace("${body[ticketId]}.json", 
"${variable.ticketId}.json"))).isEmpty();
+    }
+
+    @Test
+    public void testAFileWriteOfDataSaysToReadTheFieldsBeforeTheMarshal() {
+        // the file name reads a field of the data: after the marshal it would 
read text
+        String yaml = """
+                - route:
+                    id: classify
+                    from:
+                      uri: file:tickets
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - toD:
+                            uri: 
file:classified?fileName=${body[ticketId]}.json
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> {
+            assertThat(m).contains("toD: file needs the body as");
+            assertThat(m).contains("add marshal: json before this step - and 
the uri of toD reads ${body[ticketId]},"
+                                   + " which is text after the marshal: read 
it before the marshal into a variable"
+                                   + " (setVariable with name ticketId and 
simple ${body[ticketId]}) and use"
+                                   + " ${variable.ticketId} here");
+        });
+        // no field read in the step: the fields the steps after it read
+        assertThat(lines(yaml.replace("?fileName=${body[ticketId]}.json", 
""))).singleElement()
+                .satisfies(m -> assertThat(m).contains("add marshal: json 
before this step (read the fields you still"
+                                                       + " need, such as for a 
file name or a log, before the marshal"));
+    }
+
+    @Test
+    public void testAWireTapOrAnEnrichOfDataToAFile() {
+        // what the benchmark wrote (r22): the order Map is tapped to a file
+        String yaml = """
+                - route:
+                    id: dedupe
+                    from:
+                      uri: file:orders
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - wireTap:
+                            uri: file:audit?fileName=${body[orderId]}.json
+                        - log: "order ${body[orderId]}"
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> {
+            assertThat(m).startsWith("Line 9: ");
+            assertThat(m).contains("route dedupe: wireTap: file needs the body 
as");
+            assertThat(m).contains("add marshal: json before this step");
+        });
+        // with variableSend the variable is sent
+        assertThat(lines(yaml.replace("""
+                            uri: file:audit?fileName=${body[orderId]}.json
+                """, """
+                            uri: file:audit?fileName=${body[orderId]}.json
+                            variableSend: audit
+                """))).isEmpty();
+        // an enrich sends the body to the endpoint too
+        assertThat(lines(yaml.replace("""
+                        - wireTap:
+                            uri: file:audit?fileName=${body[orderId]}.json
+                """, """
+                        - enrich:
+                            constant: file:audit
+                """))).singleElement().satisfies(m -> 
assertThat(m).contains("enrich: file needs the body as"));
+    }
+
+    @Test
+    public void testAnUnmarshalOfDataThatIsParsedAlready() {
+        // what the benchmark wrote (r22): the order was unmarshalled, and is 
unmarshalled again
+        String yaml = """
+                - route:
+                    id: dedupe
+                    from:
+                      uri: file:orders
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - setHeader:
+                            name: orderId
+                            simple: ${body[orderId]}
+                        - unmarshal:
+                            json: {}
+                        - to:
+                            uri: file:out
+                """;
+        // one report: the unmarshal fails, and the to: after it is not run
+        assertThat(lines(yaml)).singleElement().satisfies(m -> {
+            assertThat(m).startsWith("Line 12: ");
+            assertThat(m).contains("route dedupe: unmarshal: json parses 
text");
+            assertThat(m).contains("but the body here is parsed data (a Map or 
a List), not text");
+            assertThat(m).contains("the body is data already, so leave this 
unmarshal out");
+            assertThat(m).contains("unmarshal it with variableSend and 
variableReceive set to its name");
+        });
+        // a variable that holds data
+        String variable = """
+                - route:
+                    id: dedupe
+                    from:
+                      uri: file:orders
+                      steps:
+                        - unmarshal:
+                            json: {}
+                            variableReceive: order
+                        - unmarshal:
+                            json: {}
+                            variableSend: order
+                            variableReceive: order
+                """;
+        assertThat(lines(variable)).singleElement().satisfies(m -> {
+            assertThat(m).contains("but the variable order holds parsed data 
(a Map or a List), not text");
+            assertThat(m).contains("${variable.order[name]}");
+        });
+        // the text of the file is in the body still: that unmarshal is right
+        assertThat(lines(variable.replace("variableSend: order", 
"variableSend: other"))).isEmpty();
+    }
+
+    @Test
+    public void testAFieldReadOnAVariableThatHoldsTheFile() {
+        // what the benchmark wrote (r22): the customer file is polled into a 
variable, and read as if it were parsed
+        String yaml = """
+                - route:
+                    id: enrich
+                    from:
+                      uri: direct:enrich
+                      steps:
+                        - pollEnrich:
+                            constant: 
file:customers?fileName=C-482.json&noop=true
+                            variableReceive: customer
+                            timeout: 1000
+                        - setHeader:
+                            name: customerName
+                            simple: ${variable.customer[name]}
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> {
+            assertThat(m).startsWith("Line 11: ");
+            assertThat(m).contains("route enrich: simple reads a field of the 
variable customer"
+                                   + " (${variable.customer[name]})");
+            assertThat(m).contains("the variable holds the file as it was read 
(a GenericFile)");
+            assertThat(m).contains("pollEnrich: file:customers gives it");
+            assertThat(m).contains("unmarshal: {json: {}, variableSend: 
customer, variableReceive: customer}");
+        });
+        // the .name form, and in a log message
+        assertThat(lines(yaml.replace("${variable.customer[name]}", 
"${variable.customer.name}"))).hasSize(1);
+        assertThat(lines(yaml.replace("""
+                        - setHeader:
+                            name: customerName
+                            simple: ${variable.customer[name]}
+                """, """
+                        - log: "customer ${variables.customer.name}"
+                """))).singleElement()
+                .satisfies(m -> 
assertThat(m).contains("(${variables.customer.name})"));
+        // what the file has, the variable itself, and the variable once 
unmarshalled
+        assertThat(lines(yaml.replace("${variable.customer[name]}", 
"${variable.customer.fileName}"))).isEmpty();
+        assertThat(lines(yaml.replace("${variable.customer[name]}", 
"${variable.customer}"))).isEmpty();
+        assertThat(lines(yaml.replace("""
+                        - setHeader:
+                """, """
+                        - unmarshal:
+                            json: {}
+                            variableSend: customer
+                            variableReceive: customer
+                        - setHeader:
+                """))).isEmpty();
+        // a bean may set any variable: nothing is known after it
+        assertThat(lines(yaml.replace("""
+                        - setHeader:
+                """, """
+                        - bean:
+                            ref: customers
+                        - setHeader:
+                """))).isEmpty();
+        // a setVariable of the file body is the file too
+        String copied = """
+                - route:
+                    id: copy
+                    from:
+                      uri: file:customers
+                      steps:
+                        - setVariable:
+                            name: customer
+                            simple: ${body}
+                        - setBody:
+                            constant: done
+                        - setHeader:
+                            name: customerName
+                            simple: ${variable.customer[name]}
+                """;
+        assertThat(lines(copied)).singleElement()
+                .satisfies(m -> assertThat(m).contains("simple reads a field 
of the variable customer"));
+    }
 }

Reply via email to