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");