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 5c11b9288c0a CAMEL-25749: camel-support - BodyFacts says what the body
is, the step that set it, and what to write (#28059)
5c11b9288c0a is described below
commit 5c11b9288c0a3c79468d0efadd8156cda52e3fd5
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Oct 18 18:03:21 2026 +0200
CAMEL-25749: camel-support - BodyFacts says what the body is, the step that
set it, and what to write (#28059)
Co-Authored-By: Claude Opus 5.5 (1M context) <[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;