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();
+    }
 }

Reply via email to