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;
