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 e13303ef1ef7 CAMEL-24844: the validator reports parsed data that is
sent or written where text is needed (#28047)
e13303ef1ef7 is described below
commit e13303ef1ef7a12f35b620d16239357188c07ccd
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Oct 18 13:06:48 2026 +0200
CAMEL-24844: the validator reports parsed data that is sent or written
where text is needed (#28047)
After unmarshal (json, jacksonXml, yaml, csv) the body is a Map or a List.
The body walk of the YAML validator (TextBodyFlow) now carries that state,
through the steps that keep the body, a Groovy collection call that keeps it
as data, a to: direct: route that returns it, and a split whose steps read
fields of each element, and reports it where the runtime fails with "No type
converter available to convert from type: java.util.LinkedHashMap to the
required type: java.io.InputStream":
- a to: file (ftp, ftps, sftp) that writes it
- an http producer that sends it with POST, in a route whose consumer brings
no HTTP headers
- the end of a platform-http route, where the body is the reply
No report on 1101 human-written route files; on the benchmark output every
report is a run that failed at runtime on it.
Signed-off-by: Claus Ibsen <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
.../core/commands/ai/HeaderPropertyMixupsTest.java | 6 +-
.../camel/dsl/yaml/validator/TextBodyFlow.java | 322 ++++++++++++++++++---
.../camel/dsl/yaml/validator/TextBodyFlowTest.java | 306 ++++++++++++++++++++
3 files changed, 593 insertions(+), 41 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
index c8ac6a5004fe..2619cc423d1b 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
@@ -88,9 +88,11 @@ class HeaderPropertyMixupsTest {
@Test
void theValidatorOfAWriteReportsIt() {
- // the file is unmarshalled first, or the body type check reports the
read of ${body[sku]} on a GenericFile
+ // the file is unmarshalled first, or the body type check reports the
read of ${body[sku]} on a GenericFile;
+ // and the lookup is a GET, or it reports the parsed order sent with
POST
String route = route("file:orders", "setProperty", "${header.sku}")
- .replace(" steps:\n", " steps:\n -
unmarshal:\n json: {}\n");
+ .replace(" steps:\n", " steps:\n -
unmarshal:\n json: {}\n")
+ .replace("${header.sku}\"", "${header.sku}?httpMethod=GET\"");
List<String> msgs = SourceValidator.validate("http-client.camel.yaml",
route, new DefaultCamelCatalog(), null);
assertThat(msgs).anyMatch(m -> m.contains("${header.sku} reads a
header sku"));
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 1a90314cfb73..eb68cc96ce46 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
@@ -16,6 +16,7 @@
*/
package org.apache.camel.dsl.yaml.validator;
+import java.text.MessageFormat;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@@ -28,6 +29,7 @@ import java.util.regex.Pattern;
import com.fasterxml.jackson.databind.JsonNode;
import com.networknt.schema.Error;
import com.networknt.schema.path.NodePath;
+import com.networknt.schema.path.PathType;
import org.apache.camel.util.MimeTypeHelper;
import static org.apache.camel.dsl.yaml.validator.RouteGraph.endpointOf;
@@ -43,6 +45,11 @@ import static
org.apache.camel.dsl.yaml.validator.RouteGraph.scheme;
* such property: sku for class: java.lang.Byte". The walk follows the steps
of each route in order, and into the
* {@code direct:} and {@code seda:} routes it calls, and reports only where
it is certain the body is text: anything it
* does not know makes it say nothing (CAMEL-24844).
+ * <p/>
+ * The other way round, parsed data where a step needs text: after {@code
unmarshal: json} the body is a Map or a List,
+ * and a {@code to: file}, an http producer that sends it with POST, or the
reply of a {@code platform-http} route fails
+ * at runtime, because no type converter turns a Map into the stream they
write: "No type converter available to convert
+ * from type: java.util.LinkedHashMap to the required type:
java.io.InputStream".
*/
final class TextBodyFlow {
@@ -83,11 +90,69 @@ final class TextBodyFlow {
/** Endpoints that leave the body as it is. */
private static final Set<String> KEEPS_THE_BODY = Set.of("log", "mock");
+ /** Steps that leave the body as it is. */
+ private static final Set<String> KEEPING_STEPS = Set.of("setHeader",
"setHeaders", "setProperty", "setVariable",
+ "setVariables", "removeHeader", "removeHeaders", "removeProperty",
"removeProperties", "removeVariable",
+ "convertHeaderTo", "convertVariableTo", "validate", "log",
"wireTap", "delay", "throttle", "sample", "stop");
+
+ /** ${body}, ${body[lines]} or ${body.lines}: the body, or a field of it.
*/
+ private static final Pattern THE_BODY =
Pattern.compile("\\$\\{body(\\??\\.[A-Za-z_]\\w*|\\[[^\\]]+])*}");
+
+ /** The data formats whose unmarshal makes a Map or a List of the text. */
+ private static final Set<String> PARSES = Set.of("json", "jacksonXml",
"yaml", "csv");
+
+ /** The unmarshal options that make something else of the text: an object
of a class, or an iterator. */
+ private static final Set<String> NOT_A_MAP = Set.of("unmarshalType",
"unmarshalTypeName", "lazyLoad");
+
+ /** The Groovy collection methods whose result is data again: a List or a
Map. */
+ private static final Set<String> GIVES_DATA = Set.of("findAll", "collect",
"collectEntries", "grep", "sort",
+ "groupBy", "unique");
+
+ /** The Groovy collection methods that pick an element, which is data when
the closure reads its fields. */
+ private static final Set<String> PICKS = Set.of("find", "max", "min");
+
+ /** body.findAll {, the start of a collection method on the body. */
+ private static final Pattern COLLECTION_CALL =
Pattern.compile("body\\s*\\??\\.\\s*([A-Za-z_]\\w*)\\s*\\{");
+
+ /** The parameter of a closure: x -> ... */
+ private static final Pattern CLOSURE_PARAMETER =
Pattern.compile("^\\s*([A-Za-z_]\\w*)\\s*->");
+
+ /** The producers that write the body to a file. */
+ private static final Set<String> WRITES_A_FILE = Set.of("file", "ftp",
"ftps", "sftp");
+
+ /**
+ * The consumers that bring no HTTP control headers, so an http producer
after them picks POST for a body: after an
+ * HTTP consumer, the CamelHttpMethod header of the request picks the
method instead.
+ */
+ private static final Set<String> NO_HTTP_HEADERS = Set.of("file", "ftp",
"ftps", "sftp", "timer", "scheduler",
+ "cron", "quartz");
+
+ /** The options of the http producer: any other parameter goes into the
query string, which makes the call a GET. */
+ private static final Set<String> HTTP_OPTIONS = Set.of("httpMethod",
"throwExceptionOnFailure", "bridgeEndpoint",
+ "okStatusCodeRange", "followRedirects", "copyHeaders",
"skipRequestHeaders", "skipResponseHeaders",
+ "disableStreamCache", "authMethod", "authUsername",
"authPassword", "authBearerToken",
+ "sslContextParameters", "userAgent", "logHttpActivity",
"lazyStartProducer", "useSystemProperties");
+
+ /** The HTTP methods that send the body. */
+ private static final Set<String> SENDS_A_BODY = Set.of("POST", "PUT",
"PATCH");
+
private TextBodyFlow() {
}
- /** What is known about the body at a step: text, and where it came from,
or nothing (null). */
- record Text(String origin, String format, boolean file) {
+ /** What is known about the body at a step: text or parsed data, and where
it came from; or nothing (null). */
+ sealed interface Body permits Text, Parsed {
+
+ String origin();
+
+ String format();
+ }
+
+ /** Parsed data: the Map or the List an unmarshal makes of the text. */
+ 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(String origin, String format) {
this(origin, format, false);
@@ -136,10 +201,29 @@ final class TextBodyFlow {
at = at.append("from").append("steps");
}
String id = entry.has("route") && entry.get("route").has("id") ?
entry.get("route").get("id").asText() : null;
- walk(steps, consumerText(from), at, id, byFrom, errors, new
HashSet<>());
+ String consumer = scheme(endpointOf(from));
+ boolean post = NO_HTTP_HEADERS.contains(consumer) &&
!setsHttpHeaders(steps);
+ Body end = walk(steps, consumerText(from), at, id, byFrom, errors,
new HashSet<>(), post);
+ // the body at the end of a platform-http route is the reply,
written as a stream
+ if ("platform-http".equals(consumer) && end instanceof Parsed
parsed && steps != null && steps.isArray()
+ && !steps.isEmpty() && steps.get(steps.size() -
1).fieldNames().hasNext()) {
+ int last = steps.size() - 1;
+
errors.add(error(at.append(last).append(steps.get(last).fieldNames().next()),
id,
+ new Need("the body at the end of the route is the
reply to the HTTP request", "as the last step"),
+ parsed));
+ }
}
}
+ /**
+ * Whether the steps set or read an HTTP control header (CamelHttpMethod,
CamelHttpQuery...), which can make an http
+ * producer call with GET and leave the body out. A match anywhere in the
steps only turns the http check off for
+ * the route: it errs on the quiet side.
+ */
+ private static boolean setsHttpHeaders(JsonNode steps) {
+ return steps != null &&
steps.toString().toLowerCase(Locale.ROOT).contains("camelhttp");
+ }
+
private static JsonNode from(JsonNode entry) {
if (entry == null || !entry.isObject()) {
return null;
@@ -167,51 +251,60 @@ final class TextBodyFlow {
/**
* Walks the steps in order with what is known about the body, reports the
reads that need parsed data where it is
- * text, and answers what is known at the end. A report is only made when
errors is not null: the routes a step
- * calls are walked for their outcome alone, and report from their own
walk.
+ * text and the steps that need text where it is parsed data, and answers
what is known at the end. A report is only
+ * made when errors is not null: the routes a step calls are walked for
their outcome alone, and report from their
+ * own walk. post says an http producer here sends the body with POST:
nothing in the route can pick another method.
*/
- private static Text walk(
- JsonNode steps, Text text, NodePath at, String routeId,
Map<String, JsonNode> byFrom,
- List<Error> errors, Set<String> seen) {
+ private static Body walk(
+ JsonNode steps, Body body, NodePath at, String routeId,
Map<String, JsonNode> byFrom,
+ List<Error> errors, Set<String> seen, boolean post) {
if (steps == null || !steps.isArray()) {
- return text;
+ return body;
}
for (int j = 0; j < steps.size(); j++) {
JsonNode step = steps.get(j);
if (!step.isObject() || !step.fieldNames().hasNext()) {
- text = null;
+ body = null;
continue;
}
String name = step.fieldNames().next();
JsonNode value = step.get(name);
NodePath here = at.append(j).append(name);
- if (text != null && errors != null) {
+ if (body != null && errors != null) {
if (SAME_BODY_BLOCKS.contains(name)) {
- walkBlock(value, text, here, routeId, byFrom, errors,
seen);
- } else {
+ walkBlock(value, body, here, routeId, byFrom, errors,
seen, post);
+ } else if (body instanceof Text text) {
String read = readIn(value, text.readable());
if (read != null) {
errors.add(error(here, routeId, read, text));
}
+ } else if (body instanceof Parsed parsed) {
+ Need need = needsText(name, value, post);
+ Parsed element = "split".equals(name) ?
splitElement(value, parsed) : null;
+ if (need != null) {
+ errors.add(error(here, routeId, need, parsed));
+ } else if (element != null) {
+ walk(value.get("steps"), element,
here.append("steps"), routeId, byFrom, errors, seen, post);
+ }
}
}
- text = after(name, value, step, text, byFrom, seen);
+ body = after(name, value, step, body, byFrom, seen);
}
- return text;
+ return body;
}
/**
* A choice, filter or doTry: its predicates read the body at the step,
and so do its steps until one changes it.
*/
private static void walkBlock(
- JsonNode node, Text text, NodePath at, String routeId, Map<String,
JsonNode> byFrom,
- List<Error> errors, Set<String> seen) {
+ JsonNode node, Body body, NodePath at, String routeId, Map<String,
JsonNode> byFrom,
+ List<Error> errors, Set<String> seen, boolean post) {
if (node == null) {
return;
}
if (node.isArray()) {
for (int i = 0; i < node.size(); i++) {
- walkBlock(node.get(i), text, at.append(i), routeId, byFrom,
errors, seen);
+ walkBlock(node.get(i), body, at.append(i), routeId, byFrom,
errors, seen, post);
}
return;
}
@@ -222,10 +315,10 @@ final class TextBodyFlow {
String key = it.next();
JsonNode child = node.get(key);
if ("steps".equals(key)) {
- walk(child, text, at.append(key), routeId, byFrom, errors,
seen);
+ walk(child, body, at.append(key), routeId, byFrom, errors,
seen, post);
} else if (SAME_BODY_BLOCKS.contains(key) || child.isArray()) {
- walkBlock(child, text, at.append(key), routeId, byFrom,
errors, seen);
- } else {
+ walkBlock(child, body, at.append(key), routeId, byFrom,
errors, seen, post);
+ } else if (body instanceof Text text) {
String read = readEntry(key, child, text.readable());
if (read != null) {
errors.add(error(at.append(key), routeId, read, text));
@@ -235,11 +328,21 @@ final class TextBodyFlow {
}
/** What is known about the body after a step. */
- private static Text after(
- String name, JsonNode value, JsonNode step, Text text, Map<String,
JsonNode> byFrom, Set<String> seen) {
+ private static Body after(
+ String name, JsonNode value, JsonNode step, Body body, Map<String,
JsonNode> byFrom, Set<String> seen) {
switch (name) {
case "setBody", "transform" -> {
- return setBodyText(value);
+ Text text = setBodyText(value);
+ if (text != null || !(body instanceof Parsed parsed)) {
+ return text;
+ }
+ String call = keepsData(value);
+ return call != null
+ ? new Parsed(parsed.origin() + ", and groovy " + call
+ " keeps it as data", parsed.format())
+ : null;
+ }
+ case "unmarshal" -> {
+ return unmarshalled(value);
}
case "marshal" -> {
String format = value != null && value.isObject() &&
value.fieldNames().hasNext()
@@ -253,14 +356,14 @@ final class TextBodyFlow {
String type = value != null && value.isObject() &&
value.has("type") ? value.get("type").asText()
: value != null && value.isTextual() ? value.asText()
: "";
return "String".equals(type) ||
"java.lang.String".equals(type) || "byte[]".equals(type)
- ? new Text("convertBodyTo " + type + " makes it text",
text != null ? text.format() : null)
+ ? new Text("convertBodyTo " + type + " makes it text",
body != null ? body.format() : null)
: null;
}
case "to", "toD" -> {
String uri = endpointOf(value);
String scheme = scheme(uri);
if (scheme != null && KEEPS_THE_BODY.contains(scheme)) {
- return text;
+ return body;
}
JsonNode called = uri != null ? byFrom.get(normalize(uri)) :
null;
if (called == null || !"to".equals(name) ||
!seen.add(normalize(uri))) {
@@ -269,21 +372,20 @@ final class TextBodyFlow {
JsonNode from = from(called);
JsonNode steps = from.has("steps") ? from.get("steps")
: called.has("route") ?
called.get("route").get("steps") : null;
- Text end = walk(steps, text, new
NodePath(com.networknt.schema.path.PathType.JSON_POINTER), null, byFrom,
- null, seen);
+ Body end = walk(steps, body, new
NodePath(PathType.JSON_POINTER), null, byFrom,
+ null, seen, false);
seen.remove(normalize(uri));
- return end == null
- ? null
- : new Text(
- "to: " + normalize(uri) + " returns it as text
(" + end.origin() + ")", end.format(),
- end.file());
- }
- case "setHeader", "setHeaders", "setProperty", "setVariable",
"removeHeader", "removeHeaders",
- "removeProperty", "removeProperties", "log", "wireTap",
"delay", "throttle", "stop" -> {
- return text;
+ if (end instanceof Text t) {
+ return new Text(
+ "to: " + normalize(uri) + " returns it as text ("
+ t.origin() + ")", t.format(),
+ t.file());
+ } else if (end instanceof Parsed p) {
+ return new Parsed("to: " + normalize(uri) + " returns it
as data (" + p.origin() + ")", p.format());
+ }
+ return null;
}
default -> {
- return null;
+ return KEEPING_STEPS.contains(name) ? body : null;
}
}
}
@@ -316,6 +418,136 @@ final class TextBodyFlow {
return null;
}
+ /** The body after an unmarshal: parsed data for the data formats that
make a Map or a List of the text. */
+ private static Parsed unmarshalled(JsonNode value) {
+ if (value == null || !value.isObject() ||
!value.fieldNames().hasNext()) {
+ return null;
+ }
+ String format = value.fieldNames().next();
+ if (!PARSES.contains(format)) {
+ return null;
+ }
+ JsonNode options = value.get(format);
+ for (String option : NOT_A_MAP) {
+ if (options != null && options.isObject() && options.has(option)) {
+ return null;
+ }
+ }
+ return new Parsed("unmarshal: " + format + " turns the text into
data", format);
+ }
+
+ /**
+ * 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
+ * null.
+ */
+ static String keepsData(JsonNode value) {
+ JsonNode node = value != null && value.has("expression") ?
value.get("expression") : value;
+ String script = node != null && node.isObject() ?
expressionText(node.get("groovy")) : null;
+ if (script == null) {
+ return null;
+ }
+ String s = script.trim();
+ Matcher m = COLLECTION_CALL.matcher(s);
+ if (!m.lookingAt()) {
+ return null;
+ }
+ String method = m.group(1);
+ int depth = 1;
+ int close = m.end();
+ for (; close < s.length() && depth > 0; close++) {
+ char c = s.charAt(close);
+ depth += c == '{' ? 1 : c == '}' ? -1 : 0;
+ }
+ if (depth != 0 || close != s.length()) {
+ // the closure does not end the script: something is done with its
result
+ return null;
+ }
+ String closure = s.substring(m.end(), close - 1);
+ Matcher p = CLOSURE_PARAMETER.matcher(closure);
+ String element = p.find() ? p.group(1) : "it";
+ boolean readsFields = Pattern.compile("(?<![\\w$])" + element +
"\\s*(\\??\\.\\s*[A-Za-z_]|\\[)")
+ .matcher(closure).find();
+ return GIVES_DATA.contains(method) || (PICKS.contains(method) &&
readsFields)
+ ? "body." + method + " { ... }" : null;
+ }
+
+ /**
+ * The elements a split makes of parsed data: data, when it splits the
body or a field of it with simple, and its
+ * steps read fields of each element before they change it, as
${body[sku]} does. Otherwise null: a List may hold
+ * text as well.
+ */
+ private static Parsed splitElement(JsonNode value, Parsed parsed) {
+ JsonNode node = value != null && value.has("expression") ?
value.get("expression") : value;
+ String expression = node != null && node.isObject() ?
expressionText(node.get("simple")) : null;
+ JsonNode steps = value != null ? value.get("steps") : null;
+ if (expression == null ||
!THE_BODY.matcher(expression.trim()).matches() || steps == null
+ || !steps.isArray()) {
+ return null;
+ }
+ for (JsonNode step : steps) {
+ if (!step.isObject() || !step.fieldNames().hasNext()) {
+ return null;
+ }
+ String name = step.fieldNames().next();
+ if (readIn(step.get(name), TEXT_PROPERTIES) != null) {
+ return new Parsed(
+ parsed.origin() + ", and split " + expression.trim() +
" gives its elements as data",
+ parsed.format());
+ }
+ if (!KEEPING_STEPS.contains(name)) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ /** What a step that needs the body as text does with it, and what to
write; or null. */
+ private record Need(String what, String fix) {
+ }
+
+ /** A to: that needs the body as text: one that writes a file, or an http
producer that sends it with POST. */
+ private static Need needsText(String name, JsonNode value, boolean post) {
+ if (!"to".equals(name) && !"toD".equals(name)) {
+ return null;
+ }
+ String uri = endpointOf(value);
+ String scheme = scheme(uri);
+ if (scheme == null) {
+ return null;
+ }
+ if (WRITES_A_FILE.contains(scheme)) {
+ return new Need(name + ": " + scheme + " writes the body to a
file", "before this step");
+ }
+ if (post && ("http".equals(scheme) || "https".equals(scheme)) &&
sendsTheBody(uri, value)) {
+ return new Need(
+ name + ": " + scheme + " sends the body as the request (a
message with a body is sent as a POST)",
+ "before this step, or set httpMethod: GET when the call
sends no body");
+ }
+ return null;
+ }
+
+ /**
+ * Whether an http producer sends the body: it picks POST for a body,
unless the httpMethod option says otherwise,
+ * or a query string (in the uri, or a parameter that is not an option of
the producer) makes it a GET.
+ */
+ private static boolean sendsTheBody(String uri, JsonNode value) {
+ if (uri.indexOf('?') >= 0) {
+ return false;
+ }
+ JsonNode parameters = value != null && value.isObject() ?
value.get("parameters") : null;
+ if (parameters == null || !parameters.isObject()) {
+ return true;
+ }
+ for (var it = parameters.fieldNames(); it.hasNext();) {
+ if (!HTTP_OPTIONS.contains(it.next())) {
+ return false;
+ }
+ }
+ JsonNode method = parameters.get("httpMethod");
+ return method == null ||
SENDS_A_BODY.contains(method.asText().toUpperCase(Locale.ROOT));
+ }
+
/** The text of an expression, short form or with expression: inside. */
private static String expressionText(JsonNode node) {
if (node == null) {
@@ -467,7 +699,7 @@ final class TextBodyFlow {
.keyword("type")
.instanceLocation(at)
.messageKey("type")
- .format(new java.text.MessageFormat("{0}"))
+ .format(new MessageFormat("{0}"))
.arguments((routeId != null ? "route " + routeId + ": " : "")
+ read
+ (text.file()
? ", but the body here is the file as it
was read (a GenericFile), not parsed data - "
@@ -475,4 +707,16 @@ final class TextBodyFlow {
+ text.origin() + ": " + fix)
.build();
}
+
+ private static Error error(NodePath at, String routeId, Need need, Parsed
parsed) {
+ return Error.builder()
+ .keyword("type")
+ .instanceLocation(at)
+ .messageKey("type")
+ .format(new MessageFormat("{0}"))
+ .arguments((routeId != null ? "route " + routeId + ": " : "")
+ need.what()
+ + ", but the body here is parsed data (a Map or a
List), not text - " + parsed.origin()
+ + ": add marshal: " + parsed.format() + " " +
need.fix())
+ .build();
+ }
}
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 f2b65a35919f..2498535e2009 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
@@ -18,6 +18,9 @@ package org.apache.camel.dsl.yaml.validator;
import java.util.List;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
@@ -265,6 +268,309 @@ public class TextBodyFlowTest {
assertThat(lines(yaml)).isEmpty();
}
+ @Test
+ public void testParsedDataWrittenToAFile() {
+ // the order is unmarshalled to read its fields, and then written as
it is: a Map is not file content
+ String yaml = """
+ - route:
+ id: archive
+ from:
+ uri: file:inbox
+ steps:
+ - unmarshal:
+ json: {}
+ - setHeader:
+ name: orderId
+ simple: ${body[orderId]}
+ - to:
+ uri: file:archive
+ """;
+ assertThat(lines(yaml)).singleElement().satisfies(m -> {
+ assertThat(m).startsWith("Line 12: ");
+ assertThat(m).contains("route archive: to: file writes the body to
a file");
+ assertThat(m).contains("the body here is parsed data (a Map or a
List), not text");
+ assertThat(m).contains("unmarshal: json turns the text into data:
add marshal: json before this step");
+ });
+ }
+
+ @Test
+ public void testValidateKeepsTheBody() {
+ // validate checks the body and leaves it as it is
+ String yaml = """
+ - route:
+ from:
+ uri: file:inbox
+ steps:
+ - unmarshal:
+ json: {}
+ - validate:
+ simple: ${body[orderId]} != null
+ - convertHeaderTo:
+ name: CamelFileLength
+ type: java.lang.Long
+ - to: file:valid
+ """;
+ assertThat(lines(yaml)).singleElement()
+ .satisfies(m -> assertThat(m).contains("to: file writes the
body to a file"));
+ }
+
+ @Test
+ public void testParsedDataSentWithPost() {
+ // a message with a body is sent as a POST, and the http producer
cannot make a request body of a Map
+ String yaml = """
+ - route:
+ from:
+ uri: file:orders
+ steps:
+ - unmarshal:
+ json:
+ library: Jackson
+ - to:
+ uri: http://localhost:8080/orders
+ parameters:
+ throwExceptionOnFailure: false
+ """;
+ assertThat(lines(yaml)).singleElement().satisfies(m -> {
+ assertThat(m).startsWith("Line 9: ");
+ assertThat(m).contains("to: http sends the body as the request (a
message with a body is sent as a POST)");
+ assertThat(m).contains("add marshal: json before this step, or set
httpMethod: GET when the call sends no body");
+ });
+ }
+
+ @Test
+ public void testSplitElementsSentWithPost() {
+ // what the benchmark wrote: each order line is a Map whose fields are
read, and toD meant a GET lookup
+ String yaml = """
+ - route:
+ id: check-stock
+ from:
+ uri: file:orders
+ steps:
+ - unmarshal:
+ json: {}
+ - split:
+ simple: "${body[lines]}"
+ steps:
+ - setHeader:
+ name: sku
+ simple: "${body[sku]}"
+ - toD:
+ uri:
"http://localhost:8080/stock/${header.sku}"
+ """;
+ assertThat(lines(yaml)).singleElement().satisfies(m -> {
+ assertThat(m).contains("route check-stock: toD: http sends the
body as the request");
+ assertThat(m).contains("unmarshal: json turns the text into data,
and split ${body[lines]} gives its"
+ + " elements as data");
+ assertThat(m).contains("or set httpMethod: GET when the call sends
no body");
+ });
+ }
+
+ @Test
+ public void testSplitElementsNotReadAsDataSayNothing() {
+ // nothing in the split reads a field of the element, which may be
text as well
+ String yaml = """
+ - route:
+ from:
+ uri: file:orders
+ steps:
+ - unmarshal:
+ json: {}
+ - split:
+ simple: "${body[skus]}"
+ steps:
+ - setHeader:
+ name: sku
+ simple: "${body}"
+ - to: http://localhost:8080/reserve
+ """;
+ assertThat(lines(yaml)).isEmpty();
+ }
+
+ @Test
+ public void testHttpCallsThatSendNoBodyAreFine() {
+ // a query string or httpMethod: GET makes the call a GET; a
CamelHttpMethod header can, and so can the
+ // request of an HTTP consumer, whose headers the producer sees
+ String yaml = """
+ - route:
+ from:
+ uri: file:a
+ steps:
+ - unmarshal:
+ json: {}
+ - to: http://localhost:8080/stock?sku=A1
+ - to:
+ uri: http://localhost:8080/stock
+ parameters:
+ httpMethod: GET
+ - route:
+ from:
+ uri: file:b
+ steps:
+ - unmarshal:
+ json: {}
+ - to:
+ uri: http://localhost:8080/stock
+ parameters:
+ sku: A1
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - setBody:
+ constant: resource:file:stock.json
+ - unmarshal:
+ json: {}
+ - setHeader:
+ name: CamelHttpMethod
+ constant: GET
+ - to: http://localhost:8080/stock
+ - route:
+ from:
+ uri: direct:c
+ steps:
+ - unmarshal:
+ json: {}
+ - to: http://localhost:8080/stock
+ """;
+ assertThat(lines(yaml)).isEmpty();
+ }
+
+ @Test
+ public void testParsedDataAsTheReply() {
+ // what the benchmark wrote: the stock is looked up in the parsed
file, and the Map found is the reply
+ String yaml = """
+ - route:
+ id: stock
+ from:
+ uri: platform-http:/stock
+ steps:
+ - setBody:
+ constant: resource:file:stock.json
+ - unmarshal:
+ json: {}
+ - setBody:
+ groovy: body.find { it.sku == headers.sku }
+ """;
+ assertThat(lines(yaml)).singleElement().satisfies(m -> {
+ assertThat(m).startsWith("Line 11: ");
+ assertThat(m).contains("route stock: the body at the end of the
route is the reply to the HTTP request");
+ assertThat(m).contains("unmarshal: json turns the text into data,
and groovy body.find { ... } keeps it as data");
+ assertThat(m).contains("add marshal: json as the last step");
+ });
+ }
+
+ @Test
+ public void testTheCalledRouteReturnsData() {
+ String yaml = """
+ - route:
+ from:
+ uri: direct:lookup
+ steps:
+ - setBody:
+ constant: resource:file:stock.json
+ - unmarshal:
+ json: {}
+ - route:
+ id: stock
+ from:
+ uri: platform-http:/stock
+ steps:
+ - to: direct:lookup
+ """;
+ assertThat(lines(yaml)).singleElement().satisfies(m -> {
+ assertThat(m).startsWith("Line 14: ");
+ assertThat(m).contains("to: direct:lookup returns it as data
(unmarshal: json turns the text into data)");
+ });
+ }
+
+ @Test
+ public void testDataMadeTextAgainIsFine() {
+ String yaml = """
+ - route:
+ from:
+ uri: platform-http:/stock
+ steps:
+ - setBody:
+ constant: resource:file:stock.json
+ - unmarshal:
+ json: {}
+ - setBody:
+ groovy: body.find { it.sku == headers.sku }
+ - marshal:
+ json: {}
+ - route:
+ from:
+ uri: file:orders
+ steps:
+ - unmarshal:
+ csv: {}
+ - setBody:
+ simple: "${body.size()} rows"
+ - to: file:report
+ """;
+ assertThat(lines(yaml)).isEmpty();
+ }
+
+ @Test
+ public void testWhatIsNotCertainlyDataSaysNothing() {
+ // a field of the element picked, an object of a class, an iterator, a
bean: the body may be text, or not data
+ String yaml = """
+ - route:
+ from:
+ uri: platform-http:/qty
+ steps:
+ - setBody:
+ constant: resource:file:stock.json
+ - unmarshal:
+ json: {}
+ - setBody:
+ groovy: body.find { it.sku == headers.sku }?.qty
+ - route:
+ from:
+ uri: platform-http:/order
+ steps:
+ - unmarshal:
+ json:
+ unmarshalType: com.example.Order
+ - route:
+ from:
+ uri: file:in
+ steps:
+ - unmarshal:
+ csv:
+ lazyLoad: true
+ - to: file:out
+ - route:
+ from:
+ uri: platform-http:/total
+ steps:
+ - unmarshal:
+ json: {}
+ - bean:
+ ref: totals
+ """;
+ assertThat(lines(yaml)).isEmpty();
+ }
+
+ @Test
+ public void testGroovyCallsThatKeepData() {
+ assertThat(TextBodyFlow.keepsData(groovy("body.findAll { it.qty > 0
}"))).isEqualTo("body.findAll { ... }");
+ assertThat(TextBodyFlow.keepsData(groovy("body.find { line -> line.sku
== headers.sku }")))
+ .isEqualTo("body.find { ... }");
+ assertThat(TextBodyFlow.keepsData(groovy("body.collect { [sku: it.sku,
total: it.qty * it.price] }")))
+ .isEqualTo("body.collect { ... }");
+ // what follows the closure, or an element whose fields are not read,
may be anything
+ assertThat(TextBodyFlow.keepsData(groovy("body.find { it.sku == 'A1'
}.qty"))).isNull();
+ assertThat(TextBodyFlow.keepsData(groovy("body.find { it == 'A1'
}"))).isNull();
+ assertThat(TextBodyFlow.keepsData(groovy("body.size()"))).isNull();
+ }
+
+ private static JsonNode groovy(String script) {
+ ObjectNode node = JsonNodeFactory.instance.objectNode();
+ node.putObject("expression").put("groovy", script);
+ return node;
+ }
+
private static List<String> lines(String yaml) {
try {
return YamlValidator.describeAll(yaml, validator.validate(yaml));