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 76b1e7235f3e CAMEL-26594: camel-yaml-dsl - a value under parameters:
with an & is kept whole in the endpoint uri (#29185)
76b1e7235f3e is described below
commit 76b1e7235f3efbc7bccbda4db0424914e4680eb1
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Nov 4 20:00:59 2026 +0100
CAMEL-26594: camel-yaml-dsl - a value under parameters: with an & is kept
whole in the endpoint uri (#29185)
- CAMEL-26594: camel-yaml-dsl - a value under parameters: with an & is kept
whole in the endpoint uri
- CAMEL-26594: camel-jbang - AI tools: a value under parameters: with an &
is accepted, as the YAML DSL now keeps it whole
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../org/apache/camel/catalog/docs/yaml-dsl.adoc | 27 ++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 20 ++
.../dsl/jbang/core/commands/ai/EndpointChecks.java | 124 +++++--
.../commands/ai/SourceValidatorEndpointTest.java | 125 +++++--
.../apache/camel/dsl/yaml/common/YamlSupport.java | 74 +++-
.../yaml/deserializers/KameletDeserializer.java | 4 +-
dsl/camel-yaml-dsl/camel-yaml-dsl/pom.xml | 5 +
.../camel-yaml-dsl/src/main/docs/yaml-dsl.adoc | 27 ++
.../camel/dsl/yaml/ParametersAmpersandTest.java | 382 +++++++++++++++++++++
9 files changed, 739 insertions(+), 49 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/yaml-dsl.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/yaml-dsl.adoc
index b5af1c7c832a..56d7f9d26463 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/yaml-dsl.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/yaml-dsl.adoc
@@ -320,6 +320,33 @@ To define an endpoint with the YAML dsl you have two
options:
authorizationToken: "XXX"
----
+=== Values with an & in parameters
+
+*Available as of Camel 4.24*
+
+A value under `parameters` is added to the query of the endpoint uri. When the
value has an `&`, Camel puts it in
+`RAW(...)`, as it does for a secret option, so the `&` does not end the value.
For example a file filter:
+
+[source,yaml]
+----
+- route:
+ from:
+ uri: "file:inbox"
+ parameters:
+ filterFile: "${file:name} contains 'R&D' && ${file:size} > 0"
+ steps:
+ - to:
+ uri: "log:rd"
+----
+
+The value is then taken as it is written, so a `+` in it stays a `+`. In a
value without an `&`, a `+` is read as a
+space, as in a uri. Camel does not change a value that starts with `#` (a bean
reference), a value in `RAW(...)` or
+`RAW{...}` already, and a path parameter, such as `directoryName` when the
`uri` is only `file`.
+
+NOTE: Camel sees the `&` only when it is written in the YAML as the value of
an option. When it comes from a property
+placeholder, or is in a value of a map option (see below), write the value in
RAW yourself, such as
+`+RAW({{filter}})+`.
+
=== Map values for parameters
*Available as of Camel 4.15*
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 6853e0c9fed9..d08605de8402 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -2114,6 +2114,26 @@ level when the dump was loaded (the rest configuration
or the rest, the error ha
configuration, the CamelContext), so the route behaved differently, or, for
`apiDocs`, a verb with `apiDocs="true"`
under a rest with `apiDocs="false"` was left out of the OpenAPI document.
+=== camel-yaml-dsl - a value under parameters: with an & is kept whole
+
+In the YAML DSL, a value under `parameters:` that has an `&` is now kept whole
in the endpoint uri. Before, the value
+was cut at the `&`, and the rest was read as another option. For example, with
+`+filterFile: "${file:name} contains 'R&D'"+` the route failed to start. A
lenient component, such as a Kamelet, got
+only the text before the `&`, without an error.
+
+Camel now puts such a value in `RAW(...)` in the uri, as it does for a secret
option. When the value has `)&`, Camel
+uses `RAW{...}`. The value is then taken as it is written, so a `+` in it
stays a `+`. A value without an `&` is read
+as before, so a `+` in it is a space (such as in a cron expression written
with `+`).
+
+This applies to the endpoints of `from`, `to`, `toD`, `wireTap`, `poll` and
`switch`, and to the `kamelet` EIP. Camel
+does not change a value that starts with `#` (a bean reference), a value in
`RAW(...)` or `RAW{...}` already, or a path
+parameter, such as `directoryName` when the `uri` is only `file`: an `&` does
no harm in the path. A route dump, such as
+`camel validate normalize`, writes such a value in RAW.
+
+Camel sees the `&` only when it is written in the YAML as the value of an
option. When the `&` comes from a property
+placeholder, or is in a value of a map option (such as
`additionalProperties`), write the value in RAW yourself, such
+as `+RAW({{name}})+`.
+
=== camel-jbang - camel get bean, get route-controller and top source
* `camel get bean --dsl` shows the configuration of each property next to that
property: it paired them by position,
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
index 3f7f2204d426..4fb7b8f7769c 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
@@ -61,6 +61,13 @@ final class EndpointChecks {
= Set.of("to", "toD", "to-d", "wireTap", "wire-tap", "enrich",
"interceptSendToEndpoint", "intercept-send-to-endpoint");
+ /**
+ * Why a value cannot be put in RAW, for a value that has both )& and }&,
or )& and a { at the start.
+ */
+ private static final String NO_RAW_FORM = "No RAW form can keep this value
whole: RAW(...) ends at a ) followed by"
+ + " &, and RAW{...} ends at a }
followed by & (and RAW{{ is the start of a"
+ + " property placeholder).";
+
public static List<String> validateYamlEndpoints(String content,
CamelCatalog catalog) {
List<String> errors = new ArrayList<>();
String[] lines = content.split("\n", -1);
@@ -199,7 +206,11 @@ final class EndpointChecks {
if (colonPos > 0) {
String key = unquote(paramTrimmed.substring(0,
colonPos).trim());
String val =
unquote(stripComment(paramTrimmed.substring(colonPos + 1).trim()));
- val = wholeValue(errors, catalog, uri.substring(0,
uri.indexOf(':')), key, val, k);
+ val = wholeValue(errors, catalog, uri.substring(0,
uri.indexOf(':')), mapKey, key, val, k);
+ if (val == null) {
+ // the value is cut, which is reported, and
the rest of it is not another option
+ continue;
+ }
if (mapKey != null) {
// headers: with foo: bar under it is the
entry foo of the headers map option
key = mapKey + "." + key;
@@ -231,41 +242,112 @@ final class EndpointChecks {
}
/**
- * The value of an option under parameters: as the runtime reads it whole.
The YAML DSL adds the parameters to the
- * uri as they are written, so an & in a value starts a new option:
filterFile: "${file:name} contains 'R&D'" is
- * read as filterFile=${file:name} contains 'R and an option D', and the
route fails to start (CAMEL-26591). That is
- * said with the fix, RAW(...), which Camel keeps as it is, and the value
is returned in RAW(...), so that the
- * checks of the uri see the real options and values. A secret option is
in RAW(...) already (the YAML DSL puts it
- * there), and a path option goes in the path, where an & is a character
like any other.
+ * The value of an option under parameters: as the runtime reads it. Since
CAMEL-26594 the YAML DSL keeps a value
+ * with an & whole: it puts the value in RAW(...), or in RAW{...} when the
value has )&, as it does for a secret
+ * option. So filterFile: "${file:name} contains 'R&D'" is accepted, and
the value is returned in that RAW form, so
+ * that the checks of the uri see the real options and values. A path
option goes in the path, where an & is a
+ * character like any other.
+ * <p/>
+ * The YAML DSL adds some values to the uri as they are, and there an &
still starts a new option, so the value is
+ * cut (CAMEL-26591): a value of a map option, a value that starts with #
(a reference), and a value that no RAW
+ * form can keep whole. That is reported with what to write.
+ *
+ * @param mapKey the map option that the value is an entry of, or null
for an option
+ * @return the value as the checks of the uri see it, or null to
leave the option out of the uri: a value
+ * that is cut and that no RAW form can keep whole (the cut
is reported)
*/
- static String wholeValue(List<String> errors, CamelCatalog catalog, String
scheme, String key, String value, int lineIdx) {
+ static String wholeValue(
+ List<String> errors, CamelCatalog catalog, String scheme, String
mapKey, String key, String value, int lineIdx) {
if (value.indexOf('&') < 0 || value.startsWith("RAW(") ||
value.startsWith("RAW{")) {
return value;
}
- // RAW(...) ends at a ) followed by &, RAW{...} at a } followed by &
- String raw = !value.contains(")&") ? "RAW(" + value + ")" :
!value.contains("}&") ? "RAW{" + value + "}" : null;
- if (raw == null) {
- return value;
+ // a value that is cut is reported, and returned in RAW (or left out
of the uri when no RAW form can keep it
+ // whole), so that the other checks do not take the rest of it as
another option
+ String raw = rawForm(value);
+ String cut = key + "=" + value.substring(0, value.indexOf('&'));
+ if (mapKey != null) {
+ errors.add(cutValue(lineIdx, scheme, key + " of " + mapKey,
+ "The YAML DSL adds the values of a map option to the uri
as they are.", cut,
+ raw != null ? writeInRaw(key, raw, "") : NO_RAW_FORM));
+ return raw;
}
- boolean keptWhole = false;
+ boolean secret = false;
+ boolean path = false;
try {
var model = catalog.componentModel(scheme);
if (model != null) {
- keptWhole = model.getEndpointOptions().stream().anyMatch(
- o -> o.getName().equals(key) && (o.isSecret() ||
"path".equals(o.getKind())));
+ for (var o : model.getEndpointOptions()) {
+ if (o.getName().equals(key)) {
+ secret = o.isSecret();
+ path = "path".equals(o.getKind());
+ }
+ }
}
} catch (Exception e) {
- // a component the catalog cannot read: the value is said to be
cut, as the runtime cuts it
+ // a component the catalog cannot read: the value is checked as
the value of an option
+ }
+ if (path) {
+ return raw != null ? raw : value;
+ }
+ if (value.startsWith("#")) {
+ String why = "The YAML DSL keeps a value with an & whole, but it
adds a value that starts with # (a reference)"
+ + " to the uri as it is.";
+ errors.add(cutValue(lineIdx, scheme, key, why, cut,
+ raw != null ? writeInRaw(key, raw, ", and the reference
still works") : NO_RAW_FORM));
+ return raw;
+ }
+ if (secret) {
+ // the YAML DSL puts a secret option in RAW(...), also when the
value has )&
+ if (!value.contains(")&")) {
+ return "RAW(" + value + ")";
+ }
+ errors.add(cutValue(lineIdx, scheme, key,
+ "The YAML DSL puts a secret option in RAW(...), and
RAW(...) ends at a ) followed by &.",
+ key + "=" + value.substring(0, value.indexOf(")&")),
useProperty(key, null)));
+ return raw;
}
- if (!keptWhole) {
- errors.add(linePrefix(lineIdx) + scheme + ": " + key + " has an &
in its value. The YAML DSL adds the"
- + " parameters to the uri as they are, and an & starts
a new option, so Camel reads " + key + "="
- + value.substring(0, value.indexOf('&')) + " and takes
the rest as another option. Write the"
- + " value in RAW(...), which Camel keeps as it is: " +
key + ": " + YamlLines.quoted(raw));
+ if (raw == null) {
+ errors.add(cutValue(lineIdx, scheme, key, NO_RAW_FORM + " So the
YAML DSL adds the value to the uri as it is.",
+ cut, useProperty(key, value)));
}
return raw;
}
+ private static String cutValue(int lineIdx, String scheme, String option,
String why, String cut, String fix) {
+ return linePrefix(lineIdx) + scheme + ": " + option + " has an & in
its value. " + why + " An & starts a new"
+ + " option, so Camel reads " + cut + " and takes the rest as
another option. " + fix;
+ }
+
+ /**
+ * The value in RAW(...), or in RAW{...} when the value has )& which would
end RAW(...) too soon, as the YAML DSL
+ * puts it (YamlSupport), or null when no RAW form can keep the value
whole: it has both )& and }&, or it has )& and
+ * starts with { (RAW{{ is the start of a property placeholder).
+ */
+ static String rawForm(String value) {
+ // RAW(...) ends at a ) followed by &, RAW{...} at a } followed by &
+ if (!value.contains(")&")) {
+ return "RAW(" + value + ")";
+ } else if (!value.contains("}&") && !value.startsWith("{")) {
+ return "RAW{" + value + "}";
+ }
+ return null;
+ }
+
+ private static String writeInRaw(String key, String raw, String note) {
+ return "Write the value in RAW(...), which Camel keeps as it is" +
note + ": " + key + ": " + YamlLines.quoted(raw);
+ }
+
+ /**
+ * The fix for a value that no RAW form can keep whole: a property, which
Camel reads as it is (the value of a
+ * secret option is not repeated).
+ */
+ private static String useProperty(String key, String value) {
+ return "Put the value in a property and refer to it with #property: "
+ key + ": \"#property:" + key + "\", "
+ + (value != null
+ ? "with " + key + "=" + value + " in
application.properties"
+ : "and set " + key + " in application.properties");
+ }
+
/**
* The line of the key (such as parameters:) of the same mapping as the
line at idx that comes before it: the same
* indent, up to the line that starts the mapping, or -1.
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
index c02118d172ed..f66e165a0690 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
@@ -509,10 +509,10 @@ class SourceValidatorEndpointTest {
}
@Test
- void anAmpersandInAParameterValueSaysToWriteTheValueInRaw() {
- // CAMEL-26591: the YAML DSL adds the parameters to the uri as they
are, so the & cuts the value: the route fails
- // to start on filterFile=${file:name} contains 'R. The check said
"Unknown option 'D''", and a Simple syntax
- // error on the cut value, without the cause or the fix
+ void anAmpersandInAParameterValueIsAccepted() {
+ // CAMEL-26594: the YAML DSL keeps a value with an & whole (in RAW),
so filterFile: "${file:name} contains 'R&D'"
+ // runs, where the & cut the value before (CAMEL-26591). So does a
secret option, and a path option, where an & is
+ // a character like any other
String yaml = """
- route:
from:
@@ -526,28 +526,10 @@ class SourceValidatorEndpointTest {
from:
uri: file
parameters:
- directoryName: orders
+ directoryName: R&D
filterFile: "${file:size} > 1000 && ${file:name} ends
with '.csv'"
steps:
- log: "${file:name}"
- """;
- List<String> msgs = SourceValidator.validateYamlEndpoints(yaml,
catalog);
- assertThat(msgs).hasSize(2);
- assertThat(msgs.get(0)).isEqualTo("Line 5: file: filterFile has an &
in its value. The YAML DSL adds the"
- + " parameters to the uri as they
are, and an & starts a new option, so Camel"
- + " reads filterFile=${file:name}
contains 'R and takes the rest as another"
- + " option. Write the value in
RAW(...), which Camel keeps as it is:"
- + " filterFile: \"RAW(${file:name}
contains 'R&D')\"");
- assertThat(msgs.get(1)).startsWith("Line 14: file: filterFile has an &
in its value.")
- .contains("Camel reads filterFile=${file:size} > 1000 and
takes the rest as another option")
- .endsWith("filterFile: \"RAW(${file:size} > 1000 &&
${file:name} ends with '.csv')\"");
-
- // the fix the message gives is accepted, and so is a secret option,
which the YAML DSL puts in RAW(...) itself
- String fixed = yaml.replace("\"${file:name} contains 'R&D'\"",
"\"RAW(${file:name} contains 'R&D')\"")
- .replace("\"${file:size} > 1000 && ${file:name} ends with
'.csv'\"",
- "\"RAW(${file:size} > 1000 && ${file:name} ends with
'.csv')\"");
- assertThat(SourceValidator.validateYamlEndpoints(fixed,
catalog)).isEmpty();
- assertThat(SourceValidator.validateYamlEndpoints("""
- route:
from:
uri: sftp:localhost:2222/inbox
@@ -556,7 +538,102 @@ class SourceValidatorEndpointTest {
password: "s3cr&t"
steps:
- log: "${file:name}"
- """, catalog)).isEmpty();
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - to:
+ uri: log:audit
+ parameters:
+ marker: "f(x)&g(y)"
+ """;
+ assertThat(SourceValidator.validateYamlEndpoints(yaml,
catalog)).isEmpty();
+ // the value is checked whole, as the runtime reads it: a mistake in
it is reported with the whole value
+ String wrong = yaml.replace("${file:name} contains 'R&D'",
"${file:noext} contains 'R&D'");
+ assertThat(SourceValidator.validateYamlEndpoints(wrong,
catalog)).satisfiesExactly(
+ m -> assertThat(m).startsWith("Line 5: file:
filterFile=${file:noext} contains 'R&D' has a Simple syntax"
+ + " error: Unknown file language
syntax: noext"));
+ }
+
+ @Test
+ void anAmpersandIsReportedWhereTheYamlDslStillCutsTheValue() {
+ // CAMEL-26594: the YAML DSL adds a value of a map option, a value
that starts with # and a value that no RAW form
+ // can keep whole to the uri as they are, so there the & still cuts
the value. A secret option is put in RAW(...)
+ // only, which ends at )&
+ String yaml = """
+ - route:
+ from:
+ uri: once
+ parameters:
+ name: hello
+ headers:
+ dept: "R&D"
+ steps:
+ - log: "${body}"
+ - route:
+ from:
+ uri: file:inbox
+ parameters:
+ filter: "#my&Filter"
+ steps:
+ - log: "${file:name}"
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - to:
+ uri: log:audit
+ parameters:
+ marker: "f(x)&g{y}&z"
+ - to:
+ uri: log:audit2
+ parameters:
+ marker: "{x})&y"
+ - route:
+ from:
+ uri: sftp:localhost:2222/inbox
+ parameters:
+ username: admin
+ password: "s3c)&t"
+ steps:
+ - log: "${file:name}"
+ """;
+ String noRawForm = " No RAW form can keep this value whole: RAW(...)
ends at a ) followed by &, and RAW{...} ends"
+ + " at a } followed by & (and RAW{{ is the start of
a property placeholder). So the YAML DSL"
+ + " adds the value to the uri as it is.";
+ List<String> msgs = SourceValidator.validateYamlEndpoints(yaml,
catalog);
+ assertThat(msgs).hasSize(5);
+ assertThat(msgs.get(0)).isEqualTo("Line 7: once: dept of headers has
an & in its value. The YAML DSL adds the"
+ + " values of a map option to the
uri as they are. An & starts a new option, so"
+ + " Camel reads dept=R and takes the
rest as another option. Write the value in"
+ + " RAW(...), which Camel keeps as
it is: dept: \"RAW(R&D)\"");
+ assertThat(msgs.get(1)).isEqualTo("Line 14: file: filter has an & in
its value. The YAML DSL keeps a value with"
+ + " an & whole, but it adds a value
that starts with # (a reference) to the uri"
+ + " as it is. An & starts a new
option, so Camel reads filter=#my and takes the"
+ + " rest as another option. Write
the value in RAW(...), which Camel keeps as it"
+ + " is, and the reference still
works: filter: \"RAW(#my&Filter)\"");
+ assertThat(msgs.get(2)).isEqualTo("Line 24: log: marker has an & in
its value." + noRawForm
+ + " An & starts a new option, so
Camel reads marker=f(x) and takes the rest as"
+ + " another option. Put the value in
a property and refer to it with #property:"
+ + " marker: \"#property:marker\",
with marker=f(x)&g{y}&z in"
+ + " application.properties");
+ assertThat(msgs.get(3)).isEqualTo("Line 28: log: marker has an & in
its value." + noRawForm
+ + " An & starts a new option, so
Camel reads marker={x}) and takes the rest as"
+ + " another option. Put the value in
a property and refer to it with #property:"
+ + " marker: \"#property:marker\",
with marker={x})&y in application.properties");
+ assertThat(msgs.get(4)).isEqualTo("Line 34: sftp: password has an & in
its value. The YAML DSL puts a secret"
+ + " option in RAW(...), and RAW(...)
ends at a ) followed by &. An & starts a new"
+ + " option, so Camel reads
password=s3c and takes the rest as another option. Put"
+ + " the value in a property and
refer to it with #property: password:"
+ + " \"#property:password\", and set
password in application.properties");
+
+ // the fixes the messages give are accepted
+ String fixed = yaml.replace("dept: \"R&D\"", "dept: \"RAW(R&D)\"")
+ .replace("\"#my&Filter\"", "\"RAW(#my&Filter)\"")
+ .replace("\"f(x)&g{y}&z\"", "\"#property:marker\"")
+ .replace("\"{x})&y\"", "\"#property:marker\"")
+ .replace("\"s3c)&t\"", "\"#property:password\"");
+ assertThat(SourceValidator.validateYamlEndpoints(fixed,
catalog)).isEmpty();
}
@Test
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlSupport.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlSupport.java
index 378ef82cbb52..cd6fdddf7dfd 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlSupport.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlSupport.java
@@ -21,6 +21,9 @@ import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeMap;
import java.util.function.Function;
import org.apache.camel.CamelContext;
@@ -101,7 +104,7 @@ public final class YamlSupport {
// if the uri is expressed as simple scheme, then we can
use the
// discovered EndpointUriFactory to build the uri
//
- answer = factory.buildUri(scheme, parameters, false);
+ answer = buildUri(factory, scheme, parameters);
} else {
//
// otherwise we have to compose it but we can still
leverage the
@@ -119,6 +122,8 @@ public final class YamlSupport {
}
}
}
+ // keep a value with & whole (a secret option is in RAW
already)
+ options.replaceAll((key, value) -> rawValue(value));
// flatten multiValue map parameters, where the map
parameter is written
Map<String, String> prefixes =
factory.multiValuePrefixes();
@@ -139,7 +144,7 @@ public final class YamlSupport {
answer += "?" + URISupport.createQueryString(flat, false);
}
} else {
- answer += "?" + URISupport.createQueryString(parameters,
false);
+ answer += "?" + createQueryString(parameters);
}
} catch (URISyntaxException e) {
throw new InvalidEndpointException(node, "Error creating query",
e);
@@ -148,6 +153,71 @@ public final class YamlSupport {
return answer;
}
+ /**
+ * Creates the query of an endpoint uri from the parameters, not encoded
and in the order they are written. A value
+ * with & is put in RAW to keep it whole.
+ */
+ public static String createQueryString(Map<String, Object> parameters) {
+ Map<String, Object> options = new LinkedHashMap<>(parameters);
+ options.replaceAll((key, value) -> rawValue(value));
+ return URISupport.createQueryString(options, false);
+ }
+
+ /**
+ * Builds the uri with the factory, which puts the path parameters in the
path and the other parameters in the
+ * query. A value with & is put in RAW to keep it whole in the query. In
the path an & does no harm, and RAW would
+ * become part of the value, so a parameter that the factory puts in the
path is built again as it is written.
+ */
+ private static String buildUri(EndpointUriFactory factory, String scheme,
Map<String, Object> parameters)
+ throws URISyntaxException {
+ // in the same order: as written, or sorted when the parameters have
no order (as the factory does)
+ Map<String, Object> options
+ = parameters instanceof LinkedHashMap ? new
LinkedHashMap<>(parameters) : new TreeMap<>(parameters);
+ // the factory puts a secret option in RAW itself
+ Set<String> secrets = factory.secretPropertyNames();
+ options.replaceAll((key, value) -> secrets.contains(key) ? value :
rawValue(value));
+ String answer = factory.buildUri(scheme, options, false);
+
+ boolean path = false;
+ for (Map.Entry<String, Object> option : options.entrySet()) {
+ Object value = parameters.get(option.getKey());
+ if (!Objects.equals(option.getValue(), value) &&
!isInQuery(answer, option.getKey(), option.getValue())) {
+ option.setValue(value);
+ path = true;
+ }
+ }
+ return path ? factory.buildUri(scheme, options, false) : answer;
+ }
+
+ /**
+ * Whether the option is in the query of the uri, written as the factory
writes it.
+ */
+ private static boolean isInQuery(String uri, String key, Object value) {
+ String option = URISupport.createQueryString(Map.of(key, value),
false);
+ return uri.contains("?" + option) || uri.contains("&" + option);
+ }
+
+ /**
+ * The value in RAW when it is a text with &, as the & would end the value
in the query of the uri. This is
+ * RAW(value), or RAW{value} when the value has )& which would end
RAW(value) too soon.
+ * <p/>
+ * Other values are returned as they are, and so is a bean reference
(#name), a value in RAW already, and a value
+ * that no RAW form can keep whole: it has both )& and }&, or it has )&
and starts with { (RAW{{ is the start of a
+ * property placeholder).
+ */
+ private static Object rawValue(Object value) {
+ if (value instanceof String s && s.indexOf('&') != -1 &&
!s.startsWith("#") && !s.startsWith("RAW(")
+ && !s.startsWith("RAW{")) {
+ // a RAW value ends at the first closing bracket that is followed
by & or the end of the uri
+ if (!s.contains(")&")) {
+ return "RAW(" + s + ")";
+ } else if (!s.contains("}&") && !s.startsWith("{")) {
+ return "RAW{" + s + "}";
+ }
+ }
+ return value;
+ }
+
public static Node setProperty(Node node, String key, Object value) {
node.setProperty(key, value);
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/KameletDeserializer.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/KameletDeserializer.java
index 04821dac494c..51ac84a1eb64 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/KameletDeserializer.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/main/java/org/apache/camel/dsl/yaml/deserializers/KameletDeserializer.java
@@ -21,11 +21,11 @@ import java.util.Map;
import org.apache.camel.dsl.yaml.common.YamlDeserializationContext;
import org.apache.camel.dsl.yaml.common.YamlDeserializerBase;
import org.apache.camel.dsl.yaml.common.YamlDeserializerResolver;
+import org.apache.camel.dsl.yaml.common.YamlSupport;
import org.apache.camel.dsl.yaml.common.exception.UnsupportedFieldException;
import org.apache.camel.model.KameletDefinition;
import org.apache.camel.spi.annotations.YamlProperty;
import org.apache.camel.spi.annotations.YamlType;
-import org.apache.camel.util.URISupport;
import org.snakeyaml.engine.v2.nodes.MappingNode;
import org.snakeyaml.engine.v2.nodes.Node;
import org.snakeyaml.engine.v2.nodes.NodeTuple;
@@ -96,7 +96,7 @@ public class KameletDeserializer extends
YamlDeserializerBase<KameletDefinition>
}
if (parameters != null) {
- name += "?" + URISupport.createQueryString(parameters, false);
+ name += "?" + YamlSupport.createQueryString(parameters);
}
target.setName(name);
diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/pom.xml
b/dsl/camel-yaml-dsl/camel-yaml-dsl/pom.xml
index 718f4a4e93f3..3ce920313f8f 100644
--- a/dsl/camel-yaml-dsl/camel-yaml-dsl/pom.xml
+++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/pom.xml
@@ -122,6 +122,11 @@
<artifactId>camel-log</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-file</artifactId>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.apache.camel</groupId>
<artifactId>camel-telegram</artifactId>
diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/docs/yaml-dsl.adoc
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/docs/yaml-dsl.adoc
index b5af1c7c832a..56d7f9d26463 100644
--- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/docs/yaml-dsl.adoc
+++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/docs/yaml-dsl.adoc
@@ -320,6 +320,33 @@ To define an endpoint with the YAML dsl you have two
options:
authorizationToken: "XXX"
----
+=== Values with an & in parameters
+
+*Available as of Camel 4.24*
+
+A value under `parameters` is added to the query of the endpoint uri. When the
value has an `&`, Camel puts it in
+`RAW(...)`, as it does for a secret option, so the `&` does not end the value.
For example a file filter:
+
+[source,yaml]
+----
+- route:
+ from:
+ uri: "file:inbox"
+ parameters:
+ filterFile: "${file:name} contains 'R&D' && ${file:size} > 0"
+ steps:
+ - to:
+ uri: "log:rd"
+----
+
+The value is then taken as it is written, so a `+` in it stays a `+`. In a
value without an `&`, a `+` is read as a
+space, as in a uri. Camel does not change a value that starts with `#` (a bean
reference), a value in `RAW(...)` or
+`RAW{...}` already, and a path parameter, such as `directoryName` when the
`uri` is only `file`.
+
+NOTE: Camel sees the `&` only when it is written in the YAML as the value of
an option. When it comes from a property
+placeholder, or is in a value of a map option (see below), write the value in
RAW yourself, such as
+`+RAW({{filter}})+`.
+
=== Map values for parameters
*Available as of Camel 4.15*
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/ParametersAmpersandTest.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/ParametersAmpersandTest.java
new file mode 100644
index 000000000000..e245181273ac
--- /dev/null
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/ParametersAmpersandTest.java
@@ -0,0 +1,382 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.yaml;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+
+import org.apache.camel.Endpoint;
+import org.apache.camel.Exchange;
+import org.apache.camel.component.file.FileEndpoint;
+import org.apache.camel.component.file.GenericFileFilter;
+import org.apache.camel.component.log.LogEndpoint;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.dsl.yaml.support.YamlTestSupport;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.model.PollDefinition;
+import org.apache.camel.model.RouteDefinition;
+import org.apache.camel.model.SwitchDefinition;
+import org.apache.camel.model.ToDefinition;
+import org.apache.camel.model.WireTapDefinition;
+import org.apache.camel.support.PluginHelper;
+import org.apache.camel.support.ResourceHelper;
+import org.apache.camel.util.FileUtil;
+import org.apache.camel.yaml.LwModelToYAMLDumper;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-26594: a value under parameters: with an & is kept whole in the
endpoint uri, as it is put in RAW (as a secret
+ * option is). Before, the value was cut at the &.
+ */
+class ParametersAmpersandTest extends YamlTestSupport {
+
+ private static final String DIR = "target/data/ParametersAmpersandTest";
+
+ @Override
+ public void doSetup() throws Exception {
+ FileUtil.removeDir(new File(DIR));
+ }
+
+ @Test
+ void fileFilterWithAmpersand() throws Exception {
+ files(DIR + "/in", "R&D-plan.txt", "other.txt");
+ loadRoutes("""
+ - from:
+ uri: file:target/data/ParametersAmpersandTest/in
+ parameters:
+ filterFile: "${file:name} contains 'R&D'"
+ noop: true
+ initialDelay: 0
+ delay: 10
+ steps:
+ - to: mock:result
+ """);
+
+
assertThat(fromUri()).isEqualTo("file:target/data/ParametersAmpersandTest/in"
+ + "?filterFile=RAW(${file:name}
contains 'R&D')&noop=true&initialDelay=0&delay=10");
+ assertConsumed("R&D-plan.txt");
+ }
+
+ @Test
+ void fileFilterWithAndAndWhenTheUriIsTheScheme() throws Exception {
+ files(DIR + "/in", "R&D-plan.txt", "Report.csv", "other.txt");
+ loadRoutes("""
+ - from:
+ uri: file
+ parameters:
+ directoryName: target/data/ParametersAmpersandTest/in
+ filterFile: "${file:name} contains 'R' && ${file:name}
ends with '.txt'"
+ noop: true
+ initialDelay: 0
+ delay: 10
+ steps:
+ - to: mock:result
+ """);
+
+ assertConsumed("R&D-plan.txt");
+ }
+
+ @Test
+ void pathParameterWithAmpersandIsNotInRaw() throws Exception {
+ files(DIR + "/R&D", "R&D-plan.txt", "other.txt");
+ loadRoutes("""
+ - from:
+ uri: file
+ parameters:
+ directoryName: target/data/ParametersAmpersandTest/R&D
+ filterFile: "${file:name} contains 'R&D'"
+ noop: true
+ initialDelay: 0
+ delay: 10
+ steps:
+ - to: mock:result
+ """);
+
+ // an & does no harm in the path, and RAW would be taken as part of
the directory name
+
assertThat(fromUri()).startsWith("file:target/data/ParametersAmpersandTest/R&D?filterFile=RAW(");
+ assertConsumed("R&D-plan.txt");
+ }
+
+ @Test
+ void kameletParameterWithAmpersand() throws Exception {
+ // a Kamelet is lenient, so before the value was cut at the & with no
error, and the Kamelet got R
+ assertKamelet("""
+ - from:
+ uri: direct:start
+ steps:
+ - to:
+ uri: kamelet:greet
+ parameters:
+ message: "R&D"
+ - to: mock:result
+ """);
+ }
+
+ @Test
+ void kameletEipParameterWithAmpersand() throws Exception {
+ assertKamelet("""
+ - from:
+ uri: direct:start
+ steps:
+ - kamelet:
+ name: greet
+ parameters:
+ message: "R&D"
+ - to: mock:result
+ """);
+ }
+
+ @Test
+ void plusIsReadAsBeforeInAValueWithoutAmpersand() throws Exception {
+ loadRoutes("""
+ - from:
+ uri: direct:start
+ steps:
+ - to:
+ uri: file:target/data/ParametersAmpersandTest/out
+ parameters:
+ include: "R&D+.*"
+ exclude: "draft+copy"
+ """);
+ context.start();
+
+
assertThat(toUri(0)).isEqualTo("file:target/data/ParametersAmpersandTest/out?include=RAW(R&D+.*)&exclude=draft+copy");
+ FileEndpoint file = endpoint(FileEndpoint.class);
+ // a value in RAW is taken as it is written
+ assertThat(file.getInclude()).isEqualTo("R&D+.*");
+ // in a value without & a + is a space, as before (such as in a cron
expression written with +)
+ assertThat(file.getExclude()).isEqualTo("draft copy");
+ }
+
+ @Test
+ void placeholderInValueWithAmpersand() throws Exception {
+ context.getPropertiesComponent().addInitialProperty("team", "Ops");
+ context.getPropertiesComponent().addInitialProperty("dept", "R&D");
+ loadRoutes("""
+ - from:
+ uri: direct:start
+ steps:
+ - to:
+ uri: log:foo
+ parameters:
+ marker: "{{team}} & {{dept}}"
+ """);
+ context.start();
+
+ // the placeholders are resolved in RAW, so an & in the value of a
placeholder is kept as well
+ assertThat(toUri(0)).isEqualTo("log:foo?marker=RAW({{team}} &
{{dept}})");
+ assertThat(endpoint(LogEndpoint.class).getMarker()).isEqualTo("Ops &
R&D");
+ }
+
+ @Test
+ void beanReferenceIsKept() throws Exception {
+ GenericFileFilter<File> filter = file -> true;
+ context.getRegistry().bind("myFilter", filter);
+ loadRoutes("""
+ - from:
+ uri: direct:start
+ steps:
+ - to:
+ uri: file:target/data/ParametersAmpersandTest/out
+ parameters:
+ filter: "#myFilter"
+ include: "R&D.*"
+ """);
+ context.start();
+
+
assertThat(toUri(0)).isEqualTo("file:target/data/ParametersAmpersandTest/out?filter=#myFilter&include=RAW(R&D.*)");
+ FileEndpoint file = endpoint(FileEndpoint.class);
+ assertThat(file.getFilter()).isSameAs(filter);
+ assertThat(file.getInclude()).isEqualTo("R&D.*");
+ }
+
+ @Test
+ void valueInRawIsKept() throws Exception {
+ loadRoutes("""
+ - from:
+ uri: direct:start
+ steps:
+ - to:
+ uri: file:target/data/ParametersAmpersandTest/out
+ parameters:
+ include: "RAW(R&D.*)"
+ exclude: "RAW{draft©}"
+ antInclude: "**/R&D/**"
+ schedulerProperties:
+ team: "RAW(R&D)"
+ """);
+ context.start();
+
+
assertThat(toUri(0)).isEqualTo("file:target/data/ParametersAmpersandTest/out"
+ +
"?include=RAW(R&D.*)&exclude=RAW{draft©}&antInclude=RAW(**/R&D/**)"
+ + "&scheduler.team=RAW(R&D)");
+ FileEndpoint file = endpoint(FileEndpoint.class);
+ assertThat(file.getInclude()).isEqualTo("R&D.*");
+ assertThat(file.getExclude()).isEqualTo("draft©");
+ assertThat(file.getAntInclude()).isEqualTo("**/R&D/**");
+ // a value of a map option is added as it is, so an & in it must be in
RAW as written
+ assertThat(file.getSchedulerProperties()).containsEntry("team", "R&D");
+ }
+
+ @Test
+ void toDWithHeaderAndAmpersand() throws Exception {
+ loadRoutes("""
+ - from:
+ uri: direct:start
+ steps:
+ - toD:
+ uri: file:target/data/ParametersAmpersandTest/out
+ parameters:
+ fileName: "${header.dept} & ${header.team}.txt"
+ """);
+ context.start();
+ context.createProducerTemplate().sendBodyAndHeaders("direct:start",
"plan", Map.of("dept", "R&D", "team", "Ops"));
+
+ // Simple is evaluated in RAW, and the & from the header is kept as
well
+ assertThat(Path.of(DIR, "out", "R&D & Ops.txt")).hasContent("plan");
+ }
+
+ @Test
+ void rawForm() throws Exception {
+ loadRoutes("""
+ - from:
+ uri: direct:start
+ steps:
+ - to:
+ uri: log:a
+ parameters:
+ marker: "f(x) && g(y)"
+ - to:
+ uri: log:b
+ parameters:
+ marker: "f(x)&g(y)"
+ - to:
+ uri: log:c
+ parameters:
+ marker: "{x})&y"
+ - to:
+ uri: log:d
+ parameters:
+ marker: "(x)&{y}&z"
+ """);
+
+ // RAW(value) can have a ) that is not followed by &
+ assertThat(toUri(0)).isEqualTo("log:a?marker=RAW(f(x) && g(y))");
+ assertThat(context.getEndpoint(toUri(0),
LogEndpoint.class).getMarker()).isEqualTo("f(x) && g(y)");
+ // )& would end RAW(value) too soon, so it is RAW{value}
+ assertThat(toUri(1)).isEqualTo("log:b?marker=RAW{f(x)&g(y)}");
+ assertThat(context.getEndpoint(toUri(1),
LogEndpoint.class).getMarker()).isEqualTo("f(x)&g(y)");
+ // no RAW form can keep these values whole, so they are as before:
+ // RAW{{ is the start of a property placeholder, and both )& and }&
would end the value too soon
+ assertThat(toUri(2)).isEqualTo("log:c?marker={x})&y");
+ assertThat(toUri(3)).isEqualTo("log:d?marker=(x)&{y}&z");
+ }
+
+ @Test
+ void everyEndpointWithParametersAndDumpRoundTrip() throws Exception {
+ loadRoutes("""
+ - route:
+ id: rd
+ from:
+ uri: direct:start
+ steps:
+ - wireTap:
+ uri: log:tap
+ parameters:
+ marker: "R&D"
+ - poll:
+ uri: file:target/data/ParametersAmpersandTest/in
+ parameters:
+ include: "R&D.*"
+ - switch:
+ selector:
+ header:
+ expression: dept
+ case:
+ - value: rd
+ uri: log:rd
+ parameters:
+ marker: "R&D"
+ otherwise:
+ uri: log
+ parameters:
+ loggerName: other
+ marker: "R&D"
+ """);
+
+ RouteDefinition route = context.getRouteDefinition("rd");
+ assertUris(route);
+
+ // the dump has the value in RAW, so it is loaded as the same uri,
which is not put in RAW again
+ String yaml = new LwModelToYAMLDumper().dumpModelAsYaml(context,
route, false, true, false, false);
+ try (DefaultCamelContext restored = new DefaultCamelContext()) {
+
PluginHelper.getRoutesLoader(restored).loadRoutes(ResourceHelper.fromString("rd.yaml",
yaml));
+ assertUris(restored.getRouteDefinition("rd"));
+ }
+ }
+
+ private static void assertUris(RouteDefinition route) {
+ assertThat(((WireTapDefinition<?>)
route.getOutputs().get(0)).getUri()).isEqualTo("log:tap?marker=RAW(R&D)");
+ assertThat(((PollDefinition)
route.getOutputs().get(1)).getEndpointUri())
+
.isEqualTo("file:target/data/ParametersAmpersandTest/in?include=RAW(R&D.*)");
+ SwitchDefinition sw = (SwitchDefinition) route.getOutputs().get(2);
+
assertThat(sw.getCases().get(0).getUri()).isEqualTo("log:rd?marker=RAW(R&D)");
+
assertThat(sw.getOtherwise().getUri()).isEqualTo("log:other?marker=RAW(R&D)");
+ }
+
+ private static void files(String dir, String... names) throws Exception {
+ Files.createDirectories(Path.of(dir));
+ for (String name : names) {
+ Files.writeString(Path.of(dir, name), name);
+ }
+ }
+
+ private String fromUri() {
+ return
context.getRouteDefinitions().get(0).getInput().getEndpointUri();
+ }
+
+ private String toUri(int index) {
+ return ((ToDefinition)
context.getRouteDefinitions().get(0).getOutputs().get(index)).getEndpointUri();
+ }
+
+ private <T extends Endpoint> T endpoint(Class<T> type) {
+ return
context.getEndpoints().stream().filter(type::isInstance).map(type::cast).findFirst().orElseThrow();
+ }
+
+ private void assertKamelet(String route) throws Exception {
+ addTemplate("greet", t ->
t.from("kamelet:source").setBody().constant("{{message}}"));
+ loadRoutes(route);
+
+ withMock("mock:result", mock -> mock.expectedBodiesReceived("R&D"));
+ context.start();
+ withTemplate(t -> t.to("direct:start").withBody("hello").send());
+ MockEndpoint.assertIsSatisfied(context);
+ }
+
+ private void assertConsumed(String... names) throws Exception {
+ MockEndpoint mock = withMock("mock:result",
+ m ->
m.expectedHeaderValuesReceivedInAnyOrder(Exchange.FILE_NAME, (Object[]) names));
+ // wait a little to see that no other file is consumed
+ mock.setAssertPeriod(500);
+ context.start();
+ mock.assertIsSatisfied();
+ }
+}