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 af49586fea02 CAMEL-25516: camel-jbang - the validator reports a
header, exchange property or variable read where the route keeps the name in
another (#27652)
af49586fea02 is described below
commit af49586fea0217fec42d2308cab55e380ee1c311
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Oct 10 10:58:51 2026 +0200
CAMEL-25516: camel-jbang - the validator reports a header, exchange
property or variable read where the route keeps the name in another (#27652)
A route that keeps a value in one place and reads it from another gets null
with no error. In the
local-model benchmark's http-client example the model kept the line's sku
in a header and logged
${exchangeProperty.sku}, or kept it in an exchange property and built the
URI with ${header.sku}.
The validator now reports, at the line that reads it, ${header.X},
${exchangeProperty.X} or
${variable.X} when the route sets X only as one of the other two, with the
read to write. Quiet for a
route that consumes from an endpoint that can carry headers (only timer,
scheduler, cron, quartz and
file cannot), for a path parameter {X}, and for a name set in the form it
is read. Over the
benchmark's routes it reports none of the 1077 reference versions, and 3 of
the 6976 model versions.
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../core/commands/ai/HeaderPropertyMixups.java | 200 +++++++++++++++++++++
.../jbang/core/commands/ai/SourceValidator.java | 4 +
.../core/commands/ai/HeaderPropertyMixupsTest.java | 98 ++++++++++
3 files changed, 302 insertions(+)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixups.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixups.java
new file mode 100644
index 000000000000..bb19dd0f680f
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixups.java
@@ -0,0 +1,200 @@
+/*
+ * 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.ai;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
+
+/**
+ * A route that keeps a value in one place (a header, an exchange property or
a variable) and reads it from another gets
+ * null with no error (CAMEL-25516): {@code toD:
http://localhost:8080/stock/${header.sku}} after
+ * {@code setProperty: sku} calls {@code /stock/}. {@link HeaderChecks} says
this for Camel's own names
+ * (CamelTimerCounter); this is the same mix-up with the route's own names.
+ * <p/>
+ * Quiet where the name can come from elsewhere: a route that consumes from an
endpoint that can carry headers (only
+ * timer, scheduler, cron, quartz and file cannot), a path parameter {X} of a
rest or platform-http path, and a name the
+ * route sets in both forms.
+ */
+final class HeaderPropertyMixups {
+
+ private static final Set<String> PLAIN_CONSUMERS = Set.of("timer",
"scheduler", "cron", "quartz", "file");
+ private static final Pattern HEADER_READ
+ =
Pattern.compile("\\$\\{(?:in\\.)?headers?(?:\\.([A-Za-z_][\\w-]*)|\\[['\"]?([A-Za-z_][\\w-]*)['\"]?])");
+ private static final Pattern PROPERTY_READ
+ =
Pattern.compile("\\$\\{exchangeProperty(?:\\.([A-Za-z_][\\w-]*)|\\[['\"]?([A-Za-z_][\\w-]*)['\"]?])");
+ private static final Pattern VARIABLE_READ
+ =
Pattern.compile("\\$\\{variables?(?:\\.([A-Za-z_][\\w-]*)|\\[['\"]?([A-Za-z_][\\w-]*)['\"]?])");
+
+ /** Where a route keeps a value: what a read says, what the route sets
with, and how to read it. */
+ private enum Kind {
+ HEADER("a header", "header."),
+ PROPERTY("an exchange property", "exchangeProperty."),
+ VARIABLE("a variable", "variable.");
+
+ final String what;
+ final String read;
+
+ Kind(String what, String read) {
+ this.what = what;
+ this.read = read;
+ }
+ }
+
+ /** A path parameter of a rest or platform-http path: a header of that
name on the request. */
+ private static final Pattern PATH_PARAMETER =
Pattern.compile("(?<!\\$)\\{([A-Za-z_][\\w-]*)}");
+
+ private HeaderPropertyMixups() {
+ }
+
+ /** The problems, each "Line N: message". */
+ static List<String> validate(String content) {
+ List<String> answer = new ArrayList<>();
+ if (content == null || !content.contains("${")) {
+ return answer;
+ }
+ Object doc;
+ try {
+ doc = new Yaml(new SafeConstructor(new
LoaderOptions())).load(content);
+ } catch (Exception e) {
+ return answer;
+ }
+ if (!(doc instanceof List<?> items)) {
+ return answer;
+ }
+ Set<String> pathParameters = new LinkedHashSet<>();
+ Matcher pm = PATH_PARAMETER.matcher(content);
+ while (pm.find()) {
+ pathParameters.add(pm.group(1));
+ }
+ String[] lines = content.split("\n", -1);
+ Set<String> reported = new LinkedHashSet<>();
+ try {
+ for (Object item : items) {
+ Map<?, ?> from = null;
+ if (item instanceof Map<?, ?> m && m.get("route") instanceof
Map<?, ?> route
+ && route.get("from") instanceof Map<?, ?> f) {
+ from = f;
+ } else if (item instanceof Map<?, ?> m && m.get("from")
instanceof Map<?, ?> f) {
+ from = f;
+ }
+ if (from == null ||
!PLAIN_CONSUMERS.contains(scheme(String.valueOf(from.get("uri"))))) {
+ continue;
+ }
+ Map<Kind, Set<String>> set = new
java.util.EnumMap<>(Kind.class);
+ for (Kind k : Kind.values()) {
+ set.put(k, new LinkedHashSet<>());
+ }
+ set.get(Kind.HEADER).addAll(pathParameters);
+ List<String> texts = new ArrayList<>();
+ collect(from, set, texts);
+ for (String text : texts) {
+ report(text, HEADER_READ, Kind.HEADER, set, lines,
reported, answer);
+ report(text, PROPERTY_READ, Kind.PROPERTY, set, lines,
reported, answer);
+ report(text, VARIABLE_READ, Kind.VARIABLE, set, lines,
reported, answer);
+ }
+ }
+ } catch (RuntimeException e) {
+ // a shape the walk does not know: a check must never fail the
validation
+ }
+ return answer;
+ }
+
+ private static void report(
+ String text, Pattern read, Kind kind, Map<Kind, Set<String>> set,
String[] lines, Set<String> reported,
+ List<String> answer) {
+ Matcher m = read.matcher(text);
+ while (m.find()) {
+ String name = m.group(1) != null ? m.group(1) : m.group(2);
+ if (set.get(kind).contains(name)) {
+ continue;
+ }
+ Kind kept = null;
+ for (Kind other : Kind.values()) {
+ if (other != kind && set.get(other).contains(name)) {
+ kept = other;
+ }
+ }
+ if (kept == null || !reported.add(kind + ":" + name)) {
+ continue;
+ }
+ String token = m.group() + "}";
+ int line = 0;
+ for (int i = 0; i < lines.length; i++) {
+ if (lines[i].contains(m.group())) {
+ line = i + 1;
+ break;
+ }
+ }
+ answer.add("Line " + line + ": " + token + " reads " + kind.what +
" " + name + ", but the route keeps " + name
+ + " in " + kept.what + " (the " +
kind.what.substring(kind.what.indexOf(' ') + 1)
+ + " is null here): write ${" + kept.read + name + "}");
+ }
+ }
+
+ /**
+ * The names the route sets as headers, exchange properties and variables,
and every text that may hold an
+ * expression.
+ */
+ private static void collect(Object node, Map<Kind, Set<String>> set,
List<String> texts) {
+ if (node instanceof Map<?, ?> m) {
+ for (Map.Entry<?, ?> e : m.entrySet()) {
+ String key = String.valueOf(e.getKey());
+ Object value = e.getValue();
+ switch (key) {
+ case "setHeader" -> name(value, set.get(Kind.HEADER));
+ case "setProperty" -> name(value, set.get(Kind.PROPERTY));
+ case "setVariable" -> name(value, set.get(Kind.VARIABLE));
+ case "setHeaders" -> names(value, "headers",
set.get(Kind.HEADER));
+ case "setVariables" -> names(value, "variables",
set.get(Kind.VARIABLE));
+ default -> {
+ }
+ }
+ collect(value, set, texts);
+ }
+ } else if (node instanceof List<?> list) {
+ list.forEach(o -> collect(o, set, texts));
+ } else if (node instanceof String s && s.contains("${")) {
+ texts.add(s);
+ }
+ }
+
+ private static void names(Object body, String key, Set<String> into) {
+ if (body instanceof Map<?, ?> m && m.get(key) instanceof List<?> list)
{
+ list.forEach(h -> name(h, into));
+ }
+ }
+
+ private static void name(Object body, Set<String> into) {
+ if (body instanceof Map<?, ?> m && m.get("name") != null) {
+ into.add(String.valueOf(m.get("name")));
+ }
+ }
+
+ private static String scheme(String uri) {
+ int colon = uri.indexOf(':');
+ return colon > 0 ? uri.substring(0, colon) : uri;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
index a652bcbd290d..2de9e60532c1 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
@@ -146,6 +146,10 @@ public final class SourceValidator {
msgs.addAll(EndpointConsumerChecks.validateYamlConsumers(content, directory,
fileName, catalog));
}
}
+ if (msgs.isEmpty()) {
+ // a header read where the route keeps the name in an exchange
property, or the reverse (CAMEL-25516)
+ msgs = new ArrayList<>(HeaderPropertyMixups.validate(content));
+ }
return KameletChecks.withTemplateHints(name, content, msgs);
}
if (name.endsWith(".properties")) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
new file mode 100644
index 000000000000..c8ac6a5004fe
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.ai;
+
+import java.util.List;
+
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25516: a header, exchange property or variable read where the route
keeps the name in another of them.
+ */
+class HeaderPropertyMixupsTest {
+
+ private static String route(String from, String set, String read) {
+ return "- route:\n from:\n uri: " + from + "\n steps:\n"
+ + " - " + set + ":\n name: sku\n
expression:\n simple: ${body[sku]}\n"
+ + " - toD:\n uri:
\"http://localhost:8080/stock/" + read + "\"\n";
+ }
+
+ @Test
+ void aHeaderReadWhereTheRouteKeepsAProperty() {
+ List<String> msgs = HeaderPropertyMixups.validate(route("file:orders",
"setProperty", "${header.sku}"));
+
+ assertThat(msgs).singleElement().isEqualTo("Line 10: ${header.sku}
reads a header sku, but the route keeps sku in"
+ + " an exchange property
(the header is null here): write"
+ + "
${exchangeProperty.sku}");
+ }
+
+ @Test
+ void aPropertyReadWhereTheRouteKeepsAHeader() {
+ assertThat(HeaderPropertyMixups.validate(route("timer:tick",
"setHeader", "${exchangeProperty.sku}")))
+ .singleElement().asString()
+ .startsWith("Line 10: ${exchangeProperty.sku} reads an
exchange property sku, but the route keeps sku in"
+ + " a header")
+ .endsWith("write ${header.sku}");
+ }
+
+ @Test
+ void variablesAreTheThirdPlace() {
+ assertThat(HeaderPropertyMixups.validate(route("file:orders",
"setVariable", "${header.sku}")))
+ .singleElement().asString()
+ .startsWith("Line 10: ${header.sku} reads a header sku, but
the route keeps sku in a variable")
+ .endsWith("write ${variable.sku}");
+ assertThat(HeaderPropertyMixups.validate(route("file:orders",
"setProperty", "${variable.sku}")))
+ .singleElement().asString().endsWith("write
${exchangeProperty.sku}");
+ assertThat(HeaderPropertyMixups.validate(route("file:orders",
"setVariable", "${variable.sku}"))).isEmpty();
+ }
+
+ @Test
+ void theRightReadIsNotReported() {
+ assertThat(HeaderPropertyMixups.validate(route("file:orders",
"setProperty", "${exchangeProperty.sku}"))).isEmpty();
+ assertThat(HeaderPropertyMixups.validate(route("file:orders",
"setHeader", "${header.sku}"))).isEmpty();
+ }
+
+ @Test
+ void quietWhereTheNameCanComeFromElsewhere() {
+ // a caller, or an HTTP request, can carry the header
+ assertThat(HeaderPropertyMixups.validate(route("direct:check",
"setProperty", "${header.sku}"))).isEmpty();
+ assertThat(HeaderPropertyMixups.validate(route("platform-http:/check",
"setProperty", "${header.sku}")))
+ .isEmpty();
+ // a path parameter is a header of the request
+ assertThat(HeaderPropertyMixups.validate(route("file:orders",
"setProperty", "${header.sku}")
+ + "- rest:\n get:\n -
path: /stock/{sku}\n to: direct:x\n"))
+ .isEmpty();
+ // set in both forms
+ String both = route("file:orders", "setProperty", "${header.sku}")
+ .replace(" - toD:",
+ " - setHeader:\n name: sku\n
constant: x\n - toD:");
+ assertThat(HeaderPropertyMixups.validate(both)).isEmpty();
+ }
+
+ @Test
+ void theValidatorOfAWriteReportsIt() {
+ // the file is unmarshalled first, or the body type check reports the
read of ${body[sku]} on a GenericFile
+ String route = route("file:orders", "setProperty", "${header.sku}")
+ .replace(" steps:\n", " steps:\n -
unmarshal:\n json: {}\n");
+ List<String> msgs = SourceValidator.validate("http-client.camel.yaml",
route, new DefaultCamelCatalog(), null);
+
+ assertThat(msgs).anyMatch(m -> m.contains("${header.sku} reads a
header sku"));
+ }
+}