This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch fix/CAMEL-24917
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 3332ca665af1d2fee70af683f971bcf933b7c835
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Sep 23 08:18:52 2026 +0200

    CAMEL-24917: camel-yaml-dsl-validator - a to: uri that holds an expression 
says to use toD:
    
    to: http://host/stock/${header.sku} sends the placeholder as text, 
url-encoded,
    because the endpoint of a to: is resolved once when the route starts. Every
    call then fails, and the benchmark runs show hundreds of redeliveries before
    anyone reads the log. The validator now says to write toD:, so camel 
validate
    and the camel-jbang tools catch it before the route runs.
    
    Only the path is checked, never the options after the ?, and the components
    that read their path as a script or a statement (language, the sql family,
    xslt, xquery) are left alone - the user manual's
    sql:SELECT ... :#${body.itemId} on a plain to: is correct.
    
    The to-eip page shows this very mistake on purpose ("This snippet is not 
valid
    code"), so a block can now be marked with // yaml-validator: skip to stay 
out
    of the validation and out of the samples. That also takes the invalid route 
out
    of eip-samples.json, which camel_catalog_sample hands to an agent as an
    example.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01Bp3538HRBPMQkb5ta9xRaj
---
 .../org/apache/camel/catalog/docs/to-eip.adoc      |   1 +
 .../src/main/docs/modules/eips/pages/to-eip.adoc   |   1 +
 .../dsl/jbang/core/commands/ai/eip-samples.json    |   4 -
 .../dsl/yaml/validator/GenerateDocSamplesMojo.java |  13 +-
 .../camel/dsl/yaml/validator/YamlValidator.java    |  82 ++++++++++
 .../dsl/yaml/validator/EipDocExamplesTest.java     |   6 +
 .../validator/YamlValidatorDynamicUriTest.java     | 167 +++++++++++++++++++++
 7 files changed, 269 insertions(+), 5 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/to-eip.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/to-eip.adoc
index 13f04283fb71..3869fed116a7 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/to-eip.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/to-eip.adoc
@@ -126,6 +126,7 @@ XML::
 
 YAML::
 +
+// yaml-validator: skip
 [source,yaml]
 ----
 - route:
diff --git 
a/core/camel-core-engine/src/main/docs/modules/eips/pages/to-eip.adoc 
b/core/camel-core-engine/src/main/docs/modules/eips/pages/to-eip.adoc
index 13f04283fb71..3869fed116a7 100644
--- a/core/camel-core-engine/src/main/docs/modules/eips/pages/to-eip.adoc
+++ b/core/camel-core-engine/src/main/docs/modules/eips/pages/to-eip.adoc
@@ -126,6 +126,7 @@ XML::
 
 YAML::
 +
+// yaml-validator: skip
 [source,yaml]
 ----
 - route:
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json
 
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json
index d8303bc79bc2..529cbbc4044d 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json
@@ -1458,10 +1458,6 @@
       "source": "to-eip.adoc",
       "yaml": "- route:\n    from:\n      uri: file:messages\/foo\n      
steps:\n        - to:\n            uri: jms:queue:foo\n"
     },
-    {
-      "source": "to-eip.adoc",
-      "yaml": "- route:\n    from:\n      uri: file:messages\/foo\n      
steps:\n        - to:\n            uri: 
\"freemarker:\/\/templateHome\/${body.templateName}.ftl\"\n        - to:\n      
      uri: jms:queue:foo\n"
-    },
     {
       "source": "to-eip.adoc",
       "yaml": "- route:\n    from:\n      uri: file:messages\/foo\n      
steps:\n        - toD:\n            uri: 
\"freemarker:\/\/templateHome\/${body.templateName}.ftl\"\n        - to:\n      
      uri: jms:queue:foo\n"
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator-maven-plugin/src/main/java/org/apache/camel/dsl/yaml/validator/GenerateDocSamplesMojo.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator-maven-plugin/src/main/java/org/apache/camel/dsl/yaml/validator/GenerateDocSamplesMojo.java
index fef7f9458803..f2247c8b54de 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator-maven-plugin/src/main/java/org/apache/camel/dsl/yaml/validator/GenerateDocSamplesMojo.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator-maven-plugin/src/main/java/org/apache/camel/dsl/yaml/validator/GenerateDocSamplesMojo.java
@@ -63,6 +63,11 @@ public class GenerateDocSamplesMojo extends AbstractMojo {
 
     static final Pattern YAML_BLOCK = 
Pattern.compile("\\[source,yaml\\]\\s*\\n----\\n(.*?)\\n----", Pattern.DOTALL);
     private static final Pattern CALLOUT = Pattern.compile("[ \\t]*#[ 
\\t]*<\\d+>[ \\t]*$", Pattern.MULTILINE);
+    /**
+     * An AsciiDoc comment before a block keeps it out of the samples and out 
of the validation: a page that shows what
+     * to avoid (to-eip shows a to: with an expression in its uri to say why 
toD exists) must not become a sample.
+     */
+    static final String SKIP_MARKER = "// yaml-validator: skip";
 
     @Parameter(property = "project", required = true, readonly = true)
     protected MavenProject project;
@@ -272,13 +277,19 @@ public class GenerateDocSamplesMojo extends AbstractMojo {
         Matcher m = YAML_BLOCK.matcher(doc);
         while (m.find()) {
             String yaml = 
CALLOUT.matcher(m.group(1)).replaceAll("").stripTrailing() + "\n";
-            if (yaml.stripLeading().startsWith("- ")) {
+            if (yaml.stripLeading().startsWith("- ") && !markedToSkip(doc, 
m.start())) {
                 answer.add(yaml);
             }
         }
         return answer;
     }
 
+    /** Whether the block at this offset carries {@link #SKIP_MARKER} in the 
lines before it. */
+    static boolean markedToSkip(String doc, int blockStart) {
+        int from = Math.max(0, blockStart - 200);
+        return doc.substring(from, blockStart).contains(SKIP_MARKER);
+    }
+
     private static boolean validate(YamlValidator validator, File page, String 
yaml, List<String> failures)
             throws Exception {
         List<Error> errors = validator.validate(yaml);
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 2b47f748c2dc..fadf98b0b7a3 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
@@ -563,6 +563,7 @@ public class YamlValidator {
         }
         if (errors.isEmpty()) {
             checkSimpleSyntaxInScripts(target, new 
NodePath(PathType.JSON_POINTER), errors);
+            checkDynamicUri(target, new NodePath(PathType.JSON_POINTER), 
errors);
         }
         if (canonical) {
             checkOneOfCardinality(target, new NodePath(PathType.JSON_POINTER), 
errors);
@@ -668,6 +669,87 @@ public class YamlValidator {
         }
     }
 
+    /**
+     * to: http://host/stock/${header.sku}: the endpoint of a to: is resolved 
once when the route starts, so an
+     * expression in its path is never evaluated - it is sent as the text it 
is, url-encoded. That is what toD: is for
+     * (CAMEL-24917).
+     * <p/>
+     * Only the path is checked, never the options after the {@code ?}: an 
option such as the file component's
+     * {@code fileName=${date:now:yyyyMMdd}} is evaluated by the producer and 
is correct on a plain to:.
+     */
+    void checkDynamicUri(JsonNode node, NodePath path, List<Error> errors) {
+        if (node == null) {
+            return;
+        }
+        if (node.isArray()) {
+            for (int i = 0; i < node.size(); i++) {
+                checkDynamicUri(node.get(i), path.append(i), errors);
+            }
+            return;
+        }
+        if (!node.isObject()) {
+            return;
+        }
+        var fields = node.fieldNames();
+        while (fields.hasNext()) {
+            String name = fields.next();
+            JsonNode value = node.get(name);
+            if ("to".equals(name)) {
+                String uri = null;
+                NodePath at = path.append(name);
+                if (value.isTextual()) {
+                    uri = value.asText();
+                } else if (value.isObject() && value.has("uri") && 
value.get("uri").isTextual()) {
+                    uri = value.get("uri").asText();
+                    at = at.append("uri");
+                }
+                String expression = expressionInPath(uri);
+                if (expression != null) {
+                    errors.add(Error.builder()
+                            .keyword("type")
+                            .instanceLocation(at)
+                            .messageKey("type")
+                            .format(new MessageFormat("{0}"))
+                            .arguments("to: the uri holds an expression (" + 
expression + ") but the endpoint of a to:"
+                                       + " is fixed when the route starts, so 
it is sent as text: write toD: to build"
+                                       + " the uri for each message")
+                            .build());
+                }
+            }
+            checkDynamicUri(value, path.append(name), errors);
+        }
+    }
+
+    /**
+     * The first simple expression in the path of the uri (what comes before 
the options), or null when there is none.
+     */
+    private static String expressionInPath(String uri) {
+        if (uri == null) {
+            return null;
+        }
+        int scheme = uri.indexOf(':');
+        if (scheme > 0 && EVALUATED_PATH.contains(uri.substring(0, scheme))) {
+            return null;
+        }
+        String head = uri.indexOf('?') > 0 ? uri.substring(0, 
uri.indexOf('?')) : uri;
+        int start = head.indexOf("${");
+        if (start < 0) {
+            return null;
+        }
+        if (start >= 2 && head.startsWith(":#", start - 2)) {
+            return null; // :#${...} is a parameter the component binds per 
message, not part of the address
+        }
+        int end = head.indexOf('}', start);
+        return end > 0 ? head.substring(start, end + 1) : 
head.substring(start);
+    }
+
+    /**
+     * Components that read their path as a script, a statement or a template 
name and evaluate it for each message,
+     * where an expression in the path is what the component is for.
+     */
+    private static final Set<String> EVALUATED_PATH = Set.of("language", 
"sql", "sql-stored", "elsql", "jdbc",
+            "spring-jdbc", "mybatis", "xquery", "xslt");
+
     /** Adds an error for every expression node in the tree that has neither 
expression: nor a language key. */
     void checkRequiredExpressions(JsonNode node, NodePath path, List<Error> 
errors) {
         if (node == null) {
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/EipDocExamplesTest.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/EipDocExamplesTest.java
index 248ea4363421..e1f111242002 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/EipDocExamplesTest.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/EipDocExamplesTest.java
@@ -55,6 +55,9 @@ class EipDocExamplesTest {
      */
     private static final Map<String, String> EXAMPLES_SKIPPED = 
Map.of("yaml-dsl", "myStep:");
 
+    /** The same marker the doc-samples plugin honours: a block that shows 
what to avoid is not an example. */
+    private static final String SKIP_MARKER = "// yaml-validator: skip";
+
     private static CamelCatalog catalog;
     private static YamlValidator validator;
 
@@ -141,6 +144,9 @@ class EipDocExamplesTest {
                 if (skipped != null && yaml.contains(skipped)) {
                     continue;
                 }
+                if (doc.substring(Math.max(0, m.start() - 200), 
m.start()).contains(SKIP_MARKER)) {
+                    continue;
+                }
                 examples++;
                 List<Error> errors = validator.validate(yaml);
                 if (!errors.isEmpty()) {
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorDynamicUriTest.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorDynamicUriTest.java
new file mode 100644
index 000000000000..16c722329460
--- /dev/null
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorDynamicUriTest.java
@@ -0,0 +1,167 @@
+/*
+ * 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-24917: to: with an expression in the uri path sends the text as it 
stands, url-encoded, and every call fails at
+ * runtime. The validator says to write toD: instead.
+ */
+public class YamlValidatorDynamicUriTest {
+
+    private static YamlValidator classic;
+    private static YamlValidator canonical;
+
+    @BeforeAll
+    public static void setup() throws Exception {
+        classic = new YamlValidator();
+        classic.init();
+        canonical = new YamlValidator(true);
+        canonical.init();
+    }
+
+    @Test
+    public void testToWithAnExpressionInThePath() {
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:start
+                      steps:
+                        - to:
+                            uri: "http://localhost:8080/stock/${header.sku}";
+                """;
+        assertHint(yaml, "${header.sku}", "write toD:");
+    }
+
+    @Test
+    public void testToInItsShortForm() {
+        // canonical mode has its own word about the short form, so the hint 
is the classic mode's
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:start
+                      steps:
+                        - to: "http://localhost:8080/stock/${header.sku}";
+                """;
+        List<Error> errors = validate(classic, yaml);
+        assertThat(errors).anyMatch(e -> 
e.getMessage().contains("${header.sku}"))
+                .anyMatch(e -> e.getMessage().contains("write toD:"));
+    }
+
+    @Test
+    public void testToDIsWhatToWrite() {
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:start
+                      steps:
+                        - toD:
+                            uri: "http://localhost:8080/stock/${header.sku}";
+                """;
+        assertNoHint(yaml);
+    }
+
+    @Test
+    public void testAnOptionThatTheProducerEvaluatesIsFine() {
+        // the file component evaluates fileName per message, so this is 
correct on a plain to:
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:start
+                      steps:
+                        - to:
+                            uri: "file:out?fileName=${date:now:yyyyMMdd}.txt"
+                """;
+        assertNoHint(yaml);
+    }
+
+    @Test
+    public void testASqlParameterIsFine() {
+        // the sql component binds :#${...} per message; this is what the user 
manual shows
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:start
+                      steps:
+                        - to:
+                            uri: "sql:SELECT * FROM inventory WHERE id = 
:#${body.itemId}"
+                """;
+        assertNoHint(yaml);
+    }
+
+    @Test
+    public void testALanguageScriptIsFine() {
+        // the language component's path is the script it evaluates
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:start
+                      steps:
+                        - to:
+                            uri: "language:simple:Hello ${body}"
+                """;
+        assertNoHint(yaml);
+    }
+
+    @Test
+    public void testAPropertyPlaceholderIsFine() {
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:start
+                      steps:
+                        - to:
+                            uri: "http://{{stock.host}}/stock";
+                """;
+        assertNoHint(yaml);
+    }
+
+    private void assertHint(String yaml, String... expectedInMessage) {
+        for (YamlValidator validator : List.of(classic, canonical)) {
+            String mode = validator.isCanonical() ? "canonical" : "classic";
+            List<Error> errors = validate(validator, yaml);
+            assertThat(errors).as("%s mode must report the expression:\n%s", 
mode, yaml).isNotEmpty();
+            for (String expected : expectedInMessage) {
+                assertThat(errors).as("%s mode must say '%s'", mode, expected)
+                        .anyMatch(e -> e.getMessage().contains(expected));
+            }
+        }
+    }
+
+    private void assertNoHint(String yaml) {
+        for (YamlValidator validator : List.of(classic, canonical)) {
+            assertThat(validate(validator, yaml))
+                    .as("%s mode must accept:\n%s", validator.isCanonical() ? 
"canonical" : "classic", yaml)
+                    .noneMatch(e -> e.getMessage().contains("write toD:"));
+        }
+    }
+
+    private List<Error> validate(YamlValidator validator, String yaml) {
+        try {
+            return validator.validate(yaml);
+        } catch (Exception e) {
+            throw new AssertionError("Failed to validate:\n" + yaml, e);
+        }
+    }
+}

Reply via email to