This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch CAMEL-25928-r22-body-type-gaps
in repository https://gitbox.apache.org/repos/asf/camel.git

commit ed0db043a0e7bf26211ec86cbb48c755a6b99511
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Oct 26 09:37:43 2026 +0100

    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]>
---
 .../org/apache/camel/catalog/models/wireTap.json   |   1 +
 .../META-INF/org/apache/camel/model/wireTap.json   |   1 +
 .../org/apache/camel/model/WireTapDefinition.java  |   6 +-
 .../apache/camel/processor/EipBodyTypesTest.java   |   9 +-
 .../user-manual/modules/ROOT/pages/body-types.adoc |   8 +
 .../camel/dsl/yaml/validator/BodyTypeFlow.java     |   7 +-
 .../camel/dsl/yaml/validator/CatalogBodyTypes.java |   7 +
 .../camel/dsl/yaml/validator/TextBodyFlow.java     | 479 +++++++++++++++++++--
 .../camel/dsl/yaml/validator/BodyTypeFlowTest.java |  37 ++
 .../dsl/yaml/validator/OneOfAlternativesTest.java  |   2 +-
 .../camel/dsl/yaml/validator/TextBodyFlowTest.java | 248 +++++++++++
 11 files changed, 769 insertions(+), 36 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/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/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/docs/user-manual/modules/ROOT/pages/body-types.adoc 
b/docs/user-manual/modules/ROOT/pages/body-types.adoc
index 53b77f8c6824..32a79e9a490e 100644
--- a/docs/user-manual/modules/ROOT/pages/body-types.adoc
+++ b/docs/user-manual/modules/ROOT/pages/body-types.adoc
@@ -289,6 +289,14 @@ 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 before the 
marshal.
+
 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..43088d2623e4 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,13 @@ 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_])");
+
     /** ${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 +149,27 @@ 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. */
+    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 +185,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 +211,25 @@ 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. */
         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 +285,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 +325,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 +345,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 +403,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 +430,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 +508,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 +654,317 @@ 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)));
+        if (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 +1167,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 +1191,39 @@ 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.
+     */
+    private static String beforeTheMarshal(String name, JsonNode value) {
+        for (Template template : templates(name, value)) {
+            String found = simpleRead(template.text(), TEXT_PROPERTIES);
+            if (found != null) {
+                return " - and " + template.where() + " reads " + found + ", 
which is text after the marshal: read it"
+                       + " before the marshal into a header (setHeader with 
simple " + found + ") and use the header"
+                       + " here";
+            }
+        }
+        return " (read the fields you still need, such as for a file name or a 
log, before the marshal, for example into"
+               + " a header)";
     }
 
     /**
@@ -1060,7 +1472,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 
header with setHeader, 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..e992cb9929d7 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,252 @@ 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 header with setHeader");
+            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]}"
+                        - setHeader:
+                            name: ticketId
+                            simple: ${body[ticketId]}
+                        - marshal:
+                            json: {}
+                        - toD:
+                """).replace("${body[ticketId]}.json", 
"${header.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 header");
+        });
+        // 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