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 abca5fbead82 CAMEL-25928: Body type checks - field reads in log
messages and endpoint uris, wireTap and enrich, variables, unmarshal of parsed
data, and runtime hints (#28500)
abca5fbead82 is described below
commit abca5fbead82fb19e8966220865be06a7ded4db4
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Oct 26 20:39:20 2026 +0100
CAMEL-25928: Body type checks - field reads in log messages and endpoint
uris, wireTap and enrich, variables, unmarshal of parsed data, and runtime
hints (#28500)
* CAMEL-25928: camel-yaml-dsl-validator - field reads in log messages and
endpoint uris, wireTap and enrich, variables, and an unmarshal of parsed data
Gaps the r22 benchmark found in the body checks:
- A field read is also looked for in the simple templates a step evaluates:
a log message, the uri of a toD or a
wireTap, and an endpoint option such as the fileName of file.
- A wireTap and an enrich that send parsed data to a file are reported, as
a to is. wireTap gets an eipIn in the
catalog (from producerIn, any for variableSend).
- An unmarshal of a body or a variable that is parsed data already is
reported.
- What variableReceive or a setVariable of the body puts in a variable is
followed, and a field read on a variable that
holds text or the file as read is reported (pollEnrich of a file with
variableReceive, then ${variable.customer[name]}).
- The "no body" check also fires when an unmarshal, a marshal or a
convertBodyTo is the first step to read the body.
- A field read after a marshal of parsed data says to read the field before
the marshal, or to marshal into a
variable, instead of "unmarshal is the step, not marshal"; a file write
of parsed data says to read the fields that
are still needed before the marshal.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-25928: camel-support, camel-bean, camel-groovy - runtime hints for
an unmarshal of data, a field read after a marshal, and a file in a variable
- An unmarshal of a Map or a List says the body (or the variable) is data
already, instead of the bare type converter
error.
- A field read on text that a marshal wrote from parsed data says to read
the field before the marshal, as the message
history tells, instead of "unmarshal it first".
- A key read on the payload of the body gets one hint, from what the body
is, not a second one that disagrees.
- ${variable.customer.name} on a variable that holds the file as read says
to unmarshal it in place, as the [name]
form already did.
- The Groovy hint "still text ... unmarshal it first" is given only when
the body is text: on parsed data it says the
value read is a text value of the data.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-25928: the body type hints keep a field for later in a variable,
not a header
A header is sent on with the message and becomes part of what the endpoint
gets, so a field kept for a later step
(a file name, a log) goes into a variable: setVariable with name ticketId,
and ${variable.ticketId} where it is used.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-25928: camel-groovy - the hint for JSON text in a header keeps the
parsed data in a variable, not a header
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-25928: address review feedback
- TextBodyFlow does not look at the text of a block when no variable is
known: clearing an empty map does nothing.
- The javadoc of Walk.called() says why the called route shares the
variables, where a branch gets a copy.
- MAY_SET_VARIABLES says what the "ariable" substring matches.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-25928: tell a marshal of data by the body it was reached with, not
by loading its class
The message history keeps the canonical class name of the body, and the
class of a nested type (the Map of Map.of, the List of List.of or
Arrays.asList) cannot be loaded back from it, so a marshal of such data
got the "unmarshal it first" hint. The history entry now records whether
the body was a Map or a collection as it is captured.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-25928: name a JDK internal class of the body by its interface in
the hints
The Map of Map.of is java.util.ImmutableCollections.Map1, and the List of
Arrays.asList is java.util.Arrays.ArrayList: their names say nothing to the
reader. The hints now call such a body "a Map" or "a List", and the list of
types other messages reached a step with leaves them out, as its kind says
what they are.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---------
Signed-off-by: Claus Ibsen <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../org/apache/camel/catalog/models/wireTap.json | 1 +
.../apache/camel/language/bean/BeanExpression.java | 23 +-
.../camel/language/groovy/GroovyExpression.java | 17 +-
.../groovy/GroovyMissingPropertyHintTest.java | 30 +-
.../META-INF/org/apache/camel/model/wireTap.json | 1 +
.../org/apache/camel/model/WireTapDefinition.java | 6 +-
.../language/simple/SimpleSyntaxHintsTest.java | 20 +-
.../apache/camel/language/simple/SimpleTest.java | 4 +-
.../apache/camel/processor/EipBodyTypesTest.java | 9 +-
.../camel/processor/UnmarshalProcessorTest.java | 44 ++
.../camel/support/BodyFactsOtherBodiesTest.java | 18 +
.../org/apache/camel/support/BodyFactsTest.java | 57 ++-
.../java/org/apache/camel/support/BodyFacts.java | 99 ++++-
.../camel/support/DefaultMessageHistory.java | 14 +
.../support/processor/UnmarshalProcessor.java | 31 +-
.../user-manual/modules/ROOT/pages/body-types.adoc | 9 +
.../camel/dsl/yaml/validator/BodyTypeFlow.java | 7 +-
.../camel/dsl/yaml/validator/CatalogBodyTypes.java | 7 +
.../camel/dsl/yaml/validator/TextBodyFlow.java | 493 +++++++++++++++++++--
.../camel/dsl/yaml/validator/BodyTypeFlowTest.java | 37 ++
.../dsl/yaml/validator/OneOfAlternativesTest.java | 2 +-
.../camel/dsl/yaml/validator/TextBodyFlowTest.java | 250 +++++++++++
22 files changed, 1121 insertions(+), 58 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/wireTap.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/wireTap.json
index d848b685c8fb..f9f1970290b3 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/wireTap.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/wireTap.json
@@ -34,6 +34,7 @@
"CamelToEndpoint": { "index": 0, "kind": "exchangeProperty",
"displayName": "To Endpoint", "label": "producer", "required": false,
"javaType": "String", "deprecated": false, "autowired": false, "secret": false,
"description": "Endpoint URI where this Exchange is being sent to" }
},
"bodyTypes": {
+ "eipIn": { "byOption": [ { "option": "variableSend", "value": "*",
"carriers": [ "any" ] } ], "from": "producerIn", "description": "The copy of
the message goes to the producer of the endpoint. With variableSend the
variable is sent, and the body is not read" },
"eipOut": { "carriers": [ "unchanged" ], "description": "Sends a copy of
the message to the endpoint: the body of the route stays as it was" }
}
}
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 1e51db17bef9..8a83f96c09f8 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
@@ -571,11 +571,15 @@ public class BeanExpression implements Expression,
Predicate {
Object bean = beanOf(holder, exchange);
String body = bodyHint(exchange, bean);
String list = body == null ? listHint(bean, methodName) : null;
+ // ${variable.customer.name} on a variable that holds the file as
read
+ String file = body == null && (bean instanceof WrappedFile<?> ||
bean instanceof File)
+ ? noKeysHint(bean, methodName) : null;
throw new RuntimeBeanExpressionException(
exchange, describeBean(holder, beanName, exchange),
body != null
? method + " (" + body + ")"
- : list != null ? method + list : keyHint(holder,
exchange, methodName, method),
+ : list != null ? method + list
+ : file != null ? method + file : keyHint(holder,
exchange, methodName, method),
resultExchange.getException());
}
return newResult;
@@ -692,8 +696,12 @@ public class BeanExpression implements Expression,
Predicate {
}
if (key != null) {
Object bean = holder.getBean(exchange);
+ // the payload of the message body is told by bodyHint below, from
what the message history knows
+ Object value = result != null ? result : bean;
+ boolean payloadBody = bean != null && bean ==
exchange.getMessage().getBody() && value == bean
+ && isPayload(value);
try {
- return lookupResult(resultExchange, key, result, nullSafe,
ognlPath, bean);
+ return lookupResult(resultExchange, key, result, nullSafe,
ognlPath, bean, !payloadBody);
} catch (IndexOutOfBoundsException e) {
// ${body[status]} on the file as read, or on a stream: the
same exception, saying what the body is
// and the step that parses it (CAMEL-25749)
@@ -730,8 +738,15 @@ public class BeanExpression implements Expression,
Predicate {
}
}
+ /** Text, bytes, a stream or the file as read: what an unmarshal turns
into data. */
+ private static boolean isPayload(Object value) {
+ return value instanceof WrappedFile<?> || value instanceof File ||
value instanceof StreamCache
+ || value instanceof InputStream || value instanceof byte[] ||
value instanceof String;
+ }
+
private static Object lookupResult(
- Exchange exchange, String key, Object result, boolean nullSafe,
String ognlPath, Object bean) {
+ Exchange exchange, String key, Object result, boolean nullSafe,
String ognlPath, Object bean,
+ boolean hint) {
StringHelper.notEmpty(key, "key", "in Simple language ognl path: " +
ognlPath);
final TypeConverter typeConverter =
exchange.getContext().getTypeConverter();
@@ -786,7 +801,7 @@ public class BeanExpression implements Expression,
Predicate {
throw new IndexOutOfBoundsException(
"Key: " + key + " not found in bean: " + bean + " of type:
"
+
ObjectHelper.classCanonicalName(bean) + " using OGNL path [" + ognlPath
- + "]" + noKeysHint(result !=
null ? result : bean, key));
+ + "]" + (hint ?
noKeysHint(result != null ? result : bean, key) : ""));
} else {
// null safe so we can return null
return null;
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 1631050895c6..40248accd3ce 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
@@ -34,6 +34,7 @@ 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.spi.BodyTypeRegistry;
import org.apache.camel.support.BodyFacts;
import org.apache.camel.support.ExchangeHelper;
import org.apache.camel.support.ExpressionSupport;
@@ -107,6 +108,10 @@ public class GroovyExpression extends ExpressionSupport {
// (CAMEL-25749); null for an object, where the name read is wrong
String payload = !onText && body != null && e.getType() ==
body.getClass() && !(body instanceof Map)
? BodyFacts.hint(exchange, BodyFacts.Need.DATA) : null;
+ // customer.customerNo on parsed data, where customer holds the
text C-482: the text is a value of the
+ // data, and the body needs no unmarshal
+ BodyTypeRegistry.Kind kind = BodyFacts.kindOf(body);
+ boolean bodyData = kind == BodyTypeRegistry.Kind.DATA || kind ==
BodyTypeRegistry.Kind.COLLECTION;
String hint;
if (bodyText) {
// what the body is, the step that set it, and the unmarshal
that parses it (CAMEL-25749)
@@ -116,9 +121,17 @@ public class GroovyExpression extends ExpressionSupport {
hint = BodyFacts.describe(exchange) + ": " + payload;
} else if (textHeader != null) {
String ref = isIdentifier(textHeader) ? "headers." +
textHeader : "headers['" + textHeader + "']";
+ String variable
+ = isIdentifier(textHeader) ? "variables." + textHeader
: "variables['" + textHeader + "']";
+ // the parsed data is kept in a variable, not a header: a
header is sent on with the message
hint = "header '" + textHeader + "' is still JSON text, not
parsed data: parse it in the script with"
- + " new groovy.json.JsonSlurper().parseText(" + ref +
"), or set the header from the body after"
- + " unmarshal: json";
+ + " new groovy.json.JsonSlurper().parseText(" + ref +
"), or keep the parsed data in a variable"
+ + " (setVariable with simple ${body} after unmarshal:
json) and read " + variable;
+ } else if (onText && bodyData && !bean) {
+ String keys = BodyFacts.hint(exchange, BodyFacts.Need.DATA);
+ hint = "the value read here is text (a " +
e.getType().getSimpleName() + ") in the body, not an object"
+ + " with fields: " + BodyFacts.describe(exchange) + ",
which is data already, so no unmarshal is"
+ + " needed; check the name read, and the value it is
read on" + (keys != null ? " (" + keys + ")" : "");
} else if (onText && !bean) {
hint = "the value read here is still text (a " +
e.getType().getSimpleName()
+ " comes from iterating text), not parsed data:
unmarshal it first (unmarshal: json for JSON),"
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 b24bbd5b4d71..a259aab2d58b 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
@@ -18,6 +18,8 @@ package org.apache.camel.processor.groovy;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
import groovy.lang.MissingPropertyException;
import org.apache.camel.Exchange;
@@ -28,6 +30,7 @@ import org.apache.camel.test.junit6.CamelTestSupport;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -135,16 +138,38 @@ public class GroovyMissingPropertyHintTest extends
CamelTestSupport {
assertTrue(cause.getMessage().contains("header 'skuList' is still JSON
text, not parsed data"), cause.getMessage());
assertTrue(cause.getMessage().contains("new
groovy.json.JsonSlurper().parseText(headers.skuList)"),
cause.getMessage());
+ // the parsed data goes into a variable, not back into a header
+ assertTrue(cause.getMessage().contains("or keep the parsed data in a
variable (setVariable with simple ${body}"
+ + " after unmarshal: json) and
read variables.skuList"),
+ cause.getMessage());
}
@Test
public void fieldReadOnTextElsewhereSaysItIsText() {
- // a list of plain text values: an element is text, not data with
fields
+ // a list of plain text values: an element is text, not data with
fields, and the body needs no unmarshal
Exception e = assertThrows(Exception.class,
() -> template.sendBodyAndHeader("direct:field",
java.util.List.of("A1", "B2"), "sku", "A1"));
MissingPropertyException cause =
assertInstanceOf(MissingPropertyException.class, e.getCause());
- assertTrue(cause.getMessage().contains("the value read here is still
text (a String comes from iterating text)"),
+ assertTrue(cause.getMessage().contains("the value read here is text (a
String) in the body, not an object with"
+ + " fields: the body is a
List"),
+ cause.getMessage());
+ assertTrue(cause.getMessage().contains("which is data already, so no
unmarshal is needed"), cause.getMessage());
+ assertFalse(cause.getMessage().contains("unmarshal it first"),
cause.getMessage());
+ }
+
+ @Test
+ public void fieldReadOnATextFieldOfParsedDataSaysTheBodyIsParsed() {
+ // what the benchmark wrote (r22): customer holds the customer number,
and customer.customerNo reads it
+ Exception e = assertThrows(Exception.class,
+ () -> template.sendBody("direct:customer", new
LinkedHashMap<>(Map.of("customer", "C-482"))));
+ MissingPropertyException cause =
assertInstanceOf(MissingPropertyException.class, e.getCause());
+ assertTrue(cause.getMessage().contains("No such property: customerNo
for class: java.lang.String"),
+ cause.getMessage());
+ assertTrue(cause.getMessage().contains("the value read here is text (a
String) in the body, not an object with"
+ + " fields: the body is a Map
(LinkedHashMap), which is data already"),
cause.getMessage());
+ assertTrue(cause.getMessage().contains("(its keys are: customer)"),
cause.getMessage());
+ assertFalse(cause.getMessage().contains("unmarshal it first"),
cause.getMessage());
}
@Test
@@ -169,6 +194,7 @@ public class GroovyMissingPropertyHintTest extends
CamelTestSupport {
from("direct:unknown").transform().groovy("nosuch.toUpperCase()");
from("direct:field").transform().groovy("body.find { it.sku ==
headers.sku }");
from("direct:read").transform().groovy("body.sku");
+
from("direct:customer").transform().groovy("body.customer.customerNo");
from("direct:header").transform().groovy("headers.skuList.find
{ it.sku == headers.sku }");
from("direct:message").transform().groovy("message.getHeader('name') + ' ' +
message.body");
}
diff --git
a/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/wireTap.json
b/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/wireTap.json
index d848b685c8fb..f9f1970290b3 100644
---
a/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/wireTap.json
+++
b/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/wireTap.json
@@ -34,6 +34,7 @@
"CamelToEndpoint": { "index": 0, "kind": "exchangeProperty",
"displayName": "To Endpoint", "label": "producer", "required": false,
"javaType": "String", "deprecated": false, "autowired": false, "secret": false,
"description": "Endpoint URI where this Exchange is being sent to" }
},
"bodyTypes": {
+ "eipIn": { "byOption": [ { "option": "variableSend", "value": "*",
"carriers": [ "any" ] } ], "from": "producerIn", "description": "The copy of
the message goes to the producer of the endpoint. With variableSend the
variable is sent, and the body is not read" },
"eipOut": { "carriers": [ "unchanged" ], "description": "Sends a copy of
the message to the endpoint: the body of the route stays as it was" }
}
}
diff --git
a/core/camel-core-model/src/main/java/org/apache/camel/model/WireTapDefinition.java
b/core/camel-core-model/src/main/java/org/apache/camel/model/WireTapDefinition.java
index a05df428954a..9051b88a2207 100644
---
a/core/camel-core-model/src/main/java/org/apache/camel/model/WireTapDefinition.java
+++
b/core/camel-core-model/src/main/java/org/apache/camel/model/WireTapDefinition.java
@@ -41,7 +41,11 @@ import org.apache.camel.spi.Metadata;
+ " The tapped message is sent asynchronously in a
separate thread")
@XmlRootElement(name = "wireTap")
@XmlAccessorType(XmlAccessType.FIELD)
-@BodyTypes(eipOut = @BodyType(value = Carrier.UNCHANGED,
+@BodyTypes(eipIn = @BodyType(from = "producerIn",
+ byOption = @BodyType.When(option =
"variableSend", value = "*", carriers = Carrier.ANY),
+ description = "The copy of the message goes to
the producer of the endpoint. With variableSend"
+ + " the variable is sent, and the
body is not read"),
+ eipOut = @BodyType(value = Carrier.UNCHANGED,
description = "Sends a copy of the message to
the endpoint: the body of the route stays as it"
+ " was"))
public class WireTapDefinition<Type extends ProcessorDefinition<Type>> extends
ToDynamicDefinition
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 e8c6096db880..412106724618 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
@@ -243,7 +243,7 @@ public class SimpleSyntaxHintsTest extends
ExchangeTestSupport {
exchange.getIn().setBody(List.of("A1", "A2").iterator());
Exception e = assertThrows(Exception.class,
() ->
context.resolveLanguage("simple").createExpression("${body.size()}").evaluate(exchange,
String.class));
- assertThat(e.getMessage()).contains("the body is an iterator (")
+ assertThat(e.getMessage()).contains("the body is an iterator")
.contains("it has no size and can be read once: split it, or
have the producer give a List");
}
@@ -324,6 +324,24 @@ public class SimpleSyntaxHintsTest extends
ExchangeTestSupport {
.contains("unmarshal: {json: {}, variableSend: customer,
variableReceive: customer}");
}
+ @Test
+ public void testMethodOfAVariableThatHoldsAFileSaysToUnmarshalIt() {
+ // what the benchmark wrote (r22): ${variable.customer.name} reads the
polled file, not the customer
+ GenericFile<File> file = new GenericFile<>();
+ file.setFile(new File("customers/C-482.json"));
+ file.setFileName("C-482.json");
+ exchange.setVariable("customer", file);
+ Exception e = assertThrows(Exception.class,
+ () ->
context.resolveLanguage("simple").createExpression("${variable.customer.name}").evaluate(exchange,
+ String.class));
+ assertThat(e.getMessage()).contains("(the value is the file as read (a
GenericFile), not its data: unmarshal it"
+ + " first")
+ .contains("unmarshal: {json: {}, variableSend: customer,
variableReceive: customer}");
+ // what the file has is read as it is
+ assertEquals("C-482.json",
context.resolveLanguage("simple").createExpression("${variable.customer.fileName}")
+ .evaluate(exchange, String.class));
+ }
+
@Test
public void testOgnlOnAnObjectSaysNothingAboutTheBody() {
// a name an object does not have is a wrong name, not a body of the
wrong type
diff --git
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleTest.java
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleTest.java
index 1ec074ef84cc..d4d38598eaf4 100644
---
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleTest.java
+++
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleTest.java
@@ -531,8 +531,10 @@ public class SimpleTest extends LanguageTestSupport {
IndexOutOfBoundsException cause =
assertIsInstanceOf(IndexOutOfBoundsException.class, e.getCause());
assertTrue(cause.getMessage().startsWith("Key: qty not found in bean:
"), cause.getMessage());
- assertTrue(cause.getMessage().contains("the value is the message body
as a stream, not a Map: unmarshal it first"),
+ // what the body is says it once: no second hint about the value
+ assertTrue(cause.getMessage().contains("(the body is a stream
(ByteArrayInputStream): unmarshal it first"),
cause.getMessage());
+ assertFalse(cause.getMessage().contains("the value is the message body
as a stream"), cause.getMessage());
}
@Test
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/EipBodyTypesTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/EipBodyTypesTest.java
index 755b972bd2da..1260409fbefd 100644
---
a/core/camel-core/src/test/java/org/apache/camel/processor/EipBodyTypesTest.java
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/EipBodyTypesTest.java
@@ -41,6 +41,7 @@ import org.apache.camel.Route;
import org.apache.camel.StreamCache;
import org.apache.camel.builder.AggregationStrategies;
import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
import org.apache.camel.language.constant.ConstantLanguage;
import org.apache.camel.language.simple.SimpleLanguage;
import org.apache.camel.model.AggregateDefinition;
@@ -309,7 +310,7 @@ public class EipBodyTypesTest extends ContextTestSupport {
@Test
public void variableSendSendsTheVariable() throws Exception {
for (Class<?> model : List.of(ToDefinition.class,
ToDynamicDefinition.class, EnrichDefinition.class,
- MarshalDefinition.class, UnmarshalDefinition.class)) {
+ WireTapDefinition.class, MarshalDefinition.class,
UnmarshalDefinition.class)) {
assertEquals(Carrier.ANY, anyValueOf(bodyTypes(model).eipIn(),
"variableSend", model), model.getSimpleName());
}
Exchange out = run(r -> r.setVariable("v",
constant("sent")).toV("language:simple:${body}", "v", null), new Order());
@@ -318,6 +319,12 @@ public class EipBodyTypesTest extends ContextTestSupport {
out = run(r -> r.setVariable("v",
constant("sent")).enrich().constant("language:simple:${body}").variableSend("v"),
new Order());
assertEquals("sent", out.getMessage().getBody());
+ // the wire tap sends its copy to the producer of the endpoint, and
with variableSend the variable
+ assertEquals("producerIn",
bodyTypes(WireTapDefinition.class).eipIn().from());
+ MockEndpoint tap = getMockEndpoint("mock:tap");
+ tap.expectedBodiesReceived("sent");
+ run(r -> r.setVariable("v",
constant("sent")).wireTap("mock:tap").variableSend("v"), new Order());
+ tap.assertIsSatisfied();
}
@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 57c4c9d906c1..5e1ef36396e3 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
@@ -18,6 +18,10 @@ package org.apache.camel.processor;
import java.io.InputStream;
import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
@@ -135,6 +139,46 @@ public class UnmarshalProcessorTest extends TestSupport {
exchange.getException().getMessage());
}
+ @Test
+ public void testParsedDataSaysItIsDataAlready() throws Exception {
+ // what the benchmark wrote (r22): the order was unmarshalled, and is
unmarshalled again
+ Exchange exchange = createExchangeWithBody(new DefaultCamelContext(),
new LinkedHashMap<>(Map.of("orderId", "1")));
+ Processor processor = new UnmarshalProcessor(new MyDataFormat(new
Object()));
+
+ processor.process(exchange);
+
+ NoTypeConversionAvailableException e
+ = assertInstanceOf(NoTypeConversionAvailableException.class,
exchange.getException());
+ assertEquals("Cannot unmarshal using MyDataFormat: the body is a Map
(LinkedHashMap), which is data already, and"
+ + " the data format reads text: read its fields as they
are, and leave this unmarshal out; to parse the"
+ + " text in a variable, unmarshal it with variableSend
and variableReceive set to its name",
+ e.getMessage());
+ assertEquals(InputStream.class, e.getToType());
+ assertInstanceOf(NoTypeConversionAvailableException.class,
e.getCause());
+ }
+
+ @Test
+ public void testParsedDataInAVariableSaysItIsDataAlready() throws
Exception {
+ Exchange exchange = createExchangeWithBody(new DefaultCamelContext(),
"body");
+ exchange.setVariable("order", new ArrayList<>(List.of("A1")));
+ UnmarshalProcessor processor = new UnmarshalProcessor(new
MyDataFormat(new Object()));
+ processor.setVariableSend("order");
+
+ processor.process(exchange);
+
+ assertEquals("Cannot unmarshal using MyDataFormat: the variable order
holds data (ArrayList) already, and the data"
+ + " format reads text: read its fields as they are, such
as ${variable.order[name]}, and leave this"
+ + " unmarshal out",
+ exchange.getException().getMessage());
+
+ // the List of List.of goes by its interface, not by the class the JDK
keeps to itself
+ exchange = createExchangeWithBody(new DefaultCamelContext(), "body");
+ exchange.setVariable("order", List.of("A1"));
+ processor.process(exchange);
+ assertTrue(exchange.getException().getMessage().contains("the variable
order holds data (List) already"),
+ exchange.getException().getMessage());
+ }
+
@Test
public void testBodyThatCannotBeConvertedKeepsTheConverterMessage() throws
Exception {
Exchange exchange = createExchangeWithBody(new DefaultCamelContext(),
new Object());
diff --git
a/core/camel-core/src/test/java/org/apache/camel/support/BodyFactsOtherBodiesTest.java
b/core/camel-core/src/test/java/org/apache/camel/support/BodyFactsOtherBodiesTest.java
index 35c12bde0e4f..aac4fd1423d5 100644
---
a/core/camel-core/src/test/java/org/apache/camel/support/BodyFactsOtherBodiesTest.java
+++
b/core/camel-core/src/test/java/org/apache/camel/support/BodyFactsOtherBodiesTest.java
@@ -54,6 +54,22 @@ public class BodyFactsOtherBodiesTest extends
ContextTestSupport {
message);
}
+ @Test
+ public void testAClassTheJdkKeepsToItselfIsLeftOutOfTheTypes() {
+ // the Map of Map.of is java.util.ImmutableCollections.Map1, a name
that says nothing to the reader
+ Exchange parsed = template.request("direct:start", e -> {
+ e.getMessage().setHeader("immutable", true);
+ e.getMessage().setBody("{\"qty\": 2}");
+ });
+ assertNull(parsed.getException());
+
+ Exchange text = template.request("direct:start", e ->
e.getMessage().setBody("{\"qty\": 2}"));
+ assertNotNull(text.getException());
+ String message = text.getException().getMessage();
+ assertTrue(message.contains("other messages reached this step with
data, so the body here depends on the path"),
+ message);
+ }
+
@Test
public void testNothingMoreWhenEveryMessageBroughtTheSameKind() {
template.request("direct:start", e ->
e.getMessage().setBody("{\"qty\": 2}"));
@@ -76,6 +92,8 @@ public class BodyFactsOtherBodiesTest extends
ContextTestSupport {
.choice()
.when(header("parsed"))
.process(e -> e.getMessage().setBody(new
LinkedHashMap<>(Map.of("qty", 2))))
+ .when(header("immutable"))
+ .process(e -> e.getMessage().setBody(Map.of("qty", 2)))
.end()
.setHeader("qty", simple("${body.qty}")).id("readQty")
.to("mock:result");
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
index 35b821925785..8ed358e3a299 100644
--- 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
@@ -17,12 +17,19 @@
package org.apache.camel.support;
import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Scanner;
import java.util.concurrent.CopyOnWriteArrayList;
import org.apache.camel.CamelContext;
@@ -30,7 +37,9 @@ import org.apache.camel.ContextTestSupport;
import org.apache.camel.Exchange;
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.spi.BodyTypeRegistry.Kind;
+import org.apache.camel.spi.DataFormat;
import org.apache.camel.support.BodyFacts.Need;
+import org.apache.camel.support.service.ServiceSupport;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -59,6 +68,8 @@ public class BodyFactsTest extends ContextTestSupport {
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)));
+ from("direct:marshalled").marshal(new TextDataFormat())
+ .process(e -> seen.add(BodyFacts.describe(e) + ": " +
BodyFacts.hint(e, Need.DATA)));
}
};
}
@@ -76,6 +87,29 @@ public class BodyFactsTest extends ContextTestSupport {
assertTrue(seen.get(0).startsWith("the body is a Map (LinkedHashMap),
set by setBody"), seen.get(0));
}
+ @Test
+ public void testAFieldReadAfterAMarshalOfData() {
+ // what the benchmark wrote (r22): the ticket is marshalled for a
file, and a field is read after it
+ template.sendBody("direct:marshalled", new
LinkedHashMap<>(Map.of("ticketId", "T-1")));
+ assertEquals(1, seen.size());
+ assertTrue(seen.get(0).contains(", set by marshal"), seen.get(0));
+ assertTrue(seen.get(0).contains("wrote it from data (LinkedHashMap):
read the field before the marshal, for"
+ + " example into a variable with
setVariable, or keep the data in the body and"
+ + " marshal into a variable"),
+ seen.get(0));
+ // nested classes, whose canonical names cannot be loaded back as
classes
+ seen.clear();
+ template.sendBody("direct:marshalled", Map.of("ticketId", "T-1"));
+ assertTrue(seen.get(0).contains("wrote it from data (Map): read the
field before the marshal"), seen.get(0));
+ seen.clear();
+ template.sendBody("direct:marshalled", List.of("T-1", "T-2"));
+ assertTrue(seen.get(0).contains("wrote it from data (List): read the
field before the marshal"), seen.get(0));
+ // a marshal of text: unmarshal was meant
+ seen.clear();
+ template.sendBody("direct:marshalled", "{\"ticketId\": \"T-1\"}");
+ assertTrue(seen.get(0).contains("unmarshal it first"), seen.get(0));
+ }
+
@Test
public void testTheBodyInPlainWords() {
assertEquals("null", BodyFacts.words(null));
@@ -85,7 +119,14 @@ public class BodyFactsTest extends ContextTestSupport {
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 ("));
+ // a class the JDK keeps to itself goes by the interface it implements
+ assertEquals("a Map", BodyFacts.words(Map.of("sku", "A1")));
+ assertEquals("a List", BodyFacts.words(List.of("A1")));
+ assertEquals("a List", BodyFacts.words(Arrays.asList("A1")));
+ assertEquals("a collection (Set)",
BodyFacts.words(Collections.unmodifiableSet(new HashSet<>())));
+ assertEquals("a Map", BodyFacts.words(Collections.unmodifiableMap(new
HashMap<>())));
+ assertEquals("an iterator", BodyFacts.words(List.of(1).iterator()));
+ assertEquals("an iterator (Scanner)", BodyFacts.words(new
Scanner("A1")));
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<>())));
@@ -191,6 +232,20 @@ public class BodyFactsTest extends ContextTestSupport {
assertNull(BodyFacts.dataFormatOf(null));
}
+ /** Writes the body as its text. */
+ private static final class TextDataFormat extends ServiceSupport
implements DataFormat {
+
+ @Override
+ public void marshal(Exchange exchange, Object graph, OutputStream
stream) throws Exception {
+
stream.write(String.valueOf(graph).getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Override
+ public Object unmarshal(Exchange exchange, InputStream stream) {
+ throw new UnsupportedOperationException();
+ }
+ }
+
private Exchange exchange(Object body) {
Exchange answer = new DefaultExchange(context);
answer.getMessage().setBody(body);
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
index ef8584393b57..7c884e32deba 100644
--- 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
@@ -19,6 +19,7 @@ package org.apache.camel.support;
import java.io.File;
import java.io.InputStream;
import java.io.Reader;
+import java.lang.reflect.Modifier;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Collection;
@@ -26,6 +27,7 @@ import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.Set;
import java.util.StringJoiner;
import java.util.stream.BaseStream;
import java.util.stream.Collectors;
@@ -72,6 +74,12 @@ import org.apache.camel.util.URISupport;
* </tr>
* <tr>
* <td>{@link Need#DATA}</td>
+ * <td>text, bytes or a stream a marshal wrote from a Map or a List, as the
message history says</td>
+ * <td>read the field before the marshal, or marshal into a variable: an
unmarshal would undo the marshal a later step
+ * needs</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>
@@ -214,7 +222,8 @@ public final class BodyFacts {
return readTheDataFirst();
}
if (isPayload(body)) {
- return unmarshalFirst(exchange, body);
+ String marshal = marshalledData(exchange, body);
+ return marshal != null ? marshal : unmarshalFirst(exchange, body);
}
if (body instanceof Map<?, ?> map) {
if (map.isEmpty()) {
@@ -277,6 +286,36 @@ public final class BodyFacts {
};
}
+ /**
+ * When a marshal wrote the body from a Map or a List, as the message
history says: the marshal is wanted, for a
+ * later step that needs text (a file write), and the field is read after
it, too late; an unmarshal would undo it.
+ * Null when the history does not say so, as for a marshal of text, where
an unmarshal was meant.
+ */
+ private static String marshalledData(Exchange exchange, Object body) {
+ List<MessageHistory> history =
exchange.getProperty(ExchangePropertyKey.MESSAGE_HISTORY, List.class);
+ if (history == null || history.isEmpty()) {
+ return null;
+ }
+ String now = ObjectHelper.classCanonicalName(body);
+ for (int i = history.size() - 1; i >= 0; i--) {
+ MessageHistory step = history.get(i);
+ String type = step.getBodyType();
+ if (type == null || type.equals(now)) {
+ continue;
+ }
+ // the step that set the body, and the body it was reached with
+ if (step.getNode() == null ||
!"marshal".equals(step.getNode().getShortName())
+ || !(step instanceof DefaultMessageHistory dmh) ||
dmh.getBodyDataType() == null) {
+ return null;
+ }
+ return "the " + step(step.getNode()) + " wrote it from data (" +
dmh.getBodyDataType()
+ + "): read the field before the marshal, for example into a
variable with setVariable, or keep the data"
+ + " in the body and marshal into a variable (marshal with
variableReceive, and send that variable with"
+ + " variableSend)";
+ }
+ return null;
+ }
+
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"
@@ -302,17 +341,22 @@ public final class BodyFacts {
} else if (body instanceof InputStream || body instanceof Reader) {
return "a stream (" + type + ")";
} else if (body instanceof Map<?, ?>) {
- return "a Map (" + type + ")";
+ return withType("a Map", type, "Map");
} else if (body instanceof List<?>) {
- return "a List (" + type + ")";
+ return withType("a List", type, "List");
} else if (body instanceof Collection<?>) {
- return "a collection (" + type + ")";
+ return withType("a collection", type, "Collection");
} else if (isIterator(body)) {
- return "an iterator (" + type + ")";
+ return withType("an iterator", type, "Iterator");
}
return "an object of class " + body.getClass().getName();
}
+ /** The words with the class, unless the class is only the interface the
words name already: a Map, for Map.of. */
+ private static String withType(String words, String type, String plain) {
+ return type.equals(plain) ? words : words + " (" + type + ")";
+ }
+
/**
* 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
@@ -385,8 +429,22 @@ public final class BodyFacts {
if (kind == BodyTypeRegistry.Kind.NULL || types.isEmpty()) {
return words;
}
- return words + " (" + types.stream().map(t ->
StringHelper.afterLast(t, ".", t)).collect(Collectors.joining(", "))
- + ")";
+ String names = types.stream().filter(t -> !isJdkInternal(t)).map(t ->
StringHelper.afterLast(t, ".", t))
+ .distinct().collect(Collectors.joining(", "));
+ return names.isEmpty() ? words : words + " (" + names + ")";
+ }
+
+ /**
+ * Whether a canonical class name is a class nested in a JDK class, such
as java.util.ImmutableCollections.Map1 of
+ * Map.of: its name says nothing to the reader, and the kind says what it
is.
+ */
+ private static boolean isJdkInternal(String type) {
+ if (!type.startsWith("java.")) {
+ return false;
+ }
+ String outer = StringHelper.beforeLast(type, ".", "");
+ String outerName = StringHelper.afterLast(outer, ".", outer);
+ return !outerName.isEmpty() &&
Character.isUpperCase(outerName.charAt(0));
}
/** A step as people find it: unmarshal at orders.yaml:9, or the step and
its label when there is no location. */
@@ -448,9 +506,30 @@ public final class BodyFacts {
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 class of a body for a message: its simple name, or for a class the
JDK keeps to itself (the Map of Map.of,
+ * the List of Arrays.asList) the interface it implements, as its own name
says nothing to the reader.
+ *
+ * @param body the body, not null
+ * @return the name, such as LinkedHashMap, or Map for the Map of
Map.of
+ */
+ public static String typeName(Object body) {
+ Class<?> type = body.getClass();
+ if (!Modifier.isPublic(type.getModifiers()) &&
type.getName().startsWith("java.")) {
+ if (body instanceof Map<?, ?>) {
+ return "Map";
+ } else if (body instanceof List<?>) {
+ return "List";
+ } else if (body instanceof Set<?>) {
+ return "Set";
+ } else if (body instanceof Collection<?>) {
+ return "Collection";
+ } else if (body instanceof Iterator<?>) {
+ return "Iterator";
+ }
+ }
+ String name = type.getSimpleName();
+ return name.isEmpty() ? type.getName() : name;
}
/** The first character of text that is not blank, or -1: only text in
memory is looked at, never a stream. */
diff --git
a/core/camel-support/src/main/java/org/apache/camel/support/DefaultMessageHistory.java
b/core/camel-support/src/main/java/org/apache/camel/support/DefaultMessageHistory.java
index 385e761b395d..4a8e595c2243 100644
---
a/core/camel-support/src/main/java/org/apache/camel/support/DefaultMessageHistory.java
+++
b/core/camel-support/src/main/java/org/apache/camel/support/DefaultMessageHistory.java
@@ -16,6 +16,9 @@
*/
package org.apache.camel.support;
+import java.util.Collection;
+import java.util.Map;
+
import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.Message;
@@ -39,6 +42,7 @@ public class DefaultMessageHistory implements MessageHistory {
private boolean debugSkipOver;
private long elapsed;
private String bodyType;
+ private String bodyDataType;
private long bodySize = -1;
public DefaultMessageHistory(String routeId, NamedNode node) {
@@ -66,6 +70,15 @@ public class DefaultMessageHistory implements MessageHistory
{
this.bodyType = bodyType;
}
+ /**
+ * The class of the body, as {@link BodyFacts#typeName(Object)} names it,
when it was a Map or a collection as this
+ * node was reached: data, which a marshal writes as text. Null for any
other body. Kept as the body is captured, as
+ * the class of a nested type (such as the Map of {@code Map.of}) cannot
be loaded back from its canonical name.
+ */
+ public String getBodyDataType() {
+ return bodyDataType;
+ }
+
@Override
public long getBodySize() {
return bodySize;
@@ -101,6 +114,7 @@ public class DefaultMessageHistory implements
MessageHistory {
Message current = exchange.getMessage();
Object body = current.getBody();
bodyType = body != null ? ObjectHelper.classCanonicalName(body) :
"null";
+ bodyDataType = body instanceof Map<?, ?> || body instanceof
Collection<?> ? BodyFacts.typeName(body) : null;
if (registry != null && routeId != null && nodeId != null) {
registry.addBodyType(routeId, nodeId, bodyType,
BodyFacts.kindOf(body));
}
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 056c8fb13bf0..1da209be9f39 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
@@ -17,7 +17,9 @@
package org.apache.camel.support.processor;
import java.io.InputStream;
+import java.util.Collection;
import java.util.Iterator;
+import java.util.Map;
import org.apache.camel.AsyncCallback;
import org.apache.camel.CamelContext;
@@ -124,7 +126,7 @@ public class UnmarshalProcessor extends
AsyncProcessorSupport
} catch (Exception e) {
// remove OUT message, as an exception occurred
exchange.setOut(null);
- exchange.setException(body == null ? nullBodyException(exchange,
e) : e);
+ exchange.setException(body == null ? nullBodyException(exchange,
e) : parsedDataException(exchange, body, e));
} finally {
// The Iterator will close the stream itself
if (!(result instanceof Iterator)) {
@@ -158,6 +160,33 @@ public class UnmarshalProcessor extends
AsyncProcessorSupport
return answer;
}
+ /**
+ * A Map or a List fails in the type converter too, as the data format
reads text: "No type converter available to
+ * convert from type: java.util.LinkedHashMap to the required type:
java.io.InputStream". Say what it means here:
+ * the body, or the variable, is data already, and a variable is
unmarshalled with variableSend and variableReceive.
+ * The exception type stays the same, so an onException for it still
matches.
+ */
+ private Exception parsedDataException(Exchange exchange, Object body,
Exception e) {
+ if (!(e instanceof NoTypeConversionAvailableException ntc) || !(body
instanceof Map) && !(body instanceof Collection)) {
+ return e;
+ }
+ String name
+ = dataFormat instanceof DataFormatName dfn ?
dfn.getDataFormatName() : dataFormat.getClass().getSimpleName();
+ String message = variableSend != null
+ ? "Cannot unmarshal using " + name + ": the variable " +
variableSend + " holds data ("
+ + BodyFacts.typeName(body)
+ + ") already, and the data format reads text: read its
fields as they are, such as"
+ + " ${variable." + variableSend + "[name]}, and leave this
unmarshal out"
+ : "Cannot unmarshal using " + name + ": " +
BodyFacts.describe(exchange)
+ + ", which is data already, and the data format reads text:
read its fields as they are, and leave"
+ + " this unmarshal out; to parse the text in a variable,
unmarshal it with variableSend and"
+ + " variableReceive set to its name";
+ NoTypeConversionAvailableException answer
+ = new NoTypeConversionAvailableException(message, body,
ntc.getToType());
+ answer.initCause(e);
+ return answer;
+ }
+
@Override
public String toString() {
return "Unmarshal[" + dataFormat + "]";
diff --git a/docs/user-manual/modules/ROOT/pages/body-types.adoc
b/docs/user-manual/modules/ROOT/pages/body-types.adoc
index 53b77f8c6824..7a6eb96450d2 100644
--- a/docs/user-manual/modules/ROOT/pages/body-types.adoc
+++ b/docs/user-manual/modules/ROOT/pages/body-types.adoc
@@ -289,6 +289,15 @@ that cannot take the body it gets (see
xref:camel-jbang-validation.adoc[Validati
know the body, such as after an HTTP consumer, it says nothing, unless a
running integration has shown what the body
is.
+A field read is looked for in the simple and Groovy expressions of a step, and
in the simple templates it evaluates:
+a log message, the uri of a `toD` or a `wireTap`, and an endpoint option such
as the `fileName` of `file`. A `wireTap`
+and an `enrich` send the body to their endpoint, as a `to` does. What a step
puts in a variable is followed as well:
+after a `pollEnrich` of a file with `variableReceive: customer`,
`+${variable.customer[name]}+` reads the file, not the
+customer, and the validator says to unmarshal the variable in place, with
`variableSend` and `variableReceive` set to
+its name. An `unmarshal` of a body or a variable that is parsed data already
is reported too, and so is a field read
+after a `marshal` of parsed data, with the hint to read the field into a
variable before the marshal (not into a
+header, which is sent on with the message).
+
The EIPs come from the catalog as well: their `eipOut` says which ones leave
the body as it was (`log`, `setHeader`,
`threads`, a `split` without an aggregation strategy), and their `eipSteps`
whose steps start with the body at the EIP
(`choice`, `filter`, `loop`), not with another body (`aggregate`, `split`) or
one the validator cannot know (`doCatch`).
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlow.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlow.java
index a3824b0512db..c993c40c8b28 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlow.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlow.java
@@ -209,8 +209,11 @@ final class BodyTypeFlow {
}
if (reader != null) {
// the step both reads and produces, as setBody with a
jsonpath expression does, or a choice with a
- // branch that sets the body: which comes first cannot be told
from the tree, so say nothing
- return "setBody".equals(firstName(step)) ? reader : null;
+ // branch that sets the body: which comes first cannot be told
from the tree, so say nothing. Not so
+ // for a setBody, which evaluates its expression first, nor
for an unmarshal, a marshal or a
+ // convertBodyTo, which read the body to make the new one
+ String first = firstName(step);
+ return "setBody".equals(first) || first.equals(reader) ?
reader : null;
}
if (produces) {
// a branch of a choice, a doTry or a split may set the body:
from here on nothing is certain
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/CatalogBodyTypes.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/CatalogBodyTypes.java
index 122595a70cf0..6a218b047cc9 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/CatalogBodyTypes.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/CatalogBodyTypes.java
@@ -184,6 +184,13 @@ final class CatalogBodyTypes {
? answer(model.getBodyType(BodyTypeModel.UNMARSHAL_OUT),
values(options), Set.of()) : null;
}
+ /** The bodies an unmarshal with a data format of the YAML DSL (json,
csv...) and its options can read. */
+ Answer unmarshalIn(String key, JsonNode options) {
+ DataFormatModel model = dataFormat(key, options);
+ return model != null
+ ? answer(model.getBodyType(BodyTypeModel.UNMARSHAL_IN),
values(options), Set.of()) : null;
+ }
+
/** The bodies the data type transformer of a transformDataType can
transform. */
Answer transformerIn(JsonNode step) {
return transformerSide(step, BodyTypeModel.TRANSFORMER_IN);
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
index 0dd20624d29a..daa436a79447 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
@@ -17,6 +17,7 @@
package org.apache.camel.dsl.yaml.validator;
import java.text.MessageFormat;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@@ -28,6 +29,8 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import com.networknt.schema.Error;
import com.networknt.schema.path.NodePath;
import com.networknt.schema.path.PathType;
@@ -60,6 +63,11 @@ import static
org.apache.camel.dsl.yaml.validator.RouteGraph.scheme;
* does not say, or after a to: it cannot follow - what a running integration
of the routes saw there fills the gap,
* when all the bodies it saw were of one kind (CAMEL-25750): the stream cache
of an HTTP request, the List a sql call
* returns.
+ * <p/>
+ * A field is read in a simple or Groovy expression, and in the simple
template of a log message, of the uri of a toD or
+ * a wireTap, and of an endpoint option such as the fileName of file. What a
step puts in a variable (variableReceive,
+ * or a setVariable of the body) is followed the same way, and a field read on
a variable that holds text or the file as
+ * read is reported too. So is an unmarshal of data that is parsed already,
which fails as a to: file of a Map does.
*/
final class TextBodyFlow {
@@ -90,6 +98,16 @@ final class TextBodyFlow {
/** ${body.x} or ${body?.x}, not a method call. */
private static final Pattern SIMPLE_PROPERTY =
Pattern.compile("\\$\\{body\\??\\.([A-Za-z_]\\w*)(?!\\w|\\s*\\()");
+ /**
+ * ${variable.x.y}, ${variables.x?.y} or ${variable.x[y]}: a field read on
the variable x, not a method call, nor an
+ * index.
+ */
+ private static final Pattern SIMPLE_VARIABLE = Pattern.compile(
+
"\\$\\{variables?[.:]([A-Za-z_]\\w*)(\\??\\.([A-Za-z_]\\w*)(?!\\w|\\s*\\()|\\[\\s*['\"]?[A-Za-z_])");
+
+ /** The name of the field a read such as ${body[x]} or ${body.x} reads. */
+ private static final Pattern FIELD_READ =
Pattern.compile("\\$\\{body(?:\\??\\.|\\[\\s*['\"]?)([A-Za-z_]\\w*)");
+
/** ${body[x]} where x is a key, not an index. */
private static final Pattern SIMPLE_KEY =
Pattern.compile("\\$\\{body\\[\\s*['\"]?[A-Za-z_]");
@@ -134,6 +152,31 @@ final class TextBodyFlow {
/** The REST binding modes that write a Map or a List for the route. */
private static final Set<String> BINDS = Set.of("auto", "json", "xml",
"json_xml");
+ /** The steps that send the body to an endpoint, and can need it as text.
*/
+ private static final Set<String> SENDS_THE_BODY = Set.of("to", "toD",
"wireTap", "enrich");
+
+ /** The steps whose uri, and whose endpoint options, are simple templates
read for each message. */
+ private static final Set<String> TEMPLATE_URIS = Set.of("to", "toD",
"wireTap");
+
+ /** The steps that may set any variable, which the walk then forgets. */
+ private static final Set<String> SETS_VARIABLES = Set.of("bean",
"process", "script", "setVariables",
+ "removeVariables", "convertVariableTo", "kamelet", "claimCheck");
+
+ /** The components whose endpoint is a route, which may set variables of
the message. */
+ private static final Set<String> ROUTES = Set.of("direct", "seda",
"direct-vm", "vm", "kamelet");
+
+ /**
+ * What a block that may set variables has in it: a step that does, or a
call of a route. "ariable" is one substring
+ * for setVariable, removeVariable, variableReceive, ${variable.x} and the
like, whether the v is upper or lower
+ * case.
+ */
+ private static final List<String> MAY_SET_VARIABLES = List.of("ariable",
"\"bean\"", "\"process\"", "\"script\"",
+ "direct", "seda", "kamelet");
+
+ /** The options of an unmarshal or a marshal: the other key is the data
format. */
+ private static final Set<String> DATA_FORMAT_OPTIONS = Set.of("id",
"description", "disabled", "note",
+ "variableSend", "variableReceive", "allowNullBody");
+
private TextBodyFlow() {
}
@@ -149,11 +192,15 @@ final class TextBodyFlow {
record Parsed(String origin, String format) implements Body {
}
- /** Text: the payload as it was read or written. */
- record Text(String origin, String format, boolean file) implements Body {
+ /** Text: the payload as it was read or written; marshalled when a marshal
wrote it from parsed data. */
+ record Text(String origin, String format, boolean file, boolean
marshalled) implements Body {
Text(String origin, String format) {
- this(origin, format, false);
+ this(origin, format, false, false);
+ }
+
+ Text(String origin, String format, boolean file) {
+ this(origin, format, file, false);
}
/** The names that can be read from this body without parsing it. */
@@ -171,16 +218,28 @@ final class TextBodyFlow {
* What a walk knows besides the body: the route, the routes of the file,
where reports go (null for a route a step
* calls, which reports from its own walk), whether an http producer sends
the body with POST, the verbs of the
* OpenAPI operations a rest-openapi call can be judged by (empty when a
REST binding may write the body), with the
- * fields their JSON requests need, and the body types of the catalog.
+ * fields their JSON requests need, the body types of the catalog, and
what is known about the variables of the
+ * message.
*/
private record Walk(String routeId, Map<String, JsonNode> byFrom,
List<Error> errors, Set<String> seen, boolean post,
Map<String, Map<String, String>> operations, Map<String,
Map<String, List<String>>> requestFields,
Map<String, ProjectFacts.Observed> observed, CatalogBodyTypes
bodyTypes,
- Map<String, ProjectFacts.KameletDataTypes> kamelets) {
+ Map<String, ProjectFacts.KameletDataTypes> kamelets, Map<String,
Body> variables) {
- /** The walk of a route a step calls: for its outcome alone. */
+ /**
+ * The walk of a route a step calls: for its outcome alone, and the
variables of the message it changes. The
+ * called route runs on the same exchange, so it shares the variables,
where a branch gets a copy.
+ */
Walk called() {
- return new Walk(null, byFrom, null, seen, false, operations,
requestFields, observed, bodyTypes, kamelets);
+ return new Walk(
+ null, byFrom, null, seen, false, operations,
requestFields, observed, bodyTypes, kamelets, variables);
+ }
+
+ /** The walk of the steps of a block: what a branch puts in a variable
stays in the branch. */
+ Walk branch() {
+ return new Walk(
+ routeId, byFrom, errors, seen, post, operations,
requestFields, observed, bodyTypes, kamelets,
+ new HashMap<>(variables));
}
}
@@ -236,9 +295,15 @@ final class TextBodyFlow {
// what the consumer gave in a running integration of the route
start = observed(observed, ProjectFacts.consumerKey(uri),
"from: " + normalize(uri) + " gave");
}
+ Map<String, Body> variables = new HashMap<>();
+ String received = variable ?
fixedName(from.get("variableReceive")) : null;
+ if (received != null && !"kamelet".equals(consumer)) {
+ // the message the consumer receives is in the variable
+ put(variables, received, consumerBody(uri, from, bodyTypes,
"from: " + normalize(uri) + " gives it"));
+ }
Body end = walk(steps, start, at, new Walk(
id, byFrom, errors, new HashSet<>(), post, operations,
- requestFields, observed, bodyTypes, kamelets));
+ requestFields, observed, bodyTypes, kamelets, variables));
// the body at the end of the route of a consumer that replies
with text (platform-http), or of the route of
// a REST call, is the reply
boolean rest = uri != null && restReplies.contains(normalize(uri));
@@ -270,11 +335,17 @@ final class TextBodyFlow {
* paho-mqtt5), or data (a row of sql); null when it is not known, or
neither text nor data.
*/
private static Body consumerBody(String uri, JsonNode from,
CatalogBodyTypes bodyTypes) {
+ return consumerBody(uri, from, bodyTypes, "the " + scheme(uri) + "
consumer gives it");
+ }
+
+ /**
+ * The body a consumer gives, as the catalog says, with where it comes
from in words: pollEnrich: file:in gives it.
+ */
+ private static Body consumerBody(String uri, JsonNode from,
CatalogBodyTypes bodyTypes, String consumer) {
CatalogBodyTypes.Answer out = bodyTypes.consumerOut(uri, from);
if (out == null) {
return null;
}
- String consumer = "the " + scheme(uri) + " consumer gives it";
if (out.payload()) {
// the file as read has the format of the files it reads
return out.file()
@@ -284,19 +355,36 @@ final class TextBodyFlow {
return out.data() ? new Parsed(consumer + " as " + out.inWords(),
out.dataFormat()) : null;
}
- /** The format of the files a file consumer reads, by the extension it
includes. */
+ /** The format of the files a file consumer reads, by the extension it
includes, in its parameters or its uri. */
private static String fileFormat(JsonNode from) {
JsonNode parameters = from.get("parameters");
+ String uri = endpointOf(from);
String format = null;
for (String key : new String[] { "include", "antInclude", "fileName"
}) {
- if (format == null && parameters != null && parameters.isObject()
&& parameters.has(key)) {
+ String value = parameters != null && parameters.isObject() &&
parameters.has(key)
+ ? parameters.get(key).asText() : queryValue(uri, key);
+ if (format == null && value != null) {
// .*\.csv and *.csv end with the extension, as a file name
does
- format = formatOf(parameters.get(key).asText().replace("\\",
""));
+ format = formatOf(value.replace("\\", ""));
}
}
return format;
}
+ /** The value of an option in the query of an endpoint uri, or null. */
+ private static String queryValue(String uri, String key) {
+ int q = uri != null ? uri.indexOf('?') : -1;
+ if (q < 0) {
+ return null;
+ }
+ for (String pair : uri.substring(q + 1).split("&")) {
+ if (pair.startsWith(key + "=")) {
+ return pair.substring(key.length() + 1);
+ }
+ }
+ return null;
+ }
+
/**
* The body a producer or a data type transformer leaves as the catalog
says it: text (the response of the http
* producer) or data (the List of a mongodb findAll); null when it is not
known, or neither text nor data.
@@ -325,16 +413,25 @@ final class TextBodyFlow {
JsonNode step = steps.get(j);
if (!step.isObject() || !step.fieldNames().hasNext()) {
body = null;
+ w.variables().clear();
continue;
}
String name = step.fieldNames().next();
JsonNode value = step.get(name);
NodePath here = at.append(j).append(name);
+ if (w.errors() != null && parsesData(name, value, body, here, w)) {
+ // the unmarshal fails at runtime, so what comes after it is
not run: one report
+ body = null;
+ continue;
+ }
if (body != null && w.errors() != null) {
if (w.bodyTypes().stepsHaveTheBody(name, value)) {
- walkBlock(value, body, here, w);
+ walkBlock(value, body, here, w.branch());
} else if (body instanceof Text text) {
String read = readIn(value, text.readable());
+ if (read == null) {
+ read = templateRead(name, value, text.readable());
+ }
if (read != null) {
w.errors().add(error(here, w.routeId(), read, text));
}
@@ -343,14 +440,21 @@ final class TextBodyFlow {
Parsed element = "split".equals(name) ?
splitElement(value, parsed, w.bodyTypes()) : null;
if (need != null) {
w.errors().add(error(here, w.routeId(), need, parsed));
- // the step fails at runtime, so what comes after it
is not run with this body: one report
- body = null;
- continue;
+ if (!"wireTap".equals(name)) {
+ // the step fails at runtime, so what comes after
it is not run with this body: one report.
+ // A wire tap fails on its own thread, and the
route goes on
+ body = null;
+ continue;
+ }
} else if (element != null) {
- walk(value.get("steps"), element,
here.append("steps"), w);
+ walk(value.get("steps"), element,
here.append("steps"), w.branch());
}
}
}
+ if (w.errors() != null) {
+ readsOfVariables(name, value, here, w);
+ }
+ remember(name, value, body, w);
body = after(name, value, body, w);
}
return body;
@@ -414,12 +518,7 @@ final class TextBodyFlow {
return unmarshalled(value, w.bodyTypes());
}
case "marshal" -> {
- String format = value != null && value.isObject() &&
value.fieldNames().hasNext()
- ? value.fieldNames().next() : null;
- return new Text(
- "marshal" + (format != null ? ": " + format : "")
- + " writes data as text (marshal turns data
into text, unmarshal turns text into data)",
- format);
+ return marshalled(value, body);
}
case "convertBodyTo" -> {
String type = value != null && value.isObject() &&
value.has("type") ? value.get("type").asText()
@@ -565,14 +664,318 @@ final class TextBodyFlow {
* iterator).
*/
private static Parsed unmarshalled(JsonNode value, CatalogBodyTypes
bodyTypes) {
- if (value == null || !value.isObject() ||
!value.fieldNames().hasNext()) {
+ String format = dataFormatOf(value);
+ if (format == null) {
return null;
}
- String format = value.fieldNames().next();
CatalogBodyTypes.Answer out = bodyTypes.unmarshalOut(format,
value.get(format));
return out != null && out.data() ? new Parsed("unmarshal: " + format +
" turns the text into data", format) : null;
}
+ /** The text a marshal writes; marshalled when the body it writes is known
to be parsed data. */
+ private static Text marshalled(JsonNode value, Body body) {
+ String format = dataFormatOf(value);
+ return new Text(
+ "marshal" + (format != null ? ": " + format : "")
+ + " writes data as text (marshal turns data into text,
unmarshal turns text into data)",
+ format, false, body instanceof Parsed);
+ }
+
+ /** The data format of an unmarshal or a marshal: the key that is not one
of its options, such as json. */
+ private static String dataFormatOf(JsonNode value) {
+ if (value == null || !value.isObject()) {
+ return null;
+ }
+ for (var it = value.fieldNames(); it.hasNext();) {
+ String key = it.next();
+ if (!DATA_FORMAT_OPTIONS.contains(key)) {
+ return key;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * An unmarshal of data that is parsed already: the body after an
unmarshal, or a variable that holds data
+ * (variableSend). The data format reads text, and a Map is no text: "No
type converter available to convert from
+ * type: java.util.LinkedHashMap to the required type:
java.io.InputStream". Reports it, and answers whether it did.
+ */
+ private static boolean parsesData(String name, JsonNode value, Body body,
NodePath at, Walk w) {
+ String format = "unmarshal".equals(name) ? dataFormatOf(value) : null;
+ if (format == null) {
+ return false;
+ }
+ String variable = fixedName(value.get("variableSend"));
+ Body input = variable != null ? w.variables().get(variable) :
value.has("variableSend") ? null : body;
+ CatalogBodyTypes.Answer in = input instanceof Parsed ?
w.bodyTypes().unmarshalIn(format, value.get(format)) : null;
+ if (in == null || !in.onlyPayload()) {
+ return false;
+ }
+ String held = variable != null ? "the variable " + variable + " holds"
: "the body here is";
+ String fix = variable != null
+ ? "the variable is data already, so leave this unmarshal out,
and read its fields as they are, such as"
+ + " ${variable." + variable + "[name]}"
+ : "the body is data already, so leave this unmarshal out, and
read its fields as they are; to parse text"
+ + " in a variable, unmarshal it with variableSend and
variableReceive set to its name";
+ w.errors().add(Error.builder()
+ .keyword("type")
+ .instanceLocation(at)
+ .messageKey("type")
+ .format(new MessageFormat("{0}"))
+ .arguments((w.routeId() != null ? "route " + w.routeId() + ":
" : "") + "unmarshal: " + format
+ + " parses text (" + in.inWords() + "), but " + held
+ + " parsed data (a Map or a List), not text - " +
input.origin() + ": " + fix)
+ .build());
+ return true;
+ }
+
+ /**
+ * Remembers what a step puts in a variable: the result of a step with
variableReceive, which is what the step would
+ * leave in the body, and the body or a constant a setVariable sets. A
step that may set any variable (a bean, a
+ * call of a route the walk does not follow, a block with such a step)
makes the walk forget them all, so that it
+ * only reports a variable it is certain of.
+ */
+ private static void remember(String name, JsonNode value, Body body, Walk
w) {
+ Map<String, Body> variables = w.variables();
+ String uri = sendUri(name, value);
+ boolean callsARoute = uri != null && ROUTES.contains(scheme(uri))
+ && !("to".equals(name) && !value.has("variableReceive") &&
w.byFrom().containsKey(normalize(uri)));
+ // nothing to forget when no variable is known, and the text of a
block is then not looked at
+ if (!variables.isEmpty() && (SETS_VARIABLES.contains(name) ||
callsARoute
+ || RouteGraph.SENDS.contains(name) && uri == null ||
mayChangeVariables(value))) {
+ variables.clear();
+ }
+ String receive = value != null && value.isObject() ?
fixedName(value.get("variableReceive")) : null;
+ if (receive != null) {
+ put(variables, receive, received(name, value, uri, body, w));
+ } else if (value != null && value.isObject() &&
value.has("variableReceive")) {
+ // a variable named by an expression
+ variables.clear();
+ } else if ("setVariable".equals(name) ||
"removeVariable".equals(name)) {
+ JsonNode n = value != null && value.isObject() ? value.get("name")
: value;
+ String variable = fixedName(n);
+ if (variable == null) {
+ variables.clear();
+ } else if ("setVariable".equals(name)) {
+ put(variables, variable, setVariableBody(value, body));
+ } else {
+ variables.remove(variable);
+ }
+ }
+ }
+
+ /** Whether the steps of a block may set a variable: a step that does, or
a call of a route. */
+ private static boolean mayChangeVariables(JsonNode value) {
+ if (value == null || !value.isContainerNode()) {
+ return false;
+ }
+ String text = value.toString();
+ return text.contains("\"steps\"") &&
MAY_SET_VARIABLES.stream().anyMatch(text::contains);
+ }
+
+ /** What a step with variableReceive puts in the variable: what it would
leave in the body without it. */
+ private static Body received(String name, JsonNode value, String uri, Body
body, Walk w) {
+ return switch (name) {
+ case "pollEnrich", "poll" -> uri != null
+ ? consumerBody(uri, endpointNode(uri), w.bodyTypes(), name
+ ": " + normalize(uri) + " gives it")
+ : null;
+ case "to", "toD", "enrich" -> uri != null
+ ? producerBody(w.bodyTypes().producerOut(uri,
"enrich".equals(name) ? endpointNode(uri) : value),
+ name + ": " + normalize(uri) + " returns it")
+ : null;
+ case "unmarshal" -> unmarshalled(value, w.bodyTypes());
+ case "marshal" -> marshalled(value, body);
+ default -> null;
+ };
+ }
+
+ /** What a setVariable sets: the body for simple ${body}, text for a
constant or a simple template. */
+ private static Body setVariableBody(JsonNode value, Body body) {
+ JsonNode node = value.has("expression") ? value.get("expression") :
value;
+ String language = null;
+ for (var it = node.fieldNames(); it.hasNext() && language == null;) {
+ String key = it.next();
+ if (!"name".equals(key) && !"id".equals(key) &&
!"description".equals(key) && !"disabled".equals(key)
+ && !"note".equals(key)) {
+ language = key;
+ }
+ }
+ String expression = language != null ?
expressionText(node.get(language)) : null;
+ if (expression == null) {
+ return null;
+ }
+ if ("simple".equals(language) && "${body}".equals(expression.trim())) {
+ return body;
+ }
+ ObjectNode setBody = JsonNodeFactory.instance.objectNode();
+ setBody.set(language, node.get(language));
+ Text text = setBodyText(setBody);
+ return text != null ? new Text(text.origin().replace("setBody",
"setVariable"), text.format()) : null;
+ }
+
+ /** Puts what is known about a variable, or forgets it when nothing is. */
+ private static void put(Map<String, Body> variables, String name, Body
body) {
+ if (body != null) {
+ variables.put(name, body);
+ } else {
+ variables.remove(name);
+ }
+ }
+
+ /** The name of a variable as the route sets it, or null for none, or a
name only known when the route runs. */
+ private static String fixedName(JsonNode node) {
+ String name = node != null && node.isValueNode() ? node.asText() :
null;
+ return name == null || name.isBlank() || name.contains("${") ||
name.contains("{{") ? null : name;
+ }
+
+ /**
+ * The endpoint a step sends to or polls from: the uri of a to, toD,
wireTap or poll, or the expression of an enrich
+ * or a pollEnrich (a constant, or a simple template, whose component is
known); null for any other step.
+ */
+ private static String sendUri(String name, JsonNode value) {
+ if (!RouteGraph.SENDS.contains(name) && !"poll".equals(name)) {
+ return null;
+ }
+ if (!"enrich".equals(name) && !"pollEnrich".equals(name)) {
+ return endpointOf(value);
+ }
+ JsonNode node = value != null && value.isObject() &&
value.has("expression") ? value.get("expression") : value;
+ if (node == null || node.isTextual()) {
+ return node != null ? node.asText() : null;
+ }
+ for (String language : new String[] { "constant", "simple" }) {
+ String uri = node.isObject() ? expressionText(node.get(language))
: null;
+ if (uri != null && scheme(uri) != null &&
!scheme(uri).contains("$")) {
+ return uri.trim();
+ }
+ }
+ return null;
+ }
+
+ /** A node of an endpoint with only its uri, as an enrich names it in an
expression. */
+ private static JsonNode endpointNode(String uri) {
+ return JsonNodeFactory.instance.objectNode().put("uri", uri);
+ }
+
+ /** A simple template of a step: where it is, in words, and its text. */
+ private record Template(String where, String text) {
+ }
+
+ /**
+ * The simple templates a step evaluates for each message besides its
expressions: the message of a log, and the uri
+ * and the endpoint options of a to, toD or wireTap, which toD and wireTap
evaluate, and a component an option of
+ * such as the fileName of file.
+ */
+ private static List<Template> templates(String name, JsonNode value) {
+ List<Template> answer = new ArrayList<>();
+ if (value == null) {
+ return answer;
+ }
+ if ("log".equals(name)) {
+ JsonNode message = value.isObject() ? value.get("message") : value;
+ if (message != null && message.isValueNode()) {
+ answer.add(new Template("the log message", message.asText()));
+ }
+ } else if (TEMPLATE_URIS.contains(name)) {
+ JsonNode uri = value.isObject() ? value.get("uri") : value;
+ if (uri != null && uri.isValueNode()) {
+ answer.add(new Template("the uri of " + name, uri.asText()));
+ }
+ JsonNode parameters = value.isObject() ? value.get("parameters") :
null;
+ if (parameters != null && parameters.isObject()) {
+ for (var it = parameters.fieldNames(); it.hasNext();) {
+ String option = it.next();
+ if (parameters.get(option).isValueNode()) {
+ answer.add(new Template("the " + option + " of " +
name, parameters.get(option).asText()));
+ }
+ }
+ }
+ }
+ return answer;
+ }
+
+ /** A simple read of a field of the body in the templates of a step: what
reads it, or null. */
+ private static String templateRead(String name, JsonNode value,
Set<String> readable) {
+ for (Template template : templates(name, value)) {
+ String found = simpleRead(template.text(), readable);
+ if (found != null) {
+ return template.where() + " reads a field of the body (" +
found + ")";
+ }
+ }
+ return null;
+ }
+
+ /** The simple expressions of a node, not of its nested steps. */
+ private static void simpleExpressions(JsonNode node, List<String> answer) {
+ if (node == null || !node.isContainerNode()) {
+ return;
+ }
+ if (node.isArray()) {
+ node.forEach(child -> simpleExpressions(child, answer));
+ return;
+ }
+ for (var it = node.fieldNames(); it.hasNext();) {
+ String key = it.next();
+ if ("simple".equals(key)) {
+ String text = expressionText(node.get(key));
+ if (text != null) {
+ answer.add(text);
+ }
+ } else if (!"steps".equals(key)) {
+ simpleExpressions(node.get(key), answer);
+ }
+ }
+ }
+
+ /**
+ * Reports a field read on a variable that holds text or the file as read,
as ${variable.customer[name]} after a
+ * pollEnrich of a file with variableReceive: customer reads the
GenericFile, not the customer.
+ */
+ private static void readsOfVariables(String name, JsonNode value, NodePath
at, Walk w) {
+ if (w.variables().isEmpty()) {
+ return;
+ }
+ List<String> texts = new ArrayList<>();
+ simpleExpressions(value, texts);
+ templates(name, value).forEach(t -> texts.add(t.text()));
+ for (String text : texts) {
+ Matcher m = SIMPLE_VARIABLE.matcher(text);
+ while (m.find()) {
+ String variable = m.group(1);
+ if (!(w.variables().get(variable) instanceof Text held)
+ || m.group(3) != null &&
held.readable().contains(m.group(3))) {
+ continue;
+ }
+ int end = text.indexOf('}', m.start());
+ String read = end > 0 ? text.substring(m.start(), end + 1) :
m.group();
+ w.errors().add(variableError(at, w.routeId(), variable, read,
held));
+ return;
+ }
+ }
+ }
+
+ private static Error variableError(NodePath at, String routeId, String
variable, String read, Text text) {
+ String format = text.format() != null ? text.format() : "json";
+ String unmarshal = "unmarshal: {" + format + ": {}, variableSend: " +
variable + ", variableReceive: " + variable
+ + "}";
+ String fix = text.format() != null
+ ? "unmarshal it in place before this step, with " + unmarshal
+ ", to read its fields"
+ : "unmarshal it in place before this step, with the data
format of the payload, such as " + unmarshal
+ + ", to read its fields";
+ return Error.builder()
+ .keyword("type")
+ .instanceLocation(at)
+ .messageKey("type")
+ .format(new MessageFormat("{0}"))
+ .arguments((routeId != null ? "route " + routeId + ": " : "")
+ "simple reads a field of the variable "
+ + variable + " (" + read + ")"
+ + (text.file()
+ ? ", but the variable holds the file as it
was read (a GenericFile), not parsed data - "
+ : ", but the variable holds text, not
parsed data - ")
+ + text.origin() + ": " + fix)
+ .build();
+ }
+
/**
* A Groovy setBody whose result is data again: one collection method on
the body and nothing after it, such as
* body.findAll { ... }, or body.find { it.sku == ... }, which picks an
element whose fields it reads. The call, or
@@ -775,11 +1178,11 @@ final class TextBodyFlow {
"before this step")
: null;
}
- if (!"to".equals(name) && !"toD".equals(name) ||
w.bodyTypes().needsNoBody(name, value)) {
+ if (!SENDS_THE_BODY.contains(name) || w.bodyTypes().needsNoBody(name,
value)) {
// with variableSend the variable is sent, not the body
return null;
}
- String uri = endpointOf(value);
+ String uri = sendUri(name, value);
String scheme = scheme(uri);
if (scheme == null) {
return null;
@@ -799,19 +1202,42 @@ final class TextBodyFlow {
}
return null;
}
- CatalogBodyTypes.Answer in = w.bodyTypes().producerIn(uri, value);
+ JsonNode endpoint = "enrich".equals(name) ? endpointNode(uri) : value;
+ CatalogBodyTypes.Answer in = w.bodyTypes().producerIn(uri, endpoint);
if (in == null || !in.onlyPayload()) {
return null;
}
if ("http".equals(scheme) || "https".equals(scheme)) {
- return w.post() && sendsTheBody(uri, value)
+ return w.post() && sendsTheBody(uri, endpoint)
? new Need(
name + ": " + scheme
+ " sends the body as the request (a message
with a body is sent as a POST)",
"before this step, or set httpMethod: GET when the
call sends no body")
: null;
}
- return new Need(name + ": " + scheme + " needs the body as " +
in.inWords(), "before this step");
+ return new Need(
+ name + ": " + scheme + " needs the body as " + in.inWords(),
+ "before this step" + beforeTheMarshal(name, value));
+ }
+
+ /**
+ * What to read before the marshal a step that needs text gets: the fields
this step reads in its uri or options (a
+ * file name), or else the fields the steps after it read (a log), which
are text after the marshal. Into a
+ * variable, not a header: a header is sent on with the message, and
becomes part of what the endpoint gets.
+ */
+ private static String beforeTheMarshal(String name, JsonNode value) {
+ for (Template template : templates(name, value)) {
+ String found = simpleRead(template.text(), TEXT_PROPERTIES);
+ if (found != null) {
+ Matcher field = FIELD_READ.matcher(found);
+ String variable = field.find() ? field.group(1) : "field";
+ return " - and " + template.where() + " reads " + found + ",
which is text after the marshal: read it"
+ + " before the marshal into a variable (setVariable
with name " + variable + " and simple "
+ + found + ") and use ${variable." + variable + "} here";
+ }
+ }
+ return " (read the fields you still need, such as for a file name or a
log, before the marshal, for example into"
+ + " a variable with setVariable)";
}
/**
@@ -1060,7 +1486,12 @@ final class TextBodyFlow {
private static Error error(NodePath at, String routeId, String read, Text
text) {
String format = text.format();
String fix;
- if (format == null) {
+ if (text.marshalled()) {
+ // the marshal is wanted, for a step after it that needs text: the
field is read too late
+ fix = "read the field before the marshal, for example into a
variable with setVariable, or keep the data in"
+ + " the body and marshal into a variable (marshal with
variableReceive, and send that variable with"
+ + " variableSend)";
+ } else if (format == null) {
fix = "unmarshal it first with the data format of the payload
(json, jacksonXml, csv, ...) to read its fields";
} else if (text.origin().startsWith("marshal")) {
fix = "to read its fields, unmarshal: " + format + " is the step,
not marshal";
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlowTest.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlowTest.java
index e9f96d300d2b..045684d9ff46 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlowTest.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlowTest.java
@@ -346,4 +346,41 @@ public class BodyTypeFlowTest {
""";
assertThat(messages(marshal)).noneMatch(m -> m.contains("works on the
message body"));
}
+
+ @Test
+ public void testAnUnmarshalAsTheFirstStepOfAGet() {
+ // what the benchmark wrote (r22): the lookup route of a GET starts
with an unmarshal, which reads the body
+ // before it makes the new one, and a GET carries none
+ String yaml = """
+ - rest:
+ get:
+ - path: /contracts/{id}
+ to: direct:lookup
+ - route:
+ id: lookup
+ from:
+ uri: direct:lookup
+ steps:
+ - unmarshal:
+ json: {}
+ - setBody:
+ simple: ${body[name]}
+ """;
+ assertThat(messages(yaml)).anyMatch(m -> m.contains("route lookup:
unmarshal works on the message body")
+ && m.contains("the message reaching this route has none"));
+ // the same for a marshal and a convertBodyTo of a timer route
+ for (String step : List.of("marshal: {json: {}}", "convertBodyTo:
{type: String}")) {
+ String timer = """
+ - route:
+ id: tick
+ from:
+ uri: timer:tick
+ steps:
+ - %s
+ """.formatted(step);
+ assertThat(messages(timer)).as(step).anyMatch(m ->
m.contains("works on the message body"));
+ }
+ // a POST carries a body
+ assertThat(messages(yaml.replace("get:", "post:"))).noneMatch(m ->
m.contains("works on the message body"));
+ }
}
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/OneOfAlternativesTest.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/OneOfAlternativesTest.java
index 0004656cc589..4816b411559d 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/OneOfAlternativesTest.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/OneOfAlternativesTest.java
@@ -65,7 +65,7 @@ class OneOfAlternativesTest {
- route:
id: two
from:
- uri: timer:tick
+ uri: direct:two
steps:
- unmarshal:
json:
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
index 8debd489f14a..1be98b6e9b99 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
@@ -2582,4 +2582,254 @@ public class TextBodyFlowTest {
assertThat(lines(events, KAMELETS)).singleElement()
.satisfies(m -> assertThat(m).contains("to: file needs the
body as text"));
}
+
+ @Test
+ public void testAFieldReadInTheUriOfAToDAfterAMarshal() {
+ // what the benchmark wrote (r22): the ticket is marshalled for the
file, and the file name reads a field of it
+ String yaml = """
+ - route:
+ id: classify
+ from:
+ uri: file:tickets
+ steps:
+ - unmarshal:
+ json: {}
+ - marshal:
+ json: {}
+ - toD:
+ uri:
file:classified?fileName=${body[ticketId]}.json
+ """;
+ assertThat(lines(yaml)).singleElement().satisfies(m -> {
+ assertThat(m).startsWith("Line 11: ");
+ assertThat(m).contains("route classify: the uri of toD reads a
field of the body (${body[ticketId]})");
+ assertThat(m).contains("marshal: json writes data as text");
+ // the marshal is wanted here: not "unmarshal is the step"
+ assertThat(m).contains("read the field before the marshal, for
example into a variable with setVariable");
+ assertThat(m).contains("marshal with variableReceive");
+ assertThat(m).doesNotContain("is the step, not marshal");
+ });
+ // the same read in a log message, and in an option of the endpoint
+ assertThat(lines(yaml.replace("""
+ - toD:
+ uri:
file:classified?fileName=${body[ticketId]}.json
+ """, """
+ - log:
+ message: "ticket ${body[ticketId]} classified"
+ """))).singleElement().satisfies(m -> assertThat(m)
+ .contains("the log message reads a field of the body
(${body[ticketId]})"));
+ assertThat(lines(yaml.replace("""
+ - toD:
+ uri:
file:classified?fileName=${body[ticketId]}.json
+ """, """
+ - to:
+ uri: file:classified
+ parameters:
+ fileName: ${body[ticketId]}.json
+ """))).singleElement().satisfies(m -> assertThat(m)
+ .contains("the fileName of to reads a field of the body
(${body[ticketId]})"));
+ // the body itself, and a field of the data before the marshal, are
fine
+ assertThat(lines(yaml.replace("${body[ticketId]}",
"${body}"))).isEmpty();
+ assertThat(lines(yaml.replace("""
+ - marshal:
+ json: {}
+ - toD:
+ """, """
+ - log: "ticket ${body[ticketId]}"
+ - setVariable:
+ name: ticketId
+ simple: ${body[ticketId]}
+ - marshal:
+ json: {}
+ - toD:
+ """).replace("${body[ticketId]}.json",
"${variable.ticketId}.json"))).isEmpty();
+ }
+
+ @Test
+ public void testAFileWriteOfDataSaysToReadTheFieldsBeforeTheMarshal() {
+ // the file name reads a field of the data: after the marshal it would
read text
+ String yaml = """
+ - route:
+ id: classify
+ from:
+ uri: file:tickets
+ steps:
+ - unmarshal:
+ json: {}
+ - toD:
+ uri:
file:classified?fileName=${body[ticketId]}.json
+ """;
+ assertThat(lines(yaml)).singleElement().satisfies(m -> {
+ assertThat(m).contains("toD: file needs the body as");
+ assertThat(m).contains("add marshal: json before this step - and
the uri of toD reads ${body[ticketId]},"
+ + " which is text after the marshal: read
it before the marshal into a variable"
+ + " (setVariable with name ticketId and
simple ${body[ticketId]}) and use"
+ + " ${variable.ticketId} here");
+ });
+ // no field read in the step: the fields the steps after it read
+ assertThat(lines(yaml.replace("?fileName=${body[ticketId]}.json",
""))).singleElement()
+ .satisfies(m -> assertThat(m).contains("add marshal: json
before this step (read the fields you still"
+ + " need, such as for a
file name or a log, before the marshal"));
+ }
+
+ @Test
+ public void testAWireTapOrAnEnrichOfDataToAFile() {
+ // what the benchmark wrote (r22): the order Map is tapped to a file
+ String yaml = """
+ - route:
+ id: dedupe
+ from:
+ uri: file:orders
+ steps:
+ - unmarshal:
+ json: {}
+ - wireTap:
+ uri: file:audit?fileName=${body[orderId]}.json
+ - log: "order ${body[orderId]}"
+ """;
+ assertThat(lines(yaml)).singleElement().satisfies(m -> {
+ assertThat(m).startsWith("Line 9: ");
+ assertThat(m).contains("route dedupe: wireTap: file needs the body
as");
+ assertThat(m).contains("add marshal: json before this step");
+ });
+ // with variableSend the variable is sent
+ assertThat(lines(yaml.replace("""
+ uri: file:audit?fileName=${body[orderId]}.json
+ """, """
+ uri: file:audit?fileName=${body[orderId]}.json
+ variableSend: audit
+ """))).isEmpty();
+ // an enrich sends the body to the endpoint too
+ assertThat(lines(yaml.replace("""
+ - wireTap:
+ uri: file:audit?fileName=${body[orderId]}.json
+ """, """
+ - enrich:
+ constant: file:audit
+ """))).singleElement().satisfies(m ->
assertThat(m).contains("enrich: file needs the body as"));
+ }
+
+ @Test
+ public void testAnUnmarshalOfDataThatIsParsedAlready() {
+ // what the benchmark wrote (r22): the order was unmarshalled, and is
unmarshalled again
+ String yaml = """
+ - route:
+ id: dedupe
+ from:
+ uri: file:orders
+ steps:
+ - unmarshal:
+ json: {}
+ - setHeader:
+ name: orderId
+ simple: ${body[orderId]}
+ - unmarshal:
+ json: {}
+ - to:
+ uri: file:out
+ """;
+ // one report: the unmarshal fails, and the to: after it is not run
+ assertThat(lines(yaml)).singleElement().satisfies(m -> {
+ assertThat(m).startsWith("Line 12: ");
+ assertThat(m).contains("route dedupe: unmarshal: json parses
text");
+ assertThat(m).contains("but the body here is parsed data (a Map or
a List), not text");
+ assertThat(m).contains("the body is data already, so leave this
unmarshal out");
+ assertThat(m).contains("unmarshal it with variableSend and
variableReceive set to its name");
+ });
+ // a variable that holds data
+ String variable = """
+ - route:
+ id: dedupe
+ from:
+ uri: file:orders
+ steps:
+ - unmarshal:
+ json: {}
+ variableReceive: order
+ - unmarshal:
+ json: {}
+ variableSend: order
+ variableReceive: order
+ """;
+ assertThat(lines(variable)).singleElement().satisfies(m -> {
+ assertThat(m).contains("but the variable order holds parsed data
(a Map or a List), not text");
+ assertThat(m).contains("${variable.order[name]}");
+ });
+ // the text of the file is in the body still: that unmarshal is right
+ assertThat(lines(variable.replace("variableSend: order",
"variableSend: other"))).isEmpty();
+ }
+
+ @Test
+ public void testAFieldReadOnAVariableThatHoldsTheFile() {
+ // what the benchmark wrote (r22): the customer file is polled into a
variable, and read as if it were parsed
+ String yaml = """
+ - route:
+ id: enrich
+ from:
+ uri: direct:enrich
+ steps:
+ - pollEnrich:
+ constant:
file:customers?fileName=C-482.json&noop=true
+ variableReceive: customer
+ timeout: 1000
+ - setHeader:
+ name: customerName
+ simple: ${variable.customer[name]}
+ """;
+ assertThat(lines(yaml)).singleElement().satisfies(m -> {
+ assertThat(m).startsWith("Line 11: ");
+ assertThat(m).contains("route enrich: simple reads a field of the
variable customer"
+ + " (${variable.customer[name]})");
+ assertThat(m).contains("the variable holds the file as it was read
(a GenericFile)");
+ assertThat(m).contains("pollEnrich: file:customers gives it");
+ assertThat(m).contains("unmarshal: {json: {}, variableSend:
customer, variableReceive: customer}");
+ });
+ // the .name form, and in a log message
+ assertThat(lines(yaml.replace("${variable.customer[name]}",
"${variable.customer.name}"))).hasSize(1);
+ assertThat(lines(yaml.replace("""
+ - setHeader:
+ name: customerName
+ simple: ${variable.customer[name]}
+ """, """
+ - log: "customer ${variables.customer.name}"
+ """))).singleElement()
+ .satisfies(m ->
assertThat(m).contains("(${variables.customer.name})"));
+ // what the file has, the variable itself, and the variable once
unmarshalled
+ assertThat(lines(yaml.replace("${variable.customer[name]}",
"${variable.customer.fileName}"))).isEmpty();
+ assertThat(lines(yaml.replace("${variable.customer[name]}",
"${variable.customer}"))).isEmpty();
+ assertThat(lines(yaml.replace("""
+ - setHeader:
+ """, """
+ - unmarshal:
+ json: {}
+ variableSend: customer
+ variableReceive: customer
+ - setHeader:
+ """))).isEmpty();
+ // a bean may set any variable: nothing is known after it
+ assertThat(lines(yaml.replace("""
+ - setHeader:
+ """, """
+ - bean:
+ ref: customers
+ - setHeader:
+ """))).isEmpty();
+ // a setVariable of the file body is the file too
+ String copied = """
+ - route:
+ id: copy
+ from:
+ uri: file:customers
+ steps:
+ - setVariable:
+ name: customer
+ simple: ${body}
+ - setBody:
+ constant: done
+ - setHeader:
+ name: customerName
+ simple: ${variable.customer[name]}
+ """;
+ assertThat(lines(copied)).singleElement()
+ .satisfies(m -> assertThat(m).contains("simple reads a field
of the variable customer"));
+ }
}