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

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new dd8c3d27b947 CAMEL-24961: camel-yaml-dsl-validator - the body walks 
read the body types of the catalog instead of their own tables (#28246)
dd8c3d27b947 is described below

commit dd8c3d27b947e44455029403fa157141f8a7af35
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Oct 24 08:47:33 2026 +0200

    CAMEL-24961: camel-yaml-dsl-validator - the body walks read the body types 
of the catalog instead of their own tables (#28246)
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../modules/ROOT/pages/camel-catalog.adoc          |   4 +
 .../modules/ROOT/pages/camel-jbang-validation.adoc |   8 +-
 .../camel/dsl/yaml/validator/BodyTypeFlow.java     |  25 +-
 .../camel/dsl/yaml/validator/CatalogBodyTypes.java | 348 +++++++++++++++++++++
 .../camel/dsl/yaml/validator/RouteGraph.java       |   9 +
 .../camel/dsl/yaml/validator/TextBodyFlow.java     | 154 +++++----
 .../camel/dsl/yaml/validator/YamlValidator.java    |  20 +-
 .../camel/dsl/yaml/validator/BodyTypeFlowTest.java |  36 +++
 .../camel/dsl/yaml/validator/TextBodyFlowTest.java | 230 +++++++++++++-
 9 files changed, 749 insertions(+), 85 deletions(-)

diff --git a/docs/user-manual/modules/ROOT/pages/camel-catalog.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-catalog.adoc
index ce268d3ee666..d97e2614ae78 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-catalog.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-catalog.adoc
@@ -191,6 +191,10 @@ options a side names exist, that `unchanged` is only on 
the body after a step an
 The values are read from the code that sets the body, and checked against the 
body types the message history records
 when the routes run.
 
+The YAML validator of the Camel CLI follows the body through the steps of a 
route with the body types, and reports a
+step that cannot work on it, such as a Map that a `to: file` cannot write (see
+xref:camel-jbang-validation.adoc#_what_a_running_integration_saw[Camel CLI 
validation]).
+
 === Documentation in the Catalog
 
 The catalog includes the AsciiDoc documentation pages from all modules. This 
makes the full
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-validation.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-validation.adoc
index 5553b58ff340..26c18f3af01c 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-validation.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-validation.adoc
@@ -75,8 +75,12 @@ A part of a Java route the parser cannot work out (a value 
computed at runtime)
 
 The YAML validator follows the message body through the steps of each route, 
and reports a step that cannot work on
 it: a field read such as `+${body.qty}+` on text that was never unmarshalled, 
or a Map that a `to: file` cannot write.
-Where the file does not tell what the body is, such as after a `platform-http` 
or `kafka` consumer, or after a call
-with `to: http`, it says nothing.
+What a component or a data format does with the body comes from the
+xref:camel-catalog.adoc#_body_types_in_the_catalog[body types in the catalog]: 
the file consumer gives the file it
+read, the `http` producer leaves the response as bytes or a stream, and 
`unmarshal: json` makes a Map or a List.
+Where they do not tell what the body is, the validator says nothing: after a 
`platform-http` consumer the body
+depends on the request, and the deserializer of a `kafka` consumer can be set 
on the component, which the route file
+does not show.
 
 When an integration runs the routes of the file with the message history on, 
such as with `camel run --dev`, the
 validator fills these gaps with what the integration saw at the endpoints of 
its routes: the body each consumer gave,
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 10ac2e4adcc4..613847f22302 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
@@ -29,6 +29,7 @@ import com.networknt.schema.path.NodePath;
 
 import static org.apache.camel.dsl.yaml.validator.RouteGraph.Route;
 import static org.apache.camel.dsl.yaml.validator.RouteGraph.endpointOf;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.from;
 import static org.apache.camel.dsl.yaml.validator.RouteGraph.normalize;
 import static org.apache.camel.dsl.yaml.validator.RouteGraph.routes;
 import static org.apache.camel.dsl.yaml.validator.RouteGraph.scheme;
@@ -63,21 +64,15 @@ final class BodyTypeFlow {
     /** The REST verbs that carry no body, so a route they send to starts with 
none. */
     private static final Set<String> VERBS_WITHOUT_BODY = Set.of("get", 
"delete", "head");
 
-    /** The consumers that produce no body of their own, so the message 
reaching the route has none. */
-    private static final Set<String> NO_BODY_CONSUMER = Set.of("timer", 
"quartz", "scheduler", "cron");
-
     private BodyTypeFlow() {
     }
 
-    static void check(JsonNode target, NodePath path, List<Error> errors) {
-        check(target, path, errors, Set.of());
-    }
-
     /**
-     * @param known the endpoints the caller knows deliver no body, such as 
{@code direct:getStock} for the GET
-     *              operation of an OpenAPI specification the route binds to 
(CAMEL-24844 phase B)
+     * @param known     the endpoints the caller knows deliver no body, such 
as {@code direct:getStock} for the GET
+     *                  operation of an OpenAPI specification the route binds 
to (CAMEL-24844 phase B)
+     * @param bodyTypes the body types of the catalog, which say the consumers 
that give no body (timer, cron)
      */
-    static void check(JsonNode target, NodePath path, List<Error> errors, 
Set<String> known) {
+    static void check(JsonNode target, NodePath path, List<Error> errors, 
Set<String> known, CatalogBodyTypes bodyTypes) {
         List<Route> routes = routes(target);
         if (routes.isEmpty()) {
             return;
@@ -106,7 +101,7 @@ final class BodyTypeFlow {
             if (reader == null) {
                 continue;
             }
-            if (!certainlyWithoutABody(r, callers, restless, new HashSet<>())) 
{
+            if (!certainlyWithoutABody(r, callers, restless, new HashSet<>(), 
bodyTypes)) {
                 continue;
             }
             errors.add(Error.builder()
@@ -131,7 +126,7 @@ final class BodyTypeFlow {
      */
     private static boolean certainlyWithoutABody(
             Route route, Map<Route, List<Route>> callers, Set<String> restless,
-            Set<Route> seen) {
+            Set<Route> seen, CatalogBodyTypes bodyTypes) {
         if (!seen.add(route)) {
             return false; // a cycle: say nothing
         }
@@ -139,7 +134,9 @@ final class BodyTypeFlow {
         if (scheme == null) {
             return false;
         }
-        if (NO_BODY_CONSUMER.contains(scheme)) {
+        // a consumer that produces no body of its own, as the catalog says: 
timer, cron, scheduler, quartz
+        CatalogBodyTypes.Answer out = bodyTypes.consumerOut(route.fromUri(), 
from(route.node()));
+        if (out != null && out.none()) {
             return true;
         }
         if (!"direct".equals(scheme) && !"seda".equals(scheme) && 
!"direct-vm".equals(scheme)) {
@@ -155,7 +152,7 @@ final class BodyTypeFlow {
             if (setsTheBodyBeforeSendingTo(caller, route)) {
                 return false;
             }
-            if (!certainlyWithoutABody(caller, callers, restless, seen)) {
+            if (!certainlyWithoutABody(caller, callers, restless, seen, 
bodyTypes)) {
                 return false;
             }
         }
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
new file mode 100644
index 000000000000..3259582da25f
--- /dev/null
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/CatalogBodyTypes.java
@@ -0,0 +1,348 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.yaml.validator;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.tooling.model.BaseOptionModel;
+import org.apache.camel.tooling.model.BodyTypeModel;
+import org.apache.camel.tooling.model.BodyTypeModel.OptionBodyTypeModel;
+import org.apache.camel.tooling.model.ComponentModel;
+import org.apache.camel.tooling.model.DataFormatModel;
+import org.apache.camel.tooling.model.EipModel;
+
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.scheme;
+
+/**
+ * The body types of the catalog (CAMEL-24961), as the walk of {@link 
TextBodyFlow} reads them: the body a consumer
+ * gives, the bodies a producer can send and the body it leaves, the body an 
unmarshal makes, and the bodies a consumer
+ * that answers its caller can reply with.
+ * <p/>
+ * A side holds for a step only when the step decides every option the side 
depends on. A component can be configured as
+ * well ({@code camel.component.kafka.valueDeserializer}), and the route file 
does not show that: an option of the
+ * component counts only when the endpoint sets it, and otherwise the side is 
not known for the step. A value that is
+ * only known when the route runs (a placeholder) makes it not known too.
+ */
+final class CatalogBodyTypes {
+
+    /** The carriers of a payload: the text, the bytes, the stream or the file 
as read or written. */
+    private static final Set<String> PAYLOAD = Set.of("text", "bytes", 
"stream", "file");
+
+    /** The carriers of parsed data. */
+    private static final Set<String> DATA = Set.of("map", "list");
+
+    private final CamelCatalog catalog;
+    private final CamelCatalog fallback;
+    private final Map<String, Optional<ComponentModel>> components = new 
ConcurrentHashMap<>();
+    private final Map<String, Optional<DataFormatModel>> dataFormats = new 
ConcurrentHashMap<>();
+    private volatile Map<String, List<String>> byModelName;
+
+    /**
+     * @param catalog  the catalog of the validator
+     * @param fallback the catalog to read the body types of a model from when 
the other has none, as the catalog of an
+     *                 older Camel version does; null for none
+     */
+    CatalogBodyTypes(CamelCatalog catalog, CamelCatalog fallback) {
+        this.catalog = catalog;
+        this.fallback = fallback;
+    }
+
+    /** What a side says for a step: its carriers, and its Java type and 
format when it has them. */
+    record Answer(List<String> carriers, String javaType, String format) {
+
+        /** A payload (text, bytes, a stream or a file), maybe no body as 
well, as an HTTP response without one. */
+        boolean payload() {
+            return carriers.stream().anyMatch(PAYLOAD::contains) && 
onlyPayload();
+        }
+
+        /** Nothing but a payload or no body: no Map, no List, no object of a 
class. */
+        boolean onlyPayload() {
+            return !carriers.isEmpty() && carriers.stream().allMatch(c -> 
PAYLOAD.contains(c) || "null".equals(c));
+        }
+
+        /** A Map or a List: an iterator is not one, as it is read once and 
has no index. */
+        boolean data() {
+            return !carriers.isEmpty() && DATA.containsAll(carriers);
+        }
+
+        /** No body: the consumer gives none (timer). */
+        boolean none() {
+            return carriers.equals(List.of("null"));
+        }
+
+        /** The body is left as it was. */
+        boolean unchanged() {
+            return carriers.equals(List.of("unchanged"));
+        }
+
+        /** The file as read. */
+        boolean file() {
+            return carriers.contains("file");
+        }
+
+        /** The data format of the format, as an unmarshal or a marshal names 
it: json, jacksonXml, csv or yaml. */
+        String dataFormat() {
+            if (format == null) {
+                return null;
+            }
+            return switch (format) {
+                case "json", "csv", "yaml" -> format;
+                case "xml" -> "jacksonXml";
+                default -> null;
+            };
+        }
+
+        /** The carriers in words, without no body: text, bytes, a stream or a 
file. */
+        String inWords() {
+            List<String> words = new ArrayList<>();
+            for (String carrier : carriers) {
+                switch (carrier) {
+                    case "null" -> {
+                        // a step that needs text takes no body too, and an 
HTTP response may have none
+                    }
+                    case "stream" -> words.add("a stream");
+                    case "file" -> words.add("a file");
+                    case "map" -> words.add("a Map");
+                    case "list" -> words.add("a List");
+                    case "iterator" -> words.add("an iterator");
+                    case "value" -> words.add("a value");
+                    case "pojo" -> words.add("an object");
+                    default -> words.add(carrier);
+                }
+            }
+            if (words.size() < 2) {
+                return String.join("", words);
+            }
+            return String.join(", ", words.subList(0, words.size() - 1)) + " 
or " + words.get(words.size() - 1);
+        }
+    }
+
+    /** The body a consumer gives the route it starts. */
+    Answer consumerOut(String uri, JsonNode from) {
+        return componentSide(uri, from, BodyTypeModel.CONSUMER_OUT);
+    }
+
+    /** The bodies a consumer that answers its caller can reply with: what the 
route can end with. */
+    Answer consumerReply(String uri, JsonNode from) {
+        return componentSide(uri, from, BodyTypeModel.CONSUMER_REPLY);
+    }
+
+    /** The bodies a producer can send or write. */
+    Answer producerIn(String uri, JsonNode to) {
+        return componentSide(uri, to, BodyTypeModel.PRODUCER_IN);
+    }
+
+    /** The body after a producer. */
+    Answer producerOut(String uri, JsonNode to) {
+        return componentSide(uri, to, BodyTypeModel.PRODUCER_OUT);
+    }
+
+    /** Whether the component of an endpoint is an HTTP component, by its 
label: platform-http. */
+    boolean http(String uri) {
+        ComponentModel model = component(scheme(uri));
+        return model != null && model.getLabel() != null && 
Arrays.asList(model.getLabel().split(",")).contains("http");
+    }
+
+    /**
+     * The body after an unmarshal with a data format of the YAML DSL (json, 
csv...) and its options: the data format of
+     * the catalog is the one its library option names, such as jackson for 
json.
+     */
+    Answer unmarshalOut(String key, JsonNode options) {
+        DataFormatModel model = dataFormat(key, options);
+        return model != null
+                ? answer(model.getBodyType(BodyTypeModel.UNMARSHAL_OUT), 
values(options), Set.of()) : null;
+    }
+
+    private Answer componentSide(String uri, JsonNode node, String side) {
+        ComponentModel model = component(scheme(uri));
+        if (model == null) {
+            return null;
+        }
+        Set<String> configurable = new HashSet<>();
+        for (BaseOptionModel option : model.getComponentOptions()) {
+            configurable.add(option.getName());
+        }
+        Map<String, String> set = values(node != null && node.isObject() ? 
node.get("parameters") : null);
+        String text = node != null && node.isTextual() ? node.asText()
+                : node != null && node.isObject() && node.has("uri") ? 
node.get("uri").asText() : null;
+        set.putAll(query(text));
+        return answer(model.getBodyType(side), set, configurable);
+    }
+
+    /**
+     * What a side says for the options a step sets: the body for an option 
value it names, or its body when the options
+     * it depends on keep their defaults; null when it is not known for the 
step.
+     */
+    private static Answer answer(BodyTypeModel side, Map<String, String> set, 
Set<String> configurable) {
+        if (side == null) {
+            return null;
+        }
+        Set<String> options = new LinkedHashSet<>(side.getDependsOn());
+        side.getByOption().forEach(o -> options.add(o.getOption()));
+        for (String option : options) {
+            String value = set.get(option);
+            if (value == null ? configurable.contains(option) : 
value.contains("{{") || value.contains("${")) {
+                // the component may set it, or its value is only known when 
the route runs
+                return null;
+            }
+        }
+        Answer matched = null;
+        for (OptionBodyTypeModel when : side.getByOption()) {
+            String value = set.get(when.getOption());
+            if (value != null && value.equalsIgnoreCase(when.getValue())) {
+                if (matched != null && 
!matched.carriers().equals(when.getCarriers())) {
+                    // two options the step sets say different bodies
+                    return null;
+                }
+                matched = new Answer(when.getCarriers(), when.getJavaType(), 
side.getFormat());
+            }
+        }
+        if (matched != null) {
+            return matched;
+        }
+        if (side.isUnknown() || 
side.getDependsOn().stream().anyMatch(set::containsKey)) {
+            return null;
+        }
+        return new Answer(side.getCarriers(), side.getJavaType(), 
side.getFormat());
+    }
+
+    /** The options of a node, by name in camel case: allowNullBody as well 
for allow-null-body. */
+    private static Map<String, String> values(JsonNode node) {
+        Map<String, String> answer = new HashMap<>();
+        if (node != null && node.isObject()) {
+            for (var it = node.fieldNames(); it.hasNext();) {
+                String key = it.next();
+                JsonNode value = node.get(key);
+                answer.put(camelCase(key), value.isValueNode() ? 
value.asText() : value.toString());
+            }
+        }
+        return answer;
+    }
+
+    /** The options in the query of an endpoint uri: file:inbox?noop=true. */
+    private static Map<String, String> query(String uri) {
+        Map<String, String> answer = new HashMap<>();
+        int q = uri != null ? uri.indexOf('?') : -1;
+        if (q < 0) {
+            return answer;
+        }
+        for (String pair : uri.substring(q + 1).split("&")) {
+            int eq = pair.indexOf('=');
+            if (eq > 0) {
+                answer.put(camelCase(pair.substring(0, eq)), pair.substring(eq 
+ 1));
+            }
+        }
+        return answer;
+    }
+
+    private static String camelCase(String name) {
+        if (name.indexOf('-') < 0) {
+            return name;
+        }
+        StringBuilder sb = new StringBuilder(name.length());
+        boolean upper = false;
+        for (char c : name.toCharArray()) {
+            if (c == '-') {
+                upper = true;
+            } else {
+                sb.append(upper ? Character.toUpperCase(c) : c);
+                upper = false;
+            }
+        }
+        return sb.toString();
+    }
+
+    private ComponentModel component(String scheme) {
+        if (scheme == null) {
+            return null;
+        }
+        return components.computeIfAbsent(scheme, s -> {
+            ComponentModel model = catalog.componentModel(s);
+            if ((model == null || model.getBodyTypes().isEmpty()) && fallback 
!= null) {
+                ComponentModel other = fallback.componentModel(s);
+                if (other != null && !other.getBodyTypes().isEmpty()) {
+                    model = other;
+                }
+            }
+            return Optional.ofNullable(model);
+        }).orElse(null);
+    }
+
+    private DataFormatModel dataFormat(String key, JsonNode options) {
+        List<String> names = dataFormatNames().getOrDefault(key, List.of());
+        String name = names.size() == 1 ? names.get(0) : null;
+        if (names.size() > 1) {
+            // json is jackson, gson, fastjson or jsonb: the library option 
names it
+            JsonNode library = options != null && options.isObject() ? 
options.get("library") : null;
+            String wanted = library != null && library.isValueNode() ? 
library.asText() : defaultLibrary(key);
+            name = names.stream().filter(n -> 
n.equalsIgnoreCase(wanted)).findFirst().orElse(null);
+        }
+        if (name == null) {
+            return null;
+        }
+        return dataFormats.computeIfAbsent(name, n -> {
+            DataFormatModel model = catalog.dataFormatModel(n);
+            if ((model == null || model.getBodyTypes().isEmpty()) && fallback 
!= null) {
+                DataFormatModel other = fallback.dataFormatModel(n);
+                if (other != null && !other.getBodyTypes().isEmpty()) {
+                    model = other;
+                }
+            }
+            return Optional.ofNullable(model);
+        }).orElse(null);
+    }
+
+    /** The default of the library option of a data format of the YAML DSL: 
Jackson for json. */
+    private String defaultLibrary(String key) {
+        EipModel model = catalog.eipModel(key);
+        if (model != null) {
+            for (EipModel.EipOptionModel option : model.getOptions()) {
+                if ("library".equals(option.getName()) && 
option.getDefaultValue() != null) {
+                    return option.getDefaultValue().toString();
+                }
+            }
+        }
+        return null;
+    }
+
+    /** The data formats of the catalog by the name the YAML DSL gives them: 
json to fastjson, gson, jackson, jsonb. */
+    private Map<String, List<String>> dataFormatNames() {
+        Map<String, List<String>> answer = byModelName;
+        if (answer == null) {
+            answer = new HashMap<>();
+            for (String name : catalog.findDataFormatNames()) {
+                DataFormatModel model = catalog.dataFormatModel(name);
+                if (model != null && model.getModelName() != null) {
+                    answer.computeIfAbsent(model.getModelName(), k -> new 
ArrayList<>()).add(name);
+                }
+            }
+            byModelName = answer;
+        }
+        return answer;
+    }
+}
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/RouteGraph.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/RouteGraph.java
index 686d6d1e8a7a..5e73988acea7 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/RouteGraph.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/RouteGraph.java
@@ -41,6 +41,15 @@ final class RouteGraph {
     record Route(String id, String fromUri, JsonNode steps, JsonNode node) {
     }
 
+    /** The from of a route entry, in both the canonical and the short form; 
null for another entry. */
+    static JsonNode from(JsonNode entry) {
+        if (entry == null || !entry.isObject()) {
+            return null;
+        }
+        JsonNode route = entry.get("route");
+        return route != null ? route.get("from") : entry.get("from");
+    }
+
     /** The routes of the file, in both the canonical and the short form. */
     static List<Route> routes(JsonNode target) {
         List<Route> answer = new ArrayList<>();
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 7805e50f33ed..feb84871f8b0 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
@@ -34,6 +34,7 @@ import com.networknt.schema.path.PathType;
 import org.apache.camel.util.MimeTypeHelper;
 
 import static org.apache.camel.dsl.yaml.validator.RouteGraph.endpointOf;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.from;
 import static org.apache.camel.dsl.yaml.validator.RouteGraph.normalize;
 import static org.apache.camel.dsl.yaml.validator.RouteGraph.scheme;
 
@@ -52,9 +53,13 @@ import static 
org.apache.camel.dsl.yaml.validator.RouteGraph.scheme;
  * 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".
  * <p/>
- * Where the walk does not know the body - after a consumer other than the 
file consumer, 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.
+ * What a component or a data format does with the body comes from the body 
types of the catalog (CAMEL-24961): the body
+ * a consumer gives (the file as read), what a producer can write (text, 
bytes, a stream or a file), the body it leaves
+ * (log keeps it, the http producer gives the response as bytes or a stream), 
what an unmarshal makes (a Map or a List),
+ * and what a consumer can reply with. Where the walk does not know the body - 
after a consumer or a to: the catalog
+ * 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.
  */
 final class TextBodyFlow {
 
@@ -92,9 +97,6 @@ final class TextBodyFlow {
     private static final Set<String> SAME_BODY_BLOCKS = Set.of("choice", 
"when", "otherwise", "filter", "doTry",
             "doCatch", "doFinally");
 
-    /** Endpoints that leave the body as it is. */
-    private static final Set<String> KEEPS_THE_BODY = Set.of("log", "mock");
-
     /** Steps that leave the body as it is. */
     private static final Set<String> KEEPING_STEPS = Set.of("setHeader", 
"setHeaders", "setProperty", "setVariable",
             "setVariables", "removeHeader", "removeHeaders", "removeProperty", 
"removeProperties", "removeVariable",
@@ -106,12 +108,6 @@ final class TextBodyFlow {
     /** $.sku or $['sku']: a jsonpath that reads a field of the body, which is 
then an object. */
     private static final Pattern JSONPATH_FIELD = 
Pattern.compile("^\\$\\s*(\\.\\s*[A-Za-z_]|\\[\\s*['\"])");
 
-    /** 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");
@@ -125,9 +121,6 @@ final class TextBodyFlow {
     /** 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.
@@ -185,21 +178,21 @@ 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, and the verbs of the
+     * 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.
+     * fields their JSON requests need, and the body types of the catalog.
      */
     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) {
+            Map<String, ProjectFacts.Observed> observed, CatalogBodyTypes 
bodyTypes) {
 
         /** The walk of a route a step calls: for its outcome alone. */
         Walk called() {
-            return new Walk(null, byFrom, null, seen, false, operations, 
requestFields, observed);
+            return new Walk(null, byFrom, null, seen, false, operations, 
requestFields, observed, bodyTypes);
         }
     }
 
-    static void check(JsonNode target, NodePath path, List<Error> errors, 
ProjectFacts facts) {
+    static void check(JsonNode target, NodePath path, List<Error> errors, 
ProjectFacts facts, CatalogBodyTypes bodyTypes) {
         if (target == null || !target.isArray()) {
             return;
         }
@@ -240,24 +233,26 @@ final class TextBodyFlow {
             String uri = endpointOf(from);
             String consumer = scheme(uri);
             boolean post = NO_HTTP_HEADERS.contains(consumer) && 
!setsHttpHeaders(steps);
-            Body start = consumerText(from);
+            Body start = uri != null ? consumerBody(uri, from, bodyTypes) : 
null;
             if (start == null && uri != null) {
                 // what the consumer gave in a running integration of the route
                 start = observed(observed, ProjectFacts.consumerKey(uri), 
"from: " + normalize(uri) + " gave");
             }
             Body end = walk(steps, start, at, new Walk(
                     id, byFrom, errors, new HashSet<>(), post, operations,
-                    requestFields, observed));
-            // the body at the end of a platform-http route, or of the route 
of a REST call, is the reply
+                    requestFields, observed, bodyTypes));
+            // 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));
-            if (("platform-http".equals(consumer) || rest) && end instanceof 
Parsed parsed && steps != null
+            CatalogBodyTypes.Answer reply = uri != null && !rest ? 
bodyTypes.consumerReply(uri, from) : null;
+            if ((rest || reply != null && reply.onlyPayload()) && end 
instanceof Parsed parsed && steps != null
                     && steps.isArray() && !steps.isEmpty() && 
steps.get(steps.size() - 1).fieldNames().hasNext()) {
                 int last = steps.size() - 1;
+                String caller = rest ? "REST call" : bodyTypes.http(uri) ? 
"HTTP request" : consumer + " request";
                 
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 "
-                                 + (rest ? "REST call" : "HTTP request"),
-                                "as the last step", rest),
+                                "the body at the end of the route is the reply 
to the " + caller, "as the last step",
+                                rest),
                         parsed));
             }
         }
@@ -272,20 +267,27 @@ final class TextBodyFlow {
         return steps != null && 
steps.toString().toLowerCase(Locale.ROOT).contains("camelhttp");
     }
 
-    private static JsonNode from(JsonNode entry) {
-        if (entry == null || !entry.isObject()) {
+    /**
+     * The body a consumer starts with, as the catalog says: the file as read 
(file, ftp), text or bytes (aws2-sqs,
+     * 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) {
+        CatalogBodyTypes.Answer out = bodyTypes.consumerOut(uri, from);
+        if (out == null) {
             return null;
         }
-        JsonNode route = entry.get("route");
-        return route != null ? route.get("from") : entry.get("from");
+        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()
+                    ? new Text(consumer + (out.carriers().size() > 1 ? " as " 
+ out.inWords() : ""), fileFormat(from), true)
+                    : new Text(consumer + " as " + out.inWords(), 
out.dataFormat());
+        }
+        return out.data() ? new Parsed(consumer + " as " + out.inWords(), 
out.dataFormat()) : null;
     }
 
-    /** The body a consumer starts with: the file consumer reads a file, the 
text as it is; others are not known. */
-    private static Text consumerText(JsonNode from) {
-        String uri = endpointOf(from);
-        if (!"file".equals(scheme(uri))) {
-            return null;
-        }
+    /** The format of the files a file consumer reads, by the extension it 
includes. */
+    private static String fileFormat(JsonNode from) {
         JsonNode parameters = from.get("parameters");
         String format = null;
         for (String key : new String[] { "include", "antInclude", "fileName" 
}) {
@@ -294,7 +296,21 @@ final class TextBodyFlow {
                 format = formatOf(parameters.get(key).asText().replace("\\", 
""));
             }
         }
-        return new Text("the file consumer gives it", format, true);
+        return format;
+    }
+
+    /**
+     * The body a producer 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.
+     */
+    private static Body producerBody(CatalogBodyTypes.Answer out, String 
origin) {
+        if (out == null) {
+            return null;
+        }
+        if (out.payload()) {
+            return new Text(origin + " as " + out.inWords(), out.dataFormat(), 
out.file());
+        }
+        return out.data() ? new Parsed(origin + " as " + out.inWords(), 
out.dataFormat()) : null;
     }
 
     /**
@@ -329,6 +345,9 @@ final class TextBodyFlow {
                     Parsed element = "split".equals(name) ? 
splitElement(value, parsed) : 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;
                     } else if (element != null) {
                         walk(value.get("steps"), element, 
here.append("steps"), w);
                     }
@@ -385,7 +404,7 @@ final class TextBodyFlow {
                         : null;
             }
             case "unmarshal" -> {
-                return unmarshalled(value);
+                return unmarshalled(value, w.bodyTypes());
             }
             case "marshal" -> {
                 String format = value != null && value.isObject() && 
value.fieldNames().hasNext()
@@ -404,11 +423,15 @@ final class TextBodyFlow {
             }
             case "to", "toD" -> {
                 String uri = endpointOf(value);
-                String scheme = scheme(uri);
-                if (scheme != null && KEEPS_THE_BODY.contains(scheme)) {
+                CatalogBodyTypes.Answer out = uri != null ? 
w.bodyTypes().producerOut(uri, value) : null;
+                if (out != null && out.unchanged()) {
+                    // log, a file write
                     return body;
                 }
                 Body returned = calledRoute(name, uri, body, w);
+                if (returned == null) {
+                    returned = producerBody(out, name + ": " + normalize(uri) 
+ " returns it");
+                }
                 if (returned == null && uri != null) {
                     // what the producer returned in a running integration of 
the route
                     returned = observed(w.observed(), 
ProjectFacts.producerKey(uri),
@@ -512,22 +535,18 @@ 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) {
+    /**
+     * The body after an unmarshal: parsed data for a data format that makes a 
Map or a List of the text, as the catalog
+     * says with the options of the step (not with unmarshalType, which makes 
an object of a class, or lazyLoad, an
+     * iterator).
+     */
+    private static Parsed unmarshalled(JsonNode value, CatalogBodyTypes 
bodyTypes) {
         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);
+        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;
     }
 
     /**
@@ -654,8 +673,10 @@ final class TextBodyFlow {
     }
 
     /**
-     * A to: that needs the body as text: one that writes a file, an http 
producer that sends it with POST, or a
-     * rest-openapi call to an operation whose verb sends it (the producer 
calls with the verb of the specification).
+     * A to: that needs the body as text: a producer that the catalog says can 
only send or write text, bytes, a stream
+     * or a file (file, ftp), an http producer that sends it with POST, or a 
rest-openapi call to an operation whose
+     * verb sends it (the producer calls with the verb of the specification). 
The http method decides whether the http
+     * producer sends the body, and a REST binding may write it for 
rest-openapi: they keep their own rule.
      */
     private static Need needsText(String name, JsonNode value, Walk w) {
         if (!"to".equals(name) && !"toD".equals(name)) {
@@ -666,14 +687,6 @@ final class TextBodyFlow {
         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 (w.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");
-        }
         if ("rest-openapi".equals(scheme)) {
             Operation operation = operationOf(uri, value);
             String verb = operation != null
@@ -687,8 +700,21 @@ final class TextBodyFlow {
                                 + verb.toUpperCase(Locale.ROOT) + " in " + 
operation.spec() + ")",
                         "before this step", true, fields);
             }
+            return null;
         }
-        return null;
+        CatalogBodyTypes.Answer in = w.bodyTypes().producerIn(uri, value);
+        if (in == null || !in.onlyPayload()) {
+            return null;
+        }
+        if ("http".equals(scheme) || "https".equals(scheme)) {
+            return w.post() && sendsTheBody(uri, value)
+                    ? 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");
     }
 
     /**
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
index d51ac3e72cb8..576624c766dc 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
@@ -87,6 +87,9 @@ public class YamlValidator {
     private final boolean canonical;
     private final String schemaJson;
     private CamelCatalog catalog;
+    /** The catalog of another Camel version than the one of the validator 
(CAMEL-24711), if given. */
+    private final boolean otherCatalog;
+    private CatalogBodyTypes bodyTypes;
     private Schema schema;
     /**
      * The schema for a Kamelet: the envelope, with its {@code spec.template} 
being one entry of the route schema. A
@@ -121,6 +124,7 @@ public class YamlValidator {
         this.canonical = canonical;
         this.schemaJson = schemaJson;
         this.catalog = catalog;
+        this.otherCatalog = catalog != null;
     }
 
     public boolean isCanonical() {
@@ -848,9 +852,10 @@ public class YamlValidator {
             checkSimpleSyntaxInScripts(camelOnly, new 
NodePath(PathType.JSON_POINTER), errors);
             checkDynamicUri(camelOnly, new NodePath(PathType.JSON_POINTER), 
errors);
             // where the body comes from, across the routes of the file 
(CAMEL-24844)
-            BodyTypeFlow.check(camelOnly, new NodePath(PathType.JSON_POINTER), 
errors, facts.bodylessEndpoints());
+            BodyTypeFlow.check(camelOnly, new NodePath(PathType.JSON_POINTER), 
errors, facts.bodylessEndpoints(),
+                    bodyTypes());
             // a field read on a body that is still text (CAMEL-24844)
-            TextBodyFlow.check(camelOnly, new NodePath(PathType.JSON_POINTER), 
errors, facts);
+            TextBodyFlow.check(camelOnly, new NodePath(PathType.JSON_POINTER), 
errors, facts, bodyTypes());
         }
         if (canonical) {
             checkOneOfCardinality(camelOnly, new 
NodePath(PathType.JSON_POINTER), errors);
@@ -1564,6 +1569,17 @@ public class YamlValidator {
         return catalog;
     }
 
+    /**
+     * The body types of the catalog (CAMEL-24961). The catalog of an older 
Camel version has none, so the body types of
+     * the validator's own catalog stand in for a model that has none.
+     */
+    private CatalogBodyTypes bodyTypes() {
+        if (bodyTypes == null) {
+            bodyTypes = new CatalogBodyTypes(catalog(), otherCatalog ? new 
DefaultCamelCatalog() : null);
+        }
+        return bodyTypes;
+    }
+
     private void collectProperties(JsonNode node, Set<String> answer, int 
depth) {
         if (node == null || node.isMissingNode() || depth > 3) {
             return;
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 076987525d9e..c2bf215eedd8 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
@@ -93,6 +93,42 @@ public class BodyTypeFlowTest {
         assertThat(messages(yaml)).anyMatch(m -> m.contains("reads the message 
body"));
     }
 
+    @Test
+    public void testTheConsumersWithoutABodyComeFromTheCatalog() {
+        // CAMEL-24961: cron gives no body, and neither does an ftp consumer 
that does not download the file
+        String yaml = """
+                - route:
+                    id: nightly
+                    from:
+                      uri: cron:nightly
+                      parameters:
+                        schedule: 0 0 2 * * ?
+                      steps:
+                        - setHeader:
+                            name: sku
+                            jsonpath: $.sku
+                - route:
+                    id: listing
+                    from:
+                      uri: ftp:files.example.com/inbox?download=false
+                      steps:
+                        - setHeader:
+                            name: sku
+                            jsonpath: $.sku
+                - route:
+                    id: files
+                    from:
+                      uri: ftp:files.example.com/inbox
+                      steps:
+                        - setHeader:
+                            name: sku
+                            jsonpath: $.sku
+                """;
+        assertThat(messages(yaml)).satisfiesExactly(
+                m -> assertThat(m).contains("route nightly: jsonpath reads the 
message body"),
+                m -> assertThat(m).contains("route listing: jsonpath reads the 
message body"));
+    }
+
     @Test
     public void testAPostCarriesABodySoNothingIsSaid() {
         String yaml = """
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 976f97598d03..5c0085afe5a8 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
@@ -16,6 +16,7 @@
  */
 package org.apache.camel.dsl.yaml.validator;
 
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -23,6 +24,9 @@ import java.util.Set;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.node.JsonNodeFactory;
 import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.tooling.model.ComponentModel;
+import org.apache.camel.tooling.model.DataFormatModel;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 
@@ -289,7 +293,7 @@ public class TextBodyFlowTest {
                 """;
         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("route archive: to: file needs the body as 
text, bytes, a stream or 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");
         });
@@ -313,7 +317,7 @@ public class TextBodyFlowTest {
                         - to: file:valid
                 """;
         assertThat(lines(yaml)).singleElement()
-                .satisfies(m -> assertThat(m).contains("to: file writes the 
body to a file"));
+                .satisfies(m -> assertThat(m).contains("to: file needs the 
body as text, bytes, a stream or a file"));
     }
 
     @Test
@@ -818,7 +822,8 @@ public class TextBodyFlowTest {
         ProjectFacts seen = observed(ProjectFacts.producerKey("sql:select * 
from orders"),
                 List.of("java.util.ArrayList"), List.of("collection"));
         assertThat(lines(yaml, seen)).singleElement().satisfies(m -> {
-            assertThat(m).contains("to: file writes the body to a file, but 
the body here is parsed data");
+            assertThat(m)
+                    .contains("to: file needs the body as text, bytes, a 
stream or a file, but the body here is parsed data");
             assertThat(m).contains("in the running app, to: sql:select * from 
orders returned an ArrayList");
             // the walk knows no data format for what the running app saw
             assertThat(m).contains("add marshal with the data format to write 
(such as marshal: json) before this step");
@@ -866,6 +871,225 @@ public class TextBodyFlowTest {
         });
     }
 
+    @Test
+    public void testTheConsumersOfTheCatalog() {
+        // CAMEL-24961: what a consumer gives comes from the body types of the 
catalog
+        String yaml = """
+                - route:
+                    id: sqs
+                    from:
+                      uri: aws2-sqs:orders
+                      steps:
+                        - setHeader:
+                            name: orderId
+                            simple: ${body[orderId]}
+                - route:
+                    id: mqtt
+                    from:
+                      uri: paho-mqtt5:sensors
+                      steps:
+                        - setBody:
+                            groovy: body.temperature
+                - route:
+                    id: ftp
+                    from:
+                      uri: ftp:files.example.com/inbox
+                      parameters:
+                        include: .*\\.json
+                      steps:
+                        - setHeader:
+                            name: orderId
+                            simple: ${body[orderId]}
+                - route:
+                    id: ftpStream
+                    from:
+                      uri: ftp:files.example.com/inbox?streamDownload=true
+                      steps:
+                        - setHeader:
+                            name: orderId
+                            simple: ${body[orderId]}
+                """;
+        assertThat(lines(yaml)).satisfiesExactly(
+                m -> assertThat(m).contains("route sqs: simple reads a field 
of the body (${body[orderId]})")
+                        .contains("the body here is still text, not parsed 
data - the aws2-sqs consumer gives it as text"),
+                m -> assertThat(m).contains("route mqtt: groovy reads fields 
of the body (body.temperature)")
+                        .contains("the paho-mqtt5 consumer gives it as bytes"),
+                m -> assertThat(m).contains("route ftp: simple reads a field 
of the body")
+                        .contains("the body here is the file as it was read (a 
GenericFile), not parsed data")
+                        .contains("the ftp consumer gives it: add unmarshal: 
json before this step"),
+                m -> assertThat(m).contains("route ftpStream: simple reads a 
field of the body")
+                        .contains("the ftp consumer gives it as a stream"));
+    }
+
+    @Test
+    public void testTheProducersOfTheCatalog() {
+        // the response of the http producer is bytes or a stream, a row of 
sql is data, and a file write keeps the body
+        String yaml = """
+                - route:
+                    id: price
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - to: https://api.example.com/prices
+                        - setHeader:
+                            name: price
+                            simple: ${body[price]}
+                - route:
+                    id: rows
+                    from:
+                      uri: sql:select * from orders
+                      steps:
+                        - to: file:orders
+                - route:
+                    id: archive
+                    from:
+                      uri: file:inbox
+                      steps:
+                        - to: file:backup
+                        - setBody:
+                            groovy: body.items
+                - route:
+                    id: sensors
+                    from:
+                      uri: file:readings
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - to: paho-mqtt5:readings
+                """;
+        assertThat(lines(yaml)).satisfiesExactly(
+                m -> assertThat(m).contains("route price: simple reads a field 
of the body (${body[price]})")
+                        .contains("to: https://api.example.com/prices returns 
it as bytes or a stream: unmarshal it first"),
+                m -> assertThat(m).contains("route rows: to: file needs the 
body as text, bytes, a stream or a file")
+                        .contains("the sql consumer gives it as a Map: add 
marshal with the data format to write"),
+                m -> assertThat(m).contains("route archive: groovy reads 
fields of the body (body.items)")
+                        .contains("the file as it was read (a GenericFile)"),
+                m -> assertThat(m).contains("route sensors: to: paho-mqtt5 
needs the body as text, bytes, a stream or a file")
+                        .contains("unmarshal: json turns the text into data: 
add marshal: json before this step"));
+    }
+
+    @Test
+    public void testTheOptionsTheBodyDependsOn() {
+        // xslt with output DOM gives a Document, an unmarshal with useList a 
List, gson is not known, and with download
+        // false the ftp consumer gives no body
+        String yaml = """
+                - route:
+                    from:
+                      uri: file:in
+                      steps:
+                        - to: xslt:order.xsl?output=DOM
+                        - setBody:
+                            groovy: body.order
+                - route:
+                    from:
+                      uri: file:in
+                      steps:
+                        - unmarshal:
+                            json:
+                              library: Gson
+                        - to: file:out
+                - route:
+                    from:
+                      uri: ftp:files.example.com/inbox?download=false
+                      steps:
+                        - setBody:
+                            groovy: body.items
+                - route:
+                    from:
+                      uri: file:in
+                      steps:
+                        - unmarshal:
+                            json:
+                              useList: true
+                        - to: file:out
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> assertThat(m)
+                .contains("to: file needs the body as text, bytes, a stream or 
a file, but the body here is parsed data"));
+    }
+
+    @Test
+    public void testWhatTheComponentMayConfigureSaysNothing() {
+        // the valueDeserializer of kafka and the operation of aws2-sqs can be 
set on the component, which the route
+        // does not show; a placeholder is only known when the route runs
+        String yaml = """
+                - route:
+                    from:
+                      uri: kafka:orders
+                      steps:
+                        - setHeader:
+                            name: orderId
+                            simple: ${body[orderId]}
+                - route:
+                    from:
+                      uri: file:in
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - to: aws2-sqs:orders
+                - route:
+                    from:
+                      uri: 
ftp:files.example.com/inbox?streamDownload={{stream}}
+                      steps:
+                        - setHeader:
+                            name: orderId
+                            simple: ${body[orderId]}
+                """;
+        assertThat(lines(yaml)).isEmpty();
+    }
+
+    @Test
+    public void testOneReportForAStepThatFails() {
+        // the first write fails at runtime, so the second does not run with 
this body
+        String yaml = """
+                - route:
+                    from:
+                      uri: file:inbox
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - to: file:archive
+                        - to: file:backup
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> 
assertThat(m).startsWith("Line 7: "));
+    }
+
+    @Test
+    public void testACatalogWithoutBodyTypes() throws Exception {
+        // the catalog of an older Camel version has no body types: the 
validator's own catalog stands in for it
+        DefaultCamelCatalog older = new DefaultCamelCatalog() {
+            @Override
+            public ComponentModel componentModel(String name) {
+                ComponentModel model = super.componentModel(name);
+                if (model != null) {
+                    model.setBodyTypes(new LinkedHashMap<>());
+                }
+                return model;
+            }
+
+            @Override
+            public DataFormatModel dataFormatModel(String name) {
+                DataFormatModel model = super.dataFormatModel(name);
+                if (model != null) {
+                    model.setBodyTypes(new LinkedHashMap<>());
+                }
+                return model;
+            }
+        };
+        YamlValidator other = new YamlValidator(false, null, older);
+        other.init();
+        String yaml = """
+                - route:
+                    from:
+                      uri: file:inbox
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - to: file:archive
+                """;
+        assertThat(YamlValidator.describeAll(yaml, 
other.validate(yaml))).singleElement()
+                .satisfies(m -> assertThat(m).contains("to: file needs the 
body as text, bytes, a stream or a file"));
+    }
+
     private static ProjectFacts observed(String key, List<String> types, 
List<String> kinds) {
         return new ProjectFacts(Set.of(), Map.of(), null, Map.of(), 
Map.of(key, new ProjectFacts.Observed(types, kinds)));
     }

Reply via email to