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 d7d5290793db CAMEL-25381: camel-jbang - camel validate normalize 
supports Kamelet files (#27449)
d7d5290793db is described below

commit d7d5290793dbbc2c42f7a1febe7fb37c9c4c5057
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Oct 6 19:44:57 2026 +0200

    CAMEL-25381: camel-jbang - camel validate normalize supports Kamelet files 
(#27449)
    
    * CAMEL-25381: camel-jbang - camel validate normalize supports Kamelet files
    
    A Kamelet file is loaded as a route template and its spec.template is 
replaced
    by the canonical YAML DSL of that template; the rest of the file (license,
    metadata, definition, dependencies, comments) is kept as written. Route
    templates of other files are no longer dropped from the output.
    
    The YAML dump writes a kamelet: endpoint with the Kamelet in the uri
    (kamelet:log-sink) instead of a templateId parameter, and the YAML DSL keeps
    the order of map entries such as bean properties as written.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25381: camel-yaml-io - Keep a uri as written when splitting it into 
parameters would change its meaning
    
    Found normalizing the 262 Kamelets of camel-kamelets: the first ? of
    https://wttr.in/{{?wttrLocation}}?format=j1 is inside a placeholder;
    azure-storage-blob:{{accountName}} has fewer path parts than its syntax, 
and the
    catalog gave the one part to containerName; 
pulsar:{{type}}/{{tenant}}/{{ns}}/{{topic}}
    cannot be written back to the same path from the options the catalog parsed.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25381: camel-jbang - normalize keeps the comments of a Kamelet 
template, and normalizing is idempotent
    
    Review of the camel-kamelets PR: the comments inside spec.template were 
dropped
    (82 lines in 16 Kamelets, several on security decisions). The dump is of the
    model, which has no comments: they are now put back before the line they 
were
    written before, walking the original and the normalized template in order; 
one
    whose line is gone stays after the last line found, and is reported.
    
    Normalizing a normalized Kamelet changed 7 of the 262: the uri Camel builds 
from
    parameters broke share: {{shareName}}/{{directoryName}} (an option given two
    path parts) into {}}, and the options and the RAW() of secret options 
differed
    from the order Camel builds them in. A uri with another number of path 
parts than
    its syntax, or whose parse loses a {{placeholder}}, is kept as written; a
    scheme?query uri is split as written; the options are written as Camel 
builds
    them (path options first, then sorted, a secret in RAW()). A second pass now
    changes nothing.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../camel/yaml/out/YamlModelWriterSupport.java     | 173 +++++++++-
 .../camel/yaml/out/YamlUriPlaceholderTest.java     | 102 ++++++
 .../core/commands/validate/KameletNormalizer.java  | 374 +++++++++++++++++++++
 .../commands/validate/YamlNormalizeCommand.java    |  72 +++-
 .../validate/YamlNormalizeKameletTest.java         | 228 +++++++++++++
 .../kamelets/tag-order-action.kamelet.yaml         |  62 ++++
 .../dsl/yaml/common/YamlDeserializerSupport.java   |   6 +-
 .../camel/dsl/yaml/PipeLoaderErrorHandlerTest.java |   3 +-
 8 files changed, 1008 insertions(+), 12 deletions(-)

diff --git 
a/core/camel-yaml-io/src/main/java/org/apache/camel/yaml/out/YamlModelWriterSupport.java
 
b/core/camel-yaml-io/src/main/java/org/apache/camel/yaml/out/YamlModelWriterSupport.java
index 6af0454bb719..01a3e7b34abb 100644
--- 
a/core/camel-yaml-io/src/main/java/org/apache/camel/yaml/out/YamlModelWriterSupport.java
+++ 
b/core/camel-yaml-io/src/main/java/org/apache/camel/yaml/out/YamlModelWriterSupport.java
@@ -16,12 +16,17 @@
  */
 package org.apache.camel.yaml.out;
 
+import java.util.ArrayList;
 import java.util.Base64;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.TreeMap;
 import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 import org.apache.camel.CamelContext;
 import org.apache.camel.catalog.RuntimeCamelCatalog;
@@ -29,6 +34,7 @@ import org.apache.camel.model.rest.VerbDefinition;
 import org.apache.camel.util.URISupport;
 import org.apache.camel.util.json.JsonArray;
 import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
 import org.apache.camel.yaml.io.YamlPrinter;
 
 /**
@@ -203,7 +209,9 @@ public abstract class YamlModelWriterSupport {
         if (uri == null) {
             return;
         }
-        if (!uriAsParameters) {
+        if (!uriAsParameters || questionMarkInPlaceholder(uri)) {
+            // an optional placeholder before the query 
(https://host/{{?path}}?a=b): its ? is not where the query
+            // starts, and the uri cannot be split into parameters without 
breaking it, so it is kept as written
             jo.put("uri", uri);
             return;
         }
@@ -213,8 +221,23 @@ public abstract class YamlModelWriterSupport {
                     = camelContext != null
                             ? 
camelContext.getCamelContextExtension().getContextPlugin(RuntimeCamelCatalog.class)
                             : null;
-            if (catalog != null) {
+            int colon = uri.indexOf(':');
+            int question = uri.indexOf('?');
+            boolean schemeAndQuery = colon < 0 || question >= 0 && question < 
colon;
+            if (catalog != null && !schemeAndQuery) {
+                // scheme?a=b (as a uri built from parameters is): the query 
as written, the catalog would mangle a
+                // value such as {{share}}/{{directory}} (normalizing a 
normalized Kamelet changed it)
                 params = catalog.endpointProperties(uri);
+                if (params != null && !params.isEmpty() && 
!uri.startsWith("kamelet:")
+                        && (pathPartsDifferFromSyntax(catalog, uri) || 
!rebuildsThePath(catalog, uri, params)
+                                || !samePlaceholders(uri, params))) {
+                    // the catalog parsed the path into options that would 
mean something else: fewer path parts than
+                    // the syntax has (azure-storage-blob:{{accountName}} for 
accountName/containerName: the one part went
+                    // to containerName, the component reads it as 
accountName), or options it cannot write back as the
+                    // same path (pulsar:{{type}}/{{tenant}}/{{ns}}/{{topic}} 
for persistence://tenant/namespace/topic)
+                    jo.put("uri", uri);
+                    return;
+                }
             }
             if (params == null || params.isEmpty()) {
                 Map<String, Object> raw = 
URISupport.parseQuery(URISupport.extractQuery(uri));
@@ -230,9 +253,23 @@ public abstract class YamlModelWriterSupport {
                 if (idx != -1) {
                     scheme = scheme.substring(0, idx);
                 }
-                jo.put("uri", scheme);
+                if ("kamelet".equals(scheme) && params.get("templateId") != 
null) {
+                    // the Kamelet is named in the uri (kamelet:log-sink, 
kamelet:source), not as a templateId
+                    // parameter: that is how Kamelets are written and read
+                    params = new LinkedHashMap<>(params);
+                    String path = params.remove("templateId");
+                    String routeId = params.remove("routeId");
+                    jo.put("uri", "kamelet:" + path + (routeId != null ? "/" + 
routeId : ""));
+                } else {
+                    jo.put("uri", scheme);
+                }
             }
             if (params != null && !params.isEmpty()) {
+                String written = (String) jo.get("uri");
+                if (catalog != null && written != null && 
!written.startsWith("kamelet:")) {
+                    int c = written.indexOf(':');
+                    params = asCamelBuildsThem(catalog, c > 0 ? 
written.substring(0, c) : written, params);
+                }
                 JsonObject p = new JsonObject();
                 params.forEach((k, v) -> p.put(k, parseValue(v)));
                 jo.put("parameters", p);
@@ -242,6 +279,136 @@ public abstract class YamlModelWriterSupport {
         }
     }
 
+    /**
+     * Whether the uri has another number of path parts than the syntax of its 
component has path options, when the
+     * syntax has more than one. Fewer: which option a part is cannot be told 
from the uri alone. More: an option would
+     * get two parts (share: {{shareName}}/{{directoryName}} on 
azure-files://account/share), which the uri built from
+     * the options breaks.
+     */
+    static boolean pathPartsDifferFromSyntax(RuntimeCamelCatalog catalog, 
String uri) {
+        try {
+            int colon = uri.indexOf(':');
+            if (colon < 0) {
+                return false;
+            }
+            String syntax = syntax(catalog, uri.substring(0, colon));
+            if (syntax == null || syntax.indexOf(':') < 0) {
+                return false;
+            }
+            int options = pathParts(syntax.substring(syntax.indexOf(':') + 1));
+            if (options < 2) {
+                return false;
+            }
+            return pathParts(path(uri).substring(colon + 1)) != options;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    private static JsonObject componentSchema(RuntimeCamelCatalog catalog, 
String scheme) {
+        try {
+            String json = catalog.componentJSonSchema(scheme);
+            return json != null ? (JsonObject) Jsoner.deserialize(json) : null;
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static String syntax(RuntimeCamelCatalog catalog, String scheme) {
+        JsonObject schema = componentSchema(catalog, scheme);
+        Object component = schema != null ? schema.get("component") : null;
+        return component instanceof JsonObject c ? c.getString("syntax") : 
null;
+    }
+
+    /**
+     * The options as Camel builds the uri from them: the path options first 
in the order of the syntax, then the others
+     * sorted, with a secret option in RAW() as the YAML DSL wraps it. So a 
normalized file normalizes to itself.
+     */
+    static Map<String, String> asCamelBuildsThem(RuntimeCamelCatalog catalog, 
String scheme, Map<String, String> params) {
+        JsonObject schema = componentSchema(catalog, scheme);
+        Object properties = schema != null ? schema.get("properties") : null;
+        if (!(properties instanceof JsonObject props)) {
+            return params;
+        }
+        Map<String, String> answer = new LinkedHashMap<>();
+        for (Map.Entry<String, Object> e : props.entrySet()) {
+            if (e.getValue() instanceof JsonObject o && 
"path".equals(o.getString("kind")) && params.containsKey(e.getKey())) {
+                answer.put(e.getKey(), params.get(e.getKey()));
+            }
+        }
+        new TreeMap<>(params).forEach(answer::putIfAbsent);
+        for (Map.Entry<String, String> e : answer.entrySet()) {
+            Object option = props.get(e.getKey());
+            String v = e.getValue();
+            if (option instanceof JsonObject o && 
Boolean.TRUE.equals(o.getBoolean("secret")) && v != null
+                    && !v.startsWith("#") && !v.startsWith("RAW(")) {
+                e.setValue("RAW(" + v + ")");
+            }
+        }
+        return answer;
+    }
+
+    private static int pathParts(String path) {
+        int n = 0;
+        for (String part : path.replaceFirst("^/+", "").split("[:/]")) {
+            if (!part.isEmpty()) {
+                n++;
+            }
+        }
+        return n;
+    }
+
+    /**
+     * Whether the catalog builds the uri back from the options to the same 
path: only then do the options mean what the
+     * uri says.
+     */
+    static boolean rebuildsThePath(RuntimeCamelCatalog catalog, String uri, 
Map<String, String> params) {
+        try {
+            String scheme = uri.substring(0, uri.indexOf(':'));
+            String rebuilt = catalog.asEndpointUri(scheme, params, false);
+            return rebuilt != null && path(rebuilt).equals(path(uri));
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    private static String path(String uri) {
+        int q = uri.indexOf('?');
+        return q >= 0 ? uri.substring(0, q) : uri;
+    }
+
+    private static final Pattern PLACEHOLDER = 
Pattern.compile("\\{\\{[^{}]*}}");
+
+    /** Whether the options have the same {{placeholders}} as the uri: a parse 
that broke one is not used. */
+    static boolean samePlaceholders(String uri, Map<String, String> params) {
+        List<String> inUri = placeholders(uri);
+        List<String> inParams = new ArrayList<>();
+        params.values().forEach(v -> inParams.addAll(placeholders(v)));
+        Collections.sort(inUri);
+        Collections.sort(inParams);
+        return inUri.equals(inParams);
+    }
+
+    private static List<String> placeholders(String text) {
+        List<String> answer = new ArrayList<>();
+        if (text != null) {
+            Matcher m = PLACEHOLDER.matcher(text);
+            while (m.find()) {
+                answer.add(m.group());
+            }
+        }
+        return answer;
+    }
+
+    /** Whether the first ? of the uri is inside a {{...}} property 
placeholder, such as {{?name}}. */
+    static boolean questionMarkInPlaceholder(String uri) {
+        int idx = uri.indexOf('?');
+        if (idx < 0) {
+            return false;
+        }
+        return uri.lastIndexOf("{{", idx) > uri.lastIndexOf("}}", idx);
+    }
+
     protected Object parseValue(String value) {
         if (value == null) {
             return null;
diff --git 
a/core/camel-yaml-io/src/test/java/org/apache/camel/yaml/out/YamlUriPlaceholderTest.java
 
b/core/camel-yaml-io/src/test/java/org/apache/camel/yaml/out/YamlUriPlaceholderTest.java
new file mode 100644
index 000000000000..614930105f23
--- /dev/null
+++ 
b/core/camel-yaml-io/src/test/java/org/apache/camel/yaml/out/YamlUriPlaceholderTest.java
@@ -0,0 +1,102 @@
+/*
+ * 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.yaml.out;
+
+import java.lang.reflect.Proxy;
+import java.util.Map;
+
+import org.apache.camel.catalog.RuntimeCamelCatalog;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class YamlUriPlaceholderTest {
+
+    @Test
+    void theQuestionMarkOfAnOptionalPlaceholderIsNotTheQuery() {
+        // the wttrin-source Kamelet: split at the ? of {{?wttrLocation}} it 
became httpUri "wttr.in/{{"
+        assertThat(YamlModelWriterSupport.questionMarkInPlaceholder(
+                
"https://wttr.in/{{?wttrLocation}}?format=j1&lang={{?wttrLanguage}}";)).isTrue();
+        assertThat(YamlModelWriterSupport.questionMarkInPlaceholder(
+                
"https://wttr.in/{{location}}?format=j1&lang={{?wttrLanguage}}";)).isFalse();
+        
assertThat(YamlModelWriterSupport.questionMarkInPlaceholder("timer:tick?period={{?period}}")).isFalse();
+        
assertThat(YamlModelWriterSupport.questionMarkInPlaceholder("log:{{loggerName}}")).isFalse();
+    }
+
+    @Test
+    void aPathTheCatalogCannotWriteBackIsKeptAsWritten() {
+        // the camel-kamelets azure-storage-blob-changefeed-source: the one 
path part went to containerName
+        RuntimeCamelCatalog wrong = 
catalog("azure-storage-blob:/{{accountName}}");
+        assertThat(YamlModelWriterSupport.rebuildsThePath(wrong, 
"azure-storage-blob:{{accountName}}",
+                Map.of("containerName", "{{accountName}}"))).isFalse();
+        RuntimeCamelCatalog right = 
catalog("azure-storage-blob:{{accountName}}/{{containerName}}?operation=x");
+        assertThat(YamlModelWriterSupport.rebuildsThePath(right,
+                
"azure-storage-blob:{{accountName}}/{{containerName}}?operation=x",
+                Map.of("accountName", "{{accountName}}", "containerName", 
"{{containerName}}", "operation", "x")))
+                .isTrue();
+        // a required option missing: the catalog cannot build it
+        assertThat(YamlModelWriterSupport.rebuildsThePath(catalog(null), 
"pulsar:a/b/c/d", Map.of())).isFalse();
+    }
+
+    @Test
+    void fewerPathPartsThanTheSyntax() {
+        RuntimeCamelCatalog catalog = (RuntimeCamelCatalog) 
Proxy.newProxyInstance(
+                RuntimeCamelCatalog.class.getClassLoader(), new Class<?>[] { 
RuntimeCamelCatalog.class },
+                (proxy, method, args) -> {
+                    if ("componentJSonSchema".equals(method.getName())) {
+                        String syntax = Map.of("azure-storage-blob", 
"azure-storage-blob:accountName/containerName",
+                                "timer", "timer:timerName").get((String) 
args[0]);
+                        return syntax == null ? null : "{\"component\": 
{\"syntax\": \"" + syntax + "\"}}";
+                    }
+                    return null;
+                });
+        // the round trip writes containerName={{accountName}} back as the 
same text, so it needs this check too
+        assertThat(YamlModelWriterSupport.pathPartsDifferFromSyntax(catalog, 
"azure-storage-blob:{{accountName}}"))
+                .isTrue();
+        assertThat(YamlModelWriterSupport.pathPartsDifferFromSyntax(catalog, 
"azure-storage-blob:a/b?x=y")).isFalse();
+        // more parts than options: containerName would get b/c, which the uri 
Camel builds from the options breaks
+        assertThat(YamlModelWriterSupport.pathPartsDifferFromSyntax(catalog, 
"azure-storage-blob:a/b/c")).isTrue();
+        assertThat(YamlModelWriterSupport.pathPartsDifferFromSyntax(catalog, 
"timer:tick?period=5")).isFalse();
+        assertThat(YamlModelWriterSupport.pathPartsDifferFromSyntax(catalog, 
"unknown:x")).isFalse();
+    }
+
+    @Test
+    void aParseThatBreaksAPlaceholderIsNotUsed() {
+        // normalizing the normalized azure-storage-files-source turned 
{{directoryName}} into {}}
+        String uri = 
"azure-files?account={{accountName}}&share={{shareName}}/{{directoryName}}";
+        assertThat(YamlModelWriterSupport.samePlaceholders(uri,
+                Map.of("account", "{{accountName}}", "share", 
"{{shareName}}/{{directoryName}}"))).isTrue();
+        assertThat(YamlModelWriterSupport.samePlaceholders(uri,
+                Map.of("account", "{{accountName}}", "share", 
"{{shareName}}/{}}"))).isFalse();
+        
assertThat(YamlModelWriterSupport.samePlaceholders("aws2-s3:{{bucket}}?accessKey=RAW({{?accessKey}})",
+                Map.of("bucketNameOrArn", "{{bucket}}", "accessKey", 
"RAW({{?accessKey}})"))).isTrue();
+    }
+
+    /** A catalog whose asEndpointUri answers the given uri, or fails when it 
is null. */
+    private static RuntimeCamelCatalog catalog(String rebuilt) {
+        return (RuntimeCamelCatalog) 
Proxy.newProxyInstance(RuntimeCamelCatalog.class.getClassLoader(),
+                new Class<?>[] { RuntimeCamelCatalog.class }, (proxy, method, 
args) -> {
+                    if ("asEndpointUri".equals(method.getName())) {
+                        if (rebuilt == null) {
+                            throw new IllegalArgumentException("Option topic 
is required");
+                        }
+                        return rebuilt;
+                    }
+                    return null;
+                });
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/KameletNormalizer.java
 
b/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/KameletNormalizer.java
new file mode 100644
index 000000000000..23f4f3883517
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/KameletNormalizer.java
@@ -0,0 +1,374 @@
+/*
+ * 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.validate;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Normalizes a Kamelet file: its spec.template is replaced by the canonical 
YAML DSL of the route template the Kamelet
+ * loads as, taken from a route template dump, and the rest of the file 
(metadata, definition, dependencies, comments)
+ * is kept as written.
+ */
+final class KameletNormalizer {
+
+    private static final Pattern TEMPLATE_ID = Pattern.compile("^ {4}id: 
\"?([^\"\\s]+)\"?\\s*$");
+
+    private KameletNormalizer() {
+    }
+
+    /** Whether the content is a Kamelet: a document of kind Kamelet. */
+    static boolean isKamelet(String content) {
+        return content != null && 
Pattern.compile("(?m)^kind:\\s*Kamelet\\s*$").matcher(content).find();
+    }
+
+    /** The name of the Kamelet: metadata.name, else the file name without 
.kamelet.yaml. */
+    static String kameletName(String content, String fileName) {
+        boolean inMetadata = false;
+        for (String line : content.split("\n", -1)) {
+            if (line.startsWith("metadata:")) {
+                inMetadata = true;
+            } else if (inMetadata && !line.isBlank() && !line.startsWith(" ") 
&& !line.startsWith("#")) {
+                inMetadata = false;
+            } else if (inMetadata) {
+                Matcher m = Pattern.compile("^ 
{2}name:\\s*\"?([^\"\\s]+)\"?\\s*$").matcher(line);
+                if (m.find()) {
+                    return m.group(1);
+                }
+            }
+        }
+        String name = fileName.replace('\\', '/');
+        name = name.substring(name.lastIndexOf('/') + 1);
+        return name.endsWith(".kamelet.yaml") ? name.substring(0, 
name.length() - ".kamelet.yaml".length()) : name;
+    }
+
+    /**
+     * The Kamelet with its spec.template replaced by the beans and route of 
the route template of the same id in the
+     * dump, or null when the dump has no such template or the Kamelet has no 
spec.template.
+     */
+    static String normalize(String kamelet, String dump, String templateId) {
+        return normalize(kamelet, dump, templateId, new ArrayList<>());
+    }
+
+    /**
+     * As {@link #normalize(String, String, String)}; the comments of the 
template are put back before the line they
+     * were written before, and those that cannot be placed are added to lost 
(and kept at the end of the template).
+     */
+    static String normalize(String kamelet, String dump, String templateId, 
List<String> lost) {
+        List<String> chunk = templateChunk(dump, templateId);
+        if (chunk == null) {
+            return null;
+        }
+        String[] lines = kamelet.split("\n", -1);
+        int spec = -1;
+        for (int i = 0; i < lines.length; i++) {
+            if (lines[i].matches("^spec:\\s*$")) {
+                spec = i;
+                break;
+            }
+        }
+        if (spec < 0) {
+            return null;
+        }
+        // the keys of spec are at the indent of its first key; template is 
one of them
+        int template = -1;
+        int templateIndent = -1;
+        for (int i = spec + 1; i < lines.length; i++) {
+            String l = lines[i];
+            if (l.isBlank() || l.trim().startsWith("#")) {
+                continue;
+            }
+            int indent = indent(l);
+            if (indent == 0) {
+                break;
+            }
+            if (templateIndent < 0) {
+                templateIndent = indent;
+            }
+            if (indent == templateIndent && 
l.trim().matches("^template:\\s*$")) {
+                template = i;
+                break;
+            }
+        }
+        if (template < 0) {
+            return null;
+        }
+        int end = lines.length;
+        for (int i = template + 1; i < lines.length; i++) {
+            String l = lines[i];
+            if (l.isBlank()) {
+                continue;
+            }
+            if (indent(l) <= templateIndent) {
+                end = i;
+                break;
+            }
+        }
+        // a template written as route: keeps that key, else from:
+        boolean routeKey = false;
+        for (int i = template + 1; i < end; i++) {
+            if (lines[i].trim().startsWith("route:") && indent(lines[i]) > 
templateIndent) {
+                routeKey = true;
+                break;
+            }
+            if (lines[i].trim().startsWith("from:") && indent(lines[i]) > 
templateIndent) {
+                break;
+            }
+        }
+        // the children of template at the indent the file uses for them
+        int child = templateIndent + 2;
+        for (int i = template + 1; i < end; i++) {
+            if (!lines[i].isBlank() && !lines[i].trim().startsWith("#")) {
+                child = indent(lines[i]);
+                break;
+            }
+        }
+        List<String> body = new ArrayList<>();
+        // the dump: "- routeTemplate:" at 0, its keys at 4 (id, parameters, 
beans, route), route's from at 6
+        List<String> beans = block(chunk, "beans:", 4);
+        if (beans != null) {
+            body.addAll(shift(beans, child - 4));
+        }
+        List<String> route = block(chunk, "route:", 4);
+        if (route == null) {
+            return null;
+        }
+        if (routeKey) {
+            body.addAll(shift(route, child - 4));
+        } else {
+            List<String> from = block(route, "from:", 6);
+            if (from == null) {
+                return null;
+            }
+            body.addAll(shift(from, child - 6));
+        }
+        // trailing blank lines of the template block belong to what follows it
+        int bodyEnd = end;
+        while (bodyEnd > template + 1 && lines[bodyEnd - 1].isBlank()) {
+            bodyEnd--;
+        }
+        List<String> out = new ArrayList<>();
+        for (int i = 0; i <= template; i++) {
+            out.add(lines[i]);
+        }
+        // the dump is of the model, which has no comments: put those of the 
template back where they were
+        List<String> original = new ArrayList<>();
+        for (int i = template + 1; i < bodyEnd; i++) {
+            original.add(lines[i]);
+        }
+        out.addAll(keepComments(original, body, child, lost));
+        for (int i = bodyEnd; i < lines.length; i++) {
+            out.add(lines[i]);
+        }
+        return String.join("\n", out);
+    }
+
+    /**
+     * The normalized lines with the comments of the original put back: a 
comment block goes before the normalized line
+     * that matches the line it was written before (a step such as - 
removeHeader:, or a key such as useHeaderSubject:).
+     * The two are walked in order, as normalizing keeps the order of steps 
and keys. A block whose line is not found is
+     * added to lost and kept at the end.
+     */
+    static List<String> keepComments(List<String> original, List<String> 
normalized, int indent, List<String> lost) {
+        Map<Integer, List<String>> before = new TreeMap<>();
+        List<String> block = new ArrayList<>();
+        Set<Integer> used = new HashSet<>();
+        int from = 0;
+        for (String l : original) {
+            String t = l.trim();
+            if (t.startsWith("#")) {
+                block.add(l);
+                continue;
+            }
+            if (t.isEmpty()) {
+                continue;
+            }
+            String signature = signature(t);
+            int idx = find(normalized, signature, from, used);
+            if (idx < 0 && !block.isEmpty()) {
+                // an option the normalized form sorted earlier (parameters 
are in the catalog's order)
+                idx = find(normalized, signature, 0, used);
+            }
+            if (idx >= 0) {
+                used.add(idx);
+                from = Math.max(from, idx + 1);
+                if (!block.isEmpty()) {
+                    before.computeIfAbsent(idx, k -> new 
ArrayList<>()).addAll(reindent(block, indent(normalized.get(idx))));
+                }
+            } else if (!block.isEmpty()) {
+                // its line is gone (an option folded into a uri kept as 
written): after the last line found, which
+                // is where it was in the original
+                lost.addAll(block.stream().map(String::trim).toList());
+                before.computeIfAbsent(from, k -> new ArrayList<>())
+                        .addAll(reindent(block, from > 0 ? 
indent(normalized.get(from - 1)) : indent));
+            }
+            block.clear();
+        }
+        if (!block.isEmpty()) {
+            // comments after the last line of the template: after the last 
line found
+            before.computeIfAbsent(from, k -> new ArrayList<>())
+                    .addAll(reindent(block, from > 0 ? 
indent(normalized.get(from - 1)) : indent));
+        }
+        List<String> out = new ArrayList<>();
+        for (int i = 0; i <= normalized.size(); i++) {
+            List<String> comments = before.get(i);
+            if (comments != null) {
+                out.addAll(comments);
+            }
+            if (i < normalized.size()) {
+                out.add(normalized.get(i));
+            }
+        }
+        return out;
+    }
+
+    /** What a line is, to find it after normalizing: "- key" for a step, 
"key" for a key, else the line. */
+    private static String signature(String trimmed) {
+        boolean step = trimmed.startsWith("- ");
+        String t = step ? trimmed.substring(2).trim() : trimmed;
+        int colon = t.indexOf(':');
+        String key = colon > 0 && !t.startsWith("\"") && !t.startsWith("'") ? 
t.substring(0, colon) : t;
+        return (step ? "- " : "") + key;
+    }
+
+    private static int find(List<String> lines, String signature, int from, 
Set<Integer> used) {
+        for (int i = from; i < lines.size(); i++) {
+            String t = lines.get(i).trim();
+            if (!t.isEmpty() && !t.startsWith("#") && !used.contains(i) && 
signature(t).equals(signature)) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    private static List<String> reindent(List<String> block, int indent) {
+        int min = Integer.MAX_VALUE;
+        for (String l : block) {
+            min = Math.min(min, indent(l));
+        }
+        List<String> answer = new ArrayList<>();
+        for (String l : block) {
+            answer.add(" ".repeat(indent) + l.substring(min));
+        }
+        return answer;
+    }
+
+    /** The dump without the routeTemplates of the given ids: what is left are 
the routes of the other files. */
+    static String withoutTemplates(String dump, Set<String> ids) {
+        if (ids.isEmpty()) {
+            return dump;
+        }
+        List<String> out = new ArrayList<>();
+        List<String> item = new ArrayList<>();
+        boolean drop = false;
+        for (String l : dump.split("\n", -1)) {
+            if (l.startsWith("- ")) {
+                if (!drop) {
+                    out.addAll(item);
+                }
+                item = new ArrayList<>();
+                drop = false;
+            }
+            item.add(l);
+            Matcher m = TEMPLATE_ID.matcher(l);
+            if (!item.isEmpty() && item.get(0).startsWith("- routeTemplate:") 
&& m.find() && ids.contains(m.group(1))) {
+                drop = true;
+            }
+        }
+        if (!drop) {
+            out.addAll(item);
+        }
+        return String.join("\n", out).strip();
+    }
+
+    /** The lines of the routeTemplate with the given id in the dump, or null. 
*/
+    static List<String> templateChunk(String dump, String templateId) {
+        if (dump == null) {
+            return null;
+        }
+        List<String> current = null;
+        boolean match = false;
+        for (String l : dump.split("\n", -1)) {
+            if (l.startsWith("- routeTemplate:")) {
+                if (match) {
+                    return current;
+                }
+                current = new ArrayList<>();
+                match = false;
+            }
+            if (current != null) {
+                current.add(l);
+                Matcher m = TEMPLATE_ID.matcher(l);
+                if (m.find() && m.group(1).equals(templateId)) {
+                    match = true;
+                }
+            }
+        }
+        return match ? current : null;
+    }
+
+    /** The key line at the indent and the lines under it, or null when the 
key is not there. */
+    static List<String> block(List<String> lines, String key, int indent) {
+        List<String> answer = null;
+        for (String l : lines) {
+            if (answer == null) {
+                if (indent(l) == indent && l.trim().equals(key)) {
+                    answer = new ArrayList<>();
+                    answer.add(l);
+                }
+            } else if (l.isBlank() || indent(l) > indent) {
+                answer.add(l);
+            } else {
+                break;
+            }
+        }
+        if (answer != null) {
+            while (!answer.isEmpty() && answer.get(answer.size() - 
1).isBlank()) {
+                answer.remove(answer.size() - 1);
+            }
+        }
+        return answer;
+    }
+
+    private static List<String> shift(List<String> lines, int by) {
+        List<String> answer = new ArrayList<>();
+        for (String l : lines) {
+            if (l.isBlank()) {
+                answer.add("");
+            } else if (by >= 0) {
+                answer.add(" ".repeat(by) + l);
+            } else {
+                answer.add(l.substring(Math.min(-by, indent(l))));
+            }
+        }
+        return answer;
+    }
+
+    private static int indent(String line) {
+        int i = 0;
+        while (i < line.length() && line.charAt(i) == ' ') {
+            i++;
+        }
+        return i;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlNormalizeCommand.java
 
b/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlNormalizeCommand.java
index 38fb2a0c80e0..9e382c026650 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlNormalizeCommand.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlNormalizeCommand.java
@@ -20,8 +20,12 @@ import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
 import java.util.Stack;
 
 import org.apache.camel.dsl.jbang.core.commands.CamelCommand;
@@ -71,6 +75,14 @@ public class YamlNormalizeCommand extends CamelCommand {
         if (matched.isEmpty()) {
             return 0;
         }
+        // a Kamelet loads as a route template: its template is normalized and 
put back into the Kamelet file
+        Map<String, String> kamelets = new LinkedHashMap<>();
+        for (String n : matched) {
+            String content = readFile(n);
+            if (content != null && KameletNormalizer.isKamelet(content)) {
+                kamelets.put(n, content);
+            }
+        }
 
         String dump = CommandLineHelper.CAMEL_JBANG_WORK_DIR + 
"/normalize-output.yaml";
         Files.deleteIfExists(Path.of(dump));
@@ -80,7 +92,8 @@ public class YamlNormalizeCommand extends CamelCommand {
             @Override
             protected void doAddInitialProperty(KameletMain main) {
                 main.addInitialProperty("camel.main.dumpRoutes", "yaml");
-                main.addInitialProperty("camel.main.dumpRoutesInclude", 
"routes,rests,routeConfigurations,beans,dataFormats");
+                main.addInitialProperty("camel.main.dumpRoutesInclude",
+                        
"routes,rests,routeConfigurations,routeTemplates,beans,dataFormats");
                 main.addInitialProperty("camel.main.dumpRoutesLog", "false");
                 
main.addInitialProperty("camel.main.dumpRoutesResolvePlaceholders", "false");
                 
main.addInitialProperty("camel.main.dumpRoutesUriAsParameters", "true");
@@ -101,24 +114,73 @@ public class YamlNormalizeCommand extends CamelCommand {
 
         String normalized = waitForDumpFile(Path.of(target));
         if (normalized == null) {
-            printer().printErr("Error normalizing files");
+            printer().printErr("Error normalizing files: nothing was loaded 
from " + String.join(", ", matched)
+                               + " (see the errors above)");
             return 1;
         }
 
+        // the dump has one item per routeTemplate, route, rest...: those of 
the Kamelets go back into their files
+        Map<String, String> kameletOutput = new LinkedHashMap<>();
+        Set<String> kameletIds = new HashSet<>();
+        for (Map.Entry<String, String> e : kamelets.entrySet()) {
+            String id = KameletNormalizer.kameletName(e.getValue(), 
e.getKey());
+            List<String> lost = new ArrayList<>();
+            String k = KameletNormalizer.normalize(e.getValue(), normalized, 
id, lost);
+            if (!lost.isEmpty()) {
+                printer().printErr("WARN: " + e.getKey() + ": " + lost.size() 
+ " comment line(s) of the template could"
+                                   + " not be put back where they were, they 
are at the end of the template: "
+                                   + String.join(" / ", lost));
+            }
+            if (k == null) {
+                printer().printErr("Error normalizing the Kamelet " + 
e.getKey() + ": its template " + id
+                                   + " did not load (see the errors above), or 
the file has no spec.template");
+                return 1;
+            }
+            kameletIds.add(id);
+            kameletOutput.put(e.getKey(), k);
+        }
+        String routes = KameletNormalizer.withoutTemplates(normalized, 
kameletIds);
+
+        List<String> documents = new ArrayList<>();
+        if (!routes.isBlank()) {
+            documents.add(routes);
+        }
+        documents.addAll(kameletOutput.values());
         if (output != null) {
             Path outPath = Path.of(output);
             if (Files.isDirectory(outPath)) {
-                outPath = outPath.resolve("normalized.yaml");
+                if (!routes.isBlank()) {
+                    Files.writeString(outPath.resolve("normalized.yaml"), 
routes);
+                }
+                for (Map.Entry<String, String> e : kameletOutput.entrySet()) {
+                    
Files.writeString(outPath.resolve(Path.of(e.getKey()).getFileName()), 
e.getValue());
+                }
+            } else if (documents.size() > 1 && !kameletOutput.isEmpty()) {
+                printer().printErr("--output must be a directory to normalize 
Kamelets with other files:"
+                                   + " each Kamelet is written to a file of 
its own name");
+                return 1;
+            } else {
+                Files.writeString(outPath, documents.get(0));
             }
-            Files.writeString(outPath, normalized);
             printer().println("Normalized " + matched.size() + " file(s) to " 
+ output);
         } else {
-            printer().println(normalized);
+            // more than one document: one YAML stream, separated by ---
+            printer().println(String.join("\n---\n", documents));
         }
 
         return 0;
     }
 
+    private static String readFile(String name) {
+        try {
+            String n = name.startsWith("file:") ? name.substring(5) : name;
+            Path p = Path.of(n);
+            return Files.isRegularFile(p) ? Files.readString(p) : null;
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
     private String waitForDumpFile(Path dumpFile) {
         StopWatch watch = new StopWatch();
         while (watch.taken() < 5000) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-validate/src/test/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlNormalizeKameletTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-validate/src/test/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlNormalizeKameletTest.java
new file mode 100644
index 000000000000..ee35c57a8f87
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-validate/src/test/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlNormalizeKameletTest.java
@@ -0,0 +1,228 @@
+/*
+ * 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.validate;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.StringPrinter;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import picocli.CommandLine;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25381: camel validate normalize on a Kamelet file normalizes its 
template and keeps the rest of the file.
+ */
+class YamlNormalizeKameletTest {
+
+    private static final Path KAMELET = 
Path.of("src/test/resources/kamelets/tag-order-action.kamelet.yaml");
+    private static final Path ROUTE = Path.of("src/test/resources/route.yaml");
+
+    private static StringPrinter printer;
+
+    private static int normalize(String... args) throws Exception {
+        printer = new StringPrinter();
+        YamlNormalizeCommand cmd = new YamlNormalizeCommand(new 
CamelJBangMain().withPrinter(printer));
+        CommandLine.populateCommand(cmd, args);
+        return cmd.doCall();
+    }
+
+    @Test
+    void aKameletIsNormalizedInItsTemplateOnly(@TempDir Path out) throws 
Exception {
+        assertThat(normalize("--output=" + out, KAMELET.toString())).isZero();
+        String original = Files.readString(KAMELET);
+        String normalized = 
Files.readString(out.resolve("tag-order-action.kamelet.yaml"));
+
+        // the license, metadata and definition (with its comment) as written, 
and what follows the template
+        assertThat(normalized).startsWith(original.substring(0, 
original.indexOf("  template:\n") + 12));
+        assertThat(normalized).endsWith(original.substring(original.indexOf("  
dependencies:")));
+        // the template in canonical form: expressions under expression:, 
endpoints as uri:
+        String template = normalized.substring(normalized.indexOf("  
template:\n"), normalized.indexOf("  dependencies:"));
+        assertThat(template.replaceAll("\\s+", " "))
+                .contains("- setBody: expression: simple: expression: 
\"${body} [{{tag}}]\"")
+                .contains("- expression: simple: expression: 
\"${header.priority} == true\"");
+        assertThat(template).contains("uri: kamelet:source").contains("uri: 
kamelet:sink")
+                .doesNotContain("templateId");
+        // the bean properties in the order they were written
+        
assertThat(template.indexOf("zeta:")).isLessThan(template.indexOf("alpha:"));
+    }
+
+    @Test
+    void aKameletIsPrintedWhenThereIsNoOutput() throws Exception {
+        assertThat(normalize(KAMELET.toString())).isZero();
+        assertThat(printer.getOutput()).contains("kind: 
Kamelet").contains("name: tag-order-action")
+                .doesNotContain("routeTemplate");
+    }
+
+    @Test
+    void aKameletAndARouteAreTwoDocuments() throws Exception {
+        assertThat(normalize(KAMELET.toString(), ROUTE.toString())).isZero();
+        String output = printer.getOutput();
+        assertThat(output).contains("\n---\n").contains("kind: 
Kamelet").contains("- route:")
+                .doesNotContain("routeTemplate");
+    }
+
+    @Test
+    void theTemplateIsReplacedInTheKameletText() {
+        String kamelet = """
+                apiVersion: camel.apache.org/v1
+                kind: Kamelet
+                metadata:
+                  name: my-action
+                spec:
+                  definition:
+                    title: Mine
+                  template:
+                    from:
+                      uri: kamelet:source
+                      steps:
+                        - setBody:
+                            constant: hi
+
+                  types:
+                    out:
+                      mediaType: text/plain
+                """;
+        String dump = """
+                - routeTemplate:
+                    id: other
+                    route:
+                      from:
+                        uri: timer
+                - routeTemplate:
+                    id: my-action
+                    parameters:
+                      - name: x
+                    route:
+                      from:
+                        uri: kamelet:source
+                        steps:
+                          - setBody:
+                              expression:
+                                constant:
+                                  expression: hi
+                - route:
+                    from:
+                      uri: direct
+                """;
+        String normalized = KameletNormalizer.normalize(kamelet, dump, 
"my-action");
+        assertThat(normalized).isEqualTo("""
+                apiVersion: camel.apache.org/v1
+                kind: Kamelet
+                metadata:
+                  name: my-action
+                spec:
+                  definition:
+                    title: Mine
+                  template:
+                    from:
+                      uri: kamelet:source
+                      steps:
+                        - setBody:
+                            expression:
+                              constant:
+                                expression: hi
+
+                  types:
+                    out:
+                      mediaType: text/plain
+                """);
+        assertThat(KameletNormalizer.withoutTemplates(dump, 
Set.of("my-action")))
+                .contains("id: other").contains("- 
route:").doesNotContain("my-action");
+        assertThat(KameletNormalizer.normalize(kamelet, dump, 
"missing")).isNull();
+    }
+
+    @Test
+    void theCommentsOfTheTemplateAreKept() {
+        // review of the camel-kamelets PR: the comments of 16 templates 
(several on security decisions) were dropped
+        String kamelet = """
+                kind: Kamelet
+                metadata:
+                  name: my-sink
+                spec:
+                  template:
+                    from:
+                      uri: kamelet:source
+                      steps:
+                        # drop the headers from upstream
+                        - removeHeader:
+                            name: Subject
+                        - to:
+                            uri: "mail:smtp"
+                            parameters:
+                              host: "{{host}}"
+                              # needed, else the Subject header is ignored
+                              useHeaderSubject: true
+                        # the end
+                """;
+        String dump = """
+                - routeTemplate:
+                    id: my-sink
+                    route:
+                      from:
+                        uri: kamelet:source
+                        steps:
+                          - removeHeader:
+                              name: Subject
+                          - to:
+                              uri: mail
+                              parameters:
+                                protocol: smtp
+                                host: "{{host}}"
+                                useHeaderSubject: true
+                """;
+        List<String> lost = new ArrayList<>();
+        String normalized = KameletNormalizer.normalize(kamelet, dump, 
"my-sink", lost);
+        assertThat(lost).isEmpty();
+        assertThat(normalized).isEqualTo("""
+                kind: Kamelet
+                metadata:
+                  name: my-sink
+                spec:
+                  template:
+                    from:
+                      uri: kamelet:source
+                      steps:
+                        # drop the headers from upstream
+                        - removeHeader:
+                            name: Subject
+                        - to:
+                            uri: mail
+                            parameters:
+                              protocol: smtp
+                              host: "{{host}}"
+                              # needed, else the Subject header is ignored
+                              useHeaderSubject: true
+                              # the end
+                """);
+    }
+
+    @Test
+    void theNameOfAKamelet() {
+        assertThat(KameletNormalizer.kameletName("kind: Kamelet\nmetadata:\n  
name: abc\nspec:\n", "x.kamelet.yaml"))
+                .isEqualTo("abc");
+        assertThat(KameletNormalizer.kameletName("kind: Kamelet\nspec:\n", 
"dir/xyz.kamelet.yaml")).isEqualTo("xyz");
+        assertThat(KameletNormalizer.isKamelet("apiVersion: 
camel.apache.org/v1\nkind: Kamelet\n")).isTrue();
+        assertThat(KameletNormalizer.isKamelet("- route:\n    
from:\n")).isFalse();
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-validate/src/test/resources/kamelets/tag-order-action.kamelet.yaml
 
b/dsl/camel-jbang/camel-jbang-plugin-validate/src/test/resources/kamelets/tag-order-action.kamelet.yaml
new file mode 100644
index 000000000000..ee57b86262fd
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-validate/src/test/resources/kamelets/tag-order-action.kamelet.yaml
@@ -0,0 +1,62 @@
+#
+# 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.
+#
+
+apiVersion: camel.apache.org/v1
+kind: Kamelet
+metadata:
+  name: tag-order-action
+  labels:
+    camel.apache.org/kamelet.type: action
+spec:
+  definition:
+    title: Tag Order
+    # the tag is what the agent passes
+    required:
+      - tag
+    type: object
+    properties:
+      tag:
+        title: Tag
+        type: string
+      priority:
+        title: Priority
+        type: boolean
+        default: false
+  template:
+    beans:
+      - name: tagger
+        type: "#class:java.util.HashMap"
+        properties:
+          zeta: "{{tag}}"
+          alpha: "{{priority}}"
+    from:
+      uri: kamelet:source
+      steps:
+        - choice:
+            when:
+              - simple: "${header.priority} == true"
+                steps:
+                  - setBody:
+                      simple: "${body} [!{{tag}}]"
+            otherwise:
+              steps:
+                - setBody:
+                    simple: "${body} [{{tag}}]"
+        - to: kamelet:sink
+  dependencies:
+    - "camel:core"
+    - "camel:kamelet"
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlDeserializerSupport.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlDeserializerSupport.java
index 4b2bef9d6b5d..b2baa35c1c7e 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlDeserializerSupport.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlDeserializerSupport.java
@@ -20,8 +20,8 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Base64;
 import java.util.Collection;
-import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -179,7 +179,7 @@ public class YamlDeserializerSupport {
         }
 
         final MappingNode mn = asMappingNode(node);
-        final Map<String, Object> answer = new HashMap<>();
+        final Map<String, Object> answer = new LinkedHashMap<>();
 
         for (NodeTuple tuple : mn.getValue()) {
             final String key = asText(tuple.getKeyNode());
@@ -206,7 +206,7 @@ public class YamlDeserializerSupport {
         }
 
         final MappingNode mn = asMappingNode(node);
-        final Map<String, Object> answer = new HashMap<>();
+        final Map<String, Object> answer = new LinkedHashMap<>();
 
         for (NodeTuple tuple : mn.getValue()) {
             final String key = asText(tuple.getKeyNode());
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/PipeLoaderErrorHandlerTest.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/PipeLoaderErrorHandlerTest.java
index a11cd9f8ad01..ce9530549e32 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/PipeLoaderErrorHandlerTest.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/PipeLoaderErrorHandlerTest.java
@@ -81,8 +81,9 @@ class PipeLoaderErrorHandlerTest extends YamlTestSupport {
         assertThat(route.getErrorHandlerFactory()).isNotNull();
         
assertThat(route.getErrorHandlerFactory()).isInstanceOf(DeadLetterChannelDefinition.class);
         DeadLetterChannelDefinition eh = (DeadLetterChannelDefinition) 
route.getErrorHandlerFactory();
+        // the properties in the order the Pipe declares them
         assertThat(eh.getDeadLetterUri()).isEqualTo(
-                
"kamelet:error-handler?kafkaTopic=my-first-test&logMessage=ERROR!&kafkaServiceAccountId=scott&kafkaBrokers=my-broker&kafkaServiceAccountSecret=tiger");
+                
"kamelet:error-handler?logMessage=ERROR!&kafkaBrokers=my-broker&kafkaTopic=my-first-test&kafkaServiceAccountId=scott&kafkaServiceAccountSecret=tiger");
         
assertThat(eh.getRedeliveryPolicy().getMaximumRedeliveries()).isEqualTo("1");
         
assertThat(eh.getRedeliveryPolicy().getRedeliveryDelay()).isEqualTo("2000");
         assertThat(route.getRouteId()).isEqualTo("timer-event-source");

Reply via email to