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

davsclaus pushed a commit to branch feature/CAMEL-25749-body-facts
in repository https://gitbox.apache.org/repos/asf/camel.git

commit bfb208b175789a571516bd7116f70cac6be99089
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Oct 18 16:36:46 2026 +0200

    CAMEL-25749: camel-support - BodyFacts says what the body is, the step that 
set it, and what to write; the existing body hints move onto it
    
    BodyFacts.describe(exchange) says what the body is in plain words and,
    from the message history, the step that set it ("the body is a Map
    (LinkedHashMap), set by unmarshal at orders.yaml:9", or "unchanged since
    from: file://orders"). BodyFacts.hint(exchange, need[, target]) says what
    to write for what the failing step needed: DATA (it reads fields), BYTES
    (it writes or sends the payload) or A_BODY (it needs a body at all), in the
    data format the Content-Type, the file name or the text names.
    
    The sentences that existed move onto it, so the wording is one: the http
    producer (CAMEL-25309), the platform-http reply and the file write
    (CAMEL-25751), Groovy on text (CAMEL-25330), unmarshal on a null body
    (CAMEL-25285), jsonpath and jq on a null body (CAMEL-24838), and a missing
    key of the body Map (CAMEL-25322). The three copies of the Content-Type to
    data format mapping are gone.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../apache/camel/language/bean/BeanExpression.java |   6 +
 .../camel/component/file/FileOperations.java       |  49 +--
 .../component/file/FileProducerMapBodyTest.java    |  16 +-
 .../camel/language/groovy/GroovyExpression.java    |  59 +---
 .../groovy/GroovyMissingPropertyHintTest.java      |   8 +-
 .../apache/camel/component/http/HttpProducer.java  |  47 +--
 .../component/http/HttpProducerMapBodyTest.java    |   8 +-
 .../org/apache/camel/language/jq/JqExpression.java |  15 +-
 .../camel/language/jq/JqNullBodyHintTest.java      |   2 +-
 .../org/apache/camel/jsonpath/JsonPathEngine.java  |   9 +-
 .../camel/jsonpath/JsonPathNullBodyHintTest.java   |   2 +-
 .../http/vertx/VertxPlatformHttpSupport.java       |  66 +---
 .../vertx/VertxPlatformHttpMapResponseTest.java    |  17 +-
 .../language/simple/SimpleSyntaxHintsTest.java     |   5 +-
 .../camel/processor/UnmarshalProcessorTest.java    |   7 +-
 .../org/apache/camel/support/BodyFactsTest.java    | 161 +++++++++
 .../java/org/apache/camel/support/BodyFacts.java   | 381 +++++++++++++++++++++
 .../support/processor/UnmarshalProcessor.java      |  19 +-
 18 files changed, 652 insertions(+), 225 deletions(-)

diff --git 
a/components/camel-bean/src/main/java/org/apache/camel/language/bean/BeanExpression.java
 
b/components/camel-bean/src/main/java/org/apache/camel/language/bean/BeanExpression.java
index 8be103fe316a..bdc6d8a8757e 100644
--- 
a/components/camel-bean/src/main/java/org/apache/camel/language/bean/BeanExpression.java
+++ 
b/components/camel-bean/src/main/java/org/apache/camel/language/bean/BeanExpression.java
@@ -43,6 +43,7 @@ import 
org.apache.camel.component.bean.ParameterMappingStrategyHelper;
 import org.apache.camel.component.bean.RegistryBean;
 import org.apache.camel.component.bean.RequestBeanHolder;
 import org.apache.camel.spi.Language;
+import org.apache.camel.support.BodyFacts;
 import org.apache.camel.support.ExchangeHelper;
 import org.apache.camel.support.LanguageSupport;
 import org.apache.camel.util.KeyValueHolder;
@@ -595,6 +596,11 @@ public class BeanExpression implements Expression, 
Predicate {
         }
         try {
             Object bean = holder != null ? holder.getBean(exchange) : null;
+            if (bean instanceof Map<?, ?> && bean == 
exchange.getMessage().getBody()) {
+                // the body: which step made it this Map, as a split over an 
order's lines does (CAMEL-25749)
+                return hint + " (the Map has no key " + methodName + "; " + 
BodyFacts.describe(exchange) + ": "
+                       + BodyFacts.hint(exchange, BodyFacts.Need.DATA) + ")";
+            }
             if (bean instanceof Map<?, ?> map) {
                 String keys = 
map.keySet().stream().limit(MAX_KEYS_LISTED).map(String::valueOf)
                         .collect(Collectors.joining(", "));
diff --git 
a/components/camel-file/src/main/java/org/apache/camel/component/file/FileOperations.java
 
b/components/camel-file/src/main/java/org/apache/camel/component/file/FileOperations.java
index c30bac83e3eb..9d3d2395c1d6 100644
--- 
a/components/camel-file/src/main/java/org/apache/camel/component/file/FileOperations.java
+++ 
b/components/camel-file/src/main/java/org/apache/camel/component/file/FileOperations.java
@@ -38,11 +38,7 @@ import java.nio.file.attribute.PosixFilePermissions;
 import java.security.DigestInputStream;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
-import java.util.Collection;
 import java.util.Date;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
@@ -51,9 +47,9 @@ import org.apache.camel.Exchange;
 import org.apache.camel.InvalidPayloadException;
 import org.apache.camel.Message;
 import org.apache.camel.WrappedFile;
+import org.apache.camel.support.BodyFacts;
 import org.apache.camel.util.FileUtil;
 import org.apache.camel.util.IOHelper;
-import org.apache.camel.util.MimeTypeHelper;
 import org.apache.camel.util.ObjectHelper;
 import org.apache.camel.util.StringHelper;
 import org.slf4j.Logger;
@@ -393,27 +389,20 @@ public class FileOperations implements 
GenericFileOperations<File> {
     }
 
     /**
-     * The body as a stream to write. A Map or a List (a body unmarshalled 
from JSON, XML or CSV, or built as a map)
-     * cannot become one, and the converter's message only names the 
conversion: say what to do, in the data format the
-     * file name names, or name the usual ones when it names none 
(CAMEL-25751).
+     * The body as a stream to write. A Map, a List or an object (a body 
unmarshalled from JSON, XML or CSV, or built in
+     * the route) cannot become one, and the converter's message only names 
the conversion: say what the body is, the
+     * step that set it, and the marshal that writes it, in the data format 
the file name names (CAMEL-25751,
+     * CAMEL-25749).
      */
     static InputStream mandatoryBodyStream(Exchange exchange, Message message, 
File file) throws InvalidPayloadException {
         try {
             return message.getMandatoryBody(InputStream.class);
         } catch (InvalidPayloadException e) {
-            Object body = message.getBody();
-            if (body instanceof Map || body instanceof Collection) {
-                String format = 
dataFormatOf(MimeTypeHelper.probeMimeType(file.getName()));
+            String hint = BodyFacts.hint(exchange, BodyFacts.Need.BYTES, 
file.getName());
+            if (hint != null) {
                 InvalidPayloadException answer = new InvalidPayloadException(
                         exchange, InputStream.class, message,
-                        "a " + (body instanceof Map ? "Map" : body instanceof 
List ? "List" : "Collection")
-                                                              + " is not file 
content: "
-                                                              + (format != null
-                                                                      ? 
"marshal it first (marshal: " + format
-                                                                        + ", 
for the file name " + file.getName() + ")"
-                                                                      : 
"marshal it first with the data format of the file (such as"
-                                                                        + " 
marshal: json, marshal: jacksonXml or marshal: csv)")
-                                                              + " or set the 
body to the text");
+                        BodyFacts.describe(exchange) + ", which is not file 
content: " + hint);
                 answer.initCause(e.getCause() != null ? e.getCause() : e);
                 throw answer;
             }
@@ -421,28 +410,6 @@ public class FileOperations implements 
GenericFileOperations<File> {
         }
     }
 
-    /**
-     * The data format that writes a content type, as the YAML DSL names it: 
json, jacksonXml, csv or yaml; or null
-     * (CAMEL-25751; CAMEL-25749 moves this onto one helper).
-     */
-    static String dataFormatOf(String contentType) {
-        if (contentType == null) {
-            return null;
-        }
-        int semicolon = contentType.indexOf(';');
-        String type = (semicolon > 0 ? contentType.substring(0, semicolon) : 
contentType).trim().toLowerCase(Locale.ROOT);
-        if (type.endsWith("/json") || type.endsWith("+json")) {
-            return "json";
-        } else if (type.endsWith("/xml") || type.endsWith("+xml")) {
-            return "jacksonXml";
-        } else if (type.endsWith("/csv") || 
type.endsWith("/tab-separated-values")) {
-            return "csv";
-        } else if (type.endsWith("/yaml") || type.endsWith("/x-yaml")) {
-            return "yaml";
-        }
-        return null;
-    }
-
     @Override
     public boolean storeFileDirectly(String name, String payload) throws 
GenericFileOperationFailedException {
         File file = new File(name);
diff --git 
a/components/camel-file/src/test/java/org/apache/camel/component/file/FileProducerMapBodyTest.java
 
b/components/camel-file/src/test/java/org/apache/camel/component/file/FileProducerMapBodyTest.java
index b90f5ce7c0c0..4076e78a75e9 100644
--- 
a/components/camel-file/src/test/java/org/apache/camel/component/file/FileProducerMapBodyTest.java
+++ 
b/components/camel-file/src/test/java/org/apache/camel/component/file/FileProducerMapBodyTest.java
@@ -51,8 +51,8 @@ public class FileProducerMapBodyTest {
         String message = failure(order, null, "order.json");
         assertTrue(message.startsWith("Cannot store file: "), message);
         assertTrue(message.contains("but has type: java.util.LinkedHashMap"), 
message);
-        assertTrue(message.contains("a Map is not file content: marshal it 
first (marshal: json, for the file name"
-                                    + " order.json) or set the body to the 
text"),
+        assertTrue(message.contains("the body is a Map (LinkedHashMap), which 
is not file content: marshal it first"
+                                    + " (marshal: json, for the file name 
order.json), or set the body to the text"),
                 message);
         assertFalse(Files.exists(dir.resolve("order.json")));
     }
@@ -61,7 +61,8 @@ public class FileProducerMapBodyTest {
     public void testListBodyToACsvFileWithCharset() throws Exception {
         // the file name says the data format; a charset writes through a 
reader, made from the same stream
         String message = failure(List.of(List.of("A1", "2")), "UTF-8", 
"lines.csv");
-        assertTrue(message.contains("a List is not file content: marshal it 
first (marshal: csv, for the file name"
+        assertTrue(message.contains("the body is a List ("), message);
+        assertTrue(message.contains("which is not file content: marshal it 
first (marshal: csv, for the file name"
                                     + " lines.csv)"),
                 message);
     }
@@ -69,16 +70,19 @@ public class FileProducerMapBodyTest {
     @Test
     public void testFileNameThatNamesNoDataFormat() throws Exception {
         String message = failure(Map.of("sku", "A1"), null, "order.dat");
-        assertTrue(message.contains("a Map is not file content: marshal it 
first with the data format of the file (such as"
+        assertTrue(message.contains("which is not file content: marshal it 
first with the data format to write (such as"
                                     + " marshal: json, marshal: jacksonXml or 
marshal: csv)"),
                 message);
     }
 
     @Test
-    public void testOtherBodyKeepsTheMessage() throws Exception {
+    public void testObjectBody() throws Exception {
+        // an object is written by a marshal too, as a Map is
         String message = failure(new Object(), null, "order.json");
         assertTrue(message.contains("No body available of type: 
java.io.InputStream"), message);
-        assertFalse(message.contains("is not file content"), message);
+        assertTrue(message.contains("the body is an object of class 
java.lang.Object, which is not file content: marshal it"
+                                    + " first (marshal: json, for the file 
name order.json)"),
+                message);
     }
 
     private String failure(Object body, String charset, String fileName) 
throws Exception {
diff --git 
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
 
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
index b8411e77a3d6..a7ded29fbe60 100644
--- 
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
+++ 
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
@@ -21,7 +21,6 @@ import java.lang.invoke.MethodHandles;
 import java.lang.invoke.MethodType;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
@@ -35,10 +34,10 @@ import org.apache.camel.Message;
 import org.apache.camel.RuntimeCamelException;
 import org.apache.camel.attachment.AttachmentMessage;
 import org.apache.camel.attachment.DefaultAttachmentMessage;
+import org.apache.camel.support.BodyFacts;
 import org.apache.camel.support.ExchangeHelper;
 import org.apache.camel.support.ExpressionSupport;
 import org.apache.camel.support.LanguageHelper;
-import org.apache.camel.util.MimeTypeHelper;
 import org.codehaus.groovy.control.CompilationFailedException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -106,8 +105,9 @@ public class GroovyExpression extends ExpressionSupport {
             String textHeader = onText && !bodyText ? jsonTextHeader(exchange) 
: null;
             String hint;
             if (bodyText) {
-                hint = "the body is still text (a " + (body instanceof byte[] 
? "byte[]" : "String")
-                       + "), not parsed data: " + unmarshalHint(exchange, 
body);
+                // what the body is, the step that set it, and the unmarshal 
that parses it (CAMEL-25749)
+                hint = BodyFacts.describe(exchange) + ", not parsed data: "
+                       + BodyFacts.hint(exchange, BodyFacts.Need.DATA);
             } else if (textHeader != null) {
                 String ref = isIdentifier(textHeader) ? "headers." + 
textHeader : "headers['" + textHeader + "']";
                 hint = "header '" + textHeader + "' is still JSON text, not 
parsed data: parse it in the script with"
@@ -365,57 +365,6 @@ public class GroovyExpression extends ExpressionSupport {
         }
     }
 
-    /**
-     * Which data format turns the payload text into data, from the 
Content-Type, the file name (by its extension, as
-     * {@link MimeTypeHelper} maps it to a content type), or the first 
character of the text; the generic advice when
-     * none of them tells.
-     */
-    static String unmarshalHint(Exchange exchange, Object body) {
-        String type = exchange.getMessage().getHeader(Exchange.CONTENT_TYPE, 
String.class);
-        String file = exchange.getMessage().getHeader(Exchange.FILE_NAME, 
String.class);
-        String format = formatOf(type);
-        if (format == null && file != null) {
-            format = formatOf(MimeTypeHelper.probeMimeType(file));
-        }
-        if (format == null) {
-            int first = firstNonBlank(body);
-            if (first == '{' || first == '[') {
-                format = "json";
-            } else if (first == '<') {
-                format = "xml";
-            }
-        }
-        if ("json".equals(format)) {
-            return "unmarshal it first (unmarshal: json) to read its fields";
-        } else if ("xml".equals(format)) {
-            return "unmarshal it first (unmarshal: jacksonXml) to read its 
fields, or read them with xpath";
-        } else if ("csv".equals(format)) {
-            return "unmarshal it first (unmarshal: csv) to read its rows: a 
List of rows, or a Map per row with"
-                   + " useMaps: true";
-        }
-        return "unmarshal it first with the data format of the payload (json, 
jacksonXml, csv, ...) to read its fields";
-    }
-
-    /** The kind of payload a content type is: json, xml or csv (tab-separated 
values included), or null. */
-    private static String formatOf(String contentType) {
-        if (contentType == null) {
-            return null;
-        }
-        String s = contentType.toLowerCase(Locale.ROOT);
-        int semi = s.indexOf(';');
-        if (semi > 0) {
-            s = s.substring(0, semi).trim();
-        }
-        if (s.endsWith("/json") || s.endsWith("+json")) {
-            return "json";
-        } else if (s.endsWith("/xml") || s.endsWith("+xml")) {
-            return "xml";
-        } else if (s.endsWith("/csv") || s.endsWith("/tab-separated-values")) {
-            return "csv";
-        }
-        return null;
-    }
-
     /** The first header that holds JSON text (a String or byte[] starting 
with { or [), or null. */
     private static String jsonTextHeader(Exchange exchange) {
         for (Map.Entry<String, Object> header : 
exchange.getMessage().getHeaders().entrySet()) {
diff --git 
a/components/camel-groovy/src/test/java/org/apache/camel/processor/groovy/GroovyMissingPropertyHintTest.java
 
b/components/camel-groovy/src/test/java/org/apache/camel/processor/groovy/GroovyMissingPropertyHintTest.java
index 72dd5a770858..39fdf00d7e6a 100644
--- 
a/components/camel-groovy/src/test/java/org/apache/camel/processor/groovy/GroovyMissingPropertyHintTest.java
+++ 
b/components/camel-groovy/src/test/java/org/apache/camel/processor/groovy/GroovyMissingPropertyHintTest.java
@@ -59,7 +59,7 @@ public class GroovyMissingPropertyHintTest extends 
CamelTestSupport {
         Exception e = assertThrows(Exception.class,
                 () -> template.sendBodyAndHeader("direct:field", json, "sku", 
"A1"));
         MissingPropertyException cause = 
assertInstanceOf(MissingPropertyException.class, e.getCause());
-        assertTrue(cause.getMessage().contains("the body is still text (a 
byte[])"), cause.getMessage());
+        assertTrue(cause.getMessage().contains("the body is bytes (a byte[]), 
not parsed data"), cause.getMessage());
         assertTrue(cause.getMessage().contains("unmarshal: json"), 
cause.getMessage());
     }
 
@@ -68,7 +68,7 @@ public class GroovyMissingPropertyHintTest extends 
CamelTestSupport {
         Exception e = assertThrows(Exception.class,
                 () -> template.sendBodyAndHeader("direct:field", "[{\"sku\": 
\"A1\"}]", "sku", "A1"));
         MissingPropertyException cause = 
assertInstanceOf(MissingPropertyException.class, e.getCause());
-        assertTrue(cause.getMessage().contains("the body is still text (a 
String)"), cause.getMessage());
+        assertTrue(cause.getMessage().contains("the body is text (a String), 
not parsed data"), cause.getMessage());
     }
 
     @Test
@@ -102,7 +102,9 @@ public class GroovyMissingPropertyHintTest extends 
CamelTestSupport {
         Exception e = assertThrows(Exception.class,
                 () -> template.sendBodyAndHeader("direct:field", "sku=A1", 
"sku", "A1"));
         MissingPropertyException cause = 
assertInstanceOf(MissingPropertyException.class, e.getCause());
-        assertTrue(cause.getMessage().contains("the data format of the payload 
(json, jacksonXml, csv, ...)"),
+        assertTrue(
+                cause.getMessage().contains(
+                        "the data format of the payload (such as unmarshal: 
json, unmarshal: jacksonXml or unmarshal: csv)"),
                 cause.getMessage());
     }
 
diff --git 
a/components/camel-http/src/main/java/org/apache/camel/component/http/HttpProducer.java
 
b/components/camel-http/src/main/java/org/apache/camel/component/http/HttpProducer.java
index 789eb887943e..ea868f48218f 100644
--- 
a/components/camel-http/src/main/java/org/apache/camel/component/http/HttpProducer.java
+++ 
b/components/camel-http/src/main/java/org/apache/camel/component/http/HttpProducer.java
@@ -33,7 +33,6 @@ import java.util.Date;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
-import java.util.Locale;
 import java.util.Map;
 import java.util.Map.Entry;
 import java.util.stream.Stream;
@@ -53,6 +52,7 @@ import 
org.apache.camel.http.base.HttpProtocolHeaderFilterStrategy;
 import org.apache.camel.http.base.cookie.CookieHandler;
 import org.apache.camel.http.common.HttpHelper;
 import org.apache.camel.spi.HeaderFilterStrategy;
+import org.apache.camel.support.BodyFacts;
 import org.apache.camel.support.DefaultProducer;
 import org.apache.camel.support.ExchangeHelper;
 import org.apache.camel.support.GZIPHelper;
@@ -743,28 +743,21 @@ public class HttpProducer extends DefaultProducer 
implements LineNumberAware {
     }
 
     /**
-     * The body as a stream for the request entity. A Map or a List (a body 
unmarshalled from JSON, XML or CSV, or built
-     * as a map) cannot become one, and the converter's message only names the 
conversion: say what to do, in the data
-     * format the Content-Type names, or name the usual ones when it names 
none (CAMEL-25309, CAMEL-25751).
+     * The body as a stream for the request entity. A Map, a List or an object 
(a body unmarshalled from JSON, XML or
+     * CSV, or built in the route) cannot become one, and the converter's 
message only names the conversion: say what
+     * the body is, the step that set it, and the marshal that writes it, in 
the data format the Content-Type names
+     * (CAMEL-25309, CAMEL-25749).
      */
     private static InputStream mandatoryBodyStream(Exchange exchange, Message 
in) throws InvalidPayloadException {
         try {
             return in.getMandatoryBody(InputStream.class);
         } catch (InvalidPayloadException e) {
-            Object body = in.getBody();
-            if (body instanceof Map || body instanceof Collection) {
-                String contentType = ExchangeHelper.getContentType(exchange);
-                String format = dataFormatOf(contentType);
+            // what the body is, the step that set it, and the marshal that 
writes it (CAMEL-25309, CAMEL-25749)
+            String hint = BodyFacts.hint(exchange, BodyFacts.Need.BYTES, 
ExchangeHelper.getContentType(exchange));
+            if (hint != null) {
                 InvalidPayloadException answer = new InvalidPayloadException(
                         exchange, InputStream.class, in,
-                        "a " + (body instanceof Map ? "Map" : body instanceof 
List ? "List" : "Collection")
-                                                         + " is not an HTTP 
request body: "
-                                                         + (format != null
-                                                                 ? "marshal it 
first (marshal: " + format
-                                                                   + ", for 
the Content-Type " + contentType + ")"
-                                                                 : "marshal it 
first with the data format of the request (such as"
-                                                                   + " 
marshal: json, marshal: jacksonXml or marshal: csv)")
-                                                         + " or set the body 
to the text");
+                        BodyFacts.describe(exchange) + ", which is not an HTTP 
request body: " + hint);
                 answer.initCause(e.getCause() != null ? e.getCause() : e);
                 throw answer;
             }
@@ -772,28 +765,6 @@ public class HttpProducer extends DefaultProducer 
implements LineNumberAware {
         }
     }
 
-    /**
-     * The data format that writes a content type, as the YAML DSL names it: 
json, jacksonXml, csv or yaml; or null
-     * (CAMEL-25751; CAMEL-25749 moves this onto one helper).
-     */
-    static String dataFormatOf(String contentType) {
-        if (contentType == null) {
-            return null;
-        }
-        int semicolon = contentType.indexOf(';');
-        String type = (semicolon > 0 ? contentType.substring(0, semicolon) : 
contentType).trim().toLowerCase(Locale.ROOT);
-        if (type.endsWith("/json") || type.endsWith("+json")) {
-            return "json";
-        } else if (type.endsWith("/xml") || type.endsWith("+xml")) {
-            return "jacksonXml";
-        } else if (type.endsWith("/csv") || 
type.endsWith("/tab-separated-values")) {
-            return "csv";
-        } else if (type.endsWith("/yaml") || type.endsWith("/x-yaml")) {
-            return "yaml";
-        }
-        return null;
-    }
-
     /**
      * Creates a holder object for the data to send to the remote server.
      *
diff --git 
a/components/camel-http/src/test/java/org/apache/camel/component/http/HttpProducerMapBodyTest.java
 
b/components/camel-http/src/test/java/org/apache/camel/component/http/HttpProducerMapBodyTest.java
index 7c087800b3f2..7153bed9e106 100644
--- 
a/components/camel-http/src/test/java/org/apache/camel/component/http/HttpProducerMapBodyTest.java
+++ 
b/components/camel-http/src/test/java/org/apache/camel/component/http/HttpProducerMapBodyTest.java
@@ -64,9 +64,9 @@ public class HttpProducerMapBodyTest extends BaseHttpTest {
                 e -> e.getIn().setBody(new LinkedHashMap<>(Map.of("orderId", 
"ORD-1001", "qty", 2))));
 
         
assertThat(exchange.getException()).isInstanceOf(InvalidPayloadException.class)
-                
.hasMessageContaining("java.util.LinkedHashMap").hasMessageContaining("a Map is 
not an HTTP request body")
-                .hasMessageContaining("marshal it first with the data format 
of the request (such as marshal: json,"
-                                      + " marshal: jacksonXml or marshal: csv) 
or set the body to the text");
+                .hasMessageContaining("the body is a Map (LinkedHashMap), 
which is not an HTTP request body")
+                .hasMessageContaining("marshal it first with the data format 
to write (such as marshal: json,"
+                                      + " marshal: jacksonXml or marshal: 
csv), or set the body to the text");
     }
 
     @Test
@@ -86,6 +86,6 @@ public class HttpProducerMapBodyTest extends BaseHttpTest {
                 e -> e.getIn().setBody(List.of(Map.of("sku", "CAMEL-MUG"))));
 
         
assertThat(exchange.getException()).isInstanceOf(InvalidPayloadException.class)
-                .hasMessageContaining("a List is not an HTTP request body");
+                .hasMessageContaining("the body is a List 
(").hasMessageContaining("which is not an HTTP request body");
     }
 }
diff --git 
a/components/camel-jq/src/main/java/org/apache/camel/language/jq/JqExpression.java
 
b/components/camel-jq/src/main/java/org/apache/camel/language/jq/JqExpression.java
index 6b9071447372..9696ba967722 100644
--- 
a/components/camel-jq/src/main/java/org/apache/camel/language/jq/JqExpression.java
+++ 
b/components/camel-jq/src/main/java/org/apache/camel/language/jq/JqExpression.java
@@ -35,6 +35,7 @@ import org.apache.camel.InvalidPayloadException;
 import org.apache.camel.RuntimeCamelException;
 import org.apache.camel.TypeConverter;
 import org.apache.camel.spi.ExpressionResultTypeAware;
+import org.apache.camel.support.BodyFacts;
 import org.apache.camel.support.ExpressionAdapter;
 import org.apache.camel.support.MessageHelper;
 import org.apache.camel.support.builder.ExpressionBuilder;
@@ -200,20 +201,20 @@ public class JqExpression extends ExpressionAdapter 
implements ExpressionResultT
         if (payload == null) {
             // tell apart a source that is null from one that is set but holds 
no JSON
             if (source.evaluate(exchange, Object.class) == null) {
-                throw new InvalidPayloadException(exchange, JsonNode.class, 
exchange.getIn(), nullSourceHint());
+                throw new InvalidPayloadException(exchange, JsonNode.class, 
exchange.getIn(), nullSourceHint(exchange));
             }
             throw new InvalidPayloadException(exchange, JsonNode.class);
         }
         return payload;
     }
 
-    private String nullSourceHint() {
+    private String nullSourceHint(Exchange exchange) {
         if ("body".equals(source.toString())) {
-            // a timer alone, or a jq step placed before the file was read: 
nothing to evaluate
-            return "the jq expression got no message body to evaluate, the 
body is null: a route reached with direct:"
-                   + " has the body of its caller, so when the caller has 
none, read the data first - setBody with"
-                   + " constant: resource:file:data.json for a known file, or 
poll:, pollEnrich or a from: consumer"
-                   + " for one that is not";
+            // a timer alone, or a jq step placed before the file was read: 
nothing to evaluate. What the body is, the
+            // step that left it so, and how to read the data (CAMEL-24838, 
CAMEL-25749)
+            String hint = BodyFacts.hint(exchange, BodyFacts.Need.A_BODY);
+            return "the jq expression got no message body to evaluate: " + 
BodyFacts.describe(exchange)
+                   + (hint != null ? ": " + hint : "");
         }
         return "the jq expression got no input from " + source + " to 
evaluate, it is null: set it before the step,"
                + " or leave source unset to use the message body";
diff --git 
a/components/camel-jq/src/test/java/org/apache/camel/language/jq/JqNullBodyHintTest.java
 
b/components/camel-jq/src/test/java/org/apache/camel/language/jq/JqNullBodyHintTest.java
index a0cdf39b86e3..88ab00dc7fca 100644
--- 
a/components/camel-jq/src/test/java/org/apache/camel/language/jq/JqNullBodyHintTest.java
+++ 
b/components/camel-jq/src/test/java/org/apache/camel/language/jq/JqNullBodyHintTest.java
@@ -50,7 +50,7 @@ public class JqNullBodyHintTest extends JqTestSupport {
 
         assertTrue(message.contains("No body available of type: 
com.fasterxml.jackson.databind.JsonNode"),
                 "should keep the original message, but was: " + message);
-        assertTrue(message.contains("the jq expression got no message body to 
evaluate, the body is null"),
+        assertTrue(message.contains("the jq expression got no message body to 
evaluate: the body is null"),
                 "should say the body is null, but was: " + message);
         assertTrue(message.contains("constant: resource:file:"),
                 "should show the form for a known file, but was: " + message);
diff --git 
a/components/camel-jsonpath/src/main/java/org/apache/camel/jsonpath/JsonPathEngine.java
 
b/components/camel-jsonpath/src/main/java/org/apache/camel/jsonpath/JsonPathEngine.java
index c21f096911a5..2264b7aeee25 100644
--- 
a/components/camel-jsonpath/src/main/java/org/apache/camel/jsonpath/JsonPathEngine.java
+++ 
b/components/camel-jsonpath/src/main/java/org/apache/camel/jsonpath/JsonPathEngine.java
@@ -39,6 +39,7 @@ import org.apache.camel.Exchange;
 import org.apache.camel.Expression;
 import org.apache.camel.StreamCache;
 import org.apache.camel.component.file.GenericFile;
+import org.apache.camel.support.BodyFacts;
 import org.apache.camel.util.ObjectHelper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -228,11 +229,11 @@ public class JsonPathEngine {
         if (json == null) {
             // a timer alone, or a jsonpath step placed before the file was 
read: nothing to evaluate
             if (fromBody) {
+                // what the body is, the step that left it so, and how to read 
the data (CAMEL-24838, CAMEL-25749)
+                String hint = BodyFacts.hint(exchange, BodyFacts.Need.A_BODY);
                 throw new CamelExchangeException(
-                        "The jsonpath expression got no message body to 
evaluate (the body is null): a route reached"
-                                                 + " with direct: has the body 
of its caller, so when the caller has none, read the data"
-                                                 + " first - setBody with 
constant: resource:file:data.json for a known file, or poll:,"
-                                                 + " pollEnrich or a from: 
consumer for one that is not",
+                        "The jsonpath expression got no message body to 
evaluate: " + BodyFacts.describe(exchange)
+                                                 + (hint != null ? ": " + hint 
: ""),
                         exchange);
             }
             throw new CamelExchangeException(
diff --git 
a/components/camel-jsonpath/src/test/java/org/apache/camel/jsonpath/JsonPathNullBodyHintTest.java
 
b/components/camel-jsonpath/src/test/java/org/apache/camel/jsonpath/JsonPathNullBodyHintTest.java
index 5c8a6082f35b..163df4c17e95 100644
--- 
a/components/camel-jsonpath/src/test/java/org/apache/camel/jsonpath/JsonPathNullBodyHintTest.java
+++ 
b/components/camel-jsonpath/src/test/java/org/apache/camel/jsonpath/JsonPathNullBodyHintTest.java
@@ -49,7 +49,7 @@ public class JsonPathNullBodyHintTest extends 
CamelTestSupport {
         String message = 
rootCauseMessage(assertThrows(CamelExecutionException.class,
                 () -> template.sendBody("direct:body", null)));
 
-        assertTrue(message.contains("The jsonpath expression got no message 
body to evaluate (the body is null)"),
+        assertTrue(message.contains("The jsonpath expression got no message 
body to evaluate: the body is null"),
                 "should say the body is null, but was: " + message);
         assertTrue(message.contains("constant: resource:file:"),
                 "should show the form for a known file, but was: " + message);
diff --git 
a/components/camel-platform-http-vertx/src/main/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpSupport.java
 
b/components/camel-platform-http-vertx/src/main/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpSupport.java
index df96cbaff3b1..20996fab4cfe 100644
--- 
a/components/camel-platform-http-vertx/src/main/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpSupport.java
+++ 
b/components/camel-platform-http-vertx/src/main/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpSupport.java
@@ -21,10 +21,8 @@ import java.nio.ByteBuffer;
 import java.nio.charset.Charset;
 import java.nio.charset.IllegalCharsetNameException;
 import java.nio.charset.StandardCharsets;
-import java.util.Collection;
 import java.util.Iterator;
 import java.util.List;
-import java.util.Locale;
 import java.util.Map;
 
 import io.netty.handler.codec.http.HttpHeaderValues;
@@ -43,6 +41,7 @@ import org.apache.camel.Message;
 import org.apache.camel.NoTypeConversionAvailableException;
 import org.apache.camel.TypeConverter;
 import org.apache.camel.spi.HeaderFilterStrategy;
+import org.apache.camel.support.BodyFacts;
 import org.apache.camel.support.ExceptionHelper;
 import org.apache.camel.support.ExchangeHelper;
 import org.apache.camel.support.MessageHelper;
@@ -253,56 +252,29 @@ public final class VertxPlatformHttpSupport {
             writeResponseAs(promise, ctx, bb);
             return;
         }
-        throw noTypeConversion(body, 
ExchangeHelper.getContentType(camelExchange));
+        throw noTypeConversion(camelExchange, body, 
ExchangeHelper.getContentType(camelExchange));
     }
 
     /**
-     * A Map or a List (a body unmarshalled from JSON, XML or CSV, or built as 
a map) cannot become the response, and
-     * the converter's message only names the conversion: say what to do, in 
the data format the Content-Type names, or
-     * name the usual ones when it names none (CAMEL-25751).
+     * A Map, a List or an object (a body unmarshalled from JSON, XML or CSV, 
or built in the route) cannot become the
+     * response, and the converter's message only names the conversion: say 
what the body is, the step that set it, and
+     * the marshal that writes it, in the data format the Content-Type names 
(CAMEL-25751, CAMEL-25749).
      */
-    static NoTypeConversionAvailableException noTypeConversion(Object body, 
String contentType) {
-        if (body instanceof Map || body instanceof Collection) {
-            String format = dataFormatOf(contentType);
-            String marshal = format != null
-                    ? "marshal it first (marshal: " + format + ", for the 
Content-Type " + contentType + ")"
-                    : "marshal it first with the data format of the reply 
(such as marshal: json, marshal: jacksonXml or"
-                      + " marshal: csv)";
-            // the REST binding writes json and xml
-            String binding = format == null ? ", or let the REST binding write 
it (bindingMode: json or xml)"
-                    : "json".equals(format) ? ", or let the REST binding write 
it (bindingMode: json)"
-                    : "jacksonXml".equals(format) ? ", or let the REST binding 
write it (bindingMode: xml)" : "";
-            return new NoTypeConversionAvailableException(
-                    NoTypeConversionAvailableException.createMessage(body, 
InputStream.class) + ": a "
-                                                          + (body instanceof 
Map ? "Map" : body instanceof List ? "List"
-                                                                  : 
"Collection")
-                                                          + " is not an HTTP 
response body: " + marshal
-                                                          + " as the last step 
of the route" + binding,
-                    body, InputStream.class);
+    static NoTypeConversionAvailableException noTypeConversion(Exchange 
exchange, Object body, String contentType) {
+        String hint = BodyFacts.hint(exchange, BodyFacts.Need.BYTES, 
contentType);
+        if (hint == null) {
+            return new NoTypeConversionAvailableException(body, 
InputStream.class);
         }
-        return new NoTypeConversionAvailableException(body, InputStream.class);
-    }
-
-    /**
-     * The data format that writes a content type, as the YAML DSL names it: 
json, jacksonXml, csv or yaml; or null
-     * (CAMEL-25751; CAMEL-25749 moves this onto one helper).
-     */
-    static String dataFormatOf(String contentType) {
-        if (contentType == null) {
-            return null;
-        }
-        int semicolon = contentType.indexOf(';');
-        String type = (semicolon > 0 ? contentType.substring(0, semicolon) : 
contentType).trim().toLowerCase(Locale.ROOT);
-        if (type.endsWith("/json") || type.endsWith("+json")) {
-            return "json";
-        } else if (type.endsWith("/xml") || type.endsWith("+xml")) {
-            return "jacksonXml";
-        } else if (type.endsWith("/csv") || 
type.endsWith("/tab-separated-values")) {
-            return "csv";
-        } else if (type.endsWith("/yaml") || type.endsWith("/x-yaml")) {
-            return "yaml";
-        }
-        return null;
+        // the REST binding writes json and xml
+        String format = BodyFacts.dataFormatOf(contentType);
+        String binding = format == null ? "; or let the REST binding write it 
(bindingMode: json or xml)"
+                : "json".equals(format) ? "; or let the REST binding write it 
(bindingMode: json)"
+                : "jacksonXml".equals(format) ? "; or let the REST binding 
write it (bindingMode: xml)" : "";
+        return new NoTypeConversionAvailableException(
+                NoTypeConversionAvailableException.createMessage(body, 
InputStream.class) + ": "
+                                                      + 
BodyFacts.describe(exchange) + ", which is not an HTTP response body: "
+                                                      + hint + ", as the last 
step of the route" + binding,
+                body, InputStream.class);
     }
 
     private static void writeResponseAs(Promise<Void> promise, RoutingContext 
ctx, ByteBuffer bb) {
diff --git 
a/components/camel-platform-http-vertx/src/test/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpMapResponseTest.java
 
b/components/camel-platform-http-vertx/src/test/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpMapResponseTest.java
index 3d0de403f1da..09b4ad4fa602 100644
--- 
a/components/camel-platform-http-vertx/src/test/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpMapResponseTest.java
+++ 
b/components/camel-platform-http-vertx/src/test/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpMapResponseTest.java
@@ -77,19 +77,22 @@ public class VertxPlatformHttpMapResponseTest {
             assertTrue(map.startsWith("No type converter available to convert 
from type: java.util.LinkedHashMap"
                                       + " to the required type: 
java.io.InputStream"),
                     map);
-            assertTrue(map.contains("a Map is not an HTTP response body: 
marshal it first with the data format of the reply"
-                                    + " (such as marshal: json, marshal: 
jacksonXml or marshal: csv) as the last step of the"
-                                    + " route, or let the REST binding write 
it (bindingMode: json or xml)"),
+            assertTrue(map.contains("the body is a Map (LinkedHashMap), which 
is not an HTTP response body: marshal it first"
+                                    + " with the data format to write (such as 
marshal: json, marshal: jacksonXml or marshal:"
+                                    + " csv), or set the body to the text, as 
the last step of the route; or let the REST"
+                                    + " binding write it (bindingMode: json or 
xml)"),
                     map);
             // the Content-Type names the data format; the REST binding does 
not write csv
             String csv = handler.handled.get(1).getMessage();
-            assertTrue(csv.contains("a List is not an HTTP response body: 
marshal it first (marshal: csv, for the Content-Type"
-                                    + " text/csv) as the last step of the 
route"),
+            assertTrue(csv.contains("the body is a List ("), csv);
+            assertTrue(csv.contains("which is not an HTTP response body: 
marshal it first (marshal: csv, for the"
+                                    + " Content-Type text/csv), or set the 
body to the text, as the last step of the route"),
                     csv);
             assertFalse(csv.contains("REST binding"), csv);
             String xml = handler.handled.get(2).getMessage();
-            assertTrue(xml.contains("marshal it first (marshal: jacksonXml, 
for the Content-Type application/xml) as the last"
-                                    + " step of the route, or let the REST 
binding write it (bindingMode: xml)"),
+            assertTrue(xml.contains("marshal it first (marshal: jacksonXml, 
for the Content-Type application/xml), or set the"
+                                    + " body to the text, as the last step of 
the route; or let the REST binding write it"
+                                    + " (bindingMode: xml)"),
                     xml);
         } finally {
             context.stop();
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
index 03d34ff1f601..a80ad2bbb730 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
@@ -186,7 +186,8 @@ public class SimpleSyntaxHintsTest extends 
ExchangeTestSupport {
                 () -> 
context.resolveLanguage("simple").createExpression("${body.typo}").evaluate(exchange,
                         String.class));
         // CAMEL-25322: the dot reads a key, so the hint says the key is 
missing and which keys there are
-        assertThat(e.getMessage()).contains("(the Map has no key typo; its 
keys are: type)");
+        // CAMEL-25749: and what the body is, with the step that set it when 
the message history is on
+        assertThat(e.getMessage()).contains("(the Map has no key typo; the 
body is a Map (LinkedHashMap): its keys are: type)");
     }
 
     @Test
@@ -195,7 +196,7 @@ public class SimpleSyntaxHintsTest extends 
ExchangeTestSupport {
         Exception e = assertThrows(Exception.class,
                 () -> 
context.resolveLanguage("simple").createExpression("${body.orderId}").evaluate(exchange,
                         String.class));
-        assertThat(e.getMessage()).contains("(the Map has no key orderId; it 
is empty)");
+        assertThat(e.getMessage()).contains("(the Map has no key orderId; the 
body is a Map (LinkedHashMap): it is empty)");
     }
 
     @Test
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/UnmarshalProcessorTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/UnmarshalProcessorTest.java
index 1e6a775eed3c..bc9f71589641 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/processor/UnmarshalProcessorTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/UnmarshalProcessorTest.java
@@ -107,8 +107,11 @@ public class UnmarshalProcessorTest extends TestSupport {
 
         NoTypeConversionAvailableException e
                 = assertInstanceOf(NoTypeConversionAvailableException.class, 
exchange.getException());
-        assertEquals("Cannot unmarshal using MyDataFormat: the message body is 
null, so there is nothing to unmarshal. "
-                     + "Set the message body first, or set allowNullBody=true 
to skip a null body.",
+        // what the body is, and how to read the data (CAMEL-25749)
+        assertEquals("Cannot unmarshal using MyDataFormat: the body is null, 
so there is nothing to unmarshal: read the"
+                     + " data first - setBody with constant: 
resource:file:data.json for a known file, or poll:, pollEnrich"
+                     + " or a from: consumer for one that is not (a route 
reached with direct: has the body of its caller);"
+                     + " or set allowNullBody=true to skip a null body.",
                 e.getMessage());
         assertNull(e.getValue());
         assertEquals(InputStream.class, e.getToType());
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/support/BodyFactsTest.java 
b/core/camel-core/src/test/java/org/apache/camel/support/BodyFactsTest.java
new file mode 100644
index 000000000000..04b15cafa356
--- /dev/null
+++ b/core/camel-core/src/test/java/org/apache/camel/support/BodyFactsTest.java
@@ -0,0 +1,161 @@
+/*
+ * 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.support;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.support.BodyFacts.Need;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * CAMEL-25749: what the body is, the step that set it, and what to write so 
that a step gets the body it needs.
+ */
+public class BodyFactsTest extends ContextTestSupport {
+
+    private final List<String> seen = new CopyOnWriteArrayList<>();
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext answer = super.createCamelContext();
+        answer.setMessageHistory(true);
+        return answer;
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:unchanged").process(e -> 
seen.add(BodyFacts.describe(e)));
+                from("direct:setBy").setBody(constant(new 
LinkedHashMap<>(Map.of("sku", "A1")))).log("next")
+                        .process(e -> seen.add(BodyFacts.describe(e)));
+            }
+        };
+    }
+
+    @Test
+    public void testTheConsumerGaveTheBody() {
+        template.sendBody("direct:unchanged", "hello");
+        assertEquals(List.of("the body is text (a String), unchanged since 
from: direct://unchanged"), seen);
+    }
+
+    @Test
+    public void testTheStepThatSetTheBody() {
+        template.sendBody("direct:setBy", "hello");
+        assertEquals(1, seen.size());
+        assertTrue(seen.get(0).startsWith("the body is a Map (LinkedHashMap), 
set by setBody"), seen.get(0));
+    }
+
+    @Test
+    public void testTheBodyInPlainWords() {
+        assertEquals("null", BodyFacts.words(null));
+        assertEquals("text (a String)", BodyFacts.words("{\"sku\": \"A1\"}"));
+        assertEquals("empty text (a String)", BodyFacts.words(" "));
+        assertEquals("bytes (a byte[])", BodyFacts.words(new byte[] { 1 }));
+        assertEquals("a stream (ByteArrayInputStream)", BodyFacts.words(new 
ByteArrayInputStream(new byte[0])));
+        assertEquals("a Map (LinkedHashMap)", BodyFacts.words(new 
LinkedHashMap<>()));
+        assertEquals("a List (ArrayList)", BodyFacts.words(new ArrayList<>()));
+        assertTrue(BodyFacts.words(List.of(1).iterator()).startsWith("an 
iterator ("));
+        assertEquals("an object of class java.lang.Object", 
BodyFacts.words(new Object()));
+        // without the message history the step is not known
+        assertEquals("the body is a Map (LinkedHashMap)", 
BodyFacts.describe(exchange(new LinkedHashMap<>())));
+    }
+
+    @Test
+    public void testDataHints() {
+        assertEquals("unmarshal it first (unmarshal: json) to read its fields",
+                BodyFacts.hint(exchange("{\"sku\": \"A1\"}"), Need.DATA));
+        Exchange xml = exchange("<order/>".getBytes(StandardCharsets.UTF_8));
+        xml.getMessage().setHeader(Exchange.CONTENT_TYPE, "application/xml");
+        assertEquals("unmarshal it first (unmarshal: jacksonXml, for the 
Content-Type application/xml) to read its"
+                     + " fields, or read them with xpath",
+                BodyFacts.hint(xml, Need.DATA));
+        Exchange csv = exchange("A1,2");
+        csv.getMessage().setHeader(Exchange.FILE_NAME, "orders.csv");
+        assertEquals("unmarshal it first (unmarshal: csv, for the file name 
orders.csv) to read its rows: a List of rows,"
+                     + " or a Map per row with useMaps: true",
+                BodyFacts.hint(csv, Need.DATA));
+        assertEquals("unmarshal it first with the data format of the payload 
(such as unmarshal: json, unmarshal:"
+                     + " jacksonXml or unmarshal: csv) to read its fields",
+                BodyFacts.hint(exchange("sku=A1"), Need.DATA));
+        assertTrue(BodyFacts.hint(exchange(null), Need.DATA).startsWith("read 
the data first"));
+        Map<String, Object> line = new LinkedHashMap<>();
+        line.put("sku", "A1");
+        line.put("qty", 2);
+        assertEquals("its keys are: sku, qty", BodyFacts.hint(exchange(line), 
Need.DATA));
+        assertEquals("it is empty", BodyFacts.hint(exchange(new 
LinkedHashMap<>()), Need.DATA));
+        assertTrue(BodyFacts.hint(exchange(List.of(1).iterator()), 
Need.DATA).startsWith("it has no size"));
+        // a wrong name on a List or an object is not a body type mistake
+        assertNull(BodyFacts.hint(exchange(new ArrayList<>()), Need.DATA));
+        assertNull(BodyFacts.hint(exchange(new Object()), Need.DATA));
+    }
+
+    @Test
+    public void testBytesHints() {
+        Map<String, Object> order = new LinkedHashMap<>(Map.of("orderId", 
"1001"));
+        assertEquals("marshal it first (marshal: jacksonXml, for the 
Content-Type application/xml), or set the body to"
+                     + " the text",
+                BodyFacts.hint(exchange(order), Need.BYTES, 
"application/xml"));
+        assertEquals("marshal it first (marshal: csv, for the file name 
lines.csv), or set the body to the text",
+                BodyFacts.hint(exchange(new ArrayList<>()), Need.BYTES, 
"lines.csv"));
+        assertEquals("marshal it first with the data format to write (such as 
marshal: json, marshal: jacksonXml or"
+                     + " marshal: csv), or set the body to the text",
+                BodyFacts.hint(exchange(order), Need.BYTES, null));
+        assertEquals("split it, or convert it first", 
BodyFacts.hint(exchange(List.of(1).iterator()), Need.BYTES, null));
+        // text, bytes and no body at all are not why a write fails
+        assertNull(BodyFacts.hint(exchange("text"), Need.BYTES, 
"application/json"));
+        assertNull(BodyFacts.hint(exchange(null), Need.BYTES, 
"application/json"));
+    }
+
+    @Test
+    public void testABodyHints() {
+        assertTrue(BodyFacts.hint(exchange(null), 
Need.A_BODY).startsWith("read the data first - setBody with constant:"));
+        assertTrue(BodyFacts.hint(exchange(""), Need.A_BODY).startsWith("read 
the data first"));
+        assertNull(BodyFacts.hint(exchange("x"), Need.A_BODY));
+    }
+
+    @Test
+    public void testTheDataFormatOfAContentType() {
+        assertEquals("json", BodyFacts.dataFormatOf("application/json; 
charset=UTF-8"));
+        assertEquals("json", 
BodyFacts.dataFormatOf("application/problem+json"));
+        assertEquals("jacksonXml", BodyFacts.dataFormatOf("text/xml"));
+        assertEquals("csv", 
BodyFacts.dataFormatOf("text/tab-separated-values"));
+        assertEquals("yaml", BodyFacts.dataFormatOf("application/x-yaml"));
+        assertNull(BodyFacts.dataFormatOf("text/plain"));
+        assertNull(BodyFacts.dataFormatOf(null));
+    }
+
+    private Exchange exchange(Object body) {
+        Exchange answer = new DefaultExchange(context);
+        answer.getMessage().setBody(body);
+        return answer;
+    }
+}
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/BodyFacts.java 
b/core/camel-support/src/main/java/org/apache/camel/support/BodyFacts.java
new file mode 100644
index 000000000000..be4c29076664
--- /dev/null
+++ b/core/camel-support/src/main/java/org/apache/camel/support/BodyFacts.java
@@ -0,0 +1,381 @@
+/*
+ * 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.support;
+
+import java.io.File;
+import java.io.InputStream;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.stream.BaseStream;
+import java.util.stream.Collectors;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.ExchangePropertyKey;
+import org.apache.camel.MessageHistory;
+import org.apache.camel.NamedNode;
+import org.apache.camel.StreamCache;
+import org.apache.camel.WrappedFile;
+import org.apache.camel.util.MimeTypeHelper;
+import org.apache.camel.util.ObjectHelper;
+import org.apache.camel.util.StringHelper;
+import org.apache.camel.util.URISupport;
+
+/**
+ * Says what a message body is, which step made it so, and what to write so 
that a step that failed on it gets the body
+ * it needs: the facts a person, or a model in a dev loop, otherwise gets from 
a debugger (CAMEL-25749).
+ * <p/>
+ * A failure on the body is told in two parts: {@link #describe(Exchange)} 
says what the body is and, when the message
+ * history is on, the step that set it; {@link #hint(Exchange, Need)} says 
what to write, for what the failing step
+ * needed. A caller puts them after its own words:
+ *
+ * <pre>
+ * ${body[status]} failed: the body is the file as read (a GenericFile), 
unchanged since from: file://orders:
+ * unmarshal it first (unmarshal: json, for the file name order-1003.json) to 
read its fields
+ * </pre>
+ *
+ * Only a failure about the body gets a body sentence: an error about a header 
or an exchange property gets none.
+ *
+ * <table>
+ * <caption>The hint, by what the step needs and what the body is</caption>
+ * <tr>
+ * <th>Need</th>
+ * <th>The body is</th>
+ * <th>The hint</th>
+ * </tr>
+ * <tr>
+ * <td>{@link Need#DATA}</td>
+ * <td>text, bytes, a stream, the file as read</td>
+ * <td>unmarshal it first, in the data format the Content-Type, the file name 
or the text names</td>
+ * </tr>
+ * <tr>
+ * <td>{@link Need#DATA}</td>
+ * <td>null, or empty text or bytes</td>
+ * <td>as for {@link Need#A_BODY}</td>
+ * </tr>
+ * <tr>
+ * <td>{@link Need#DATA}</td>
+ * <td>a Map</td>
+ * <td>its keys</td>
+ * </tr>
+ * <tr>
+ * <td>{@link Need#DATA}</td>
+ * <td>an iterator</td>
+ * <td>split it, or have the producer give a List</td>
+ * </tr>
+ * <tr>
+ * <td>{@link Need#DATA}</td>
+ * <td>a List, or an object</td>
+ * <td>none: the name read is wrong, not the body</td>
+ * </tr>
+ * <tr>
+ * <td>{@link Need#BYTES}</td>
+ * <td>a Map, a List or an object</td>
+ * <td>marshal it first, in the data format the Content-Type or the name of 
the file being written names, or set the
+ * body to the text</td>
+ * </tr>
+ * <tr>
+ * <td>{@link Need#BYTES}</td>
+ * <td>an iterator</td>
+ * <td>split it, or convert it first</td>
+ * </tr>
+ * <tr>
+ * <td>{@link Need#BYTES}</td>
+ * <td>null, or already text, bytes, a stream or a file</td>
+ * <td>none: that is not this failure</td>
+ * </tr>
+ * <tr>
+ * <td>{@link Need#A_BODY}</td>
+ * <td>null, or empty text or bytes</td>
+ * <td>read the data first - setBody with constant: resource:file:..., poll:, 
pollEnrich or a from: consumer</td>
+ * </tr>
+ * </table>
+ * When the Content-Type, the file name or the text names no data format, the 
hint lists the usual ones (json,
+ * jacksonXml, csv). A hint is null when the body is not why the step failed.
+ *
+ * @since 4.24
+ */
+public final class BodyFacts {
+
+    /** What a step that failed on the body needed the body to be. */
+    public enum Need {
+
+        /**
+         * The step reads fields of the body: {@code ${body[sku]}}, {@code 
${body.sku}}, Groovy {@code body.sku} or
+         * {@code body.find { ... }}, bean OGNL. So the body must be data: a 
Map (its keys), a List (its elements) or an
+         * object (its properties, by getter or field). Not the payload as 
text or bytes, which an unmarshal turns into
+         * data. A jsonpath expression is not such a read: it reads the fields 
of JSON text as well.
+         */
+        DATA,
+
+        /**
+         * The step writes or sends the payload: an HTTP request or reply, a 
file. So the body must be in its serialized
+         * form: text, a byte[], a stream or a file. Binary payloads count, 
not only characters. Not a Map, a List or an
+         * object, which a marshal turns into text.
+         */
+        BYTES,
+
+        /**
+         * The step needs a body at all, of any type: unmarshal, marshal, 
jsonpath, jq, xslt. So the body must not be
+         * null, nor empty text or bytes.
+         */
+        A_BODY
+    }
+
+    /** At most this many keys of a Map are listed. */
+    private static final int MAX_KEYS = 20;
+
+    /** The usual data formats, named when nothing names the one of the 
payload. */
+    private static final String USUAL_UNMARSHAL = "unmarshal: json, unmarshal: 
jacksonXml or unmarshal: csv";
+    private static final String USUAL_MARSHAL = "marshal: json, marshal: 
jacksonXml or marshal: csv";
+
+    private BodyFacts() {
+    }
+
+    /**
+     * What the body is, in plain words, and the step that set it when the 
message history is on: "the body is a Map
+     * (LinkedHashMap), set by unmarshal at orders.yaml:9", or "the body is 
null, unchanged since from: timer://tick".
+     */
+    public static String describe(Exchange exchange) {
+        Object body = exchange.getMessage().getBody();
+        return "the body is " + words(body) + setBy(exchange, body);
+    }
+
+    /** What to write so that the failing step gets the body it needs, for 
{@link Need#DATA} and {@link Need#A_BODY}. */
+    public static String hint(Exchange exchange, Need need) {
+        return hint(exchange, need, null);
+    }
+
+    /**
+     * What to write so that the failing step gets the body it needs; null 
when the body is not why it failed. For
+     * {@link Need#BYTES}, target is what the step writes: its Content-Type, 
or the name of the file, which names the
+     * data format to marshal to; it is not used for the other needs.
+     */
+    public static String hint(Exchange exchange, Need need, String target) {
+        Object body = exchange.getMessage().getBody();
+        return switch (need) {
+            case DATA -> dataHint(exchange, body);
+            case BYTES -> bytesHint(body, target);
+            case A_BODY -> isEmpty(body) ? readTheDataFirst() : null;
+        };
+    }
+
+    /**
+     * The data format that reads and writes a content type, as the YAML DSL 
names it: json, jacksonXml, csv (tab
+     * separated values too) or yaml; or null.
+     */
+    public static String dataFormatOf(String contentType) {
+        if (contentType == null) {
+            return null;
+        }
+        String type = StringHelper.before(contentType, ";", 
contentType).trim().toLowerCase(Locale.ROOT);
+        if (type.endsWith("/json") || type.endsWith("+json")) {
+            return "json";
+        } else if (type.endsWith("/xml") || type.endsWith("+xml")) {
+            return "jacksonXml";
+        } else if (type.endsWith("/csv") || 
type.endsWith("/tab-separated-values")) {
+            return "csv";
+        } else if (type.endsWith("/yaml") || type.endsWith("/x-yaml")) {
+            return "yaml";
+        }
+        return null;
+    }
+
+    private static String dataHint(Exchange exchange, Object body) {
+        if (isEmpty(body)) {
+            return readTheDataFirst();
+        }
+        if (isPayload(body)) {
+            return unmarshalFirst(exchange, body);
+        }
+        if (body instanceof Map<?, ?> map) {
+            if (map.isEmpty()) {
+                return "it is empty";
+            }
+            String keys = 
map.keySet().stream().limit(MAX_KEYS).map(String::valueOf).collect(Collectors.joining(",
 "));
+            return "its keys are: " + keys + (map.size() > MAX_KEYS ? ", ... 
(" + map.size() + " keys)" : "");
+        }
+        if (isIterator(body)) {
+            return "it has no size and can be read once: split it, or have the 
producer give a List";
+        }
+        return null;
+    }
+
+    private static String bytesHint(Object body, String target) {
+        if (body == null || isPayload(body)) {
+            return null;
+        }
+        if (isIterator(body)) {
+            return "split it, or convert it first";
+        }
+        String format = dataFormatOf(target);
+        String named = format != null ? "for the Content-Type " + target : 
null;
+        if (format == null && target != null) {
+            format = dataFormatOf(MimeTypeHelper.probeMimeType(target));
+            named = format != null ? "for the file name " + target : null;
+        }
+        return (format != null
+                ? "marshal it first (marshal: " + format + ", " + named + ")"
+                : "marshal it first with the data format to write (such as " + 
USUAL_MARSHAL + ")")
+               + ", or set the body to the text";
+    }
+
+    /**
+     * Unmarshal the payload, in the data format the Content-Type, the name of 
the file it was read from, or the first
+     * character of the text names.
+     */
+    private static String unmarshalFirst(Exchange exchange, Object body) {
+        String contentType = 
exchange.getMessage().getHeader(Exchange.CONTENT_TYPE, String.class);
+        String fileName = exchange.getMessage().getHeader(Exchange.FILE_NAME, 
String.class);
+        String format = dataFormatOf(contentType);
+        String named = format != null ? ", for the Content-Type " + 
contentType : "";
+        if (format == null && fileName != null) {
+            format = dataFormatOf(MimeTypeHelper.probeMimeType(fileName));
+            named = format != null ? ", for the file name " + fileName : "";
+        }
+        if (format == null) {
+            int first = firstNonBlank(body);
+            format = first == '{' || first == '[' ? "json" : first == '<' ? 
"jacksonXml" : null;
+        }
+        if (format == null) {
+            return "unmarshal it first with the data format of the payload 
(such as " + USUAL_UNMARSHAL
+                   + ") to read its fields";
+        }
+        String unmarshal = "unmarshal it first (unmarshal: " + format + named 
+ ")";
+        return switch (format) {
+            case "jacksonXml" -> unmarshal + " to read its fields, or read 
them with xpath";
+            case "csv" -> unmarshal + " to read its rows: a List of rows, or a 
Map per row with useMaps: true";
+            default -> unmarshal + " to read its fields";
+        };
+    }
+
+    private static String readTheDataFirst() {
+        return "read the data first - setBody with constant: 
resource:file:data.json for a known file, or poll:,"
+               + " pollEnrich or a from: consumer for one that is not (a route 
reached with direct: has the body of its"
+               + " caller)";
+    }
+
+    /** The body in plain words, with its class. */
+    static String words(Object body) {
+        if (body == null) {
+            return "null";
+        }
+        String type = typeName(body);
+        if (body instanceof String s) {
+            return (s.isBlank() ? "empty text" : "text") + " (a " + type + ")";
+        } else if (body instanceof byte[] b) {
+            return (b.length == 0 ? "empty bytes" : "bytes") + " (a " + type + 
")";
+        } else if (body instanceof WrappedFile<?>) {
+            return "the file as read (a " + type + ")";
+        } else if (body instanceof File || body instanceof Path) {
+            return "a file (a " + type + ")";
+        } else if (body instanceof StreamCache) {
+            return "a cached stream (" + type + ")";
+        } else if (body instanceof InputStream || body instanceof Reader) {
+            return "a stream (" + type + ")";
+        } else if (body instanceof Map<?, ?>) {
+            return "a Map (" + type + ")";
+        } else if (body instanceof List<?>) {
+            return "a List (" + type + ")";
+        } else if (body instanceof Collection<?>) {
+            return "a collection (" + type + ")";
+        } else if (isIterator(body)) {
+            return "an iterator (" + type + ")";
+        }
+        return "an object of class " + body.getClass().getName();
+    }
+
+    /**
+     * The step that set the body, from the message history: the last node 
reached with a body of another type, which
+     * ran on it and left this one. When every node was reached with this 
type, the consumer gave it. Empty when the
+     * message history is off.
+     */
+    private static String setBy(Exchange exchange, Object body) {
+        List<MessageHistory> history = 
exchange.getProperty(ExchangePropertyKey.MESSAGE_HISTORY, List.class);
+        if (history == null || history.isEmpty()) {
+            return "";
+        }
+        String now = body != null ? ObjectHelper.classCanonicalName(body) : 
"null";
+        boolean recorded = false;
+        for (int i = history.size() - 1; i >= 0; i--) {
+            String type = history.get(i).getBodyType();
+            if (type == null) {
+                continue;
+            }
+            recorded = true;
+            if (!type.equals(now)) {
+                return ", set by " + step(history.get(i).getNode());
+            }
+        }
+        if (!recorded || exchange.getFromEndpoint() == null) {
+            return "";
+        }
+        return ", unchanged since from: " + 
URISupport.sanitizeUri(exchange.getFromEndpoint().getEndpointUri());
+    }
+
+    /** A step as people find it: unmarshal at orders.yaml:9, or the step and 
its label when there is no location. */
+    private static String step(NamedNode node) {
+        String location = LoggerHelper.getLineNumberLoggerName(node);
+        if (location != null) {
+            return node.getShortName() + " at " + location;
+        }
+        String label = node.getLabel();
+        return label != null && !label.isBlank()
+                ? node.getShortName() + " (" + 
StringHelper.limitLength(URISupport.sanitizeUri(label), 60) + ")"
+                : node.getShortName();
+    }
+
+    /** The payload in its serialized form: text, bytes, a stream or a file. */
+    private static boolean isPayload(Object body) {
+        return body instanceof String || body instanceof byte[] || body 
instanceof InputStream || body instanceof Reader
+                || body instanceof StreamCache || body instanceof 
WrappedFile<?> || body instanceof File
+                || body instanceof Path;
+    }
+
+    private static boolean isIterator(Object body) {
+        return body instanceof Iterator<?> || body instanceof BaseStream<?, ?>
+                || body instanceof Iterable<?> && !(body instanceof 
Collection<?>) && !(body instanceof Path);
+    }
+
+    private static boolean isEmpty(Object body) {
+        return body == null || body instanceof String s && s.isBlank() || body 
instanceof byte[] b && b.length == 0;
+    }
+
+    private static String typeName(Object body) {
+        String name = body.getClass().getSimpleName();
+        return name.isEmpty() ? body.getClass().getName() : name;
+    }
+
+    /** The first character of text that is not blank, or -1: only text in 
memory is looked at, never a stream. */
+    private static int firstNonBlank(Object body) {
+        String text = body instanceof String s ? s
+                : body instanceof byte[] b ? new String(b, 0, 
Math.min(b.length, 64), StandardCharsets.UTF_8) : null;
+        if (text != null) {
+            for (int i = 0; i < text.length(); i++) {
+                if (!Character.isWhitespace(text.charAt(i))) {
+                    return text.charAt(i);
+                }
+            }
+        }
+        return -1;
+    }
+}
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/processor/UnmarshalProcessor.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/processor/UnmarshalProcessor.java
index 5a00379abdbb..e4cc72d3e158 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/processor/UnmarshalProcessor.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/processor/UnmarshalProcessor.java
@@ -34,6 +34,7 @@ import org.apache.camel.spi.IdAware;
 import org.apache.camel.spi.RouteIdAware;
 import org.apache.camel.spi.StepIdAware;
 import org.apache.camel.support.AsyncProcessorSupport;
+import org.apache.camel.support.BodyFacts;
 import org.apache.camel.support.ExchangeHelper;
 import org.apache.camel.support.service.ServiceHelper;
 import org.apache.camel.util.IOHelper;
@@ -121,7 +122,7 @@ public class UnmarshalProcessor extends 
AsyncProcessorSupport
         } catch (Exception e) {
             // remove OUT message, as an exception occurred
             exchange.setOut(null);
-            exchange.setException(body == null ? nullBodyException(e) : e);
+            exchange.setException(body == null ? nullBodyException(exchange, 
e) : e);
         } finally {
             // The Iterator will close the stream itself
             if (!(result instanceof Iterator)) {
@@ -134,18 +135,22 @@ public class UnmarshalProcessor extends 
AsyncProcessorSupport
 
     /**
      * A null body fails in the type converter, whose message only says that 
null cannot become an InputStream. Say what
-     * it means here: there is nothing to unmarshal, and allowNullBody skips 
it. The exception type stays the same, so
-     * an onException for it still matches.
+     * it means here: there is nothing to unmarshal, the step that left the 
body so, how to read the data, and that
+     * allowNullBody skips it (CAMEL-25285, CAMEL-25749). The exception type 
stays the same, so an onException for it
+     * still matches.
      */
-    private Exception nullBodyException(Exception e) {
+    private Exception nullBodyException(Exchange exchange, Exception e) {
         if (!(e instanceof NoTypeConversionAvailableException ntc) || 
ntc.getValue() != null) {
             return e;
         }
         String name
                 = dataFormat instanceof DataFormatName dfn ? 
dfn.getDataFormatName() : dataFormat.getClass().getSimpleName();
-        String what = variableSend != null ? "the variable " + variableSend : 
"the message body";
-        String message = "Cannot unmarshal using " + name + ": " + what + " is 
null, so there is nothing to unmarshal. "
-                         + "Set " + what + " first, or set allowNullBody=true 
to skip a null body.";
+        String message = variableSend != null
+                ? "Cannot unmarshal using " + name + ": the variable " + 
variableSend + " is null, so there is nothing to"
+                  + " unmarshal. Set the variable " + variableSend + " first, 
or set allowNullBody=true to skip a null body."
+                : "Cannot unmarshal using " + name + ": " + 
BodyFacts.describe(exchange)
+                  + ", so there is nothing to unmarshal: " + 
BodyFacts.hint(exchange, BodyFacts.Need.A_BODY)
+                  + "; or set allowNullBody=true to skip a null body.";
         NoTypeConversionAvailableException answer = new 
NoTypeConversionAvailableException(message, null, ntc.getToType());
         answer.initCause(e);
         return answer;

Reply via email to