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 948aaf5702fa CAMEL-24955: camel-yaml-dsl-validator - report a direct:
or seda: endpoint that no route consumes (#26920)
948aaf5702fa is described below
commit 948aaf5702fae6611703752e6ebb9920ff9aaf9e
Author: Adriano Machado <[email protected]>
AuthorDate: Tue Sep 29 05:10:42 2026 -0400
CAMEL-24955: camel-yaml-dsl-validator - report a direct: or seda: endpoint
that no route consumes (#26920)
* CAMEL-24955: camel-yaml-dsl-validator - report a direct: or seda:
endpoint that no route consumes
* CAMEL-24955: camel-yaml-dsl-validator - address review of the
direct:/seda: consumer check
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../modules/ROOT/pages/camel-jbang-devtools.adoc | 6 +
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 10 +-
.../core/commands/ai/EndpointConsumerChecks.java | 217 ++++++++++++++++++++
.../jbang/core/commands/ai/SourceValidator.java | 15 ++
.../jbang/core/commands/ai/AuthoringToolsTest.java | 22 +++
.../ai/SourceValidatorEndpointConsumersTest.java | 220 +++++++++++++++++++++
.../dsl/yaml/validator/EndpointConsumers.java | 177 +++++++++++++++++
.../dsl/yaml/validator/EndpointConsumersTest.java | 183 +++++++++++++++++
8 files changed, 846 insertions(+), 4 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc
index 574f18cfc282..44a580ed4062 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc
@@ -426,6 +426,12 @@ Validation success (files:2)
against the Camel catalog (`--catalog=false` for the schema only);
`--canonical` validates against the canonical
schema and reports the deprecated compact notation. `camel validate source`
validates any file `camel run` would
load: YAML routes, `.properties` files (the `camel.*` keys), Java (compiles
it), XSLT and XML.
+As the routes of an application are spread over the files of its directory,
`camel validate source` also reports a
+`direct:` or `seda:` endpoint a YAML route sends to that no route consumes, in
that file or in the YAML, Java and
+XML route files of its directory and its subdirectories: with `direct:` the
route would fail to start, with `seda:`
+the messages would be queued and never read. The check stays quiet when it
cannot be certain: an endpoint only known
+at runtime (a property placeholder, an expression), route templates and
Kamelets, and a directory that is only a part
+of a Maven or Gradle project, such as `src/main/resources/camel`.
=== Validating against another Camel version or runtime
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
index 478fbcd3062c..0c3d23d2d65b 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
@@ -65,10 +65,10 @@ public final class AuthoringTools {
static final String FILE_PATH_DESC = "File path relative to the directory,
e.g. src/main/resources/camel/foo.camel.yaml";
private static final int MAX_FILES = 99;
/** How many files a listing looks at before it stops; the route and
configuration files are found among them. */
- private static final int SCAN_LIMIT = 2000;
- private static final int MAX_DEPTH = 8;
+ static final int SCAN_LIMIT = 2000;
+ static final int MAX_DEPTH = 8;
/** Build output, tooling and VCS directories: never sources. */
- private static final Set<String> SKIPPED_DIRS = Set.of(
+ static final Set<String> SKIPPED_DIRS = Set.of(
"target", "build", "out", "node_modules", ".git", ".mvn", ".idea",
".vscode", ".gradle", ".settings",
".camel-jbang");
private static final Pattern YAML_ROUTE = Pattern.compile(
@@ -703,7 +703,9 @@ public final class AuthoringTools {
throw new ToolExecutionException(file + " is not a regular file");
}
if (validate && SourceValidator.isValidatableFile(file)) {
- List<String> errors = SourceValidator.validate(file, content,
ctx.catalog(), ctx.propertyLineValidator(), dir);
+ // a missing consumer of a direct: endpoint does not refuse the
write: it is often a file not written yet
+ List<String> errors = SourceValidator.validate(file, content,
ctx.catalog(), ctx.propertyLineValidator(), dir,
+ null, false);
if (!errors.isEmpty()) {
JsonObject result = new JsonObject();
result.put("status", "invalid");
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointConsumerChecks.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointConsumerChecks.java
new file mode 100644
index 000000000000..ed9c848ff39f
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointConsumerChecks.java
@@ -0,0 +1,217 @@
+/*
+ * 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.io.IOException;
+import java.nio.file.FileVisitOption;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.camel.dsl.yaml.validator.EndpointConsumers;
+
+/**
+ * A {@code direct:} or {@code seda:} endpoint a YAML route sends to, and no
route of the application consumes
+ * (CAMEL-24955). The routes of an application are spread over the files of
its directory, so the endpoints the other
+ * route files consume - YAML, Java and XML - are read from them and handed to
the check.
+ */
+public final class EndpointConsumerChecks {
+
+ /**
+ * A Java DSL route input: from( not called on something else, so not
Instant.from( or List.from(; its endpoint is
+ * read only when a string literal is the whole argument, not the start of
from("direct:" + NAME).
+ */
+ private static final Pattern JAVA_FROM =
Pattern.compile("(?<![.\\w])from\\s*\\(\\s*(\"([^\"]*)\"\\s*(?=[),]))?");
+ /** Route inputs the scan cannot read: a route template's from( and fromF(
with a format. */
+ private static final Pattern JAVA_UNREADABLE =
Pattern.compile("\\b(routeTemplate|fromF)\\s*\\(");
+ /** The build files of a project whose routes are spread over
src/main/java and src/main/resources. */
+ private static final List<String> BUILD_FILES = List.of("pom.xml",
"build.gradle", "build.gradle.kts");
+ private static final Pattern XML_FROM =
Pattern.compile("<from\\s[^>]*?\\buri\\s*=\\s*[\"']([^\"']*)[\"']");
+
+ private EndpointConsumerChecks() {
+ }
+
+ /**
+ * @param content the YAML route file
+ * @param directory the directory of the application's route files;
null says nothing
+ * @param excludeFile the file being validated, whose routes come from
the content
+ * @return the messages, one per endpoint no route consumes
+ */
+ public static List<String> validateYamlConsumers(String content, Path
directory, String excludeFile) {
+ return EndpointConsumers.check(content, consumed(directory,
excludeFile));
+ }
+
+ /**
+ * The {@code direct:} and {@code seda:} endpoints the route files under
the directory consume, leaving out the file
+ * being validated; null when they cannot be known: no directory, a
directory that is only a part of a Maven or
+ * Gradle project, more files than the scan looks at, or a route input the
scan cannot read (a Java {@code from(}
+ * with no literal, a placeholder, a route template), which could be any
endpoint.
+ */
+ static Set<String> consumed(Path directory, String excludeFile) {
+ if (directory == null || !Files.isDirectory(directory)) {
+ return null;
+ }
+ Path root = directory.toAbsolutePath().normalize();
+ if (isPartOfAProject(root)) {
+ return null;
+ }
+ List<Path> files = routeFiles(root, excludeFile != null ?
root.resolve(excludeFile).normalize() : null);
+ if (files == null) {
+ return null;
+ }
+ Set<String> answer = new HashSet<>();
+ try {
+ for (Path p : files) {
+ String lower =
p.getFileName().toString().toLowerCase(Locale.ROOT);
+ Set<String> found;
+ if (lower.endsWith(".yaml") || lower.endsWith(".yml")) {
+ found = EndpointConsumers.consumed(Files.readString(p));
+ } else if (lower.endsWith(".java")) {
+ found = javaConsumed(Files.readString(p));
+ } else {
+ found = xmlConsumed(Files.readString(p));
+ }
+ if (found == null) {
+ return null;
+ }
+ answer.addAll(found);
+ }
+ } catch (IOException e) {
+ // an unreadable file: what it consumes is not known
+ return null;
+ }
+ return answer;
+ }
+
+ /**
+ * Whether the directory is inside a Maven or Gradle project without being
its root, as the directory of
+ * src/main/resources/camel is: the RouteBuilders under src/main/java are
not under it.
+ */
+ private static boolean isPartOfAProject(Path root) {
+ if (BUILD_FILES.stream().anyMatch(f ->
Files.isRegularFile(root.resolve(f)))) {
+ return false;
+ }
+ for (Path p = root.getParent(); p != null; p = p.getParent()) {
+ Path dir = p;
+ if (BUILD_FILES.stream().anyMatch(f ->
Files.isRegularFile(dir.resolve(f)))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * The YAML, Java and XML files under the directory, the way the file
tools look at a project: build and tooling
+ * directories skipped. Null when the scan does not see them all: a
directory deeper than it goes, more files than
+ * it looks at, or one it cannot read.
+ */
+ private static List<Path> routeFiles(Path root, Path exclude) {
+ List<Path> files = new ArrayList<>();
+ boolean[] partial = { false };
+ try {
+ Files.walkFileTree(root, EnumSet.noneOf(FileVisitOption.class),
AuthoringTools.MAX_DEPTH,
+ new SimpleFileVisitor<>() {
+ @Override
+ public FileVisitResult preVisitDirectory(Path d,
BasicFileAttributes attrs) {
+ return d.equals(root) || !isSkipped(d)
+ ? FileVisitResult.CONTINUE :
FileVisitResult.SKIP_SUBTREE;
+ }
+
+ @Override
+ public FileVisitResult visitFile(Path f,
BasicFileAttributes attrs) {
+ if (attrs.isDirectory()) {
+ // a directory at the depth limit: its files
are not seen
+ partial[0] = !isSkipped(f);
+ } else if (attrs.isRegularFile() &&
!f.equals(exclude) && isRouteFile(f)) {
+ files.add(f);
+ partial[0] = files.size() >=
AuthoringTools.SCAN_LIMIT;
+ }
+ return partial[0] ? FileVisitResult.TERMINATE :
FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFileFailed(Path f,
IOException e) {
+ partial[0] = true;
+ return FileVisitResult.TERMINATE;
+ }
+ });
+ } catch (IOException e) {
+ return null;
+ }
+ return partial[0] ? null : files;
+ }
+
+ private static boolean isSkipped(Path dir) {
+ String name = dir.getFileName().toString();
+ return AuthoringTools.SKIPPED_DIRS.contains(name) ||
name.startsWith(".");
+ }
+
+ private static boolean isRouteFile(Path f) {
+ String fn = f.getFileName().toString();
+ String lower = fn.toLowerCase(Locale.ROOT);
+ return !fn.startsWith(".")
+ && (lower.endsWith(".yaml") || lower.endsWith(".yml") ||
lower.endsWith(".java") || lower.endsWith(".xml"));
+ }
+
+ static Set<String> javaConsumed(String src) {
+ if (JAVA_UNREADABLE.matcher(src).find()) {
+ return null;
+ }
+ Set<String> answer = new HashSet<>();
+ Matcher m = JAVA_FROM.matcher(src);
+ while (m.find()) {
+ // from(someConstant) or from(direct("x")): the endpoint is not in
the source as a literal
+ if (m.group(2) == null || !add(m.group(2), answer)) {
+ return null;
+ }
+ }
+ return answer;
+ }
+
+ static Set<String> xmlConsumed(String src) {
+ Set<String> answer = new HashSet<>();
+ Matcher m = XML_FROM.matcher(src);
+ while (m.find()) {
+ if (!add(m.group(1), answer)) {
+ return null;
+ }
+ }
+ return answer;
+ }
+
+ /** Adds the endpoint when it is a direct: or seda: one; false when it is
only known at runtime. */
+ private static boolean add(String uri, Set<String> answer) {
+ if (EndpointConsumers.isDynamic(uri)) {
+ return false;
+ }
+ String endpoint = EndpointConsumers.endpoint(uri);
+ if (endpoint != null) {
+ answer.add(endpoint);
+ }
+ return true;
+ }
+}
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 b840f3f61a7c..459be17c7fe7 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
@@ -101,6 +101,17 @@ public final class SourceValidator {
public static List<String> validate(
String fileName, String content, CamelCatalog catalog,
Function<String, String> extraPropertyLine,
Path directory, YamlValidator schemaValidator) {
+ return validate(fileName, content, catalog, extraPropertyLine,
directory, schemaValidator, true);
+ }
+
+ /**
+ * As {@link #validate(String, String, CamelCatalog, Function, Path,
YamlValidator)}, telling whether to check that
+ * the direct: and seda: endpoints the YAML sends to are consumed. A write
leaves it out: the consuming route is
+ * often a file not written yet, and two files that call each other could
never be written (CAMEL-24955).
+ */
+ public static List<String> validate(
+ String fileName, String content, CamelCatalog catalog,
Function<String, String> extraPropertyLine,
+ Path directory, YamlValidator schemaValidator, boolean
checkConsumers) {
Objects.requireNonNull(catalog, "catalog");
String name = fileName == null ? "" :
fileName.toLowerCase(Locale.ROOT);
if (name.endsWith(".yaml") || name.endsWith(".yml")) {
@@ -114,6 +125,10 @@ public final class SourceValidator {
msgs.addAll(validateYamlBeanRefs(content, declarations,
catalog));
msgs.addAll(validateResourceRefs(content, directory));
msgs.addAll(GroovyImportChecks.validateYamlGroovyImports(content, null,
declarations.javaClasses()));
+ if (checkConsumers) {
+ // a direct: or seda: endpoint no route of the application
consumes (CAMEL-24955)
+
msgs.addAll(EndpointConsumerChecks.validateYamlConsumers(content, directory,
fileName));
+ }
}
return msgs;
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
index d4ed18b57354..176b779e1e11 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
@@ -170,6 +170,28 @@ class AuthoringToolsTest {
.getString("status"));
}
+ @Test
+ void aMissingConsumerIsReportedButDoesNotRefuseTheWrite(@TempDir Path dir)
throws IOException {
+ // the route that consumes direct:lookup is often the next file the
agent writes (CAMEL-24955)
+ String caller = """
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - to: direct:lookup
+ """;
+ JsonObject validated = call("camel_validate_source", new ToolContext(),
+ Map.of("directory", dir.toString(), "file", "main.camel.yaml",
"content", caller));
+ assertFalse(validated.getBoolean("valid"), validated.toJson());
+ assertTrue(validated.getCollection("errors").stream().anyMatch(m ->
m.toString().contains("direct:lookup")),
+ validated.toJson());
+
+ JsonObject written = call("camel_write_file", new ToolContext(),
+ Map.of("directory", dir.toString(), "file", "main.camel.yaml",
"content", caller));
+ assertEquals("created", written.getString("status"), written.toJson());
+ assertEquals(caller, Files.readString(dir.resolve("main.camel.yaml"),
StandardCharsets.UTF_8));
+ }
+
@Test
void filePathsStayInsideTheDirectoryButMayNameASubdirectory(@TempDir Path
dir) throws IOException {
for (String bad : List.of("../etc/passwd", "/tmp/x.yaml",
"sub/../../x.yaml")) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointConsumersTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointConsumersTest.java
new file mode 100644
index 000000000000..c752979d5f63
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointConsumersTest.java
@@ -0,0 +1,220 @@
+/*
+ * 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.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24955: a direct: or seda: endpoint a route sends to, checked against
the routes of every route file in the
+ * directory - YAML, Java and XML - since the routes of an application are
spread over files.
+ */
+class SourceValidatorEndpointConsumersTest {
+
+ private static final CamelCatalog CATALOG = new DefaultCamelCatalog();
+
+ private static final String ROUTE = """
+ - route:
+ id: tick
+ from:
+ uri: timer:tick
+ steps:
+ - to:
+ uri: direct:lookup
+ """;
+
+ @TempDir
+ Path dir;
+
+ @Test
+ void anEndpointNoRouteFileConsumesIsReported() throws Exception {
+ Files.writeString(dir.resolve("other.camel.yaml"), """
+ - route:
+ from:
+ uri: direct:other
+ steps:
+ - log: other
+ """);
+ List<String> msgs = SourceValidator.validate("r.camel.yaml", ROUTE,
CATALOG, null, dir);
+ assertThat(msgs).singleElement().asString()
+ .startsWith("route tick: sends to direct:lookup, and no route
consumes it")
+ .contains("the route fails to start");
+ }
+
+ @Test
+ void aSiblingYamlFileConsumesIt() throws Exception {
+ Files.writeString(dir.resolve("lookup.camel.yaml"), """
+ - route:
+ from:
+ uri: direct:lookup
+ steps:
+ - log: found
+ """);
+ assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG,
null, dir)).isEmpty();
+ }
+
+ @Test
+ void aSiblingJavaRouteConsumesIt() throws Exception {
+ Files.writeString(dir.resolve("Lookup.java"), """
+ import org.apache.camel.builder.RouteBuilder;
+
+ public class Lookup extends RouteBuilder {
+ @Override
+ public void configure() {
+ from("direct://lookup?timeout=1000").log("found");
+ }
+ }
+ """);
+ assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG,
null, dir)).isEmpty();
+ }
+
+ @Test
+ void aSiblingXmlRouteConsumesIt() throws Exception {
+ Files.writeString(dir.resolve("lookup.camel.xml"), """
+ <routes>
+ <route>
+ <from uri="direct:lookup"/>
+ <log message="found"/>
+ </route>
+ </routes>
+ """);
+ assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG,
null, dir)).isEmpty();
+ }
+
+ @Test
+ void aJavaRouteInputThatIsNotALiteralKeepsTheCheckQuiet() throws Exception
{
+ // from(LOOKUP) may well be direct:lookup: the check cannot be
certain, so it says nothing
+ Files.writeString(dir.resolve("Lookup.java"), """
+ import org.apache.camel.builder.RouteBuilder;
+
+ public class Lookup extends RouteBuilder {
+ static final String LOOKUP = "direct:lookup";
+
+ @Override
+ public void configure() {
+ from(LOOKUP).log("found");
+ }
+ }
+ """);
+ assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG,
null, dir)).isEmpty();
+ }
+
+ @Test
+ void javaRouteInputsTheScanCannotReadKeepTheCheckQuiet() throws Exception {
+ // each may well consume direct:lookup: a route template's from(,
fromF( and a concatenated uri
+ for (String configure : List.of(
+
"routeTemplate(\"t\").templateParameter(\"n\").from(\"direct:{{n}}\").log(\"found\");",
+ "fromF(\"direct:%s\", \"lookup\").log(\"found\");",
+ "from(\"direct:\" + NAME).log(\"found\");")) {
+ Files.writeString(dir.resolve("Lookup.java"), """
+ import org.apache.camel.builder.RouteBuilder;
+
+ public class Lookup extends RouteBuilder {
+ static final String NAME = "lookup";
+
+ @Override
+ public void configure() {
+ %s
+ }
+ }
+ """.formatted(configure));
+ assertThat(SourceValidator.validate("r.camel.yaml", ROUTE,
CATALOG, null, dir)).as(configure).isEmpty();
+ }
+ }
+
+ @Test
+ void aRouteFileInASubdirectoryConsumesIt() throws Exception {
+ Files.createDirectories(dir.resolve("routes"));
+ Files.writeString(dir.resolve("routes/lookup.camel.yaml"), """
+ - route:
+ from:
+ uri: direct:lookup
+ steps:
+ - log: found
+ """);
+ assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG,
null, dir)).isEmpty();
+ }
+
+ @Test
+ void theFileBeingValidatedIsLeftOutByItsPathUnderTheDirectory() throws
Exception {
+ // camel_validate_source passes the project directory and the file's
path relative to it
+ Files.createDirectories(dir.resolve("routes"));
+ Files.writeString(dir.resolve("routes/r.camel.yaml"), ROUTE + """
+ - route:
+ from:
+ uri: direct:lookup
+ steps:
+ - log: found
+ """);
+ assertThat(SourceValidator.validate("routes/r.camel.yaml", ROUTE,
CATALOG, null, dir))
+ .singleElement().asString().contains("sends to direct:lookup");
+ }
+
+ @Test
+ void aMavenProjectIsScannedFromItsRootOnly() throws Exception {
+ Path camel =
Files.createDirectories(dir.resolve("src/main/resources/camel"));
+ Path java =
Files.createDirectories(dir.resolve("src/main/java/com/acme"));
+ Files.writeString(dir.resolve("pom.xml"), "<project/>");
+ Files.writeString(java.resolve("Lookup.java"), """
+ package com.acme;
+
+ import org.apache.camel.builder.RouteBuilder;
+
+ public class Lookup extends RouteBuilder {
+ @Override
+ public void configure() {
+ from("direct:lookup").log("found");
+ }
+ }
+ """);
+ // from the project root, the RouteBuilder under src/main/java is found
+
assertThat(SourceValidator.validate("src/main/resources/camel/r.camel.yaml",
ROUTE, CATALOG, null, dir))
+ .isEmpty();
+ // from the directory of the YAML file, the rest of the project is not
seen: the check says nothing
+ Files.delete(java.resolve("Lookup.java"));
+ assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG,
null, camel)).isEmpty();
+
assertThat(SourceValidator.validate("src/main/resources/camel/r.camel.yaml",
ROUTE, CATALOG, null, dir))
+ .singleElement().asString().contains("sends to direct:lookup");
+ }
+
+ @Test
+ void theFileBeingValidatedIsReadFromItsContentNotFromDisk() throws
Exception {
+ // the editor's buffer is newer than the file on disk, which still
consumes direct:lookup
+ Files.writeString(dir.resolve("r.camel.yaml"), ROUTE + """
+ - route:
+ from:
+ uri: direct:lookup
+ steps:
+ - log: found
+ """);
+ assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG,
null, dir))
+ .singleElement().asString().contains("sends to direct:lookup");
+ }
+
+ @Test
+ void withoutADirectoryTheCheckSaysNothing() {
+ assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG,
null)).isEmpty();
+ }
+}
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/EndpointConsumers.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/EndpointConsumers.java
new file mode 100644
index 000000000000..c43f3fe140ad
--- /dev/null
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/EndpointConsumers.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.yaml.validator;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.Route;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.normalize;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.routes;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.scheme;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.sendsTo;
+
+/**
+ * A {@code direct:} or {@code seda:} endpoint a route sends to, and no route
of the application consumes (CAMEL-24955).
+ * With {@code direct:} the route fails to start - <i>No consumers available
on endpoint</i>; with {@code seda:} nothing
+ * fails, and the message is queued and never read.
+ * <p/>
+ * The routes of an application are spread over files, so a file on its own
cannot answer: the caller scans the other
+ * route files of the directory and passes what they consume. Without them the
check says nothing.
+ */
+public final class EndpointConsumers {
+
+ /** The components whose consumer is a route of the same application. */
+ private static final Set<String> CHECKED = Set.of("direct", "seda");
+ /** The route templates and the routes made from them, whose endpoints are
only known once instantiated. */
+ private static final Set<String> TEMPLATES = Set.of(
+ "routeTemplate", "route-template", "templatedRoute",
"templated-route");
+
+ private static final ObjectMapper MAPPER = new ObjectMapper(new
YAMLFactory());
+
+ private EndpointConsumers() {
+ }
+
+ /**
+ * The {@code direct:} and {@code seda:} endpoints the routes of a YAML
file consume, without their options. Empty
+ * when the file is not YAML the routes can be read from; null when a
route consumes an endpoint only known at
+ * runtime ({@code from: direct:{{name}}}), or the file has route
templates or is a Kamelet, which could consume any
+ * of them.
+ */
+ public static Set<String> consumed(String yaml) {
+ JsonNode target = read(yaml);
+ if (target == null) {
+ return Set.of();
+ }
+ return hasTemplates(target) ? null : consumed(routes(target));
+ }
+
+ /**
+ * @param yaml the YAML DSL source
+ * @param consumedElsewhere the endpoints the other route files of the
application consume, as returned by
+ * {@link #consumed(String)}; null when they are
not known, which keeps the check quiet
+ * @return a message for each endpoint a route sends to
and no route consumes
+ */
+ public static List<String> check(String yaml, Set<String>
consumedElsewhere) {
+ if (consumedElsewhere == null) {
+ return List.of();
+ }
+ JsonNode target = read(yaml);
+ if (target == null || hasTemplates(target)) {
+ return List.of();
+ }
+ List<Route> routes = routes(target);
+ Set<String> own = consumed(routes);
+ if (own == null) {
+ return List.of();
+ }
+ Set<String> consumed = new HashSet<>(consumedElsewhere);
+ consumed.addAll(own);
+ Set<String> messages = new LinkedHashSet<>();
+ for (Route r : routes) {
+ for (String uri : sendsTo(r.steps())) {
+ String endpoint = endpoint(uri);
+ if (endpoint == null || consumed.contains(endpoint)) {
+ continue;
+ }
+ messages.add((r.id() != null ? "route " + r.id() + ": " : "")
+ + "sends to " + endpoint + ", and no route
consumes it - not in this file, nor in the"
+ + " other route files of the directory: "
+ + ("direct".equals(scheme(endpoint))
+ ? "the route fails to start with No
consumers available on endpoint"
+ : "nothing fails, the messages are queued
and never read")
+ + "; add a route with from: " + endpoint + ", or
correct the name");
+ }
+ }
+ return new ArrayList<>(messages);
+ }
+
+ private static Set<String> consumed(List<Route> routes) {
+ Set<String> answer = new HashSet<>();
+ for (Route r : routes) {
+ if (isDynamic(r.fromUri())) {
+ return null;
+ }
+ String endpoint = endpoint(r.fromUri());
+ if (endpoint != null) {
+ answer.add(endpoint);
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * The endpoint in the form the check compares, {@code direct:lookup} for
{@code direct://lookup?timeout=1000}; null
+ * for an endpoint of another component, or one only known at runtime (a
placeholder, an expression).
+ */
+ public static String endpoint(String uri) {
+ String s = normalize(uri);
+ String scheme = scheme(s);
+ if (scheme == null || !CHECKED.contains(scheme) || s.length() ==
scheme.length() || isDynamic(s)) {
+ return null;
+ }
+ String name = s.substring(scheme.length() + 1);
+ if (name.startsWith("//")) {
+ name = name.substring(2);
+ }
+ return name.isEmpty() ? null : scheme + ":" + name;
+ }
+
+ /** Whether the endpoint is only known at runtime: a property placeholder
or an expression. */
+ public static boolean isDynamic(String uri) {
+ return uri != null && (uri.contains("{{") || uri.contains("${"));
+ }
+
+ /**
+ * Whether the file has a route template, a route made from one, or is a
Kamelet: the routes they create are not
+ * read here, so what they consume is not known.
+ */
+ private static boolean hasTemplates(JsonNode target) {
+ if (target.isObject()) {
+ return "Kamelet".equals(target.path("kind").asText());
+ }
+ for (JsonNode entry : target) {
+ if (entry.isObject()) {
+ for (String key : TEMPLATES) {
+ if (entry.has(key)) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ private static JsonNode read(String yaml) {
+ if (yaml == null || yaml.isBlank()) {
+ return null;
+ }
+ try {
+ return MAPPER.readTree(yaml);
+ } catch (Exception e) {
+ // not YAML the routes can be read from: nothing to learn from it
+ return null;
+ }
+ }
+}
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/EndpointConsumersTest.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/EndpointConsumersTest.java
new file mode 100644
index 000000000000..49c01d68e2aa
--- /dev/null
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/EndpointConsumersTest.java
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.yaml.validator;
+
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24955: a direct: or seda: endpoint a route sends to, and no route of
the application consumes. With direct: the
+ * route fails to start; with seda: nothing fails and the message is never
read.
+ */
+public class EndpointConsumersTest {
+
+ private static final String SENDS_TO = """
+ - route:
+ id: tick
+ from:
+ uri: timer:tick
+ steps:
+ - to:
+ uri: %s
+ """;
+
+ @Test
+ public void testNoRouteConsumesTheDirectEndpoint() {
+ List<String> messages =
EndpointConsumers.check(SENDS_TO.formatted("direct:lookup"), Set.of());
+ assertThat(messages).containsExactly(
+ "route tick: sends to direct:lookup, and no route consumes it
- not in this file, nor in the other route"
+ + " files of the directory: the
route fails to start with No consumers available"
+ + " on endpoint; add a route with
from: direct:lookup, or correct the name");
+ }
+
+ @Test
+ public void testAnotherFileConsumesIt() {
+ // the routes of an application are spread over files: what the
siblings consume is passed in
+
assertThat(EndpointConsumers.check(SENDS_TO.formatted("direct:lookup?timeout=1000"),
Set.of("direct:lookup")))
+ .isEmpty();
+ }
+
+ @Test
+ public void testWithoutADirectoryTheCheckSaysNothing() {
+ // a file on its own cannot know what the rest of the application
consumes
+
assertThat(EndpointConsumers.check(SENDS_TO.formatted("direct:lookup"),
null)).isEmpty();
+ }
+
+ @Test
+ public void testTheSameFileConsumesIt() {
+ // both spellings of the endpoint: direct://lookup, and uri: direct
with parameters: {name: lookup}
+ String yaml = SENDS_TO.formatted("direct://lookup") + """
+ - route:
+ from:
+ uri: direct
+ parameters:
+ name: lookup
+ steps:
+ - log: found
+ """;
+ assertThat(EndpointConsumers.check(yaml, Set.of())).isEmpty();
+ }
+
+ @Test
+ public void testSedaSaysTheMessageIsNeverRead() {
+ String yaml = """
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - choice:
+ when:
+ - simple: "${header.urgent}"
+ steps:
+ - wireTap:
+ uri: seda:audit
+ """;
+ assertThat(EndpointConsumers.check(yaml, Set.of())).containsExactly(
+ "sends to seda:audit, and no route consumes it - not in this
file, nor in the other route files of the"
+ +
" directory: nothing fails, the messages are queued and never read;"
+ +
" add a route with from: seda:audit, or correct the name");
+ }
+
+ @Test
+ public void testAnExternalEndpointIsNotItsBusiness() {
+ // kafka:, vm: (another context) and an endpoint only known at runtime
are not the routes of this application
+ assertThat(EndpointConsumers.check(SENDS_TO.formatted("kafka:orders"),
Set.of())).isEmpty();
+ assertThat(EndpointConsumers.check(SENDS_TO.formatted("vm:orders"),
Set.of())).isEmpty();
+
assertThat(EndpointConsumers.check(SENDS_TO.formatted("direct:{{target}}"),
Set.of())).isEmpty();
+
assertThat(EndpointConsumers.check(SENDS_TO.formatted("\"direct:${header.target}\""),
Set.of())).isEmpty();
+ }
+
+ @Test
+ public void testConsumedListsTheDirectAndSedaRoutesOfAFile() {
+ String yaml = """
+ - from:
+ uri: seda:orders?concurrentConsumers=4
+ steps:
+ - log: order
+ - route:
+ from:
+ uri: direct://lookup
+ steps:
+ - log: found
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - log: tick
+ """;
+
assertThat(EndpointConsumers.consumed(yaml)).containsExactlyInAnyOrder("seda:orders",
"direct:lookup");
+ assertThat(EndpointConsumers.consumed("not: [valid")).isEmpty();
+ }
+
+ @Test
+ public void testAConsumerOnlyKnownAtRuntimeKeepsTheCheckQuiet() {
+ // from: direct:{{name}} may well be direct:lookup: the check cannot
be certain, so it says nothing
+ String yaml = SENDS_TO.formatted("direct:lookup") + """
+ - route:
+ from:
+ uri: "direct:{{name}}"
+ steps:
+ - log: found
+ """;
+ assertThat(EndpointConsumers.check(yaml, Set.of())).isEmpty();
+ assertThat(EndpointConsumers.consumed(yaml)).isNull();
+ }
+
+ @Test
+ public void testARouteTemplateKeepsTheCheckQuiet() {
+ // the templated route lookup is made from the template, and consumes
direct:lookup once the template is
+ // instantiated: the routes a template creates are not read, so the
check says nothing
+ String yaml = SENDS_TO.formatted("direct:lookup") + """
+ - routeTemplate:
+ id: lookup-template
+ parameters:
+ - name: name
+ from:
+ uri: "direct:{{name}}"
+ steps:
+ - log: found
+ - templatedRoute:
+ routeTemplateRef: lookup-template
+ parameters:
+ - name: name
+ value: lookup
+ """;
+ assertThat(EndpointConsumers.check(yaml, Set.of())).isEmpty();
+ assertThat(EndpointConsumers.consumed(yaml)).isNull();
+ }
+
+ @Test
+ public void testAKameletConsumesWhatIsNotKnown() {
+ String kamelet = """
+ apiVersion: camel.apache.org/v1
+ kind: Kamelet
+ metadata:
+ name: lookup-action
+ spec:
+ template:
+ from:
+ uri: kamelet:source
+ steps:
+ - to: direct:lookup
+ """;
+ assertThat(EndpointConsumers.consumed(kamelet)).isNull();
+ }
+}