You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/09/23 06:19:11 UTC

(camel) branch fix/CAMEL-24917 created (now 3332ca665af1)

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

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


      at 3332ca665af1 CAMEL-24917: camel-yaml-dsl-validator - a to: uri that holds an expression says to use toD:

This branch includes the following new commits:

     new 3332ca665af1 CAMEL-24917: camel-yaml-dsl-validator - a to: uri that holds an expression says to use toD:

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(camel) 01/01: CAMEL-24917: camel-yaml-dsl-validator - a to: uri that holds an expression says to use toD:

Posted by da...@apache.org.
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 <cl...@gmail.com>
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) <no...@anthropic.com>
    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);
+        }
+    }
+}