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 934bf0787bc7 CAMEL-25928: camel-yaml-dsl-validator - read the body
types of the EIPs from the catalog (#28447)
934bf0787bc7 is described below
commit 934bf0787bc75c59d75cb7f43982c6c0c1e9f8ff
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Oct 25 13:31:33 2026 +0100
CAMEL-25928: camel-yaml-dsl-validator - read the body types of the EIPs
from the catalog (#28447)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
.../user-manual/modules/ROOT/pages/body-types.adoc | 4 +
.../camel/dsl/yaml/validator/BodyTypeFlow.java | 30 ++++---
.../camel/dsl/yaml/validator/CatalogBodyTypes.java | 78 ++++++++++++++++++
.../camel/dsl/yaml/validator/TextBodyFlow.java | 26 +++---
.../camel/dsl/yaml/validator/BodyTypeFlowTest.java | 31 ++++++++
.../camel/dsl/yaml/validator/TextBodyFlowTest.java | 93 ++++++++++++++++++++++
6 files changed, 231 insertions(+), 31 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/body-types.adoc
b/docs/user-manual/modules/ROOT/pages/body-types.adoc
index 4e1b7ff4bf6b..c12adb664145 100644
--- a/docs/user-manual/modules/ROOT/pages/body-types.adoc
+++ b/docs/user-manual/modules/ROOT/pages/body-types.adoc
@@ -245,6 +245,10 @@ 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.
+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`).
+
=== Asking an AI assistant
An AI assistant with the xref:camel-jbang-mcp.adoc[MCP server] of the Camel
CLI can answer "is something wrong with
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 613847f22302..73756a5e9fc8 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
@@ -56,11 +56,6 @@ final class BodyTypeFlow {
/** The steps that work on the body itself, and have nothing to work on
when there is none. */
private static final Set<String> STEPS_THAT_NEED_THE_BODY =
Set.of("unmarshal", "marshal", "convertBodyTo");
- /** Steps that put something in the body, whatever it is. */
- private static final Set<String> SETS_THE_BODY = Set.of("setBody",
"transform", "unmarshal", "marshal",
- "convertBodyTo", "convertVariableTo", "poll", "pollEnrich",
"enrich", "process", "bean", "to", "toD",
- "recipientList", "serviceCall", "claimCheck", "aggregate",
"split", "loadBalance", "removeBody");
-
/** The REST verbs that carry no body, so a route they send to starts with
none. */
private static final Set<String> VERBS_WITHOUT_BODY = Set.of("get",
"delete", "head");
@@ -97,7 +92,7 @@ final class BodyTypeFlow {
restless.add(normalize(uri));
}
for (Route r : routes) {
- String reader = firstBodyReaderBeforeAnyProducer(r.steps());
+ String reader = firstBodyReaderBeforeAnyProducer(r.steps(),
bodyTypes);
if (reader == null) {
continue;
}
@@ -149,7 +144,7 @@ final class BodyTypeFlow {
return fromRestWithoutBody;
}
for (Route caller : from) {
- if (setsTheBodyBeforeSendingTo(caller, route)) {
+ if (setsTheBodyBeforeSendingTo(caller, route, bodyTypes)) {
return false;
}
if (!certainlyWithoutABody(caller, callers, restless, seen,
bodyTypes)) {
@@ -163,7 +158,7 @@ final class BodyTypeFlow {
* Whether the caller puts something in the body <em>before</em> it sends
to the route. What it does afterwards
* cannot help: the call has already happened.
*/
- private static boolean setsTheBodyBeforeSendingTo(Route caller, Route
target) {
+ private static boolean setsTheBodyBeforeSendingTo(Route caller, Route
target, CatalogBodyTypes bodyTypes) {
JsonNode steps = caller.steps();
if (steps == null || !steps.isArray()) {
return false;
@@ -176,7 +171,7 @@ final class BodyTypeFlow {
if (sendsToTheRoute(name, value, wanted)) {
return false; // reached the call, and nothing before it
set the body
}
- if (producesTheBody(step)) {
+ if (producesTheBody(step, bodyTypes)) {
return true;
}
}
@@ -198,13 +193,13 @@ final class BodyTypeFlow {
* itself when the step is the one that works on the body. Null when
nothing reads it, or when a step produced a
* body first.
*/
- private static String firstBodyReaderBeforeAnyProducer(JsonNode steps) {
+ private static String firstBodyReaderBeforeAnyProducer(JsonNode steps,
CatalogBodyTypes bodyTypes) {
if (steps == null || !steps.isArray()) {
return null;
}
for (JsonNode step : steps) {
String reader = readsTheBody(step);
- boolean produces = producesTheBody(step);
+ boolean produces = producesTheBody(step, bodyTypes);
if (reader != null && !produces) {
return reader;
}
@@ -242,14 +237,17 @@ final class BodyTypeFlow {
return null;
}
- /** Whether this step, or anything nested in it, puts something in the
body. */
- private static boolean producesTheBody(JsonNode node) {
+ /**
+ * Whether this step, or anything nested in it, may put something in the
body, as the body types of the EIPs in the
+ * catalog say.
+ */
+ private static boolean producesTheBody(JsonNode node, CatalogBodyTypes
bodyTypes) {
if (node == null) {
return false;
}
if (node.isArray()) {
for (JsonNode child : node) {
- if (producesTheBody(child)) {
+ if (producesTheBody(child, bodyTypes)) {
return true;
}
}
@@ -260,10 +258,10 @@ final class BodyTypeFlow {
}
for (var it = node.fieldNames(); it.hasNext();) {
String name = it.next();
- if (SETS_THE_BODY.contains(name)) {
+ if (bodyTypes.isEip(name) && bodyTypes.maySetTheBody(name)) {
return true;
}
- if (producesTheBody(node.get(name))) {
+ if (producesTheBody(node.get(name), bodyTypes)) {
return true;
}
}
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 6cd0a7753bf4..20df56539e19 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
@@ -59,6 +59,7 @@ final class CatalogBodyTypes {
private final CamelCatalog fallback;
private final Map<String, Optional<ComponentModel>> components = new
ConcurrentHashMap<>();
private final Map<String, Optional<DataFormatModel>> dataFormats = new
ConcurrentHashMap<>();
+ private final Map<String, Optional<EipModel>> eips = new
ConcurrentHashMap<>();
private volatile Map<String, List<String>> byModelName;
/**
@@ -286,6 +287,83 @@ final class CatalogBodyTypes {
return sb.toString();
}
+ /**
+ * Whether the name is an EIP, a step of a route: not a language or a data
format, which have a model in the catalog
+ * as well, nor an option.
+ */
+ boolean isEip(String name) {
+ return eip(name) != null;
+ }
+
+ /** Whether the EIP leaves the body as it was, as log and setHeader do,
for the options the step sets. */
+ boolean keepsTheBody(String name, JsonNode step) {
+ Answer out = eipSide(name, step, BodyTypeModel.EIP_OUT);
+ return out != null && out.unchanged();
+ }
+
+ /**
+ * Whether the steps of the EIP start with the body the message has at the
EIP, as those of a choice, a filter or a
+ * loop do, for the options the step sets. Not those of an aggregate or a
split, nor of a doCatch, whose body is the
+ * one of the failed step.
+ */
+ boolean stepsHaveTheBody(String name, JsonNode step) {
+ Answer steps = eipSide(name, step, BodyTypeModel.EIP_STEPS);
+ return steps != null && steps.unchanged();
+ }
+
+ /**
+ * Whether the EIP may put a body in the message, whatever its options:
everything but leaving the body as it was,
+ * or the body its own steps leave, which are looked at by themselves. An
EIP the catalog does not say counts, as
+ * does one whose body depends on an option (a split with an
aggregationStrategy), and one that gives its own steps
+ * another body (aggregate, split).
+ */
+ boolean maySetTheBody(String name) {
+ EipModel model = eip(name);
+ if (model == null) {
+ return false;
+ }
+ BodyTypeModel out = model.getBodyType(BodyTypeModel.EIP_OUT);
+ BodyTypeModel steps = model.getBodyType(BodyTypeModel.EIP_STEPS);
+ if (out == null || out.getDependsOn() != null &&
!out.getDependsOn().isEmpty()
+ || out.getByOption() != null && !out.getByOption().isEmpty()) {
+ return true;
+ }
+ boolean keeps = BodyTypeModel.FROM_STEPS.equals(out.getFrom())
+ || out.getFrom() == null &&
out.getCarriers().equals(List.of("unchanged"));
+ boolean stepsKeep = steps == null || steps.getFrom() == null &&
steps.getCarriers().equals(List.of("unchanged"));
+ return !keeps || !stepsKeep;
+ }
+
+ /**
+ * A side of an EIP for the options the step sets; null when it is not
known for the step, or it is the body of
+ * another side (from), such as the steps of a choice or the producer of a
to.
+ */
+ private Answer eipSide(String name, JsonNode step, String side) {
+ EipModel model = eip(name);
+ BodyTypeModel type = model != null ? model.getBodyType(side) : null;
+ if (type == null) {
+ return null;
+ }
+ return answer(type, values(step), Set.of());
+ }
+
+ private EipModel eip(String name) {
+ if (name == null) {
+ return null;
+ }
+ return eips.computeIfAbsent(name, n -> {
+ EipModel model = catalog.eipModel(n);
+ if (model != null && model.getBodyTypes().isEmpty() && fallback !=
null) {
+ EipModel other = fallback.eipModel(n);
+ if (other != null && !other.getBodyTypes().isEmpty()) {
+ model = other;
+ }
+ }
+ // the languages and data formats have models too, but no input:
they are not steps
+ return Optional.ofNullable(model != null && model.isInput() ?
model : null);
+ }).orElse(null);
+ }
+
private ComponentModel component(String scheme) {
if (scheme == null) {
return null;
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 b29bc22a7100..e6a0e07f5a6e 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
@@ -93,15 +93,6 @@ final class TextBodyFlow {
/** ${body[x]} where x is a key, not an index. */
private static final Pattern SIMPLE_KEY =
Pattern.compile("\\$\\{body\\[\\s*['\"]?[A-Za-z_]");
- /** Steps whose own steps run on the same body, so a read inside them sees
the body at the step. */
- private static final Set<String> SAME_BODY_BLOCKS = Set.of("choice",
"when", "otherwise", "filter", "doTry",
- "doCatch", "doFinally");
-
- /** 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*|\\[[^\\]]+])*}");
@@ -333,7 +324,7 @@ final class TextBodyFlow {
JsonNode value = step.get(name);
NodePath here = at.append(j).append(name);
if (body != null && w.errors() != null) {
- if (SAME_BODY_BLOCKS.contains(name)) {
+ if (w.bodyTypes().stepsHaveTheBody(name, value)) {
walkBlock(value, body, here, w);
} else if (body instanceof Text text) {
String read = readIn(value, text.readable());
@@ -342,7 +333,7 @@ final class TextBodyFlow {
}
} else if (body instanceof Parsed parsed) {
Need need = needsText(name, value, w);
- Parsed element = "split".equals(name) ?
splitElement(value, parsed) : null;
+ 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
@@ -379,7 +370,12 @@ final class TextBodyFlow {
JsonNode child = node.get(key);
if ("steps".equals(key)) {
walk(child, body, at.append(key), w);
- } else if (SAME_BODY_BLOCKS.contains(key) || child.isArray()) {
+ } else if (w.bodyTypes().isEip(key)) {
+ // a when or an otherwise has the body of its choice, a
doCatch the one of the step that failed
+ if (w.bodyTypes().stepsHaveTheBody(key, child)) {
+ walkBlock(child, body, at.append(key), w);
+ }
+ } else if (child.isArray()) {
walkBlock(child, body, at.append(key), w);
} else if (body instanceof Text text) {
String read = readEntry(key, child, text.readable());
@@ -440,7 +436,7 @@ final class TextBodyFlow {
return returned;
}
default -> {
- return KEEPING_STEPS.contains(name) ? body : null;
+ return w.bodyTypes().keepsTheBody(name, value) ? body : null;
}
}
}
@@ -590,7 +586,7 @@ final class TextBodyFlow {
* jsonpath, which reads parsed data as well), and its steps read fields
of each element before they change it, as
* ${body[sku]} or a jsonpath $.sku does. Otherwise null: a List may hold
text as well.
*/
- private static Parsed splitElement(JsonNode value, Parsed parsed) {
+ private static Parsed splitElement(JsonNode value, Parsed parsed,
CatalogBodyTypes bodyTypes) {
JsonNode node = value != null && value.has("expression") ?
value.get("expression") : value;
String language = node != null && node.isObject() &&
node.fieldNames().hasNext() ? node.fieldNames().next() : null;
String expression = language != null ?
expressionText(node.get(language)) : null;
@@ -611,7 +607,7 @@ final class TextBodyFlow {
parsed.origin() + ", and split " + expression.trim() +
" gives its elements as data",
parsed.format());
}
- if (!KEEPING_STEPS.contains(name)) {
+ if (!bodyTypes.keepsTheBody(name, step.get(name))) {
return null;
}
}
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 c2bf215eedd8..634a1e399960 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
@@ -256,4 +256,35 @@ public class BodyTypeFlowTest {
throw new AssertionError("Failed to validate:\n" + yaml, e);
}
}
+
+ @Test
+ public void testAKameletMayGiveTheBody() {
+ // a kamelet step may put any body in the message, as the body types
of the EIPs in the catalog say
+ String yaml = """
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - kamelet:
+ name: my-source
+ - setBody:
+ expression:
+ jsonpath:
+ expression: "$.sku"
+ """;
+ assertThat(messages(yaml)).noneMatch(m -> m.contains("reads the
message body"));
+ // log leaves the body as it was, so there is still none
+ String logged = """
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - log: tick
+ - setBody:
+ expression:
+ jsonpath:
+ expression: "$.sku"
+ """;
+ assertThat(messages(logged)).anyMatch(m -> m.contains("reads the
message body"));
+ }
}
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 50107d90e188..b75d8675bcef 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
@@ -2262,4 +2262,97 @@ public class TextBodyFlowTest {
throw new AssertionError("Failed to validate:\n" + yaml, e);
}
}
+
+ @Test
+ public void testAnEipTheCatalogSaysKeepsTheBody() {
+ // threads leaves the body as it was, as the body types of the EIPs in
the catalog say
+ String yaml = """
+ - route:
+ from:
+ uri: file:inbox
+ steps:
+ - threads: {}
+ - setHeader:
+ name: status
+ simple: "${body[status]}"
+ """;
+ assertThat(lines(yaml)).singleElement()
+ .satisfies(m -> assertThat(m).contains("simple reads a field
of the body (${body[status]})"));
+ }
+
+ @Test
+ public void testTheStepsOfALoopHaveTheBody() {
+ // the steps of a loop start with the body at the loop, as the body
types of the EIPs in the catalog say
+ String yaml = """
+ - route:
+ from:
+ uri: file:inbox
+ steps:
+ - unmarshal:
+ json: {}
+ - loop:
+ constant: "2"
+ steps:
+ - to: file:outbox
+ """;
+ assertThat(lines(yaml)).singleElement()
+ .satisfies(m -> assertThat(m).contains("to: file needs the
body as text"));
+ }
+
+ @Test
+ public void testTheStepsOfADoCatchHaveTheBodyOfTheFailedStep() {
+ // the body in a doCatch is the one of the step that failed, which the
walk does not know
+ String yaml = """
+ - route:
+ from:
+ uri: file:inbox
+ steps:
+ - doTry:
+ steps:
+ - to: mock:work
+ doCatch:
+ - exception:
+ - java.io.IOException
+ steps:
+ - setHeader:
+ name: status
+ simple: "${body[status]}"
+ """;
+ assertThat(lines(yaml)).isEmpty();
+ }
+
+ @Test
+ public void testASplitKeepsTheBodyUnlessItAggregates() {
+ String yaml = """
+ - route:
+ from:
+ uri: file:inbox
+ steps:
+ - split:
+ tokenize: "\\n"
+ steps:
+ - to: mock:line
+ - setHeader:
+ name: status
+ simple: "${body[status]}"
+ """;
+ assertThat(lines(yaml)).singleElement()
+ .satisfies(m -> assertThat(m).contains("simple reads a field
of the body (${body[status]})"));
+ // with an aggregation strategy the body after the split is what it
aggregates
+ String aggregating = """
+ - route:
+ from:
+ uri: file:inbox
+ steps:
+ - split:
+ aggregationStrategy: myStrategy
+ tokenize: "\\n"
+ steps:
+ - to: mock:line
+ - setHeader:
+ name: status
+ simple: "${body[status]}"
+ """;
+ assertThat(lines(aggregating)).isEmpty();
+ }
}