This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25591 in repository https://gitbox.apache.org/repos/asf/camel.git
commit c1af970191c7bc565c4765fad7907fdd2ce55d7e Author: Claus Ibsen <[email protected]> AuthorDate: Mon Oct 12 15:45:52 2026 +0200 CAMEL-25591: camel-report-maven-plugin - read Java routes with the Java DSL parser instead of camel-route-parser 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. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[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 5a3c1009e3ab..b767adc91b38 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 @@ -27,3 +27,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]
