This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25180 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 5532c1e4b979f30ad0e86bbf1eff288f2fc011dc Author: Claus Ibsen <[email protected]> AuthorDate: Mon Oct 12 12:16:33 2026 +0200 CAMEL-25180: camel-jbang - camel overview and camel validate share one set of route/endpoint rules EndpointRules in camel-yaml-dsl-validator holds the endpoints a route of the application must consume (direct, seda, disruptor), the EIPs that consume (from, poll, pollEnrich) and send (to, toD, wireTap, enrich, deadLetterUri), and when an endpoint is only known at runtime. The YAML validator, the Java and XML checks, the cross-file consumer scan and camel overview use it, and OverviewValidateParityTest checks they report the same endpoints. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 30 +++ .../modules/ROOT/pages/camel-4x-upgrade-guide.adoc | 1 + .../modules/ROOT/pages/camel-jbang-ai.adoc | 3 +- .../modules/ROOT/pages/camel-jbang-validation.adoc | 16 +- .../core/commands/ai/EndpointConsumerChecks.java | 85 +++++---- .../dsl/jbang/core/commands/ai/ModelChecks.java | 73 ++++--- .../jbang/core/commands/ai/ProjectOverview.java | 30 ++- .../dsl/jbang/core/commands/ai/ProjectRoutes.java | 54 +++++- .../commands/ai/OverviewValidateParityTest.java | 163 ++++++++++++++++ .../ai/SourceValidatorEndpointConsumersTest.java | 2 +- .../dsl/yaml/validator/EndpointConsumers.java | 211 +++++++++++++++------ .../camel/dsl/yaml/validator/EndpointRules.java | 110 +++++++++++ .../camel/dsl/yaml/validator/RouteGraph.java | 3 - .../dsl/yaml/validator/EndpointConsumersTest.java | 113 ++++++++++- 14 files changed, 749 insertions(+), 145 deletions(-) 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 new file mode 100644 index 000000000000..f9e0ac391b70 --- /dev/null +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -0,0 +1,30 @@ += Apache Camel 4.x Upgrade Guide + +This document is for helping you upgrade your Apache Camel application +from Camel 4.x to 4.y. For example, if you are upgrading Camel 4.0 to 4.2, then you should follow the guides +from both 4.0 to 4.1 and 4.1 to 4.2. + +[NOTE] +==== +https://github.com/apache/camel-upgrade-recipes/[The Camel Upgrade Recipes project] provides automated assistance for some common migration tasks. +Note that manual migration is still required. +See the xref:camel-upgrade-recipes-tool.adoc[documentation] page for details. +==== + +== Upgrading Camel 4.23 to 4.24 + +=== camel-jbang - camel validate and camel overview report the same endpoints no route consumes + +`camel validate source` and `camel overview` now use the same rules for a `direct:`, `seda:` or `disruptor:` endpoint +that a route sends to and no route consumes, so they report the same endpoints on the same project: + +* `disruptor:` endpoints are checked too, and `camel overview` no longer treats `disruptor:q` as `seda:q` (they are + different endpoints at runtime); `vm:` and `direct-vm:`, which do not exist in Camel 4, are no longer joined with + `seda:` and `direct:`. +* `camel validate` counts a `poll` or `pollEnrich` as consuming its endpoint, and a `toD` without an expression, an + `enrich` with a constant endpoint, a rest verb, and the dead letter channel of an error handler as sending to it. + It reads the flows of an `Integration` resource, and stays quiet for a `Pipe`. +* `camel overview` stays quiet, as `camel validate` does, when a route consumes an endpoint only known at runtime + (`from: direct:{{name}}`), and no longer reports a `to` whose uri holds an expression. + +So `camel validate` can report an endpoint it did not report before. diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide.adoc index 982e18bb2fba..91f8f833c7fe 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide.adoc @@ -32,6 +32,7 @@ You can find the upgrade guide for each release in the following pages: - xref:camel-4x-upgrade-guide-4_21.adoc[Upgrade guide 4.20 -> 4.21] - xref:camel-4x-upgrade-guide-4_22.adoc[Upgrade guide 4.21 -> 4.22 LTS] - xref:camel-4x-upgrade-guide-4_23.adoc[Upgrade guide 4.22 -> 4.23] +- xref:camel-4x-upgrade-guide-4_24.adoc[Upgrade guide 4.23 -> 4.24] [NOTE] ==== diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc index 8b2b87d79e6d..7117be329dce 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc @@ -99,7 +99,8 @@ a project directory, read from the route sources (no running integration needed) over a broker (Kafka, JMS, ...), or *shares data* over the same folder or table; * the *external systems* grouped by category (AI, databases, messaging, files, email, HTTP, cloud), taken from the component labels of the Camel catalog; -* *findings*: a `direct` or `seda` endpoint that no route consumes, routes that call each other in a cycle, +* *findings*: a `direct`, `seda` or `disruptor` endpoint that no route consumes (by the same rules as + `camel validate`), routes that call each other in a cycle, duplicate route ids, routes without a description; * the *architecture*: the routes grouped by what they do. Routes that set `group` in the source keep it. Routes that routes of two or more groups call are *shared services*. Routes reached only while diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-validation.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-validation.adoc index 3503d080929f..450969d5bb6c 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-validation.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-validation.adoc @@ -74,13 +74,17 @@ A part of a Java route the parser cannot work out (a value computed at runtime) === Endpoints no route consumes As the routes of an application are spread over the files of its directory, `camel validate source` also reports -a `direct:` or `seda:` endpoint that a route sends to, but that no YAML, Java or XML route in the directory -(and its subdirectories) consumes. With `direct:` sending a message to it fails; with `seda:` the messages -would be queued and never read. +a `direct:`, `seda:` or `disruptor:` endpoint that a route sends to, but that no YAML, Java or XML route in the +directory (and its subdirectories) consumes. With `direct:` sending a message to it fails; with `seda:` and +`disruptor:` 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`. +A route sends to an endpoint with `to`, a `toD` without an expression, `wireTap`, `enrich`, a rest verb, or the +dead letter channel of an error handler. A route consumes an endpoint with its `from`, or with a `poll` or +`pollEnrich`. `camel overview` reports the same endpoints, by the same rules. + +The check stays quiet when it cannot be certain: a route that consumes an endpoint only known at runtime (a +property placeholder, an expression), route templates, Kamelets and Pipes, and a directory that is only a part of a +Maven or Gradle project, such as `src/main/resources/camel`. == Validating configuration properties 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 index 23a8de47fab6..034cdd267c63 100644 --- 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 @@ -28,20 +28,19 @@ import java.util.EnumSet; import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; -import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.camel.catalog.CamelCatalog; import org.apache.camel.dsl.yaml.validator.EndpointConsumers; -import org.apache.camel.java.in.JavaParseResult; -import org.apache.camel.java.in.LwJavaParser; -import org.apache.camel.model.RouteDefinition; +import org.apache.camel.dsl.yaml.validator.EndpointRules; /** * 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. + * route files consume - YAML, Java and XML - are read from them and handed to the check. What consumes an endpoint is + * the {@link EndpointRules} that {@code camel overview} uses too: a route's input, and a poll or pollEnrich. */ public final class EndpointConsumerChecks { @@ -49,7 +48,6 @@ public final class EndpointConsumerChecks { private static final Pattern JAVA_FROM = Pattern.compile("(?<![.\\w])from\\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() { } @@ -98,9 +96,9 @@ public final class EndpointConsumerChecks { if (lower.endsWith(".yaml") || lower.endsWith(".yml")) { found = EndpointConsumers.consumed(Files.readString(p)); } else if (lower.endsWith(".java")) { - found = javaConsumed(Files.readString(p), catalog); + found = javaConsumed(p.getFileName().toString(), Files.readString(p), catalog); } else { - found = xmlConsumed(Files.readString(p)); + found = xmlConsumed(p.getFileName().toString(), Files.readString(p)); } if (found == null) { return null; @@ -189,46 +187,55 @@ public final class EndpointConsumerChecks { * constants, String.format and the endpoint DSL are resolved; null when they cannot be known: a route template, * whose input is a parameter, an input known only at runtime, or a from( the parser could not read. */ - static Set<String> javaConsumed(String src, CamelCatalog catalog) { - JavaParseResult result = JavaRouteReader.parse(src, catalog, null); - if (!result.routeTemplates().getRouteTemplates().isEmpty()) { - return null; - } - List<RouteDefinition> routes = result.routes().getRoutes(); - if (routes.isEmpty() && JAVA_FROM.matcher(src).find()) { + static Set<String> javaConsumed(String fileName, String src, CamelCatalog catalog) { + RouteModel model = RouteModel.read(fileName, src, catalog, Map.of()); + if (model == null || model.routes().isEmpty()) { // a from( the parser did not read as a route: what it consumes is not known - return null; + return JAVA_FROM.matcher(src).find() ? null : Set.of(); } - Set<String> answer = new HashSet<>(); - for (RouteDefinition r : routes) { - String uri = r.getInput() != null ? r.getInput().getUri() : null; - if (uri == null || uri.contains(LwJavaParser.UNRESOLVED_PREFIX) || !add(uri, answer)) { - return null; - } + return consumed(model, RouteNodes.of(model, catalog)); + } + + /** + * The endpoints the routes of an XML DSL source consume, read into the model; empty for an XML file that is not a + * Camel route file, null when it cannot be read or a route consumes an endpoint only known at runtime. + */ + static Set<String> xmlConsumed(String fileName, String src) { + RouteModel model = RouteModel.read(fileName, src, null, Map.of()); + if (model == null) { + return Set.of(); } - return answer; + if (model.unread().stream().anyMatch(RouteModel.Unread::error)) { + // a file the XML DSL refuses: what it consumes is not known + return null; + } + return consumed(model, RouteNodes.of(model, null)); } - static Set<String> xmlConsumed(String src) { + /** + * The checked endpoints the routes of a Java or XML source consume: their inputs, and the poll and pollEnrich + * steps; null when they cannot be known: a route template (or a route made from one), or an endpoint only known at + * runtime (a placeholder, an expression, a value the parser could not work out), which could be any of them. + */ + static Set<String> consumed(RouteModel model, List<RouteNodes.Node> nodes) { + if (model.hasTemplates()) { + return null; + } Set<String> answer = new HashSet<>(); - Matcher m = XML_FROM.matcher(src); - while (m.find()) { - if (!add(m.group(1), answer)) { + for (RouteNodes.Node n : nodes) { + if (n.kind() != RouteNodes.Kind.ENDPOINT || !EndpointRules.CONSUMERS.contains(n.eip())) { + continue; + } + String uri = n.uri(); + if (uri == null || RouteModel.hasUnknownValue(uri) + || EndpointRules.isDynamic(uri) && EndpointRules.mayBeChecked(uri)) { return null; } + String endpoint = EndpointRules.endpoint(uri); + if (endpoint != null) { + answer.add(endpoint); + } } 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/ModelChecks.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ModelChecks.java index 1f7c59a064ac..6ee6897bd72d 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ModelChecks.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ModelChecks.java @@ -17,16 +17,21 @@ package org.apache.camel.dsl.jbang.core.commands.ai; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; +import org.apache.camel.ErrorHandlerFactory; import org.apache.camel.catalog.CamelCatalog; import org.apache.camel.dsl.jbang.core.commands.ai.RouteNodes.Kind; import org.apache.camel.dsl.jbang.core.commands.ai.RouteNodes.Node; -import org.apache.camel.dsl.yaml.validator.EndpointConsumers; +import org.apache.camel.dsl.yaml.validator.EndpointRules; +import org.apache.camel.model.RouteConfigurationDefinition; +import org.apache.camel.model.RouteDefinition; +import org.apache.camel.model.errorhandler.DeadLetterChannelDefinition; import org.apache.camel.tooling.model.BaseOptionModel; import org.apache.camel.tooling.model.ComponentModel; @@ -39,9 +44,7 @@ import org.apache.camel.tooling.model.ComponentModel; final class ModelChecks { /** The EIPs that consume from their endpoint; the others send to it. */ - private static final Set<String> CONSUMERS = Set.of("from", "poll", "pollEnrich"); - /** The EIPs that send to a direct: or seda: endpoint some route must consume. */ - private static final Set<String> SENDING = Set.of("to", "wireTap", "enrich"); + private static final Set<String> CONSUMERS = EndpointRules.CONSUMERS; /** The one component whose path is a script, so what is in it is not the catalog's to say. */ private static final Set<String> SCRIPT_PATH = Set.of("language"); @@ -132,31 +135,33 @@ final class ModelChecks { /** * The direct: and seda: endpoints the routes send to and no route consumes: not the routes of this source, nor * those of the other route files. A direct: without a consumer fails the exchange, a seda: one fills a queue no one - * reads. + * reads. The endpoints and EIPs that count are the {@link EndpointRules} that {@code camel overview} uses too. */ private void consumers(Set<String> consumedElsewhere) { - if (consumedElsewhere == null || model.hasTemplates()) { + if (consumedElsewhere == null) { + return; + } + Set<String> own = EndpointConsumerChecks.consumed(model, nodes); + if (own == null) { + // a route that consumes an endpoint only known at runtime could consume any of them return; } Set<String> consumed = new HashSet<>(consumedElsewhere); + consumed.addAll(own); + List<Node> sends = new ArrayList<>(); for (Node n : nodes) { - if (n.kind() == Kind.ENDPOINT && "from".equals(n.eip())) { - if (n.uri() == null || EndpointConsumers.isDynamic(n.uri()) || RouteModel.hasUnknownValue(n.uri())) { - // a route that consumes an endpoint only known at runtime could consume any of them - return; - } - String endpoint = EndpointConsumers.endpoint(n.uri()); - if (endpoint != null) { - consumed.add(endpoint); - } + if (n.kind() == Kind.ENDPOINT && EndpointRules.SENDERS.contains(n.eip())) { + sends.add(n); } } + sends.addAll(deadLetterEndpoints()); + sends.sort(Comparator.comparingInt(Node::line)); Set<String> reported = new HashSet<>(); - for (Node n : nodes) { - if (n.kind() != Kind.ENDPOINT || !SENDING.contains(n.eip()) || RouteModel.hasUnknownValue(n.uri())) { + for (Node n : sends) { + if (RouteModel.hasUnknownValue(n.uri())) { continue; } - String endpoint = EndpointConsumers.endpoint(n.uri()); + String endpoint = EndpointRules.endpoint(n.uri()); if (endpoint == null || consumed.contains(endpoint) || !reported.add(endpoint)) { continue; } @@ -164,10 +169,34 @@ final class ModelChecks { ? "<from uri=\"" + endpoint + "\"/>" : "from(\"" + endpoint + "\")"; errors.add(EndpointChecks.linePrefix(Math.max(0, n.line() - 1)) + "sends to " + endpoint + ", and no route consumes it - not in this file, nor in the other route files of the directory: " - + (endpoint.startsWith("direct:") - ? "the exchange fails with No consumers available on endpoint" - : "nothing fails, the messages are queued and never read") - + "; add a route with " + from + ", or correct the name"); + + EndpointRules.withoutConsumer(endpoint) + "; add a route with " + from + ", or correct the name"); + } + } + + /** + * The dead letter endpoints of the error handlers of the routes and route configurations, as endpoint nodes at the + * line of their route: the error handler sends a failed message there. + */ + private List<Node> deadLetterEndpoints() { + List<Node> answer = new ArrayList<>(); + for (RouteDefinition r : model.routes()) { + if (r.isErrorHandlerFactorySet()) { + addDeadLetter(answer, r.getErrorHandlerFactory(), r.getLineNumber()); + } + } + for (RouteConfigurationDefinition rc : model.routeConfigurations()) { + if (rc.getErrorHandler() != null) { + addDeadLetter(answer, rc.getErrorHandler().getErrorHandlerType(), rc.getLineNumber()); + } + } + return answer; + } + + private static void addDeadLetter(List<Node> answer, ErrorHandlerFactory factory, int line) { + if (factory instanceof DeadLetterChannelDefinition dlc && dlc.getDeadLetterUri() != null) { + answer.add(new Node( + Kind.ENDPOINT, Math.max(1, line), "deadLetterUri", dlc.getDeadLetterUri(), null, null, + null, false, List.of())); } } diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectOverview.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectOverview.java index 25aa87faee89..24cbe1658331 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectOverview.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectOverview.java @@ -38,6 +38,7 @@ import java.util.function.Supplier; import org.apache.camel.catalog.CamelCatalog; import org.apache.camel.dsl.jbang.core.commands.ai.ProjectRoutes.Endpoint; import org.apache.camel.dsl.jbang.core.commands.ai.ProjectRoutes.Route; +import org.apache.camel.dsl.yaml.validator.EndpointRules; import org.apache.camel.tooling.model.ComponentModel; import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; @@ -56,6 +57,8 @@ public final class ProjectOverview { /** The most routes an overview lists; a project with more is summarized by its groups and systems. */ static final int MAX_ROUTES = 300; + /** The components that hand a message to another route in memory, without waiting for it. */ + private static final Set<String> IN_MEMORY = Set.of("seda", "disruptor", "disruptor-vm"); private ProjectOverview() { } @@ -196,8 +199,7 @@ public final class ProjectOverview { */ static String category(String scheme, CamelCatalog catalog) { String s = scheme.toLowerCase(Locale.ROOT); - String family = ProjectRoutes.family(s); - if ("direct".equals(family) || "seda".equals(family) || "kamelet".equals(s) || s.startsWith("dynamic")) { + if ("direct".equals(s) || IN_MEMORY.contains(s) || "kamelet".equals(s) || s.startsWith("dynamic")) { return "internal"; } ComponentModel model = componentModel(s, catalog); @@ -307,11 +309,11 @@ public final class ProjectOverview { /** call (waits for the answer), async (hands off in memory), event (over a broker) or shared (e.g. a folder). */ static String linkKind(String scheme, CamelCatalog catalog) { - String family = ProjectRoutes.family(scheme.toLowerCase(Locale.ROOT)); - if ("direct".equals(family) || "kamelet".equals(family)) { + String s = scheme.toLowerCase(Locale.ROOT); + if ("direct".equals(s) || "kamelet".equals(s)) { return "call"; } - if ("seda".equals(family)) { + if (IN_MEMORY.contains(s)) { return "async"; } return "messaging".equals(category(scheme, catalog)) ? "event" : "shared"; @@ -377,6 +379,14 @@ public final class ProjectOverview { // ---- findings ---- + /** + * Whether the endpoint is a fixed one of a component whose consumer is a route of the application, by the + * {@link EndpointRules} camel validate uses too. + */ + private static boolean isChecked(Endpoint e) { + return EndpointRules.endpoint(e.key()) != null; + } + private static List<Finding> findings(List<Route> routes, List<Link> links, boolean truncated) { List<Finding> answer = new ArrayList<>(); Map<String, List<Route>> byId = new LinkedHashMap<>(); @@ -399,11 +409,12 @@ public final class ProjectOverview { id)); } }); + // as camel validate: a route that consumes an endpoint only known at runtime could consume any of them + boolean consumersKnown = routes.stream().flatMap(r -> r.consumes().stream()) + .noneMatch(e -> EndpointRules.isDynamic(e.key()) && EndpointRules.mayBeChecked(e.key())); for (Route r : routes) { for (Endpoint e : r.produces()) { - String family = e.key().substring(0, e.key().indexOf(':')); - if (!e.dynamic() && ("direct".equals(family) || "seda".equals(family)) && !e.uri().contains("{{") - && !consumed.contains(e.key())) { + if (consumersKnown && !e.dynamic() && isChecked(e) && !consumed.contains(e.key())) { answer.add(new Finding( "warning", "missing-route", r.key() + " sends to " + e.uri() + " but no route in the project consumes from it", r.key())); @@ -413,8 +424,7 @@ public final class ProjectOverview { for (Route r : routes) { Endpoint from = r.from(); if (from != null && "route".equals(r.kind())) { - String family = from.key().substring(0, from.key().indexOf(':')); - if (("direct".equals(family) || "seda".equals(family)) && !produced.contains(from.key())) { + if (isChecked(from) && !produced.contains(from.key())) { answer.add(new Finding( "info", "unreferenced", r.key() + " consumes from " + from.uri() + " but no route in the project sends there (it may be called from code)", diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectRoutes.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectRoutes.java index 3cbab6597102..b53f941b84ca 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectRoutes.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectRoutes.java @@ -65,9 +65,14 @@ import org.yaml.snakeyaml.nodes.SequenceNode; */ public final class ProjectRoutes { - /** Keys whose value is an endpoint the route sends to. */ + /** + * Keys whose value is an endpoint the route sends to: the senders of the {@code EndpointRules} camel validate uses + * too (deadLetterUri, an option of the error handler, is read on its own). + */ private static final Set<String> PRODUCER_KEYS = Set.of("to", "toD", "wireTap", "enrich"); - /** Keys whose value is an endpoint the route polls from: the route consumes it, too. */ + /** + * Keys whose value is an endpoint the route polls from: the route consumes it, too, as camel validate counts it. + */ private static final Set<String> POLL_KEYS = Set.of("poll", "pollEnrich"); /** EIPs whose destinations are only known at runtime. */ private static final Set<String> DYNAMIC_KEYS = Set.of("recipientList", "routingSlip", "dynamicRouter"); @@ -288,12 +293,13 @@ public final class ProjectRoutes { return new Endpoint("(" + eip + ")", eip, "dynamic:" + eip, true, null); } - /** The brokers several components talk to under one name. */ + /** + * The brokers several components talk to under one name. The in-memory components are not joined: seda:q and + * disruptor:q are different endpoints at runtime. + */ static String family(String scheme) { return switch (scheme) { case "activemq", "activemq6", "sjms", "sjms2", "amqp", "jms" -> "jms"; - case "direct-vm" -> "direct"; - case "vm", "disruptor", "disruptor-vm" -> "seda"; default -> scheme; }; } @@ -739,6 +745,11 @@ public final class ProjectRoutes { } else if (PRODUCER_KEYS.contains(key) || POLL_KEYS.contains(key)) { Endpoint e = stepEndpoint(value, "toD".equals(key) || "enrich".equals(key) || "pollEnrich".equals(key)); + if (e == null && ("enrich".equals(key) || "pollEnrich".equals(key)) + && value instanceof MappingNode m) { + // enrich: {constant: direct:x}, as camel validate reads it + e = constantEndpoint(m); + } if (e == null && !(value instanceof ScalarNode)) { // enrich with an expression: known only at runtime e = dynamicEndpoint(key); @@ -780,6 +791,19 @@ public final class ProjectRoutes { } } + /** The endpoint an enrich or pollEnrich names with a constant expression, or null. */ + private Endpoint constantEndpoint(MappingNode m) { + org.yaml.snakeyaml.nodes.Node constant = child(m, "constant"); + if (constant == null && child(m, "expression") instanceof MappingNode expression) { + constant = child(expression, "constant"); + } + if (constant instanceof MappingNode c) { + constant = child(c, "expression"); + } + String uri = constant instanceof ScalarNode s ? s.getValue() : null; + return uri != null ? endpoint(uri, null, false, catalog) : null; + } + private Endpoint stepEndpoint(org.yaml.snakeyaml.nodes.Node value, boolean dynamic) { if (value instanceof ScalarNode s) { return endpoint(s.getValue(), null, dynamic, catalog); @@ -929,11 +953,11 @@ public final class ProjectRoutes { steps++; } if (PRODUCER_KEYS.contains(name)) { - String uri = attr(s, "uri"); + String uri = uriOrConstant(s); Endpoint ep = uri != null ? endpoint(uri, null, !"to".equals(name), catalog) : dynamicEndpoint(name); add(produces, handlesFailure(s, e) ? onErrorOf(ep) : ep); } else if (POLL_KEYS.contains(name)) { - String uri = attr(s, "uri"); + String uri = uriOrConstant(s); add(consumes, uri != null ? endpoint(uri, null, true, catalog) : dynamicEndpoint(name)); } else if ("kamelet".equals(name) && attr(s, "name") != null) { add(produces, endpoint("kamelet:" + attr(s, "name"), null, false, catalog)); @@ -1148,6 +1172,22 @@ public final class ProjectRoutes { return e.hasAttribute(name) ? str(e.getAttribute(name)) : null; } + /** + * The uri of a step, or for an enrich and a pollEnrich the constant their expression is + * ({@code <pollEnrich><constant>seda:orders</constant></pollEnrich>}), as camel validate reads them. + */ + private static String uriOrConstant(Element e) { + String uri = attr(e, "uri"); + String name = localName(e); + if (uri == null && ("enrich".equals(name) || "pollEnrich".equals(name))) { + Element constant = firstChild(e, "constant"); + if (constant != null) { + uri = str(constant.getTextContent().strip()); + } + } + return uri; + } + private static Element firstChild(Element e, String name) { NodeList children = e.getChildNodes(); for (int i = 0; i < children.getLength(); i++) { diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/OverviewValidateParityTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/OverviewValidateParityTest.java new file mode 100644 index 000000000000..61a5312f2efb --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/OverviewValidateParityTest.java @@ -0,0 +1,163 @@ +/* + * 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.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.catalog.DefaultCamelCatalog; +import org.apache.camel.dsl.jbang.core.commands.ai.ProjectOverview.Finding; +import org.apache.camel.dsl.yaml.validator.EndpointRules; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * camel overview and camel validate report the same direct:, seda: and disruptor: endpoints that a route sends to and + * no route consumes, on the same project of YAML, Java and XML routes: both use the {@link EndpointRules} + * (CAMEL-25180). + */ +class OverviewValidateParityTest { + + private static final CamelCatalog CATALOG = new DefaultCamelCatalog(); + + private static final Pattern OVERVIEW = Pattern.compile("sends to (\\S+) but no route in the project consumes"); + private static final Pattern VALIDATE = Pattern.compile("sends to (\\S+), and no route consumes it"); + + private static final String ORDERS_YAML = """ + - route: + id: intake + from: + uri: timer:orders + steps: + - to: direct:billing + - to: direct:shipping + - wireTap: + uri: seda:audit + - to: disruptor:events + - toD: direct:fixed + - toD: "direct:${header.target}" + - enrich: + constant: direct:prices + - route: + id: events + from: + uri: seda:events + steps: + - log: "${body}" + - rest: + get: + - path: /orders + to: direct:lookup + - errorHandler: + deadLetterChannel: + deadLetterUri: seda:dead + """; + + private static final String BILLING_JAVA = """ + import org.apache.camel.builder.RouteBuilder; + + public class Billing extends RouteBuilder { + @Override + public void configure() { + from("direct:billing").routeId("billing") + .errorHandler(deadLetterChannel("direct:dlq")) + .to("seda:invoices") + .toD("direct:fixed"); + } + } + """; + + private static final String AUDIT_XML = """ + <routes xmlns="http://camel.apache.org/schema/xml-io"> + <!-- not a route: <route><from uri="direct:shipping"/></route> --> + <route id="audit"> + <from uri="timer:audit"/> + <pollEnrich> + <constant>seda:audit</constant> + </pollEnrich> + <enrich> + <constant>direct:prices</constant> + </enrich> + </route> + </routes> + """; + + @TempDir + Path dir; + + @Test + void overviewAndValidateReportTheSameEndpoints() throws Exception { + Files.writeString(dir.resolve("orders.camel.yaml"), ORDERS_YAML); + Files.writeString(dir.resolve("Billing.java"), BILLING_JAVA); + Files.writeString(dir.resolve("audit.xml"), AUDIT_XML); + + Set<String> overview = new TreeSet<>(); + for (Finding f : ProjectOverview.analyze(dir, CATALOG).findings()) { + if ("missing-route".equals(f.kind())) { + overview.add(endpoint(OVERVIEW, f.message())); + } + } + Set<String> validate = new TreeSet<>(); + try (Stream<Path> files = Files.list(dir)) { + for (Path p : files.toList()) { + String name = p.getFileName().toString(); + for (String msg : SourceValidator.validate(name, Files.readString(p), CATALOG, null, dir)) { + if (VALIDATE.matcher(msg).find()) { + validate.add(endpoint(VALIDATE, msg)); + } + } + } + } + + // direct:billing, seda:events (not disruptor:events), seda:audit (by a pollEnrich) are consumed; a toD with an + // expression is only known at runtime; the commented-out XML route is no route + assertThat(validate).containsExactly( + "direct:dlq", "direct:fixed", "direct:lookup", "direct:prices", "direct:shipping", "disruptor:events", + "seda:dead", "seda:invoices"); + assertThat(overview).isEqualTo(validate); + } + + @Test + void bothAreQuietWhenARouteConsumesAnEndpointOnlyKnownAtRuntime() throws Exception { + Files.writeString(dir.resolve("orders.camel.yaml"), ORDERS_YAML); + Files.writeString(dir.resolve("any.camel.yaml"), """ + - from: + uri: "direct:{{name}}" + steps: + - log: any + """); + + assertThat(ProjectOverview.analyze(dir, CATALOG).findings()) + .noneMatch(f -> "missing-route".equals(f.kind())); + assertThat(SourceValidator.validate("orders.camel.yaml", ORDERS_YAML, CATALOG, null, dir)) + .noneMatch(m -> VALIDATE.matcher(m).find()); + } + + private static String endpoint(Pattern pattern, String message) { + Matcher m = pattern.matcher(message); + assertThat(m.find()).as(message).isTrue(); + return EndpointRules.endpoint(m.group(1)); + } +} 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 index 703c3c777e20..287cfb9dbb9e 100644 --- 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 @@ -60,7 +60,7 @@ class SourceValidatorEndpointConsumersTest { 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"); + .contains("the exchange fails with No consumers available on endpoint"); } @Test 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 index c43f3fe140ad..b8863f1d46b1 100644 --- 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 @@ -26,27 +26,26 @@ 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; +import static org.apache.camel.dsl.yaml.validator.RouteGraph.endpointOf; /** * 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. + * With {@code direct:} the exchange fails - <i>No consumers available on endpoint</i>; with {@code seda:} nothing + * fails, and the message is queued and never read. The endpoints and EIPs that count are the {@link EndpointRules} that + * {@code camel overview} uses too. * <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"); + /** The resources whose routes are not in the file: what they consume is not known here. */ + private static final Set<String> UNKNOWN_KINDS = Set.of("Kamelet", "Pipe", "KameletBinding"); + /** The top-level entries that are not routes, nor have steps that send: the beans of the file. */ + private static final Set<String> NOT_STEPS = Set.of("beans", "dataFormats"); private static final ObjectMapper MAPPER = new ObjectMapper(new YAMLFactory()); @@ -54,17 +53,21 @@ public final class 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. + * The {@code direct:} and {@code seda:} endpoints the routes of a YAML file consume (their input, and a poll or + * pollEnrich in a route), 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, is a Kamelet or a Pipe, 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)); + JsonNode entries = entries(target); + if (entries == null || hasTemplates(entries)) { + return null; + } + return consumed(entries, new HashSet<>()) ? answer(entries) : null; } /** @@ -78,80 +81,180 @@ public final class EndpointConsumers { return List.of(); } JsonNode target = read(yaml); - if (target == null || hasTemplates(target)) { + JsonNode entries = target != null ? entries(target) : null; + if (entries == null || hasTemplates(entries)) { return List.of(); } - List<Route> routes = routes(target); - Set<String> own = consumed(routes); - if (own == null) { + Set<String> consumed = new HashSet<>(consumedElsewhere); + if (!consumed(entries, consumed)) { 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())) { + for (JsonNode entry : entries) { + String routeId = routeId(entry); + for (String uri : sends(entry)) { String endpoint = endpoint(uri); if (endpoint == null || consumed.contains(endpoint)) { continue; } - messages.add((r.id() != null ? "route " + r.id() + ": " : "") + messages.add((routeId != null ? "route " + routeId + ": " : "") + "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") + + " other route files of the directory: " + EndpointRules.withoutConsumer(endpoint) + "; add a route with from: " + endpoint + ", or correct the name"); } } return new ArrayList<>(messages); } - private static Set<String> consumed(List<Route> routes) { + /** + * 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) { + return EndpointRules.endpoint(uri); + } + + /** Whether the endpoint is only known at runtime: a property placeholder or an expression. */ + public static boolean isDynamic(String uri) { + return EndpointRules.isDynamic(uri); + } + + private static Set<String> answer(JsonNode entries) { Set<String> answer = new HashSet<>(); - for (Route r : routes) { - if (isDynamic(r.fromUri())) { - return null; + consumed(entries, answer); + return answer; + } + + /** + * Adds the checked endpoints the entries consume: the route inputs, and the poll and pollEnrich steps at any depth. + * + * @return false when one is only known at runtime, which could be any endpoint + */ + private static boolean consumed(JsonNode entries, Set<String> answer) { + for (JsonNode entry : entries) { + if (!entry.isObject()) { + continue; + } + JsonNode route = entry.get("route"); + JsonNode from = route != null ? route.get("from") : entry.get("from"); + if (from != null && !add(endpointOf(from), answer)) { + return false; } - String endpoint = endpoint(r.fromUri()); - if (endpoint != null) { - answer.add(endpoint); + List<String> polled = new ArrayList<>(); + collect(entry, Set.of("poll", "pollEnrich"), polled); + for (String uri : polled) { + if (!add(uri, answer)) { + return false; + } } } - return answer; + return true; + } + + /** Adds the endpoint when it is a checked one; false when it is one only known at runtime. */ + private static boolean add(String uri, Set<String> answer) { + if (uri == null) { + return true; + } + if (EndpointRules.isDynamic(uri) && EndpointRules.mayBeChecked(uri)) { + return false; + } + String endpoint = endpoint(uri); + if (endpoint != null) { + answer.add(endpoint); + } + return true; } /** - * 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). + * The endpoints an entry sends to at any depth: the steps of a route, of a route configuration (onException...), of + * a rest verb, and the dead letter endpoint of an error handler. */ - 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; + private static List<String> sends(JsonNode entry) { + List<String> answer = new ArrayList<>(); + if (entry.isObject()) { + for (var it = entry.fieldNames(); it.hasNext();) { + String name = it.next(); + if (!NOT_STEPS.contains(name)) { + collect(entry.get(name), EndpointRules.SENDERS, answer); + } + } } - String name = s.substring(scheme.length() + 1); - if (name.startsWith("//")) { - name = name.substring(2); + return answer; + } + + private static void collect(JsonNode node, Set<String> keys, List<String> answer) { + if (node == null) { + return; + } + if (node.isArray()) { + for (JsonNode child : node) { + collect(child, keys, answer); + } + return; + } + if (!node.isObject()) { + return; + } + for (var it = node.fieldNames(); it.hasNext();) { + String name = it.next(); + JsonNode value = node.get(name); + if (keys.contains(name)) { + String uri = uriOf(name, value); + if (uri != null) { + answer.add(uri); + } + } + collect(value, keys, answer); } - 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("${")); + /** + * The endpoint of a step: its uri, or for an enrich and a pollEnrich the constant their expression is (the endpoint + * of {@code pollEnrich: {constant: seda:orders}}), as the Java and XML checks read them; an expression of another + * language is only known at runtime, and not an endpoint here. + */ + private static String uriOf(String key, JsonNode value) { + if (("enrich".equals(key) || "pollEnrich".equals(key)) && value != null && value.isObject() + && !value.has("uri")) { + JsonNode constant = value.has("expression") ? value.get("expression").get("constant") : value.get("constant"); + if (constant != null && constant.isObject()) { + constant = constant.get("expression"); + } + return constant != null && constant.isTextual() ? constant.asText() : null; + } + return endpointOf(value); + } + + private static String routeId(JsonNode entry) { + JsonNode route = entry.get("route"); + return route != null && route.has("id") ? route.get("id").asText() : null; } /** - * 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. + * The entries of the file: a list of routes and the like, or the flows of an Integration; null for a Kamelet or a + * Pipe, whose routes are not in the file, and an empty list for a YAML file that has no routes. */ - private static boolean hasTemplates(JsonNode target) { + private static JsonNode entries(JsonNode target) { + if (target.isArray()) { + return target; + } if (target.isObject()) { - return "Kamelet".equals(target.path("kind").asText()); + String kind = target.path("kind").asText(); + if (UNKNOWN_KINDS.contains(kind)) { + return null; + } + if ("Integration".equals(kind)) { + JsonNode flows = target.path("spec").path("flows"); + return flows.isArray() ? flows : MAPPER.createArrayNode(); + } } - for (JsonNode entry : target) { + return MAPPER.createArrayNode(); + } + + /** Whether the entries have a route template or a route made from one: the routes they create are not read here. */ + private static boolean hasTemplates(JsonNode entries) { + for (JsonNode entry : entries) { if (entry.isObject()) { for (String key : TEMPLATES) { if (entry.has(key)) { diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/EndpointRules.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/EndpointRules.java new file mode 100644 index 000000000000..c7691f5da3aa --- /dev/null +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/EndpointRules.java @@ -0,0 +1,110 @@ +/* + * 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.Set; + +/** + * The rules for the endpoints that link the routes of one application: which components a route of the application must + * consume, which EIPs consume an endpoint and which send to one, and when an endpoint is only known at runtime. + * <p/> + * {@code camel validate} (the YAML DSL validator and the checks of Java and XML routes) and {@code camel overview} both + * report a {@code direct:} or {@code seda:} endpoint that a route sends to and no route consumes; they use these rules, + * so they agree on the same project (CAMEL-25180). + */ +public final class EndpointRules { + + /** + * The components whose consumer is a route of the same application: a message sent to one of their endpoints needs + * a route that consumes it. disruptor-vm is left out, as its consumer can be another application in the same JVM. + */ + public static final Set<String> CHECKED = Set.of("direct", "seda", "disruptor"); + + /** The EIPs that consume their endpoint: a route's input, and the polling ones in the middle of a route. */ + public static final Set<String> CONSUMERS = Set.of("from", "poll", "pollEnrich"); + + /** + * The EIPs (and the error handler option) that send to their endpoint. A toD sends to a fixed endpoint when its uri + * has no expression; else its endpoint is only known at runtime. + */ + public static final Set<String> SENDERS = Set.of("to", "toD", "wireTap", "enrich", "deadLetterUri"); + + private EndpointRules() { + } + + /** + * The endpoint in the form the checks compare, {@code direct:lookup} for {@code direct://lookup?timeout=1000}; null + * for an endpoint of a component that is not checked, or one only known at runtime (a placeholder, an expression). + */ + public static String endpoint(String uri) { + String s = withoutOptions(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's component is one whose consumer is a route of the same application. */ + public static boolean isChecked(String uri) { + String scheme = scheme(withoutOptions(uri)); + return scheme != null && CHECKED.contains(scheme); + } + + /** + * Whether the endpoint could be one of a component that is checked: it is, or its component is only known at + * runtime ({@code {{endpoint}}}). A route that consumes such an endpoint, written with a placeholder or an + * expression, could consume any of them. + */ + public static boolean mayBeChecked(String uri) { + String scheme = scheme(withoutOptions(uri)); + return scheme == null || isDynamic(scheme) || CHECKED.contains(scheme); + } + + /** 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("${")); + } + + /** What happens to a message sent to the endpoint when no route consumes it. */ + public static String withoutConsumer(String endpoint) { + return "direct".equals(scheme(endpoint)) + ? "the exchange fails with No consumers available on endpoint" + : "nothing fails, the messages are queued and never read"; + } + + private static String withoutOptions(String uri) { + if (uri == null) { + return ""; + } + String s = uri.trim(); + int q = s.indexOf('?'); + return q > 0 ? s.substring(0, q) : s; + } + + private static String scheme(String uri) { + if (uri == null) { + return null; + } + int colon = uri.indexOf(':'); + return colon > 0 ? uri.substring(0, colon) : null; + } +} diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/RouteGraph.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/RouteGraph.java index 34563ad295d6..686d6d1e8a7a 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/RouteGraph.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/RouteGraph.java @@ -31,9 +31,6 @@ import com.fasterxml.jackson.databind.JsonNode; */ final class RouteGraph { - /** The components whose endpoints link one route of the application to another. */ - static final Set<String> INTERNAL = Set.of("direct", "seda", "direct-vm", "vm", "disruptor", "disruptor-vm"); - /** The steps that send a message to an endpoint. */ static final Set<String> SENDS = Set.of("to", "toD", "enrich", "pollEnrich", "wireTap"); 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 index 49c01d68e2aa..e8c363234048 100644 --- 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 @@ -25,7 +25,8 @@ 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. + * exchange fails; with seda: nothing fails and the message is never read. The endpoints and EIPs that count are the + * {@link EndpointRules} camel overview uses too (CAMEL-25180). */ public class EndpointConsumersTest { @@ -44,7 +45,7 @@ public class EndpointConsumersTest { 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" + + " files of the directory: the exchange fails with No consumers available" + " on endpoint; add a route with from: direct:lookup, or correct the name"); } @@ -180,4 +181,112 @@ public class EndpointConsumersTest { """; assertThat(EndpointConsumers.consumed(kamelet)).isNull(); } + + @Test + public void testAPollingStepConsumesTheEndpoint() { + // a poll or a pollEnrich of seda:orders reads the queue: the route consumes it, as camel overview counts it + String yaml = SENDS_TO.formatted("seda:orders") + """ + - route: + from: + uri: timer:batch + steps: + - pollEnrich: + constant: seda:orders + timeout: 1000 + """; + assertThat(EndpointConsumers.check(yaml, Set.of())).isEmpty(); + assertThat(EndpointConsumers.consumed(yaml)).containsExactly("seda:orders"); + } + + @Test + public void testDisruptorIsCheckedAndNotMixedWithSeda() { + // disruptor:q and seda:q are different endpoints at runtime + assertThat(EndpointConsumers.check(SENDS_TO.formatted("disruptor:orders"), Set.of("seda:orders"))) + .singleElement().asString().contains("sends to disruptor:orders"); + assertThat(EndpointConsumers.check(SENDS_TO.formatted("disruptor-vm:orders"), Set.of())).isEmpty(); + } + + @Test + public void testRestDeadLetterAndRouteConfigurationSend() { + String yaml = """ + - rest: + get: + - path: /orders + to: direct:orders + - routeConfiguration: + onException: + - onException: + exception: + - java.lang.Exception + steps: + - to: seda:errors + - errorHandler: + deadLetterChannel: + deadLetterUri: direct:dlq + """; + assertThat(EndpointConsumers.check(yaml, Set.of())) + .hasSize(3) + .anySatisfy(m -> assertThat(m).contains("sends to direct:orders")) + .anySatisfy(m -> assertThat(m).contains("sends to seda:errors")) + .anySatisfy(m -> assertThat(m).contains("sends to direct:dlq")); + } + + @Test + public void testTheFlowsOfAnIntegrationConsume() { + String integration = """ + apiVersion: camel.apache.org/v1 + kind: Integration + metadata: + name: lookup + spec: + flows: + - from: + uri: direct:lookup + steps: + - to: seda:audit + """; + assertThat(EndpointConsumers.consumed(integration)).containsExactly("direct:lookup"); + assertThat(EndpointConsumers.check(integration, Set.of())) + .singleElement().asString().contains("sends to seda:audit"); + } + + @Test + public void testAPipeConsumesWhatIsNotKnown() { + String pipe = """ + apiVersion: camel.apache.org/v1 + kind: Pipe + metadata: + name: orders + spec: + source: + ref: + kind: Kamelet + name: timer-source + sink: + uri: direct:orders + """; + assertThat(EndpointConsumers.consumed(pipe)).isNull(); + // a YAML file that is not a route file consumes nothing, and does not keep the check quiet + assertThat(EndpointConsumers.consumed("services:\n db:\n image: postgres\n")).isEmpty(); + } + + @Test + public void testAnExternalConsumerOnlyKnownAtRuntimeDoesNotKeepTheCheckQuiet() { + // kafka:{{topic}} is no direct: or seda: endpoint, whatever the topic; {{endpoint}} could be any of them + String yaml = SENDS_TO.formatted("direct:lookup") + """ + - from: + uri: "kafka:{{topic}}" + steps: + - log: got + """; + assertThat(EndpointConsumers.check(yaml, Set.of())).hasSize(1); + String anyEndpoint = SENDS_TO.formatted("direct:lookup") + """ + - from: + uri: "{{endpoint}}" + steps: + - log: got + """; + assertThat(EndpointConsumers.consumed(anyEndpoint)).isNull(); + } + }
