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 cfe31eabf356 CAMEL-24844: where the body comes from, across the routes 
of a file (#26800)
cfe31eabf356 is described below

commit cfe31eabf356754813e8a1c54cd4183f0b2f6bd1
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Sep 23 19:44:45 2026 +0200

    CAMEL-24844: where the body comes from, across the routes of a file (#26800)
    
    A step that reads the message body - a jsonpath, jq or xpath expression - 
fails at runtime when there is no body, and the question cannot be answered 
inside one route: a route reached with direct: has the body of its caller.
    
    RouteGraph builds the route topology from the source, the same graph 
DefaultRouteTopologyDumper builds from the route definitions at runtime, and 
the walk over it reports a body-reading step only when every way into its route 
provably carries none. Phase B reads the OpenAPI specification beside the 
route, so a GET operation of a rest: openApi: binding is known to carry no body 
- the shape phase A had to stay silent on, and the one the benchmark failure 
sits in.
    
    Measured over real files: 12 reports over 208 routes written against an 
OpenAPI contract, each the missing read that made the route answer 500; zero 
over 997 camel-kamelets and zero over 2010 example and benchmark files.
    
    Closes #26800
---
 .../dsl/jbang/core/commands/ai/OpenApiVerbs.java   |  95 ++++++
 .../jbang/core/commands/ai/SourceValidator.java    |  24 +-
 .../core/commands/ai/OpenApiBodyFlowTest.java      | 113 +++++++
 .../camel/dsl/yaml/validator/BodyTypeFlow.java     | 334 +++++++++++++++++++++
 .../camel/dsl/yaml/validator/RouteGraph.java       | 154 ++++++++++
 .../camel/dsl/yaml/validator/YamlValidator.java    |  17 +-
 .../camel/dsl/yaml/validator/BodyTypeFlowTest.java | 223 ++++++++++++++
 7 files changed, 954 insertions(+), 6 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/OpenApiVerbs.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/OpenApiVerbs.java
new file mode 100644
index 000000000000..c50f10aee1dd
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/OpenApiVerbs.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.ai;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * What a {@code rest: openApi:} binding hides: which of its operations carry 
a body.
+ * <p/>
+ * {@code rest-openapi} routes each operation of the specification to {@code 
direct:<operationId>}, and whether that
+ * message has a body is decided by the verb - a GET and a DELETE carry none - 
which is written in the specification and
+ * not in the route. The specification is a file beside the route, so it can 
be read (CAMEL-24844).
+ */
+public final class OpenApiVerbs {
+
+    /** The verbs whose request carries no body. */
+    private static final Set<String> WITHOUT_BODY = Set.of("get", "delete", 
"head");
+
+    private static final Pattern SPECIFICATION = Pattern.compile(
+            
"openApi:\\s*\\n\\s*(?:[a-zA-Z]+:[^\\n]*\\n\\s*)*?specification:\\s*[\"']?([^\"'\\s]+)[\"']?");
+
+    private OpenApiVerbs() {
+    }
+
+    /**
+     * The {@code direct:} endpoints of the operations that carry no body, for 
a file that binds to an OpenAPI
+     * specification in the given directory. Empty when the file binds to 
none, or the specification cannot be read -
+     * nothing is guessed.
+     */
+    public static Set<String> bodylessEndpoints(String content, Path 
directory) {
+        Set<String> answer = new LinkedHashSet<>();
+        if (content == null || directory == null) {
+            return answer;
+        }
+        Matcher m = SPECIFICATION.matcher(content);
+        while (m.find()) {
+            Path spec = directory.resolve(m.group(1));
+            if (!Files.isRegularFile(spec)) {
+                continue;
+            }
+            try {
+                String text = Files.readString(spec);
+                if (!text.stripLeading().startsWith("{")) {
+                    continue; // a YAML specification: not read here
+                }
+                JsonObject root = (JsonObject) Jsoner.deserialize(text);
+                JsonObject paths = root.getMap("paths");
+                if (paths == null) {
+                    continue;
+                }
+                for (Map.Entry<String, Object> path : paths.entrySet()) {
+                    if (!(path.getValue() instanceof Map<?, ?> operations)) {
+                        continue;
+                    }
+                    for (Map.Entry<?, ?> operation : operations.entrySet()) {
+                        String verb = 
String.valueOf(operation.getKey()).toLowerCase(java.util.Locale.ROOT);
+                        if (!WITHOUT_BODY.contains(verb) || 
!(operation.getValue() instanceof Map<?, ?> details)) {
+                            continue;
+                        }
+                        Object id = details.get("operationId");
+                        if (id != null) {
+                            answer.add("direct:" + id);
+                        }
+                    }
+                }
+            } catch (Exception e) {
+                // an unreadable or unparseable specification says nothing
+            }
+        }
+        return answer;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
index 24f6eaa7b99b..b840f3f61a7c 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
@@ -104,7 +104,10 @@ public final class SourceValidator {
         Objects.requireNonNull(catalog, "catalog");
         String name = fileName == null ? "" : 
fileName.toLowerCase(Locale.ROOT);
         if (name.endsWith(".yaml") || name.endsWith(".yml")) {
-            List<String> msgs = validateCamelYaml(content, catalog, 
schemaValidator);
+            // a rest binding to an OpenAPI specification hides the verb, and 
the specification is a file beside the
+            // route: read it, so that a GET operation is known to carry no 
body (CAMEL-24844)
+            List<String> msgs = validateCamelYaml(content, catalog, 
schemaValidator,
+                    directory != null ? 
OpenApiVerbs.bodylessEndpoints(content, directory) : Set.of());
             if (directory != null && msgs.isEmpty()) {
                 msgs = new ArrayList<>(msgs);
                 BeanDeclarations declarations = 
BeanDeclarations.scan(directory, fileName);
@@ -148,11 +151,20 @@ public final class SourceValidator {
      * catalog's version.
      */
     public static List<String> validateCamelYaml(String content, CamelCatalog 
catalog, YamlValidator schemaValidator) {
+        return validateCamelYaml(content, catalog, schemaValidator, Set.of());
+    }
+
+    /**
+     * As {@link #validateCamelYaml(String, CamelCatalog, YamlValidator)} with 
the endpoints known to deliver no body,
+     * such as the {@code direct:} endpoint of a GET operation of an OpenAPI 
specification the file binds to.
+     */
+    public static List<String> validateCamelYaml(
+            String content, CamelCatalog catalog, YamlValidator 
schemaValidator, Set<String> bodylessEndpoints) {
         List<String> msgs = new ArrayList<>();
         if (content == null || content.isBlank()) {
             return msgs;
         }
-        if (validateYamlSchema(content, catalog, schemaValidator, msgs)) {
+        if (validateYamlSchema(content, catalog, schemaValidator, 
bodylessEndpoints, msgs)) {
             msgs.addAll(validateYamlCatalog(content, catalog));
         }
         return msgs;
@@ -175,6 +187,12 @@ public final class SourceValidator {
     /** Adds the schema errors to msgs; false when the YAML could not be 
checked at all (no schema, not YAML). */
     private static boolean validateYamlSchema(
             String content, CamelCatalog catalog, YamlValidator 
schemaValidator, List<String> msgs) {
+        return validateYamlSchema(content, catalog, schemaValidator, Set.of(), 
msgs);
+    }
+
+    private static boolean validateYamlSchema(
+            String content, CamelCatalog catalog, YamlValidator 
schemaValidator, Set<String> bodylessEndpoints,
+            List<String> msgs) {
         YamlValidator validator;
         try {
             validator = schemaValidator != null ? schemaValidator : 
yamlValidator(catalog);
@@ -184,7 +202,7 @@ public final class SourceValidator {
             return false;
         }
         try {
-            msgs.addAll(formatSchemaErrors(validator.validate(content)));
+            msgs.addAll(formatSchemaErrors(validator.validate(content, 
bodylessEndpoints)));
             return true;
         } catch (Exception e) {
             msgs.add("Invalid YAML: " + e.getMessage());
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/OpenApiBodyFlowTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/OpenApiBodyFlowTest.java
new file mode 100644
index 000000000000..e9d4fb1b39da
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/OpenApiBodyFlowTest.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.ai;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24844 phase B: the verb of a rest-openapi operation is in the 
specification beside the route, so a GET that
+ * carries no body can be read from it - and a route it reaches that needs a 
body can be reported.
+ */
+class OpenApiBodyFlowTest {
+
+    private static final String SPEC = """
+            {
+              "openapi": "3.0.2",
+              "paths": {
+                "/stock/{sku}": {
+                  "get": { "operationId": "getStock", "responses": { "200": { 
"description": "ok" } } }
+                },
+                "/orders": {
+                  "post": { "operationId": "createOrder", "responses": { 
"201": { "description": "created" } } }
+                }
+              }
+            }
+            """;
+
+    private static final String ROUTES = """
+            - rest:
+                openApi:
+                  specification: stock-api.json
+            - route:
+                id: getStock
+                from:
+                  uri: direct:getStock
+                  steps:
+                    - to:
+                        uri: direct:lookup
+            - route:
+                id: lookup
+                from:
+                  uri: direct:lookup
+                  steps:
+                    - setBody:
+                        expression:
+                          jsonpath:
+                            expression: "$.sku"
+            """;
+
+    @Test
+    void theVerbsOfTheSpecificationAreRead(@TempDir Path dir) throws Exception 
{
+        Files.writeString(dir.resolve("stock-api.json"), SPEC);
+        Set<String> bodyless = OpenApiVerbs.bodylessEndpoints(ROUTES, dir);
+        assertThat(bodyless).containsExactly("direct:getStock");
+    }
+
+    @Test
+    void aGetOperationMakesTheMissingReadVisible(@TempDir Path dir) throws 
Exception {
+        Files.writeString(dir.resolve("stock-api.json"), SPEC);
+        Files.writeString(dir.resolve("routes.camel.yaml"), ROUTES);
+        CamelCatalog catalog = new DefaultCamelCatalog();
+
+        List<String> withTheSpec
+                = SourceValidator.validate("routes.camel.yaml", ROUTES, 
catalog, null, dir);
+        assertThat(withTheSpec).as("the specification says getStock is a GET, 
so lookup can have no body")
+                .anyMatch(m -> m.contains("reads the message body"));
+
+        // without the directory the specification cannot be read, and nothing 
is claimed
+        List<String> withoutIt = SourceValidator.validateCamelYaml(ROUTES, 
catalog);
+        assertThat(withoutIt).noneMatch(m -> m.contains("reads the message 
body"));
+    }
+
+    @Test
+    void aPostOperationSaysNothing(@TempDir Path dir) throws Exception {
+        Files.writeString(dir.resolve("stock-api.json"), SPEC);
+        String routes = ROUTES.replace("direct:getStock", "direct:createOrder")
+                .replace("id: getStock", "id: createOrder");
+        Files.writeString(dir.resolve("routes.camel.yaml"), routes);
+        assertThat(SourceValidator.validate("routes.camel.yaml", routes, new 
DefaultCamelCatalog(), null, dir))
+                .as("a POST carries a body, so nothing is certain")
+                .noneMatch(m -> m.contains("reads the message body"));
+    }
+
+    @Test
+    void aMissingSpecificationSaysNothing(@TempDir Path dir) throws Exception {
+        Files.writeString(dir.resolve("routes.camel.yaml"), ROUTES);
+        assertThat(SourceValidator.validate("routes.camel.yaml", ROUTES, new 
DefaultCamelCatalog(), null, dir))
+                .noneMatch(m -> m.contains("reads the message body"));
+    }
+}
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
new file mode 100644
index 000000000000..10ac2e4adcc4
--- /dev/null
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlow.java
@@ -0,0 +1,334 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.yaml.validator;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.networknt.schema.Error;
+import com.networknt.schema.path.NodePath;
+
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.Route;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.endpointOf;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.normalize;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.routes;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.scheme;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.sendsTo;
+
+/**
+ * Where the body comes from, across the routes of a file.
+ * <p/>
+ * A step that reads the message body - a jsonpath, jq or xpath expression - 
fails at runtime when there is no body. A
+ * route reached with {@code direct:} has the body of its caller, so the 
question is not answered inside one route: the
+ * routes of the file form a graph through their {@code direct:} and {@code 
seda:} endpoints, the way
+ * {@code DefaultRouteTopologyDumper} builds it from the route definitions at 
runtime, and the answer follows the edges
+ * (CAMEL-24844).
+ * <p/>
+ * This first pass reports one thing, and only when it is certain: a route 
that reads the body although nothing in it,
+ * or in any route that calls it, ever sets one. It does not claim to know the 
type - a POST carries a body that no
+ * route sets - it reports that the file itself never produces one.
+ */
+final class BodyTypeFlow {
+
+    /** The expressions that read the message body and fail when there is 
none. */
+    private static final Set<String> READS_THE_BODY = Set.of("jsonpath", "jq", 
"xpath", "xquery", "xtokenize");
+
+    /** 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");
+
+    /** The consumers that produce no body of their own, so the message 
reaching the route has none. */
+    private static final Set<String> NO_BODY_CONSUMER = Set.of("timer", 
"quartz", "scheduler", "cron");
+
+    private BodyTypeFlow() {
+    }
+
+    static void check(JsonNode target, NodePath path, List<Error> errors) {
+        check(target, path, errors, Set.of());
+    }
+
+    /**
+     * @param known the endpoints the caller knows deliver no body, such as 
{@code direct:getStock} for the GET
+     *              operation of an OpenAPI specification the route binds to 
(CAMEL-24844 phase B)
+     */
+    static void check(JsonNode target, NodePath path, List<Error> errors, 
Set<String> known) {
+        List<Route> routes = routes(target);
+        if (routes.isEmpty()) {
+            return;
+        }
+        // the graph: which routes send to the endpoint a route starts from
+        Map<String, List<Route>> byFrom = new HashMap<>();
+        for (Route r : routes) {
+            if (r.fromUri() != null) {
+                byFrom.computeIfAbsent(normalize(r.fromUri()), k -> new 
ArrayList<>()).add(r);
+            }
+        }
+        Map<Route, List<Route>> callers = new HashMap<>();
+        for (Route caller : routes) {
+            for (String uri : sendsTo(caller.steps())) {
+                for (Route target2 : byFrom.getOrDefault(normalize(uri), 
List.of())) {
+                    callers.computeIfAbsent(target2, k -> new 
ArrayList<>()).add(caller);
+                }
+            }
+        }
+        Set<String> restless = new 
HashSet<>(restEndpointsWithoutABody(target));
+        for (String uri : known) {
+            restless.add(normalize(uri));
+        }
+        for (Route r : routes) {
+            String reader = firstBodyReaderBeforeAnyProducer(r.steps());
+            if (reader == null) {
+                continue;
+            }
+            if (!certainlyWithoutABody(r, callers, restless, new HashSet<>())) 
{
+                continue;
+            }
+            errors.add(Error.builder()
+                    .keyword("type")
+                    .instanceLocation(path)
+                    .messageKey("type")
+                    .format(new java.text.MessageFormat("{0}"))
+                    .arguments((r.id() != null ? "route " + r.id() + ": " : 
"") + reader
+                               + (READS_THE_BODY.contains(reader) ? " reads 
the message body" : " works on the message body")
+                               + ", and the message reaching this route has 
none"
+                               + (callers.containsKey(r) ? " - the routes that 
call it do not set one either" : "")
+                               + ": read the data first with setBody and 
constant: resource:file:... for a known"
+                               + " file, or poll: for one that is not")
+                    .build());
+        }
+    }
+
+    /**
+     * Whether it is certain that no message reaching this route can have a 
body: every way in starts at a consumer or a
+     * REST verb that produces none, and nothing on the way sets one. Anything 
unknown answers false, which keeps the
+     * check quiet.
+     */
+    private static boolean certainlyWithoutABody(
+            Route route, Map<Route, List<Route>> callers, Set<String> restless,
+            Set<Route> seen) {
+        if (!seen.add(route)) {
+            return false; // a cycle: say nothing
+        }
+        String scheme = scheme(route.fromUri());
+        if (scheme == null) {
+            return false;
+        }
+        if (NO_BODY_CONSUMER.contains(scheme)) {
+            return true;
+        }
+        if (!"direct".equals(scheme) && !"seda".equals(scheme) && 
!"direct-vm".equals(scheme)) {
+            return false; // a consumer of its own: it brings whatever it 
brings
+        }
+        boolean fromRestWithoutBody = 
restless.contains(normalize(route.fromUri()));
+        List<Route> from = callers.get(route);
+        if (from == null || from.isEmpty()) {
+            // nothing in the file calls it: only a REST verb that carries no 
body makes this certain
+            return fromRestWithoutBody;
+        }
+        for (Route caller : from) {
+            if (setsTheBodyBeforeSendingTo(caller, route)) {
+                return false;
+            }
+            if (!certainlyWithoutABody(caller, callers, restless, seen)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * 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) {
+        JsonNode steps = caller.steps();
+        if (steps == null || !steps.isArray()) {
+            return false;
+        }
+        String wanted = normalize(target.fromUri());
+        for (JsonNode step : steps) {
+            for (var it = step.fieldNames(); it.hasNext();) {
+                String name = it.next();
+                JsonNode value = step.get(name);
+                if (sendsToTheRoute(name, value, wanted)) {
+                    return false; // reached the call, and nothing before it 
set the body
+                }
+                if (producesTheBody(step)) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    /** Whether this step is the call to that route. */
+    private static boolean sendsToTheRoute(String name, JsonNode value, String 
wanted) {
+        if (!"to".equals(name) && !"toD".equals(name) && 
!"enrich".equals(name) && !"wireTap".equals(name)) {
+            return false;
+        }
+        String uri = endpointOf(value);
+        return uri != null && normalize(uri).equals(wanted);
+    }
+
+    /**
+     * What first reads the body when no step before it produced one: the name 
of an expression language, or of the step
+     * 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) {
+        if (steps == null || !steps.isArray()) {
+            return null;
+        }
+        for (JsonNode step : steps) {
+            String reader = readsTheBody(step);
+            boolean produces = producesTheBody(step);
+            if (reader != null && !produces) {
+                return reader;
+            }
+            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;
+            }
+            if (produces) {
+                // a branch of a choice, a doTry or a split may set the body: 
from here on nothing is certain
+                return null;
+            }
+        }
+        return null;
+    }
+
+    /** The first key of a step, which is the EIP it is. */
+    private static String firstName(JsonNode step) {
+        var it = step.fieldNames();
+        return it.hasNext() ? it.next() : "";
+    }
+
+    /** What reads the body in this step: an expression language, or the step 
itself. Null when nothing does. */
+    private static String readsTheBody(JsonNode step) {
+        for (var it = step.fieldNames(); it.hasNext();) {
+            String name = it.next();
+            if (STEPS_THAT_NEED_THE_BODY.contains(name)) {
+                return name;
+            }
+            String reader = readerIn(step.get(name));
+            if (reader != null) {
+                return reader;
+            }
+        }
+        return null;
+    }
+
+    /** Whether this step, or anything nested in it, puts something in the 
body. */
+    private static boolean producesTheBody(JsonNode node) {
+        if (node == null) {
+            return false;
+        }
+        if (node.isArray()) {
+            for (JsonNode child : node) {
+                if (producesTheBody(child)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+        if (!node.isObject()) {
+            return false;
+        }
+        for (var it = node.fieldNames(); it.hasNext();) {
+            String name = it.next();
+            if (SETS_THE_BODY.contains(name)) {
+                return true;
+            }
+            if (producesTheBody(node.get(name))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** The name of a body-reading language used in this node, at any depth, 
or null. */
+    private static String readerIn(JsonNode node) {
+        if (node == null) {
+            return null;
+        }
+        if (node.isArray()) {
+            for (JsonNode child : node) {
+                String found = readerIn(child);
+                if (found != null) {
+                    return found;
+                }
+            }
+            return null;
+        }
+        if (!node.isObject()) {
+            return null;
+        }
+        for (var it = node.fieldNames(); it.hasNext();) {
+            String name = it.next();
+            if (READS_THE_BODY.contains(name)) {
+                return name;
+            }
+            String found = readerIn(node.get(name));
+            if (found != null) {
+                return found;
+            }
+        }
+        return null;
+    }
+
+    /** The endpoints a REST verb without a body sends to, such as a get: that 
routes to direct:getStock. */
+    private static Set<String> restEndpointsWithoutABody(JsonNode target) {
+        Set<String> answer = new HashSet<>();
+        if (target == null || !target.isArray()) {
+            return answer;
+        }
+        for (JsonNode entry : target) {
+            JsonNode rest = entry.isObject() ? entry.get("rest") : null;
+            if (rest == null || !rest.isObject()) {
+                continue;
+            }
+            for (var it = rest.fieldNames(); it.hasNext();) {
+                String verb = it.next();
+                if (!VERBS_WITHOUT_BODY.contains(verb)) {
+                    continue;
+                }
+                JsonNode value = rest.get(verb);
+                for (JsonNode operation : value.isArray() ? value : 
List.of(value)) {
+                    String uri = endpointOf(operation.get("to"));
+                    if (uri != null) {
+                        answer.add(normalize(uri));
+                    }
+                }
+            }
+        }
+        return answer;
+    }
+
+}
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/RouteGraph.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/RouteGraph.java
new file mode 100644
index 000000000000..34563ad295d6
--- /dev/null
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/RouteGraph.java
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.yaml.validator;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+/**
+ * The routes of a file and the endpoints between them: what the route 
topology is at runtime, read from the source.
+ * <p/>
+ * {@code DefaultRouteTopologyDumper} builds the same graph from the route 
definitions of a running context, by indexing
+ * each route's input and matching the outputs against it. Here the routes are 
still text, so an endpoint has to be
+ * recognised in both the forms the YAML DSL allows (CAMEL-24844).
+ */
+final class RouteGraph {
+
+    /** The components whose endpoints link one route of the application to 
another. */
+    static final Set<String> INTERNAL = Set.of("direct", "seda", "direct-vm", 
"vm", "disruptor", "disruptor-vm");
+
+    /** The steps that send a message to an endpoint. */
+    static final Set<String> SENDS = Set.of("to", "toD", "enrich", 
"pollEnrich", "wireTap");
+
+    private RouteGraph() {
+    }
+
+    /** One route of the file: where it starts, what it does, and the node it 
was read from. */
+    record Route(String id, String fromUri, JsonNode steps, JsonNode node) {
+    }
+
+    /** The routes of the file, in both the canonical and the short form. */
+    static List<Route> routes(JsonNode target) {
+        List<Route> answer = new ArrayList<>();
+        if (target == null || !target.isArray()) {
+            return answer;
+        }
+        for (JsonNode entry : target) {
+            if (!entry.isObject()) {
+                continue;
+            }
+            JsonNode route = entry.get("route");
+            JsonNode from = route != null ? route.get("from") : 
entry.get("from");
+            if (from == null) {
+                continue;
+            }
+            String id = route != null && route.has("id") ? 
route.get("id").asText() : null;
+            JsonNode steps = from.get("steps");
+            if (steps == null && route != null) {
+                steps = route.get("steps");
+            }
+            answer.add(new Route(id, endpointOf(from), steps, entry));
+        }
+        return answer;
+    }
+
+    /**
+     * The endpoint a node means, whether its path is in the uri or in the 
parameters: {@code uri: direct} with
+     * {@code parameters: {name: lookup}} is the endpoint {@code 
direct:lookup}, which is how the YAML DSL lets an
+     * endpoint be written and how it is often written.
+     */
+    static String endpointOf(JsonNode node) {
+        if (node == null) {
+            return null;
+        }
+        if (node.isTextual()) {
+            return node.asText();
+        }
+        if (!node.isObject() || !node.has("uri")) {
+            return null;
+        }
+        String uri = node.get("uri").asText();
+        if (uri.indexOf(':') > 0) {
+            return uri;
+        }
+        JsonNode parameters = node.get("parameters");
+        if (parameters == null || !parameters.isObject()) {
+            return uri;
+        }
+        for (String key : new String[] { "name", "destinationName", "topic", 
"queue", "path", "address" }) {
+            if (parameters.has(key) && parameters.get(key).isValueNode()) {
+                return uri + ":" + parameters.get(key).asText();
+            }
+        }
+        return uri;
+    }
+
+    /** The endpoints a route sends to, at any depth: to, toD and the enrich 
family. */
+    static List<String> sendsTo(JsonNode steps) {
+        List<String> answer = new ArrayList<>();
+        collect(steps, answer);
+        return answer;
+    }
+
+    private static void collect(JsonNode node, List<String> answer) {
+        if (node == null) {
+            return;
+        }
+        if (node.isArray()) {
+            for (JsonNode child : node) {
+                collect(child, answer);
+            }
+            return;
+        }
+        if (!node.isObject()) {
+            return;
+        }
+        for (var it = node.fieldNames(); it.hasNext();) {
+            String name = it.next();
+            JsonNode value = node.get(name);
+            if (SENDS.contains(name)) {
+                String uri = endpointOf(value);
+                if (uri != null) {
+                    answer.add(uri);
+                }
+            }
+            collect(value, answer);
+        }
+    }
+
+    /** The endpoint without its options: direct:lookup?timeout=1000 is the 
endpoint direct:lookup. */
+    static String normalize(String uri) {
+        if (uri == null) {
+            return "";
+        }
+        String s = uri.trim();
+        int q = s.indexOf('?');
+        return q > 0 ? s.substring(0, q) : s;
+    }
+
+    /** The component of an endpoint, or null. */
+    static String scheme(String uri) {
+        if (uri == null) {
+            return null;
+        }
+        int colon = uri.indexOf(':');
+        return colon > 0 ? uri.substring(0, colon) : uri;
+    }
+}
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
index 715c16efcab5..a991ce33b718 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
@@ -21,6 +21,7 @@ import java.text.MessageFormat;
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
@@ -130,6 +131,14 @@ public class YamlValidator {
     }
 
     public List<Error> validate(String content) throws Exception {
+        return validate(content, Set.of());
+    }
+
+    /**
+     * @param bodylessEndpoints endpoints the caller knows deliver no body, 
such as the {@code direct:} endpoint of a
+     *                          GET operation of an OpenAPI specification the 
file binds to (CAMEL-24844)
+     */
+    public List<Error> validate(String content, Set<String> bodylessEndpoints) 
throws Exception {
         if (schema == null) {
             init();
         }
@@ -149,7 +158,7 @@ public class YamlValidator {
         }
         try {
             var target = mapper.readTree(content);
-            return validate(target);
+            return validate(target, bodylessEndpoints);
         } catch (Exception e) {
             return List.of(parseError(e, content));
         }
@@ -544,7 +553,7 @@ public class YamlValidator {
         return null;
     }
 
-    private List<Error> validate(JsonNode target) {
+    private List<Error> validate(JsonNode target, Set<String> 
bodylessEndpoints) {
         var errors = filterOneOfNoise(new 
ArrayList<>(schema.validate(target)));
         errors.removeIf(YamlValidator::isRuntimeAcceptedScalar);
         if (canonical) {
@@ -567,7 +576,7 @@ public class YamlValidator {
         errors.addAll(missing);
         // an unknown property that got a hint (bean: as a language, a header 
name as the key...) is the cause; the
         // oneOf and required errors the strict schema adds at the same 
location only repeat it thirty times
-        java.util.Set<String> hinted = new java.util.HashSet<>();
+        Set<String> hinted = new HashSet<>();
         for (Error e : errors) {
             if ("additionalProperties".equals(e.getKeyword())) {
                 hinted.add(String.valueOf(e.getInstanceLocation()));
@@ -580,6 +589,8 @@ public class YamlValidator {
         if (errors.isEmpty()) {
             checkSimpleSyntaxInScripts(target, new 
NodePath(PathType.JSON_POINTER), errors);
             checkDynamicUri(target, new NodePath(PathType.JSON_POINTER), 
errors);
+            // where the body comes from, across the routes of the file 
(CAMEL-24844)
+            BodyTypeFlow.check(target, new NodePath(PathType.JSON_POINTER), 
errors, bodylessEndpoints);
         }
         if (canonical) {
             checkOneOfCardinality(target, new NodePath(PathType.JSON_POINTER), 
errors);
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
new file mode 100644
index 000000000000..076987525d9e
--- /dev/null
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/BodyTypeFlowTest.java
@@ -0,0 +1,223 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.yaml.validator;
+
+import java.util.List;
+
+import com.networknt.schema.Error;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24844: a route that reads the body although nothing in the file ever 
sets one. The answer follows the direct:
+ * edges between the routes, the way the route topology does at runtime.
+ */
+public class BodyTypeFlowTest {
+
+    private static YamlValidator validator;
+
+    @BeforeAll
+    public static void setup() throws Exception {
+        validator = new YamlValidator();
+        validator.init();
+    }
+
+    @Test
+    public void testTheBodyIsNeverSetInTheChain() {
+        // what the benchmark wrote, with the verb spelled out: a GET carries 
no body, getStock passes it on, and
+        // lookup reads it with jsonpath
+        String yaml = """
+                - rest:
+                    get:
+                      - path: /stock/{sku}
+                        to: direct:getStock
+                - route:
+                    id: getStock
+                    from:
+                      uri: direct:getStock
+                      steps:
+                        - to:
+                            uri: direct:lookup
+                - route:
+                    id: lookup
+                    from:
+                      uri: direct:lookup
+                      steps:
+                        - setBody:
+                            expression:
+                              jsonpath:
+                                expression: "$[?(@.sku == '${header.sku}')]"
+                """;
+        assertThat(messages(yaml))
+                .anyMatch(m -> m.contains("jsonpath reads the message body")
+                        && m.contains("has none")
+                        && m.contains("resource:file:"));
+    }
+
+    @Test
+    public void testATimerAtTheRootOfTheChain() {
+        String yaml = """
+                - route:
+                    id: tick
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - to:
+                            uri: direct:lookup
+                - route:
+                    id: lookup
+                    from:
+                      uri: direct:lookup
+                      steps:
+                        - setBody:
+                            expression:
+                              jsonpath:
+                                expression: "$.sku"
+                """;
+        assertThat(messages(yaml)).anyMatch(m -> m.contains("reads the message 
body"));
+    }
+
+    @Test
+    public void testAPostCarriesABodySoNothingIsSaid() {
+        String yaml = """
+                - rest:
+                    post:
+                      - path: /orders
+                        to: direct:orders
+                - route:
+                    id: orders
+                    from:
+                      uri: direct:orders
+                      steps:
+                        - setBody:
+                            expression:
+                              jsonpath:
+                                expression: "$.sku"
+                """;
+        assertThat(messages(yaml)).noneMatch(m -> m.contains("reads the 
message body"));
+    }
+
+    @Test
+    public void testAnOpenApiBindingHidesTheVerbSoNothingIsSaid() {
+        // the verb lives in the specification, not in the route: this is the 
gap, and it must stay quiet
+        String yaml = """
+                - rest:
+                    openApi:
+                      specification: stock-api.json
+                - route:
+                    id: getStock
+                    from:
+                      uri: direct:getStock
+                      steps:
+                        - setBody:
+                            expression:
+                              jsonpath:
+                                expression: "$.sku"
+                """;
+        assertThat(messages(yaml)).noneMatch(m -> m.contains("reads the 
message body"));
+    }
+
+    @Test
+    public void testTheReadIsThere() {
+        String yaml = """
+                - route:
+                    id: lookup
+                    from:
+                      uri: direct:lookup
+                      steps:
+                        - setBody:
+                            expression:
+                              constant:
+                                expression: resource:file:stock.json
+                        - setBody:
+                            expression:
+                              jsonpath:
+                                expression: "$[?(@.sku == '${header.sku}')]"
+                """;
+        assertThat(messages(yaml)).noneMatch(m -> m.contains("reads the 
message body"));
+    }
+
+    @Test
+    public void testTheCallerSetsTheBody() {
+        String yaml = """
+                - route:
+                    id: caller
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - setBody:
+                            expression:
+                              constant:
+                                expression: resource:file:stock.json
+                        - to:
+                            uri: direct:lookup
+                - route:
+                    id: lookup
+                    from:
+                      uri: direct:lookup
+                      steps:
+                        - setBody:
+                            expression:
+                              jsonpath:
+                                expression: "$.sku"
+                """;
+        assertThat(messages(yaml)).noneMatch(m -> m.contains("reads the 
message body"));
+    }
+
+    @Test
+    public void testAConsumerThatBringsABody() {
+        String yaml = """
+                - route:
+                    id: fromFile
+                    from:
+                      uri: file:inbox
+                      steps:
+                        - setBody:
+                            expression:
+                              jsonpath:
+                                expression: "$.sku"
+                """;
+        assertThat(messages(yaml)).noneMatch(m -> m.contains("reads the 
message body"));
+    }
+
+    @Test
+    public void testARouteNobodyInTheFileCalls() {
+        // the caller may be in another file: say nothing
+        String yaml = """
+                - route:
+                    id: lookup
+                    from:
+                      uri: direct:lookup
+                      steps:
+                        - setBody:
+                            expression:
+                              jsonpath:
+                                expression: "$.sku"
+                """;
+        assertThat(messages(yaml)).noneMatch(m -> m.contains("reads the 
message body"));
+    }
+
+    private List<String> messages(String yaml) {
+        try {
+            return 
validator.validate(yaml).stream().map(Error::getMessage).toList();
+        } catch (Exception e) {
+            throw new AssertionError("Failed to validate:\n" + yaml, e);
+        }
+    }
+}

Reply via email to