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 24c674bafaf6 CAMEL-25591: camel-report-maven-plugin - read Java routes
with the Java DSL parser instead of camel-route-parser (#27732)
24c674bafaf6 is described below
commit 24c674bafaf6c7653586f4ac3b060b6e8affad48
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Oct 12 19:20:47 2026 +0200
CAMEL-25591: camel-report-maven-plugin - read Java routes with the Java DSL
parser instead of camel-route-parser (#27732)
JavaRouteModelReader reads a Java source into the Camel model with the Java
DSL parser of camel-java-io and gives the details the goals work with: the
node tree of each route for route-coverage (built with the rules of the XML
tree), and the endpoints, simple expressions and route ids for validate. As
camel-route-parser did, a part of a uri only known at runtime is validated
as
a placeholder, and endpoints injected with @EndpointInject and String field
initializers are read. XML routes stay on camel-route-parser.
Signed-off-by: Claus Ibsen <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
catalog/camel-report-maven-plugin/pom.xml | 9 +-
.../src/main/docs/camel-report-maven-plugin.adoc | 5 +
.../apache/camel/maven/JavaRouteModelReader.java | 534 +++++++++++++++++++++
.../org/apache/camel/maven/RouteCoverageMojo.java | 30 +-
.../java/org/apache/camel/maven/ValidateMojo.java | 45 +-
.../camel/maven/JavaRouteModelReaderTest.java | 243 ++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 8 +
.../ROOT/pages/camel-report-maven-plugin.adoc | 5 +
8 files changed, 840 insertions(+), 39 deletions(-)
diff --git a/catalog/camel-report-maven-plugin/pom.xml
b/catalog/camel-report-maven-plugin/pom.xml
index fac7aeda25cc..e83857b793a6 100644
--- a/catalog/camel-report-maven-plugin/pom.xml
+++ b/catalog/camel-report-maven-plugin/pom.xml
@@ -117,7 +117,14 @@
</exclusions>
</dependency>
- <!-- camel route parser -->
+ <!-- the Java DSL parser, which reads Java routes into the Camel model
without compiling them -->
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-java-io</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <!-- camel route parser (XML routes) -->
<dependency>
<groupId>org.apache.camel</groupId>
<artifactId>camel-route-parser</artifactId>
diff --git
a/catalog/camel-report-maven-plugin/src/main/docs/camel-report-maven-plugin.adoc
b/catalog/camel-report-maven-plugin/src/main/docs/camel-report-maven-plugin.adoc
index 4db61c3f05d7..488086d31933 100644
---
a/catalog/camel-report-maven-plugin/src/main/docs/camel-report-maven-plugin.adoc
+++
b/catalog/camel-report-maven-plugin/src/main/docs/camel-report-maven-plugin.adoc
@@ -15,6 +15,11 @@ For validating the source code for mis-configured Camel:
- seda/direct endpoint pairs
- configuration in properties files
+Java routes are read with the Java DSL parser of camel-java-io, and XML routes
with camel-route-parser, without
+compiling or running them. In a Java route, a value only known at runtime,
such as a port held in a field, is
+validated as a property placeholder (`+http://localhost:{{port}}/orders+`); an
endpoint injected into a field with
+`@EndpointInject`, and a field with a string initializer, are read as well.
+
Then you can run the `validate` goal from the command line or from within your
Java editor such as IDEA or Eclipse.
[source,bash]
diff --git
a/catalog/camel-report-maven-plugin/src/main/java/org/apache/camel/maven/JavaRouteModelReader.java
b/catalog/camel-report-maven-plugin/src/main/java/org/apache/camel/maven/JavaRouteModelReader.java
new file mode 100644
index 000000000000..feffe3ab1c92
--- /dev/null
+++
b/catalog/camel-report-maven-plugin/src/main/java/org/apache/camel/maven/JavaRouteModelReader.java
@@ -0,0 +1,534 @@
+/*
+ * 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.maven;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.camel.LineNumberAware;
+import org.apache.camel.NamedNode;
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.java.in.JavaParseResult;
+import org.apache.camel.java.in.LwJavaParser;
+import org.apache.camel.model.ChoiceDefinition;
+import org.apache.camel.model.EnrichDefinition;
+import org.apache.camel.model.ExpressionNode;
+import org.apache.camel.model.ExpressionSubElementDefinition;
+import org.apache.camel.model.FromDefinition;
+import org.apache.camel.model.InterceptFromDefinition;
+import org.apache.camel.model.InterceptSendToEndpointDefinition;
+import org.apache.camel.model.LoopDefinition;
+import org.apache.camel.model.PollDefinition;
+import org.apache.camel.model.PollEnrichDefinition;
+import org.apache.camel.model.ProcessorDefinition;
+import org.apache.camel.model.RouteDefinition;
+import org.apache.camel.model.SendDefinition;
+import org.apache.camel.model.ToDynamicDefinition;
+import org.apache.camel.model.language.ConstantExpression;
+import org.apache.camel.model.language.ExpressionDefinition;
+import org.apache.camel.parser.model.CamelEndpointDetails;
+import org.apache.camel.parser.model.CamelNodeDetails;
+import org.apache.camel.parser.model.CamelNodeDetailsFactory;
+import org.apache.camel.parser.model.CamelRouteDetails;
+import org.apache.camel.parser.model.CamelSimpleExpressionDetails;
+import org.apache.camel.tooling.model.EipModel;
+
+/**
+ * The routes of a Java source read by the Java DSL parser of camel-java-io
(CAMEL-25591), as the details the report
+ * goals work with: the node tree of each route (route coverage), and the
endpoints, simple expressions and route ids
+ * (validate). The source is read into the Camel model without compiling it; a
part the parser cannot work out (a value
+ * computed at runtime, a lambda) is left out, and an endpoint uri it cannot
work out is returned as unparsable.
+ * <p/>
+ * The node tree follows the rules of the XML DSL tree of camel-route-parser:
the EIPs with an input or output, without
+ * the route (the tree starts with from) and without when and otherwise (their
steps are the outputs of the choice).
+ */
+final class JavaRouteModelReader {
+
+ private static final String METHOD_NAME = "configure";
+ private static final Pattern PACKAGE =
Pattern.compile("(?m)^\\s*package\\s+([\\w.]+)\\s*;");
+ private static final int MAX_DEPTH = 64;
+ /** A part of a value the parser could not work out, as it marks it in the
model. */
+ private static final Pattern UNRESOLVED =
Pattern.compile("\\?\\{([^}]*)}");
+ private static final Pattern FIELD_NAME =
Pattern.compile("[A-Za-z_$][\\w$]*");
+ /** The uri of an endpoint injected into a field, which is not a part of
configure(). */
+ private static final Pattern ENDPOINT_INJECT
+ =
Pattern.compile("@EndpointInject\\s*\\(\\s*(?:(?:value|uri)\\s*=\\s*)?\"([^\"]+)\"");
+
+ private final CamelCatalog catalog;
+ private final String fileName;
+ private final String className;
+ private final String content;
+ private final JavaParseResult result;
+
+ private JavaRouteModelReader(CamelCatalog catalog, String fileName, String
content) {
+ this.catalog = catalog;
+ this.fileName = fileName;
+ this.content = content;
+ this.className = className(fileName, content);
+ this.result = new LwJavaParser().parse(content);
+ }
+
+ /**
+ * Reads a Java source.
+ *
+ * @param catalog the catalog, to know the EIPs and which expressions are
predicates
+ * @param fileName the file the details refer to
+ * @param content the Java source
+ */
+ static JavaRouteModelReader read(CamelCatalog catalog, String fileName,
String content) {
+ return new JavaRouteModelReader(catalog, fileName, content);
+ }
+
+ /** Whether the source has routes the parser could read. */
+ boolean hasRoutes() {
+ return !result.routes().getRoutes().isEmpty();
+ }
+
+ /** The class the details refer to: the package of the source and the name
of the file. */
+ String className() {
+ return className;
+ }
+
+ // ---- route coverage ----
+
+ /** The node tree of each route, starting with its from. */
+ List<CamelNodeDetails> routeTrees() {
+ List<CamelNodeDetails> answer = new ArrayList<>();
+ for (RouteDefinition route : result.routes().getRoutes()) {
+ FromDefinition input = route.getInput();
+ if (input == null) {
+ continue;
+ }
+ CamelNodeDetailsFactory factory =
CamelNodeDetailsFactory.newInstance();
+ CamelNodeDetails from = factory.newNode(null, "from");
+ from.setRouteId(route.getRouteId());
+ int line = line(input, line(route, 0));
+ describe(from, line);
+ answer.add(from);
+ for (ProcessorDefinition<?> p : route.getOutputs()) {
+ tree(factory, p, from, line, 0);
+ }
+ }
+ return answer;
+ }
+
+ private void tree(
+ CamelNodeDetailsFactory factory, ProcessorDefinition<?> p,
CamelNodeDetails parent, int parentLine,
+ int depth) {
+ if (p == null || depth > MAX_DEPTH) {
+ return;
+ }
+ String name = p.getShortName();
+ int line = line(p, parentLine);
+ CamelNodeDetails node = parent;
+ if (!"when".equals(name) && !"otherwise".equals(name) && isEip(name)) {
+ node = factory.newNode(parent, name);
+ node.setRouteId(parent.getRouteId());
+ if (p.hasCustomIdAssigned()) {
+ node.setNodeId(p.getId());
+ }
+ describe(node, line);
+ parent.addOutput(node);
+ }
+ // the steps of a choice are those of its when and otherwise
+ for (ProcessorDefinition<?> child : p.getOutputs()) {
+ tree(factory, child, node, line, depth + 1);
+ }
+ }
+
+ private void describe(CamelNodeDetails node, int line) {
+ node.setFileName(fileName);
+ node.setClassName(className);
+ node.setMethodName(METHOD_NAME);
+ node.setLineNumber(Integer.toString(line));
+ node.setLineNumberEnd(Integer.toString(line));
+ }
+
+ /** Whether the name is an EIP of the catalog that has an input or output,
as the XML tree has it. */
+ private boolean isEip(String name) {
+ if (name == null) {
+ return false;
+ }
+ try {
+ EipModel model = catalog.eipModel(name);
+ return model != null && (model.isInput() || model.isOutput());
+ } catch (RuntimeException e) {
+ return false;
+ }
+ }
+
+ // ---- validate ----
+
+ /**
+ * Adds the endpoints of the routes: the inputs and a poll or pollEnrich
(consumers), and a to, toD, wireTap or
+ * enrich (producers).
+ *
+ * @param unparsable the uris the parser could not work out; may be null
+ */
+ void endpoints(List<CamelEndpointDetails> endpoints, List<String>
unparsable) {
+ for (Object o : modelObjects()) {
+ String uri = null;
+ boolean consumer = false;
+ if (o instanceof FromDefinition from) {
+ uri = from.getUri();
+ consumer = true;
+ } else if (o instanceof InterceptFromDefinition || o instanceof
InterceptSendToEndpointDefinition) {
+ // their uri is a pattern of the endpoints they intercept:
validated when it names one endpoint, and
+ // not a consumer nor a producer of it
+ String pattern = o instanceof InterceptFromDefinition f
+ ? f.getUri()
+ : ((InterceptSendToEndpointDefinition) o).getUri();
+ String known = pattern != null && !pattern.contains("*") ?
known(pattern) : null;
+ if (known != null) {
+ endpoints.add(endpoint(known, line(o, 0)));
+ }
+ continue;
+ } else if (o instanceof ToDynamicDefinition d) {
+ // toD and wireTap
+ uri = d.getUri();
+ } else if (o instanceof SendDefinition<?> s) {
+ uri = s.getUri();
+ } else if (o instanceof PollDefinition poll) {
+ uri = poll.getUri();
+ consumer = true;
+ } else if ((o instanceof EnrichDefinition || o instanceof
PollEnrichDefinition)
+ && ((ExpressionNode) o).getExpression() instanceof
ConstantExpression c) {
+ uri = c.getExpression();
+ consumer = o instanceof PollEnrichDefinition;
+ }
+ if (uri == null) {
+ continue;
+ }
+ String known = known(uri);
+ if (known == null) {
+ if (unparsable != null) {
+ unparsable.add(uri);
+ }
+ continue;
+ }
+ CamelEndpointDetails detail = endpoint(known, line(o, 0));
+ detail.setConsumerOnly(consumer);
+ detail.setProducerOnly(!consumer);
+ endpoints.add(detail);
+ }
+ // the endpoints injected into fields: @EndpointInject("mock:result")
+ Matcher m = ENDPOINT_INJECT.matcher(content);
+ while (m.find()) {
+ endpoints.add(endpoint(m.group(1), lineOf(m.start())));
+ }
+ }
+
+ private CamelEndpointDetails endpoint(String uri, int line) {
+ CamelEndpointDetails detail = new CamelEndpointDetails();
+ detail.setFileName(fileName);
+ detail.setClassName(className);
+ detail.setMethodName(METHOD_NAME);
+ detail.setLineNumber(Integer.toString(line));
+ detail.setLineNumberEnd(Integer.toString(line));
+ detail.setEndpointUri(uri);
+ detail.setEndpointComponentName(uri.contains(":") ? uri.substring(0,
uri.indexOf(':')) : null);
+ return detail;
+ }
+
+ /**
+ * The uri with each part the parser could not work out (a value only
known at runtime, such as a port in a field)
+ * as a property placeholder, so its component and options can still be
validated, as camel-route-parser did:
+ * {@code "http://localhost:" + port + "/x"} is
http://localhost:{{port}}/x. Null when the component itself is not
+ * known.
+ */
+ static String withPlaceholders(String uri) {
+ String answer = uri;
+ if (uri.startsWith(LwJavaParser.UNRESOLVED_PREFIX) &&
uri.endsWith("}")) {
+ // the whole uri is a value the parser could not work out: build
it from its string literals
+ answer =
concatenation(uri.substring(LwJavaParser.UNRESOLVED_PREFIX.length(),
uri.length() - 1));
+ if (answer == null) {
+ return null;
+ }
+ } else if (uri.contains(LwJavaParser.UNRESOLVED_PREFIX)) {
+ Matcher m = UNRESOLVED.matcher(uri);
+ StringBuilder sb = new StringBuilder();
+ while (m.find()) {
+ m.appendReplacement(sb,
Matcher.quoteReplacement(placeholder(m.group(1))));
+ }
+ m.appendTail(sb);
+ answer = sb.toString();
+ }
+ int colon = answer.indexOf(':');
+ if (colon <= 0 || answer.contains(LwJavaParser.UNRESOLVED_PREFIX) ||
answer.substring(0, colon).contains("{{")) {
+ return null;
+ }
+ return answer;
+ }
+
+ /**
+ * The value of a string concatenation in Java source, with each term that
is not a string literal as a placeholder;
+ * null when it has no string literal or is not a concatenation the method
can read.
+ */
+ private static String concatenation(String expression) {
+ StringBuilder answer = new StringBuilder();
+ StringBuilder term = new StringBuilder();
+ boolean literal = false;
+ int depth = 0;
+ for (int i = 0; i < expression.length(); i++) {
+ char c = expression.charAt(i);
+ if (c == '"' && depth == 0 && term.toString().isBlank()) {
+ // a string literal: up to the closing quote, with its escapes
+ int end = i + 1;
+ StringBuilder text = new StringBuilder();
+ while (end < expression.length() && expression.charAt(end) !=
'"') {
+ char e = expression.charAt(end);
+ if (e == '\\' && end + 1 < expression.length()) {
+ end++;
+ e = expression.charAt(end);
+ }
+ text.append(e);
+ end++;
+ }
+ if (end >= expression.length()) {
+ return null;
+ }
+ answer.append(text);
+ literal = true;
+ term.setLength(0);
+ term.append(' ');
+ i = end;
+ } else if (c == '+' && depth == 0) {
+ if (!term.toString().isBlank()) {
+ answer.append(placeholder(term.toString()));
+ }
+ term.setLength(0);
+ } else {
+ if (c == '(') {
+ depth++;
+ } else if (c == ')') {
+ depth--;
+ }
+ if (!Character.isWhitespace(c) || !term.isEmpty()) {
+ term.append(c);
+ }
+ }
+ }
+ if (!term.toString().isBlank()) {
+ answer.append(placeholder(term.toString()));
+ }
+ return literal ? answer.toString() : null;
+ }
+
+ private static String placeholder(String term) {
+ String name = term.strip().replaceAll("[^\\w.]", "");
+ return "{{" + (name.isEmpty() ? "value" : name) + "}}";
+ }
+
+ /**
+ * The uri as {@link #withPlaceholders(String)}; for a uri that is only
the name of a field of the class, the
+ * endpoint injected into it or its string initializer, as
camel-route-parser read them: {@code to(result)} with
+ * {@code @EndpointInject("mock:result") MockEndpoint result}, {@code
from(ftp)} with
+ * {@code private String ftp = "ftp:localhost/inbox"}.
+ */
+ private String known(String uri) {
+ if (uri.startsWith(LwJavaParser.UNRESOLVED_PREFIX) &&
uri.endsWith("}")) {
+ String name =
uri.substring(LwJavaParser.UNRESOLVED_PREFIX.length(), uri.length() -
1).strip();
+ if (FIELD_NAME.matcher(name).matches()) {
+ // an endpoint injected into the field:
@EndpointInject("mock:result") MockEndpoint result
+ Matcher m =
Pattern.compile("@EndpointInject\\s*\\(\\s*(?:(?:value|uri)\\s*=\\s*)?\"([^\"]+)\"\\s*\\)[^;]*?\\b"
+ + Pattern.quote(name) + "\\s*;")
+ .matcher(content);
+ if (m.find()) {
+ return withPlaceholders(m.group(1));
+ }
+ m = Pattern.compile("\\bString\\s+" + Pattern.quote(name) +
"\\s*=\\s*([^;]+);").matcher(content);
+ String value = m.find() ? concatenation(m.group(1).strip()) :
null;
+ return value != null ? withPlaceholders(value) : null;
+ }
+ }
+ return withPlaceholders(uri);
+ }
+
+ /** The 1-based line of a position in the source. */
+ private int lineOf(int position) {
+ int line = 1;
+ for (int i = 0; i < position && i < content.length(); i++) {
+ if (content.charAt(i) == '\n') {
+ line++;
+ }
+ }
+ return line;
+ }
+
+ /** Adds the simple expressions and predicates of the routes. */
+ void simpleExpressions(List<CamelSimpleExpressionDetails> expressions) {
+ for (Object o : modelObjects()) {
+ // a step, or a when of a choice (which is no processor of its own)
+ if (!(o instanceof NamedNode p)) {
+ continue;
+ }
+ forEachExpression(p, (option, e) -> {
+ String text = e.getExpression();
+ if (!"simple".equals(e.getLanguage()) || text == null ||
text.contains(LwJavaParser.UNRESOLVED_PREFIX)) {
+ return;
+ }
+ CamelSimpleExpressionDetails detail = new
CamelSimpleExpressionDetails();
+ detail.setFileName(fileName);
+ detail.setClassName(className);
+ detail.setMethodName(METHOD_NAME);
+ String line = Integer.toString(line(e, line(p, 0)));
+ detail.setLineNumber(line);
+ detail.setLineNumberEnd(line);
+ detail.setSimple(text);
+ boolean predicate = isPredicate(p, option);
+ detail.setPredicate(predicate);
+ detail.setExpression(!predicate);
+ expressions.add(detail);
+ });
+ }
+ }
+
+ /** Adds the ids of the routes that have one. */
+ void routeIds(List<CamelRouteDetails> routes) {
+ for (RouteDefinition route : result.routes().getRoutes()) {
+ String id = route.getRouteId();
+ if (id == null || id.contains(LwJavaParser.UNRESOLVED_PREFIX)) {
+ continue;
+ }
+ CamelRouteDetails detail = new CamelRouteDetails();
+ detail.setFileName(fileName);
+ detail.setClassName(className);
+ detail.setMethodName(METHOD_NAME);
+ String line = Integer.toString(line(route.getInput(), line(route,
0)));
+ detail.setLineNumber(line);
+ detail.setLineNumberEnd(line);
+ detail.setRouteId(id);
+ routes.add(detail);
+ }
+ }
+
+ private boolean isPredicate(NamedNode p, String option) {
+ if (p instanceof LoopDefinition loop) {
+ return "true".equals(loop.getDoWhile());
+ }
+ try {
+ EipModel model = catalog.eipModel(p.getShortName());
+ return model != null && model.getOptions().stream()
+ .anyMatch(o -> option.equals(o.getName()) &&
"expression".equals(o.getKind()) && o.isAsPredicate());
+ } catch (RuntimeException e) {
+ return false;
+ }
+ }
+
+ // ---- the model ----
+
+ /**
+ * The objects of the Camel model the source has: the routes and their
steps, and the onException, onCompletion and
+ * intercepts of the route builder, at any depth.
+ */
+ private List<Object> modelObjects() {
+ List<Object> answer = new ArrayList<>();
+ Set<Object> seen = Collections.newSetFromMap(new IdentityHashMap<>());
+ for (RouteDefinition route : result.routes().getRoutes()) {
+ collect(route.getInput(), answer, seen, 0);
+ route.getOutputs().forEach(p -> collect(p, answer, seen, 0));
+ }
+ result.routes().getOnExceptions().forEach(p -> collect(p, answer,
seen, 0));
+ result.routes().getOnCompletions().forEach(p -> collect(p, answer,
seen, 0));
+ result.routes().getIntercepts().forEach(p -> collect(p, answer, seen,
0));
+ result.routes().getInterceptFroms().forEach(p -> collect(p, answer,
seen, 0));
+ result.routes().getInterceptSendTos().forEach(p -> collect(p, answer,
seen, 0));
+ return answer;
+ }
+
+ private static void collect(Object o, List<Object> answer, Set<Object>
seen, int depth) {
+ if (o == null || depth > MAX_DEPTH || !seen.add(o)) {
+ return;
+ }
+ answer.add(o);
+ if (o instanceof ChoiceDefinition choice) {
+ // the outputs of a choice are the steps of its when and
otherwise, not the when and otherwise
+ choice.getWhenClauses().forEach(w -> collect(w, answer, seen,
depth + 1));
+ collect(choice.getOtherwise(), answer, seen, depth + 1);
+ }
+ if (o instanceof ProcessorDefinition<?> p) {
+ p.getOutputs().forEach(c -> collect(c, answer, seen, depth + 1));
+ }
+ }
+
+ /** Calls the action with each expression of the step and the name of its
option. */
+ private static void forEachExpression(Object p, ExpressionAction action) {
+ for (Class<?> c = p.getClass(); c != null && c != Object.class; c =
c.getSuperclass()) {
+ for (Field f : c.getDeclaredFields()) {
+ if (Modifier.isStatic(f.getModifiers()) ||
f.getType().isPrimitive() || f.getType() == String.class) {
+ continue;
+ }
+ Object v;
+ try {
+ f.setAccessible(true);
+ v = f.get(p);
+ } catch (ReflectiveOperationException | RuntimeException e) {
+ continue;
+ }
+ if (v instanceof ExpressionSubElementDefinition sub) {
+ v = sub.getExpressionType();
+ }
+ if (v instanceof ExpressionDefinition e) {
+ action.accept(f.getName(), e);
+ } else if (v instanceof Collection<?> list) {
+ for (Object item : list) {
+ if (item instanceof ExpressionDefinition e) {
+ action.accept(f.getName(), e);
+ }
+ }
+ } else if (v instanceof Map<?, ?> map) {
+ for (Object item : map.values()) {
+ if (item instanceof ExpressionDefinition e) {
+ action.accept(f.getName(), e);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @FunctionalInterface
+ private interface ExpressionAction {
+ void accept(String option, ExpressionDefinition expression);
+ }
+
+ private static int line(Object node, int fallback) {
+ if (node instanceof LineNumberAware aware && aware.getLineNumber() >
0) {
+ return aware.getLineNumber();
+ }
+ return fallback;
+ }
+
+ private static String className(String fileName, String content) {
+ String name = fileName.replace('\\', '/');
+ name = name.substring(name.lastIndexOf('/') + 1);
+ if (name.endsWith(".java")) {
+ name = name.substring(0, name.length() - ".java".length());
+ }
+ Matcher m = PACKAGE.matcher(content);
+ return m.find() ? m.group(1) + "." + name : name;
+ }
+}
diff --git
a/catalog/camel-report-maven-plugin/src/main/java/org/apache/camel/maven/RouteCoverageMojo.java
b/catalog/camel-report-maven-plugin/src/main/java/org/apache/camel/maven/RouteCoverageMojo.java
index b35fc2fe0b02..0c385a303a5f 100644
---
a/catalog/camel-report-maven-plugin/src/main/java/org/apache/camel/maven/RouteCoverageMojo.java
+++
b/catalog/camel-report-maven-plugin/src/main/java/org/apache/camel/maven/RouteCoverageMojo.java
@@ -21,6 +21,8 @@ import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
@@ -47,9 +49,10 @@ import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
import org.apache.camel.maven.htmlxlsx.process.CoverageResultsProcessor;
import org.apache.camel.maven.model.RouteCoverageNode;
-import org.apache.camel.parser.RouteBuilderParser;
import org.apache.camel.parser.XmlRouteParser;
import org.apache.camel.parser.helper.RouteCoverageHelper;
import org.apache.camel.parser.model.CamelNodeDetails;
@@ -60,9 +63,6 @@ import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
-import org.jboss.forge.roaster.Roaster;
-import org.jboss.forge.roaster.model.JavaType;
-import org.jboss.forge.roaster.model.source.JavaClassSource;
import static org.apache.camel.catalog.common.CatalogHelper.asRelativeFile;
import static
org.apache.camel.catalog.common.CatalogHelper.findJavaRouteBuilderClasses;
@@ -115,6 +115,8 @@ public class RouteCoverageMojo extends AbstractMojo {
/**
* Whether to include test source code
*/
+ private CamelCatalog catalog;
+
@Parameter(property = "camel.includeTest", defaultValue = "false")
private boolean includeTest;
@@ -469,17 +471,21 @@ public class RouteCoverageMojo extends AbstractMojo {
}
}
+ private CamelCatalog catalog() {
+ if (catalog == null) {
+ catalog = new DefaultCamelCatalog();
+ }
+ return catalog;
+ }
+
private void addJavaFiles(File file, List<CamelNodeDetails> routeTrees) {
if (matchFile(file)) {
try {
- // parse the java source code and find Camel RouteBuilder
classes
- String fqn = file.getPath();
- JavaType<?> out = Roaster.parse(file);
- // we should only parse java classes (not interfaces and enums
etc)
- if (out instanceof JavaClassSource clazz) {
- List<CamelNodeDetails> result =
RouteBuilderParser.parseRouteBuilderTree(clazz, fqn, true);
- routeTrees.addAll(result);
- }
+ // read the java source into the Camel model with the Java DSL
parser, without compiling it, and build
+ // the node tree of its routes (CAMEL-25591)
+ JavaRouteModelReader reader = JavaRouteModelReader.read(
+ catalog(), file.getPath(),
Files.readString(file.toPath(), StandardCharsets.UTF_8));
+ routeTrees.addAll(reader.routeTrees());
} catch (Exception e) {
getLog().warn("Error parsing java file " + file + " code due "
+ e.getMessage(), e);
}
diff --git
a/catalog/camel-report-maven-plugin/src/main/java/org/apache/camel/maven/ValidateMojo.java
b/catalog/camel-report-maven-plugin/src/main/java/org/apache/camel/maven/ValidateMojo.java
index c95e423cc40f..4c8bed060315 100644
---
a/catalog/camel-report-maven-plugin/src/main/java/org/apache/camel/maven/ValidateMojo.java
+++
b/catalog/camel-report-maven-plugin/src/main/java/org/apache/camel/maven/ValidateMojo.java
@@ -17,6 +17,7 @@
package org.apache.camel.maven;
import java.io.*;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -34,7 +35,6 @@ import org.apache.camel.catalog.EndpointValidationResult;
import org.apache.camel.catalog.LanguageValidationResult;
import org.apache.camel.catalog.common.FileUtil;
import org.apache.camel.catalog.maven.MavenVersionManager;
-import org.apache.camel.parser.RouteBuilderParser;
import org.apache.camel.parser.XmlRouteParser;
import org.apache.camel.parser.model.CamelEndpointDetails;
import org.apache.camel.parser.model.CamelRouteDetails;
@@ -55,9 +55,6 @@ import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
import org.eclipse.aether.RepositorySystem;
import org.eclipse.aether.RepositorySystemSession;
-import org.jboss.forge.roaster.Roaster;
-import org.jboss.forge.roaster.model.JavaType;
-import org.jboss.forge.roaster.model.source.JavaClassSource;
import static org.apache.camel.catalog.common.CatalogHelper.asRelativeFile;
import static
org.apache.camel.catalog.common.CatalogHelper.findJavaRouteBuilderClasses;
@@ -560,7 +557,7 @@ public class ValidateMojo extends AbstractMojo {
for (File file : javaFiles) {
if (matchFile(file)) {
- parseJavaRouteFile(endpoints, simpleExpressions, routeIds,
file);
+ parseJavaRouteFile(catalog, endpoints, simpleExpressions,
routeIds, file);
}
}
for (File file : xmlFiles) {
@@ -835,7 +832,7 @@ public class ValidateMojo extends AbstractMojo {
}
private void parseJavaRouteFile(
- List<CamelEndpointDetails> endpoints,
List<CamelSimpleExpressionDetails> simpleExpressions,
+ CamelCatalog catalog, List<CamelEndpointDetails> endpoints,
List<CamelSimpleExpressionDetails> simpleExpressions,
List<CamelRouteDetails> routeIds, File file) {
try {
List<CamelEndpointDetails> fileEndpoints = new ArrayList<>();
@@ -843,28 +840,24 @@ public class ValidateMojo extends AbstractMojo {
List<CamelSimpleExpressionDetails> fileSimpleExpressions = new
ArrayList<>();
List<String> unparsable = new ArrayList<>();
- // parse the java source code and find Camel RouteBuilder classes
- String fqn = file.getPath();
- String baseDir = ".";
- JavaType<?> out = Roaster.parse(file);
- // we should only parse java classes (not interfaces and enums
etc.)
- if (out instanceof JavaClassSource clazz) {
- RouteBuilderParser.parseRouteBuilderEndpoints(clazz, baseDir,
fqn, fileEndpoints, unparsable, includeTest);
- RouteBuilderParser.parseRouteBuilderSimpleExpressions(clazz,
baseDir, fqn, fileSimpleExpressions);
- if (duplicateRouteId) {
- RouteBuilderParser.parseRouteBuilderRouteIds(clazz,
baseDir, fqn, fileRouteIds);
- }
+ // read the java source into the Camel model with the Java DSL
parser, without compiling it (CAMEL-25591)
+ JavaRouteModelReader reader = JavaRouteModelReader.read(
+ catalog, file.getPath(), Files.readString(file.toPath(),
StandardCharsets.UTF_8));
+ reader.endpoints(fileEndpoints, unparsable);
+ reader.simpleExpressions(fileSimpleExpressions);
+ if (duplicateRouteId) {
+ reader.routeIds(fileRouteIds);
+ }
- // add what we found in this file to the total list
- endpoints.addAll(fileEndpoints);
- simpleExpressions.addAll(fileSimpleExpressions);
- routeIds.addAll(fileRouteIds);
+ // add what we found in this file to the total list
+ endpoints.addAll(fileEndpoints);
+ simpleExpressions.addAll(fileSimpleExpressions);
+ routeIds.addAll(fileRouteIds);
- // was there any unparsable?
- if (logUnparseable && !unparsable.isEmpty()) {
- for (String uri : unparsable) {
- getLog().warn("Cannot parse endpoint uri " + uri + "
in java file " + file);
- }
+ // was there any unparsable?
+ if (logUnparseable && !unparsable.isEmpty()) {
+ for (String uri : unparsable) {
+ getLog().warn("Cannot parse endpoint uri " + uri + " in
java file " + file);
}
}
} catch (Exception e) {
diff --git
a/catalog/camel-report-maven-plugin/src/test/java/org/apache/camel/maven/JavaRouteModelReaderTest.java
b/catalog/camel-report-maven-plugin/src/test/java/org/apache/camel/maven/JavaRouteModelReaderTest.java
new file mode 100644
index 000000000000..38706c59ac2e
--- /dev/null
+++
b/catalog/camel-report-maven-plugin/src/test/java/org/apache/camel/maven/JavaRouteModelReaderTest.java
@@ -0,0 +1,243 @@
+/*
+ * 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.maven;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.parser.XmlRouteParser;
+import org.apache.camel.parser.model.CamelEndpointDetails;
+import org.apache.camel.parser.model.CamelNodeDetails;
+import org.apache.camel.parser.model.CamelRouteDetails;
+import org.apache.camel.parser.model.CamelSimpleExpressionDetails;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * CAMEL-25591: the report goals read Java routes with the Java DSL parser,
into the same details camel-route-parser
+ * gives.
+ */
+class JavaRouteModelReaderTest {
+
+ private static final CamelCatalog CATALOG = new DefaultCamelCatalog();
+
+ private static final String ORDERS = """
+ package com.foo.orders;
+
+ import org.apache.camel.builder.RouteBuilder;
+
+ public class OrderRoute extends RouteBuilder {
+ private static final String AUDIT = "seda:audit";
+
+ @Override
+ public void configure() {
+ from("direct:orders").routeId("orders")
+ .filter(simple("${header.amount} > 100"))
+ .to(AUDIT)
+ .end()
+ .choice()
+ .when(simple("${header.vip}"))
+ .setBody(simple("vip ${body}"))
+ .to("seda:vip")
+ .otherwise()
+ .log("normal")
+ .end()
+ .toD("direct:${header.next}")
+ .to(helper());
+
+ from("timer:tick")
+ .pollEnrich("seda:audit")
+ .to("log:tick");
+ }
+ }
+ """;
+
+ /** The same routes in the XML DSL, the tree camel-route-parser builds for
them. */
+ private static final String ORDERS_XML = """
+ <routes xmlns="http://camel.apache.org/schema/spring">
+ <route id="orders">
+ <from uri="direct:orders"/>
+ <filter>
+ <simple>${header.amount} > 100</simple>
+ <to uri="seda:audit"/>
+ </filter>
+ <choice>
+ <when>
+ <simple>${header.vip}</simple>
+ <setBody><simple>vip ${body}</simple></setBody>
+ <to uri="seda:vip"/>
+ </when>
+ <otherwise>
+ <log message="normal"/>
+ </otherwise>
+ </choice>
+ <toD uri="direct:${header.next}"/>
+ <to uri="direct:helper"/>
+ </route>
+ <route>
+ <from uri="timer:tick"/>
+ <pollEnrich><constant>seda:audit</constant></pollEnrich>
+ <to uri="log:tick"/>
+ </route>
+ </routes>
+ """;
+
+ private static JavaRouteModelReader reader() {
+ return JavaRouteModelReader.read(CATALOG,
"src/main/java/com/foo/orders/OrderRoute.java", ORDERS);
+ }
+
+ @Test
+ void routeTreesAreTheTreesOfTheXmlDsl() {
+ List<CamelNodeDetails> java = reader().routeTrees();
+ List<CamelNodeDetails> xml = XmlRouteParser.parseXmlRouteTree(
+ new
ByteArrayInputStream(ORDERS_XML.getBytes(StandardCharsets.UTF_8)), ".",
"orders.xml");
+
+ assertEquals(2, java.size());
+ assertEquals(withoutLines(xml.get(0).dump(0)),
withoutLines(java.get(0).dump(0)));
+ assertEquals(withoutLines(xml.get(1).dump(0)),
withoutLines(java.get(1).dump(0)));
+
+ CamelNodeDetails from = java.get(0);
+ assertEquals("orders", from.getRouteId());
+ assertEquals("10", from.getLineNumber());
+ assertEquals("com.foo.orders.OrderRoute", from.getClassName());
+ assertEquals("configure", from.getMethodName());
+ // the filter is the first step, on its own line
+ assertEquals("11", from.getOutputs().get(0).getLineNumber());
+ assertNull(java.get(1).getRouteId());
+ }
+
+ @Test
+ void endpointsWithConsumersAndProducers() {
+ List<CamelEndpointDetails> endpoints = new ArrayList<>();
+ List<String> unparsable = new ArrayList<>();
+ reader().endpoints(endpoints, unparsable);
+
+ List<String> uris =
endpoints.stream().map(CamelEndpointDetails::getEndpointUri).toList();
+ assertTrue(uris.containsAll(List.of(
+ "direct:orders", "seda:audit", "seda:vip",
"direct:${header.next}", "timer:tick", "log:tick")), uris::toString);
+ CamelEndpointDetails orders = endpoints.stream().filter(e ->
"direct:orders".equals(e.getEndpointUri()))
+ .findFirst().orElseThrow();
+ assertTrue(orders.isConsumerOnly());
+ assertFalse(orders.isProducerOnly());
+ assertEquals("direct", orders.getEndpointComponentName());
+ assertEquals("10", orders.getLineNumber());
+ // the pollEnrich consumes from seda:audit, the to in the filter
produces to it (the constant is resolved)
+ assertTrue(endpoints.stream().anyMatch(e ->
"seda:audit".equals(e.getEndpointUri()) && e.isConsumerOnly()));
+ assertTrue(endpoints.stream().anyMatch(e ->
"seda:audit".equals(e.getEndpointUri()) && e.isProducerOnly()));
+ // to(helper()) is only known at runtime
+ assertEquals(1, unparsable.size(), unparsable::toString);
+ }
+
+ @Test
+ void simpleExpressionsAndPredicates() {
+ List<CamelSimpleExpressionDetails> expressions = new ArrayList<>();
+ reader().simpleExpressions(expressions);
+
+ CamelSimpleExpressionDetails filter = find(expressions,
"${header.amount} > 100");
+ assertTrue(filter.isPredicate());
+ assertEquals("11", filter.getLineNumber());
+ assertTrue(find(expressions, "${header.vip}").isPredicate());
+ CamelSimpleExpressionDetails body = find(expressions, "vip ${body}");
+ assertFalse(body.isPredicate());
+ assertTrue(body.isExpression());
+ }
+
+ @Test
+ void routeIds() {
+ List<CamelRouteDetails> routes = new ArrayList<>();
+ reader().routeIds(routes);
+
+ assertEquals(1, routes.size());
+ assertEquals("orders", routes.get(0).getRouteId());
+ assertEquals("10", routes.get(0).getLineNumber());
+ assertEquals("com.foo.orders.OrderRoute",
routes.get(0).getClassName());
+ }
+
+ @Test
+ void aValueOnlyKnownAtRuntimeIsAPlaceholder() {
+ JavaRouteModelReader reader = JavaRouteModelReader.read(CATALOG,
"HttpRoute.java", """
+ import org.apache.camel.EndpointInject;
+ import org.apache.camel.builder.RouteBuilder;
+
+ public class HttpRoute extends RouteBuilder {
+ protected int port;
+
+ @EndpointInject("mock:result")
+ protected Object result;
+
+ private String ftp = "ftp:localhost:21/inbox?delay=" +
5000 + "&passiveMode=true";
+
+ @Override
+ public void configure() {
+ from("jetty:http://localhost:" + port + "/sender")
+ .to("http://localhost:" + port +
"/other?bridgeEndpoint=true")
+ .to(result);
+ from(ftp).to("log:ftp");
+ }
+ }
+ """);
+ List<CamelEndpointDetails> endpoints = new ArrayList<>();
+ List<String> unparsable = new ArrayList<>();
+ reader.endpoints(endpoints, unparsable);
+
+ List<String> uris =
endpoints.stream().map(CamelEndpointDetails::getEndpointUri).toList();
+ // as camel-route-parser did: the component and options of the uri are
still validated
+ assertTrue(uris.contains("jetty:http://localhost:{{port}}/sender"),
uris::toString);
+
assertTrue(uris.contains("http://localhost:{{port}}/other?bridgeEndpoint=true"),
uris::toString);
+ // the to of the injected endpoint in the route, and its field
+ assertEquals(List.of("16", "7"), endpoints.stream().filter(e ->
"mock:result".equals(e.getEndpointUri()))
+ .map(CamelEndpointDetails::getLineNumber).toList());
+ // a field with a string initializer
+
assertTrue(uris.contains("ftp:localhost:21/inbox?delay={{5000}}&passiveMode=true"),
uris::toString);
+ assertTrue(unparsable.isEmpty(), unparsable::toString);
+
+ assertNull(JavaRouteModelReader.withPlaceholders("?{scheme()}:foo"));
+ }
+
+ @Test
+ void aClassWithoutRoutes() {
+ JavaRouteModelReader reader = JavaRouteModelReader.read(CATALOG,
"Util.java", """
+ public class Util {
+ public static String name() {
+ return "util";
+ }
+ }
+ """);
+ assertFalse(reader.hasRoutes());
+ assertTrue(reader.routeTrees().isEmpty());
+ assertEquals("Util", reader.className());
+ }
+
+ private static CamelSimpleExpressionDetails
find(List<CamelSimpleExpressionDetails> list, String simple) {
+ return list.stream().filter(e ->
simple.equals(e.getSimple())).findFirst()
+ .orElseThrow(() -> new AssertionError(
+ "no " + simple + " in " + list.stream()
+
.map(CamelSimpleExpressionDetails::getSimple).toList()));
+ }
+
+ private static String withoutLines(String dump) {
+ return dump.replaceAll("(?m)^[^\\t]*\\t", "");
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 24d8b1ea2530..94a6e5353229 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -59,3 +59,11 @@ A `hashicorp:` property with a field of a secret that does
not exist, such as `{
now resolves to no value, as it already did without a field, so its default
value or an optional placeholder
(`{{?hashicorp:secret:missing#field}}`) applies. Before, without a default
value, it failed with a
`NullPointerException`.
+
+=== camel-report-maven-plugin - Java routes are read with the Java DSL parser
+
+The `validate` and `route-coverage` goals read Java routes with the Java DSL
parser of camel-java-io instead of
+camel-route-parser (Roaster). XML routes are read as before. The parser reads
every `configure()` method of a source
+file, including those of a route builder created inside a test, so `validate`
can find and validate endpoints it did
+not see before. An endpoint uri that is only a method call, such as
`from(inboxQueue())`, is not validated (it was
+validated as a property placeholder without a component before); with
`logUnparseable=true` it is logged.
diff --git a/docs/user-manual/modules/ROOT/pages/camel-report-maven-plugin.adoc
b/docs/user-manual/modules/ROOT/pages/camel-report-maven-plugin.adoc
index 4db61c3f05d7..488086d31933 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-report-maven-plugin.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-report-maven-plugin.adoc
@@ -15,6 +15,11 @@ For validating the source code for mis-configured Camel:
- seda/direct endpoint pairs
- configuration in properties files
+Java routes are read with the Java DSL parser of camel-java-io, and XML routes
with camel-route-parser, without
+compiling or running them. In a Java route, a value only known at runtime,
such as a port held in a field, is
+validated as a property placeholder (`+http://localhost:{{port}}/orders+`); an
endpoint injected into a field with
+`@EndpointInject`, and a field with a string initializer, are read as well.
+
Then you can run the `validate` goal from the command line or from within your
Java editor such as IDEA or Eclipse.
[source,bash]