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

davsclaus pushed a commit to branch feature/CAMEL-24844-parsed-data-needs-text
in repository https://gitbox.apache.org/repos/asf/camel.git

commit f27df5f4e160145358e20f03d5509d9685216d11
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Oct 18 11:25:05 2026 +0200

    CAMEL-24844: the validator reports parsed data that is sent or written 
where text is needed
    
    After unmarshal (json, jacksonXml, yaml, csv) the body is a Map or a List.
    TextBodyFlow now follows that state, through a Groovy collection call that
    keeps it data (body.find { it.sku == ... }) and through a split whose steps
    read fields of each element, and reports it where the runtime fails with
    "No type converter available to convert from type: java.util.LinkedHashMap
    to the required type: java.io.InputStream":
    
    - a to: file (ftp, ftps, sftp) that writes it
    - an http producer that sends it with POST, in a route whose consumer brings
      no HTTP headers
    - the end of a platform-http route, where the body is the reply
    
    No report on 1101 human-written route files. On the routes the local model
    wrote in the r17 benchmark series, it catches the order lines posted to the
    stock service (2 of the 3 runs that failed on it).
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../camel/dsl/yaml/validator/TextBodyFlow.java     | 321 ++++++++++++++++++---
 .../camel/dsl/yaml/validator/TextBodyFlowTest.java | 285 ++++++++++++++++++
 2 files changed, 567 insertions(+), 39 deletions(-)

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 1a90314cfb73..9554ee9ab067 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
@@ -16,6 +16,7 @@
  */
 package org.apache.camel.dsl.yaml.validator;
 
+import java.text.MessageFormat;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -28,6 +29,7 @@ import java.util.regex.Pattern;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.networknt.schema.Error;
 import com.networknt.schema.path.NodePath;
+import com.networknt.schema.path.PathType;
 import org.apache.camel.util.MimeTypeHelper;
 
 import static org.apache.camel.dsl.yaml.validator.RouteGraph.endpointOf;
@@ -43,6 +45,11 @@ import static 
org.apache.camel.dsl.yaml.validator.RouteGraph.scheme;
  * such property: sku for class: java.lang.Byte". The walk follows the steps 
of each route in order, and into the
  * {@code direct:} and {@code seda:} routes it calls, and reports only where 
it is certain the body is text: anything it
  * does not know makes it say nothing (CAMEL-24844).
+ * <p/>
+ * The other way round, parsed data where a step needs text: after {@code 
unmarshal: json} the body is a Map or a List,
+ * and a {@code to: file}, an http producer that sends it with POST, or the 
reply of a {@code platform-http} route fails
+ * at runtime, because no type converter turns a Map into the stream they 
write: "No type converter available to convert
+ * from type: java.util.LinkedHashMap to the required type: 
java.io.InputStream".
  */
 final class TextBodyFlow {
 
@@ -83,11 +90,69 @@ final class TextBodyFlow {
     /** Endpoints that leave the body as it is. */
     private static final Set<String> KEEPS_THE_BODY = Set.of("log", "mock");
 
+    /** Steps that leave the body as it is. */
+    private static final Set<String> KEEPING_STEPS = Set.of("setHeader", 
"setHeaders", "setProperty", "setVariable",
+            "removeHeader", "removeHeaders", "removeProperty", 
"removeProperties", "log", "wireTap", "delay", "throttle",
+            "stop");
+
+    /** ${body}, ${body[lines]} or ${body.lines}: the body, or a field of it. 
*/
+    private static final Pattern THE_BODY = 
Pattern.compile("\\$\\{body(\\??\\.[A-Za-z_]\\w*|\\[[^\\]]+])*}");
+
+    /** The data formats whose unmarshal makes a Map or a List of the text. */
+    private static final Set<String> PARSES = Set.of("json", "jacksonXml", 
"yaml", "csv");
+
+    /** The unmarshal options that make something else of the text: an object 
of a class, or an iterator. */
+    private static final Set<String> NOT_A_MAP = Set.of("unmarshalType", 
"unmarshalTypeName", "lazyLoad");
+
+    /** The Groovy collection methods whose result is data again: a List or a 
Map. */
+    private static final Set<String> GIVES_DATA = Set.of("findAll", "collect", 
"collectEntries", "grep", "sort",
+            "groupBy", "unique");
+
+    /** The Groovy collection methods that pick an element, which is data when 
the closure reads its fields. */
+    private static final Set<String> PICKS = Set.of("find", "max", "min");
+
+    /** body.findAll {, the start of a collection method on the body. */
+    private static final Pattern COLLECTION_CALL = 
Pattern.compile("body\\s*\\??\\.\\s*([A-Za-z_]\\w*)\\s*\\{");
+
+    /** The parameter of a closure: x -> ... */
+    private static final Pattern CLOSURE_PARAMETER = 
Pattern.compile("^\\s*([A-Za-z_]\\w*)\\s*->");
+
+    /** The producers that write the body to a file. */
+    private static final Set<String> WRITES_A_FILE = Set.of("file", "ftp", 
"ftps", "sftp");
+
+    /**
+     * The consumers that bring no HTTP control headers, so an http producer 
after them picks POST for a body: after an
+     * HTTP consumer, the CamelHttpMethod header of the request picks the 
method instead.
+     */
+    private static final Set<String> NO_HTTP_HEADERS = Set.of("file", "ftp", 
"ftps", "sftp", "timer", "scheduler",
+            "cron", "quartz");
+
+    /** The options of the http producer: any other parameter goes into the 
query string, which makes the call a GET. */
+    private static final Set<String> HTTP_OPTIONS = Set.of("httpMethod", 
"throwExceptionOnFailure", "bridgeEndpoint",
+            "okStatusCodeRange", "followRedirects", "copyHeaders", 
"skipRequestHeaders", "skipResponseHeaders",
+            "disableStreamCache", "authMethod", "authUsername", 
"authPassword", "authBearerToken",
+            "sslContextParameters", "userAgent", "logHttpActivity", 
"lazyStartProducer", "useSystemProperties");
+
+    /** The HTTP methods that send the body. */
+    private static final Set<String> SENDS_A_BODY = Set.of("POST", "PUT", 
"PATCH");
+
     private TextBodyFlow() {
     }
 
-    /** What is known about the body at a step: text, and where it came from, 
or nothing (null). */
-    record Text(String origin, String format, boolean file) {
+    /** What is known about the body at a step: text or parsed data, and where 
it came from; or nothing (null). */
+    sealed interface Body permits Text, Parsed {
+
+        String origin();
+
+        String format();
+    }
+
+    /** Parsed data: the Map or the List an unmarshal makes of the text. */
+    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(String origin, String format) {
             this(origin, format, false);
@@ -136,10 +201,28 @@ final class TextBodyFlow {
                 at = at.append("from").append("steps");
             }
             String id = entry.has("route") && entry.get("route").has("id") ? 
entry.get("route").get("id").asText() : null;
-            walk(steps, consumerText(from), at, id, byFrom, errors, new 
HashSet<>());
+            String consumer = scheme(endpointOf(from));
+            boolean post = NO_HTTP_HEADERS.contains(consumer) && 
!setsHttpHeaders(steps);
+            Body end = walk(steps, consumerText(from), at, id, byFrom, errors, 
new HashSet<>(), post);
+            // the body at the end of a platform-http route is the reply, 
written as a stream
+            if ("platform-http".equals(consumer) && end instanceof Parsed 
parsed && steps != null && steps.isArray()
+                    && !steps.isEmpty() && steps.get(steps.size() - 
1).fieldNames().hasNext()) {
+                int last = steps.size() - 1;
+                
errors.add(error(at.append(last).append(steps.get(last).fieldNames().next()), 
id,
+                        new Need("the body at the end of the route is the 
reply to the HTTP request", "as the last step"),
+                        parsed));
+            }
         }
     }
 
+    /**
+     * Whether the steps set or read an HTTP control header (CamelHttpMethod, 
CamelHttpQuery...), which can make an http
+     * producer call with GET and leave the body out.
+     */
+    private static boolean setsHttpHeaders(JsonNode steps) {
+        return steps != null && 
steps.toString().toLowerCase(Locale.ROOT).contains("camelhttp");
+    }
+
     private static JsonNode from(JsonNode entry) {
         if (entry == null || !entry.isObject()) {
             return null;
@@ -167,51 +250,60 @@ final class TextBodyFlow {
 
     /**
      * Walks the steps in order with what is known about the body, reports the 
reads that need parsed data where it is
-     * text, and answers what is known at the end. A report is only made when 
errors is not null: the routes a step
-     * calls are walked for their outcome alone, and report from their own 
walk.
+     * text and the steps that need text where it is parsed data, and answers 
what is known at the end. A report is only
+     * made when errors is not null: the routes a step calls are walked for 
their outcome alone, and report from their
+     * own walk. post says an http producer here sends the body with POST: 
nothing in the route can pick another method.
      */
-    private static Text walk(
-            JsonNode steps, Text text, NodePath at, String routeId, 
Map<String, JsonNode> byFrom,
-            List<Error> errors, Set<String> seen) {
+    private static Body walk(
+            JsonNode steps, Body body, NodePath at, String routeId, 
Map<String, JsonNode> byFrom,
+            List<Error> errors, Set<String> seen, boolean post) {
         if (steps == null || !steps.isArray()) {
-            return text;
+            return body;
         }
         for (int j = 0; j < steps.size(); j++) {
             JsonNode step = steps.get(j);
             if (!step.isObject() || !step.fieldNames().hasNext()) {
-                text = null;
+                body = null;
                 continue;
             }
             String name = step.fieldNames().next();
             JsonNode value = step.get(name);
             NodePath here = at.append(j).append(name);
-            if (text != null && errors != null) {
+            if (body != null && errors != null) {
                 if (SAME_BODY_BLOCKS.contains(name)) {
-                    walkBlock(value, text, here, routeId, byFrom, errors, 
seen);
-                } else {
+                    walkBlock(value, body, here, routeId, byFrom, errors, 
seen, post);
+                } else if (body instanceof Text text) {
                     String read = readIn(value, text.readable());
                     if (read != null) {
                         errors.add(error(here, routeId, read, text));
                     }
+                } else if (body instanceof Parsed parsed) {
+                    Need need = needsText(name, value, post);
+                    Parsed element = "split".equals(name) ? 
splitElement(value, parsed) : null;
+                    if (need != null) {
+                        errors.add(error(here, routeId, need, parsed));
+                    } else if (element != null) {
+                        walk(value.get("steps"), element, 
here.append("steps"), routeId, byFrom, errors, seen, post);
+                    }
                 }
             }
-            text = after(name, value, step, text, byFrom, seen);
+            body = after(name, value, step, body, byFrom, seen);
         }
-        return text;
+        return body;
     }
 
     /**
      * A choice, filter or doTry: its predicates read the body at the step, 
and so do its steps until one changes it.
      */
     private static void walkBlock(
-            JsonNode node, Text text, NodePath at, String routeId, Map<String, 
JsonNode> byFrom,
-            List<Error> errors, Set<String> seen) {
+            JsonNode node, Body body, NodePath at, String routeId, Map<String, 
JsonNode> byFrom,
+            List<Error> errors, Set<String> seen, boolean post) {
         if (node == null) {
             return;
         }
         if (node.isArray()) {
             for (int i = 0; i < node.size(); i++) {
-                walkBlock(node.get(i), text, at.append(i), routeId, byFrom, 
errors, seen);
+                walkBlock(node.get(i), body, at.append(i), routeId, byFrom, 
errors, seen, post);
             }
             return;
         }
@@ -222,10 +314,10 @@ final class TextBodyFlow {
             String key = it.next();
             JsonNode child = node.get(key);
             if ("steps".equals(key)) {
-                walk(child, text, at.append(key), routeId, byFrom, errors, 
seen);
+                walk(child, body, at.append(key), routeId, byFrom, errors, 
seen, post);
             } else if (SAME_BODY_BLOCKS.contains(key) || child.isArray()) {
-                walkBlock(child, text, at.append(key), routeId, byFrom, 
errors, seen);
-            } else {
+                walkBlock(child, body, at.append(key), routeId, byFrom, 
errors, seen, post);
+            } else if (body instanceof Text text) {
                 String read = readEntry(key, child, text.readable());
                 if (read != null) {
                     errors.add(error(at.append(key), routeId, read, text));
@@ -235,11 +327,21 @@ final class TextBodyFlow {
     }
 
     /** What is known about the body after a step. */
-    private static Text after(
-            String name, JsonNode value, JsonNode step, Text text, Map<String, 
JsonNode> byFrom, Set<String> seen) {
+    private static Body after(
+            String name, JsonNode value, JsonNode step, Body body, Map<String, 
JsonNode> byFrom, Set<String> seen) {
         switch (name) {
             case "setBody", "transform" -> {
-                return setBodyText(value);
+                Text text = setBodyText(value);
+                if (text != null || !(body instanceof Parsed parsed)) {
+                    return text;
+                }
+                String call = keepsData(value);
+                return call != null
+                        ? new Parsed(parsed.origin() + ", and groovy " + call 
+ " keeps it data", parsed.format())
+                        : null;
+            }
+            case "unmarshal" -> {
+                return unmarshalled(value);
             }
             case "marshal" -> {
                 String format = value != null && value.isObject() && 
value.fieldNames().hasNext()
@@ -253,14 +355,14 @@ final class TextBodyFlow {
                 String type = value != null && value.isObject() && 
value.has("type") ? value.get("type").asText()
                         : value != null && value.isTextual() ? value.asText() 
: "";
                 return "String".equals(type) || 
"java.lang.String".equals(type) || "byte[]".equals(type)
-                        ? new Text("convertBodyTo " + type + " makes it text", 
text != null ? text.format() : null)
+                        ? new Text("convertBodyTo " + type + " makes it text", 
body != null ? body.format() : null)
                         : null;
             }
             case "to", "toD" -> {
                 String uri = endpointOf(value);
                 String scheme = scheme(uri);
                 if (scheme != null && KEEPS_THE_BODY.contains(scheme)) {
-                    return text;
+                    return body;
                 }
                 JsonNode called = uri != null ? byFrom.get(normalize(uri)) : 
null;
                 if (called == null || !"to".equals(name) || 
!seen.add(normalize(uri))) {
@@ -269,21 +371,20 @@ final class TextBodyFlow {
                 JsonNode from = from(called);
                 JsonNode steps = from.has("steps") ? from.get("steps")
                         : called.has("route") ? 
called.get("route").get("steps") : null;
-                Text end = walk(steps, text, new 
NodePath(com.networknt.schema.path.PathType.JSON_POINTER), null, byFrom,
-                        null, seen);
+                Body end = walk(steps, body, new 
NodePath(PathType.JSON_POINTER), null, byFrom,
+                        null, seen, false);
                 seen.remove(normalize(uri));
-                return end == null
-                        ? null
-                        : new Text(
-                                "to: " + normalize(uri) + " returns it as text 
(" + end.origin() + ")", end.format(),
-                                end.file());
-            }
-            case "setHeader", "setHeaders", "setProperty", "setVariable", 
"removeHeader", "removeHeaders",
-                    "removeProperty", "removeProperties", "log", "wireTap", 
"delay", "throttle", "stop" -> {
-                return text;
+                if (end instanceof Text t) {
+                    return new Text(
+                            "to: " + normalize(uri) + " returns it as text (" 
+ t.origin() + ")", t.format(),
+                            t.file());
+                } else if (end instanceof Parsed p) {
+                    return new Parsed("to: " + normalize(uri) + " returns it 
as data (" + p.origin() + ")", p.format());
+                }
+                return null;
             }
             default -> {
-                return null;
+                return KEEPING_STEPS.contains(name) ? body : null;
             }
         }
     }
@@ -316,6 +417,136 @@ final class TextBodyFlow {
         return null;
     }
 
+    /** The body after an unmarshal: parsed data for the data formats that 
make a Map or a List of the text. */
+    private static Parsed unmarshalled(JsonNode value) {
+        if (value == null || !value.isObject() || 
!value.fieldNames().hasNext()) {
+            return null;
+        }
+        String format = value.fieldNames().next();
+        if (!PARSES.contains(format)) {
+            return null;
+        }
+        JsonNode options = value.get(format);
+        for (String option : NOT_A_MAP) {
+            if (options != null && options.isObject() && options.has(option)) {
+                return null;
+            }
+        }
+        return new Parsed("unmarshal: " + format + " turns the text into 
data", format);
+    }
+
+    /**
+     * 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
+     * null.
+     */
+    static String keepsData(JsonNode value) {
+        JsonNode node = value != null && value.has("expression") ? 
value.get("expression") : value;
+        String script = node != null && node.isObject() ? 
expressionText(node.get("groovy")) : null;
+        if (script == null) {
+            return null;
+        }
+        String s = script.trim();
+        Matcher m = COLLECTION_CALL.matcher(s);
+        if (!m.lookingAt()) {
+            return null;
+        }
+        String method = m.group(1);
+        int depth = 1;
+        int close = m.end();
+        for (; close < s.length() && depth > 0; close++) {
+            char c = s.charAt(close);
+            depth += c == '{' ? 1 : c == '}' ? -1 : 0;
+        }
+        if (depth != 0 || close != s.length()) {
+            // the closure does not end the script: something is done with its 
result
+            return null;
+        }
+        String closure = s.substring(m.end(), close - 1);
+        Matcher p = CLOSURE_PARAMETER.matcher(closure);
+        String element = p.find() ? p.group(1) : "it";
+        boolean readsFields = Pattern.compile("(?<![\\w$])" + element + 
"\\s*(\\??\\.\\s*[A-Za-z_]|\\[)")
+                .matcher(closure).find();
+        return GIVES_DATA.contains(method) || PICKS.contains(method) && 
readsFields
+                ? "body." + method + " { ... }" : null;
+    }
+
+    /**
+     * The elements a split makes of parsed data: data, when it splits the 
body or a field of it with simple, and its
+     * steps read fields of each element before they change it, as 
${body[sku]} does. Otherwise null: a List may hold
+     * text as well.
+     */
+    private static Parsed splitElement(JsonNode value, Parsed parsed) {
+        JsonNode node = value != null && value.has("expression") ? 
value.get("expression") : value;
+        String expression = node != null && node.isObject() ? 
expressionText(node.get("simple")) : null;
+        JsonNode steps = value != null ? value.get("steps") : null;
+        if (expression == null || 
!THE_BODY.matcher(expression.trim()).matches() || steps == null
+                || !steps.isArray()) {
+            return null;
+        }
+        for (JsonNode step : steps) {
+            if (!step.isObject() || !step.fieldNames().hasNext()) {
+                return null;
+            }
+            String name = step.fieldNames().next();
+            if (readIn(step.get(name), TEXT_PROPERTIES) != null) {
+                return new Parsed(
+                        parsed.origin() + ", and split " + expression.trim() + 
" gives its elements as data",
+                        parsed.format());
+            }
+            if (!KEEPING_STEPS.contains(name)) {
+                return null;
+            }
+        }
+        return null;
+    }
+
+    /** What a step that needs the body as text does with it, and what to 
write; or null. */
+    private record Need(String what, String fix) {
+    }
+
+    /** A to: that needs the body as text: one that writes a file, or an http 
producer that sends it with POST. */
+    private static Need needsText(String name, JsonNode value, boolean post) {
+        if (!"to".equals(name) && !"toD".equals(name)) {
+            return null;
+        }
+        String uri = endpointOf(value);
+        String scheme = scheme(uri);
+        if (scheme == null) {
+            return null;
+        }
+        if (WRITES_A_FILE.contains(scheme)) {
+            return new Need(name + ": " + scheme + " writes the body to a 
file", "before this step");
+        }
+        if (post && ("http".equals(scheme) || "https".equals(scheme)) && 
sendsTheBody(uri, value)) {
+            return 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");
+        }
+        return null;
+    }
+
+    /**
+     * Whether an http producer sends the body: it picks POST for a body, 
unless the httpMethod option says otherwise,
+     * or a query string (in the uri, or a parameter that is not an option of 
the producer) makes it a GET.
+     */
+    private static boolean sendsTheBody(String uri, JsonNode value) {
+        if (uri.indexOf('?') >= 0) {
+            return false;
+        }
+        JsonNode parameters = value != null && value.isObject() ? 
value.get("parameters") : null;
+        if (parameters == null || !parameters.isObject()) {
+            return true;
+        }
+        for (var it = parameters.fieldNames(); it.hasNext();) {
+            if (!HTTP_OPTIONS.contains(it.next())) {
+                return false;
+            }
+        }
+        JsonNode method = parameters.get("httpMethod");
+        return method == null || 
SENDS_A_BODY.contains(method.asText().toUpperCase(Locale.ROOT));
+    }
+
     /** The text of an expression, short form or with expression: inside. */
     private static String expressionText(JsonNode node) {
         if (node == null) {
@@ -467,7 +698,7 @@ final class TextBodyFlow {
                 .keyword("type")
                 .instanceLocation(at)
                 .messageKey("type")
-                .format(new java.text.MessageFormat("{0}"))
+                .format(new MessageFormat("{0}"))
                 .arguments((routeId != null ? "route " + routeId + ": " : "") 
+ read
                            + (text.file()
                                    ? ", but the body here is the file as it 
was read (a GenericFile), not parsed data - "
@@ -475,4 +706,16 @@ final class TextBodyFlow {
                            + text.origin() + ": " + fix)
                 .build();
     }
+
+    private static Error error(NodePath at, String routeId, Need need, Parsed 
parsed) {
+        return Error.builder()
+                .keyword("type")
+                .instanceLocation(at)
+                .messageKey("type")
+                .format(new MessageFormat("{0}"))
+                .arguments((routeId != null ? "route " + routeId + ": " : "") 
+ need.what()
+                           + ", but the body here is parsed data (a Map or a 
List), not text - " + parsed.origin()
+                           + ": add marshal: " + parsed.format() + " " + 
need.fix())
+                .build();
+    }
 }
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 f2b65a35919f..820a03f707ac 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
@@ -18,6 +18,9 @@ package org.apache.camel.dsl.yaml.validator;
 
 import java.util.List;
 
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 
@@ -265,6 +268,288 @@ public class TextBodyFlowTest {
         assertThat(lines(yaml)).isEmpty();
     }
 
+    @Test
+    public void testParsedDataWrittenToAFile() {
+        // the order is unmarshalled to read its fields, and then written as 
it is: a Map is not file content
+        String yaml = """
+                - route:
+                    id: archive
+                    from:
+                      uri: file:inbox
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - setHeader:
+                            name: orderId
+                            simple: ${body[orderId]}
+                        - to:
+                            uri: file:archive
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> {
+            assertThat(m).startsWith("Line 12: ");
+            assertThat(m).contains("route archive: to: file writes the body to 
a file");
+            assertThat(m).contains("the body here is parsed data (a Map or a 
List), not text");
+            assertThat(m).contains("unmarshal: json turns the text into data: 
add marshal: json before this step");
+        });
+    }
+
+    @Test
+    public void testParsedDataSentWithPost() {
+        // a message with a body is sent as a POST, and the http producer 
cannot make a request body of a Map
+        String yaml = """
+                - route:
+                    from:
+                      uri: file:orders
+                      steps:
+                        - unmarshal:
+                            json:
+                              library: Jackson
+                        - to:
+                            uri: http://localhost:8080/orders
+                            parameters:
+                              throwExceptionOnFailure: false
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> {
+            assertThat(m).startsWith("Line 9: ");
+            assertThat(m).contains("to: http sends the body as the request (a 
message with a body is sent as a POST)");
+            assertThat(m).contains("add marshal: json before this step, or set 
httpMethod: GET when the call sends no body");
+        });
+    }
+
+    @Test
+    public void testSplitElementsSentWithPost() {
+        // what the benchmark wrote: each order line is a Map whose fields are 
read, and toD meant a GET lookup
+        String yaml = """
+                - route:
+                    id: check-stock
+                    from:
+                      uri: file:orders
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - split:
+                            simple: "${body[lines]}"
+                            steps:
+                              - setHeader:
+                                  name: sku
+                                  simple: "${body[sku]}"
+                              - toD:
+                                  uri: 
"http://localhost:8080/stock/${header.sku}";
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> {
+            assertThat(m).contains("route check-stock: toD: http sends the 
body as the request");
+            assertThat(m).contains("unmarshal: json turns the text into data, 
and split ${body[lines]} gives its"
+                                   + " elements as data");
+            assertThat(m).contains("or set httpMethod: GET when the call sends 
no body");
+        });
+    }
+
+    @Test
+    public void testSplitElementsNotReadAsDataSayNothing() {
+        // nothing in the split reads a field of the element, which may be 
text as well
+        String yaml = """
+                - route:
+                    from:
+                      uri: file:orders
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - split:
+                            simple: "${body[skus]}"
+                            steps:
+                              - setHeader:
+                                  name: sku
+                                  simple: "${body}"
+                              - to: http://localhost:8080/reserve
+                """;
+        assertThat(lines(yaml)).isEmpty();
+    }
+
+    @Test
+    public void testHttpCallsThatSendNoBodyAreFine() {
+        // a query string or httpMethod: GET makes the call a GET; a 
CamelHttpMethod header can, and so can the
+        // request of an HTTP consumer, whose headers the producer sees
+        String yaml = """
+                - route:
+                    from:
+                      uri: file:a
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - to: http://localhost:8080/stock?sku=A1
+                        - to:
+                            uri: http://localhost:8080/stock
+                            parameters:
+                              httpMethod: GET
+                - route:
+                    from:
+                      uri: file:b
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - to:
+                            uri: http://localhost:8080/stock
+                            parameters:
+                              sku: A1
+                - route:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - setBody:
+                            constant: resource:file:stock.json
+                        - unmarshal:
+                            json: {}
+                        - setHeader:
+                            name: CamelHttpMethod
+                            constant: GET
+                        - to: http://localhost:8080/stock
+                - route:
+                    from:
+                      uri: direct:c
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - to: http://localhost:8080/stock
+                """;
+        assertThat(lines(yaml)).isEmpty();
+    }
+
+    @Test
+    public void testParsedDataAsTheReply() {
+        // what the benchmark wrote: the stock is looked up in the parsed 
file, and the Map found is the reply
+        String yaml = """
+                - route:
+                    id: stock
+                    from:
+                      uri: platform-http:/stock
+                      steps:
+                        - setBody:
+                            constant: resource:file:stock.json
+                        - unmarshal:
+                            json: {}
+                        - setBody:
+                            groovy: body.find { it.sku == headers.sku }
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> {
+            assertThat(m).startsWith("Line 11: ");
+            assertThat(m).contains("route stock: the body at the end of the 
route is the reply to the HTTP request");
+            assertThat(m).contains("unmarshal: json turns the text into data, 
and groovy body.find { ... } keeps it data");
+            assertThat(m).contains("add marshal: json as the last step");
+        });
+    }
+
+    @Test
+    public void testTheCalledRouteReturnsData() {
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:lookup
+                      steps:
+                        - setBody:
+                            constant: resource:file:stock.json
+                        - unmarshal:
+                            json: {}
+                - route:
+                    id: stock
+                    from:
+                      uri: platform-http:/stock
+                      steps:
+                        - to: direct:lookup
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> {
+            assertThat(m).startsWith("Line 14: ");
+            assertThat(m).contains("to: direct:lookup returns it as data 
(unmarshal: json turns the text into data)");
+        });
+    }
+
+    @Test
+    public void testDataMadeTextAgainIsFine() {
+        String yaml = """
+                - route:
+                    from:
+                      uri: platform-http:/stock
+                      steps:
+                        - setBody:
+                            constant: resource:file:stock.json
+                        - unmarshal:
+                            json: {}
+                        - setBody:
+                            groovy: body.find { it.sku == headers.sku }
+                        - marshal:
+                            json: {}
+                - route:
+                    from:
+                      uri: file:orders
+                      steps:
+                        - unmarshal:
+                            csv: {}
+                        - setBody:
+                            simple: "${body.size()} rows"
+                        - to: file:report
+                """;
+        assertThat(lines(yaml)).isEmpty();
+    }
+
+    @Test
+    public void testWhatIsNotCertainlyDataSaysNothing() {
+        // a field of the element picked, an object of a class, an iterator, a 
bean: the body may be text, or not data
+        String yaml = """
+                - route:
+                    from:
+                      uri: platform-http:/qty
+                      steps:
+                        - setBody:
+                            constant: resource:file:stock.json
+                        - unmarshal:
+                            json: {}
+                        - setBody:
+                            groovy: body.find { it.sku == headers.sku }?.qty
+                - route:
+                    from:
+                      uri: platform-http:/order
+                      steps:
+                        - unmarshal:
+                            json:
+                              unmarshalType: com.example.Order
+                - route:
+                    from:
+                      uri: file:in
+                      steps:
+                        - unmarshal:
+                            csv:
+                              lazyLoad: true
+                        - to: file:out
+                - route:
+                    from:
+                      uri: platform-http:/total
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - bean:
+                            ref: totals
+                """;
+        assertThat(lines(yaml)).isEmpty();
+    }
+
+    @Test
+    public void testGroovyCallsThatKeepData() {
+        assertThat(TextBodyFlow.keepsData(groovy("body.findAll { it.qty > 0 
}"))).isEqualTo("body.findAll { ... }");
+        assertThat(TextBodyFlow.keepsData(groovy("body.find { line -> line.sku 
== headers.sku }")))
+                .isEqualTo("body.find { ... }");
+        assertThat(TextBodyFlow.keepsData(groovy("body.collect { [sku: it.sku, 
total: it.qty * it.price] }")))
+                .isEqualTo("body.collect { ... }");
+        // what follows the closure, or an element whose fields are not read, 
may be anything
+        assertThat(TextBodyFlow.keepsData(groovy("body.find { it.sku == 'A1' 
}.qty"))).isNull();
+        assertThat(TextBodyFlow.keepsData(groovy("body.find { it == 'A1' 
}"))).isNull();
+        assertThat(TextBodyFlow.keepsData(groovy("body.size()"))).isNull();
+    }
+
+    private static JsonNode groovy(String script) {
+        ObjectNode node = JsonNodeFactory.instance.objectNode();
+        node.putObject("expression").put("groovy", script);
+        return node;
+    }
+
     private static List<String> lines(String yaml) {
         try {
             return YamlValidator.describeAll(yaml, validator.validate(yaml));

Reply via email to