This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25258 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 6c585814420fc4c136fa449863f7447f235da57c Author: Claus Ibsen <[email protected]> AuthorDate: Fri Oct 2 09:42:54 2026 +0200 CAMEL-25258: camel-jbang-mcp - camel_transform_route converts with the route DSL converter 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_23.adoc | 10 + .../modules/ROOT/pages/camel-jbang-mcp.adoc | 5 +- .../dsl/jbang/core/commands/RouteDslConverter.java | 13 +- .../jbang/core/commands/RouteDslConverterTest.java | 5 +- dsl/camel-jbang/camel-jbang-mcp/pom.xml | 9 - .../jbang/core/commands/mcp/TransformTools.java | 214 ++++----------------- .../core/commands/mcp/TransformToolsTest.java | 77 ++++++-- 7 files changed, 124 insertions(+), 209 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc index c15293a15c68..fb0711352596 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc @@ -4213,3 +4213,13 @@ as Java, the error handler of an XML or YAML route is now `.errorHandler(...)` o The `timeout(long)` of the Resequencer EIP in Java keeps the millis as given (`2000`), instead of a duration (`2s0ms`). + +=== camel-jbang-mcp - camel_transform_route + +The `camel_transform_route` tool now converts with the same route DSL converter as `camel transform route +--format=java`. It reads the routes without running them, so a Java route is no longer compiled. Instead it is read +by the Java DSL parser, and a route the parser cannot read (such as a processor lambda) is refused with the reason. +Property placeholders are kept as written instead of being resolved. + +In return, Java is also a target format, and rests, route templates, route configurations and beans are converted +instead of only routes. A new `notes` field of the result lists what differs or is not carried over. diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc index e4f19d052b4c..e95cae788727 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc @@ -574,7 +574,10 @@ The advisories are also browseable as MCP resources: `camel://security/advisorie | Tool | Description | `camel_transform_route` -| Assists with route DSL format transformation between YAML and XML. +| Converts routes between the YAML, XML and Java DSLs, as `camel transform route` does, without running them: + routes, rests, route templates, route configurations and beans. The result is read back and compared with the + source, and what differs or is not carried over is returned in `notes`. A route that cannot be converted without + running it (such as a processor lambda) is refused with the reason. | `camel_configuration_validate` | Validate Camel configuration property lines (e.g., from `application.properties`). Detects misspelled option diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/RouteDslConverter.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/RouteDslConverter.java index b5fa941e030a..e10491a9a2e9 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/RouteDslConverter.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/RouteDslConverter.java @@ -351,6 +351,9 @@ public final class RouteDslConverter { DefaultCamelContext context, String fileName, String content, Map<String, Supplier<String>> javaSources, Resource resource, List<String> notes) throws Exception { + if (JAVA_SEMANTIC.matcher(content).find()) { + return SEMANTIC_REFUSED; + } CamelCatalog catalog = new DefaultCamelCatalog(); JavaParseResult r = ProjectRoutes.parseJava(content, javaSources, catalog); for (JavaParseResult.Unresolved u : r.unresolved()) { @@ -430,12 +433,16 @@ public final class RouteDslConverter { } private static final Pattern YAML_SEMANTIC = Pattern.compile("(?m)^-\\s+semantic\\s*:"); + private static final Pattern JAVA_SEMANTIC = Pattern.compile("\\bsemanticQuestions\\s*\\("); + + // they live outside the route model the writers export: converting would lose them without a word + private static final String SEMANTIC_REFUSED + = "semantic declarations cannot be converted with the routes: keep them in a separate declaration resource" + + " and convert only the routes"; private static String loadYaml(DefaultCamelContext context, String content, Resource resource) throws Exception { if (YAML_SEMANTIC.matcher(content).find()) { - // they live outside the route model the writers export: converting would lose them without a word - return "semantic declarations cannot be converted with the routes: keep them in a separate declaration" - + " resource and convert only the routes"; + return SEMANTIC_REFUSED; } // the beans are taken out and read as definitions: the YAML DSL would create them, running code of the project StringBuilder routes = new StringBuilder(); diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/RouteDslConverterTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/RouteDslConverterTest.java index c23a8877ba8c..bc952e5cd541 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/RouteDslConverterTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/RouteDslConverterTest.java @@ -172,9 +172,8 @@ class RouteDslConverterTest { } } """; - // the parser cannot read the declaration: refused at its line, not dropped - assertThat(convert("SemanticRoute.java", java, "yaml").refused()).contains("SemanticRoute.java:5", - "cannot be converted without running it"); + // refused for the same reason as in YAML, not dropped + assertThat(convert("SemanticRoute.java", java, "yaml").refused()).contains("semantic declarations"); } @Test diff --git a/dsl/camel-jbang/camel-jbang-mcp/pom.xml b/dsl/camel-jbang/camel-jbang-mcp/pom.xml index 40786dbc0ab9..602d5c4dd3ce 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/pom.xml +++ b/dsl/camel-jbang/camel-jbang-mcp/pom.xml @@ -111,10 +111,6 @@ <groupId>org.apache.camel</groupId> <artifactId>camel-yaml-dsl</artifactId> </dependency> - <dependency> - <groupId>org.apache.camel</groupId> - <artifactId>camel-java-joor-dsl</artifactId> - </dependency> <!-- Swagger/OpenAPI parser for contract-first OpenAPI tools --> <dependency> @@ -147,11 +143,6 @@ </dependency> <!-- test dependencies --> - <dependency> - <groupId>org.apache.camel</groupId> - <artifactId>camel-semantic</artifactId> - <scope>test</scope> - </dependency> <dependency> <groupId>org.apache.camel</groupId> <artifactId>camel-xml-io-dsl</artifactId> diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformTools.java index d068f2403245..e7c64364d8e8 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformTools.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformTools.java @@ -16,11 +16,9 @@ */ package org.apache.camel.dsl.jbang.core.commands.mcp; -import java.io.ByteArrayInputStream; -import java.io.StringWriter; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -29,22 +27,11 @@ import jakarta.enterprise.context.ApplicationScoped; import io.quarkiverse.mcp.server.Tool; import io.quarkiverse.mcp.server.ToolArg; import io.quarkiverse.mcp.server.ToolCallException; -import org.apache.camel.impl.DefaultCamelContext; -import org.apache.camel.model.ExpressionNode; -import org.apache.camel.model.ProcessorDefinitionHelper; -import org.apache.camel.model.RouteDefinition; -import org.apache.camel.model.RoutesDefinition; -import org.apache.camel.spi.Resource; -import org.apache.camel.support.PluginHelper; -import org.apache.camel.support.ResourceHelper; -import org.apache.camel.util.ObjectHelper; -import org.apache.camel.util.json.JsonObject; -import org.apache.camel.xml.in.ModelParser; -import org.apache.camel.yaml.out.YamlModelWriter; +import org.apache.camel.dsl.jbang.core.commands.RouteDslConverter; /** - * MCP tool transforming Camel routes between DSL formats using Quarkus MCP Server. Validation is the shared - * {@code camel_validate_source} tool. + * MCP tool transforming Camel routes between DSL formats with the route DSL converter of camel-jbang, which reads the + * routes without running them. Validation is the shared {@code camel_validate_source} tool. */ @McpSecured @ApplicationScoped @@ -56,13 +43,16 @@ public class TransformTools { * Tool to transform routes between DSL formats. */ @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false, openWorldHint = false), - description = "Transform a Camel route between different DSL formats (YAML, XML). " + - "Note: Java to YAML/XML transformation has limitations." - + " Java DSL can only be used as source format, not as target format.") + description = "Transform Camel routes between the YAML, XML and Java DSLs, without running them." + + " Routes, rests, route templates, route configurations and beans are converted." + + " The result is read back and compared with the source: what differs, or is not carried" + + " over (such as comments, or the beans when the target is Java), is listed in notes." + + " What cannot be converted without running it (a processor lambda) is refused with the reason.") public TransformResult camel_transform_route( - @ToolArg(description = "Route definition to transform") String route, + @ToolArg(description = "Route definition to transform: a YAML or XML routes file, a Java RouteBuilder class," + + " or Java route statements such as from(\"timer:tick\").to(\"log:out\");") String route, @ToolArg(description = "Source format (yaml, xml, java)") String fromFormat, - @ToolArg(description = "Target format (yaml, xml)") String toFormat) { + @ToolArg(description = "Target format (yaml, xml, java)") String toFormat) { if (route == null || fromFormat == null || toFormat == null) { throw new ToolCallException("route, fromFormat, and toFormat are required", null); @@ -72,175 +62,40 @@ public class TransformTools { result.fromFormat = fromFormat; result.toFormat = toFormat; - String from = fromFormat.toLowerCase(); - String to = toFormat.toLowerCase(); + String from = fromFormat.toLowerCase(Locale.ROOT); + String to = toFormat.toLowerCase(Locale.ROOT); if (from.equals(to)) { result.supported = true; result.result = route; return result; } - - try { - if ("xml".equals(from) && "yaml".equals(to)) { - result.result = transformXmlToYaml(route); - result.supported = true; - } else if ("yaml".equals(from) && "xml".equals(to)) { - result.result = transformYamlToXml(route); - result.supported = true; - } else if ("java".equals(from) && "yaml".equals(to)) { - result.result = transformJavaToFormat(route, "yaml"); - result.supported = true; - } else if ("java".equals(from) && "xml".equals(to)) { - result.result = transformJavaToFormat(route, "xml"); - result.supported = true; - } else { - result.supported = false; - result.note = "Unsupported transformation: " + fromFormat + " to " + toFormat; - } - } catch (Throwable e) { - Throwable cause = ObjectHelper.createExceptionIterator(e).next(); - String message = e.getMessage(); - if (cause != e) { - message += ": " + cause.getMessage(); - } - throw new ToolCallException( - "Failed to transform route (" + e.getClass().getName() + "): " + message, null); - } - - return result; - } - - /** - * Transform an XML route definition to YAML format. - */ - private String transformXmlToYaml(String xml) throws Exception { - // Try Spring namespace first (most common), then fall back to no namespace - RoutesDefinition routes = null; - try (ByteArrayInputStream is = new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))) { - routes = new ModelParser(is, "http://camel.apache.org/schema/spring") - .parseRoutesDefinition().orElse(null); - } - if (routes == null) { - try (ByteArrayInputStream is = new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))) { - routes = new ModelParser(is) - .parseRoutesDefinition().orElse(null); - } - } - if (routes == null) { - throw new IllegalArgumentException( - "Could not parse XML route. Ensure it contains a valid <routes> or <route> element."); - } - - YamlModelWriter writer = new YamlModelWriter(); - List<JsonObject> roots = new ArrayList<>(); - for (RouteDefinition route : routes.getRoutes()) { - roots.add(writer.writeRouteDefinition(route)); - } - return writer.printAsYaml(roots); - } - - /** - * Transform a YAML route definition to XML format. - */ - private String transformYamlToXml(String yaml) throws Exception { - DefaultCamelContext ctx = new DefaultCamelContext(); - try { - ctx.build(); - - Resource resource = ResourceHelper.fromString("route.yaml", yaml); - PluginHelper.getRoutesLoader(ctx).loadRoutes(resource); - - List<RouteDefinition> routeDefs = ctx.getRouteDefinitions(); - if (routeDefs == null || routeDefs.isEmpty()) { - throw new IllegalArgumentException( - "Could not parse YAML route. Ensure it contains a valid route definition."); - } - - RoutesDefinition rd = new RoutesDefinition(); - rd.setRoutes(routeDefs); - requireSeparateDeclarations(ctx); - - StringWriter sw = new StringWriter(); - new org.apache.camel.xml.out.ModelWriter(sw).writeRoutesDefinition(rd); - return sw.toString(); - } finally { - ctx.stop(); + if (!RouteDslConverter.FORMATS.contains(from) || !RouteDslConverter.FORMATS.contains(to)) { + result.supported = false; + result.note = "Unsupported transformation: " + fromFormat + " to " + toFormat; + return result; } - } - private static void requireSeparateDeclarations(DefaultCamelContext context) throws ReflectiveOperationException { - // Semantic declarations are optional and live outside the model exported by this converter. - Class<?> type = context.getClassResolver().resolveClass("org.apache.camel.semantic.SemanticQuestions"); - if (type == null) { - return; + RouteDslConverter.Result r = RouteDslConverter.convert(fileName(route, from), route, to, Map.of()); + if (!r.converted()) { + throw new ToolCallException("Cannot transform route: " + r.refused(), null); } - Object questions = context.getCamelContextExtension().getContextPlugin(type); - if (questions != null && !(boolean) type.getMethod("isEmpty").invoke(questions)) { - throw new IllegalArgumentException( - "Semantic declarations cannot be exported by the generic route converter. " - + "Keep them in a separate declaration resource and convert only the routes."); - } - } - - private String transformJavaToFormat(String java, String targetFormat) throws Exception { - DefaultCamelContext ctx = new DefaultCamelContext(); - try { - ctx.build(); - - String source = wrapSnippetIfNeeded(java); - String className = extractClassName(source); - Resource resource = ResourceHelper.fromString(className + ".java", source); - PluginHelper.getRoutesLoader(ctx).loadRoutes(resource); - - List<RouteDefinition> routeDefs = ctx.getRouteDefinitions(); - if (routeDefs == null || routeDefs.isEmpty()) { - throw new IllegalArgumentException( - "Could not parse Java route. Ensure it contains a valid route definition."); - } - - // Java expression clauses are normally materialized when processors are created. - routeDefs.forEach(route -> ProcessorDefinitionHelper.filterTypeInOutputs(route.getOutputs(), ExpressionNode.class) - .forEach(ExpressionNode::preCreateProcessor)); - - if ("yaml".equals(targetFormat)) { - YamlModelWriter writer = new YamlModelWriter(); - List<JsonObject> roots = new ArrayList<>(); - requireSeparateDeclarations(ctx); - for (RouteDefinition route : routeDefs) { - roots.add(writer.writeRouteDefinition(route)); - } - return writer.printAsYaml(roots); - } else { - RoutesDefinition rd = new RoutesDefinition(); - rd.setRoutes(routeDefs); - requireSeparateDeclarations(ctx); - - StringWriter sw = new StringWriter(); - new org.apache.camel.xml.out.ModelWriter(sw).writeRoutesDefinition(rd); - return sw.toString(); - } - } finally { - ctx.stop(); + result.supported = true; + result.result = r.content(); + result.notes = r.notes(); + if (!r.notes().isEmpty()) { + result.note = String.join("; ", r.notes()); } + return result; } - private static String wrapSnippetIfNeeded(String source) { - if (CLASS_NAME_PATTERN.matcher(source).find()) { - return source; + /** A file name for the route as the converter tells the DSLs apart: Java by its class. */ + private static String fileName(String route, String format) { + if ("java".equals(format)) { + Matcher m = CLASS_NAME_PATTERN.matcher(route); + return (m.find() ? m.group(1) : "Route") + ".java"; } - return "import org.apache.camel.builder.RouteBuilder;\n\n" - + "public class SnippetRoute extends RouteBuilder {\n" - + " @Override\n" - + " public void configure() {\n" - + " " + source + "\n" - + " }\n" - + "}\n"; - } - - private static String extractClassName(String source) { - Matcher m = CLASS_NAME_PATTERN.matcher(source); - return m.find() ? m.group(1) : "Route"; + return "route.camel." + format; } // Result class for Jackson serialization @@ -249,6 +104,7 @@ public class TransformTools { public String fromFormat; public String toFormat; public String note; + public List<String> notes = List.of(); public boolean supported; public String result; } diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformToolsTest.java b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformToolsTest.java index a5dc8ed53fb3..fcee87765f55 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformToolsTest.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformToolsTest.java @@ -66,12 +66,12 @@ class TransformToolsTest { @CsvSource({ "yaml,xml", "java,xml", "java,yaml" }) void genericConversionDoesNotSilentlyLoseSemanticDeclarations(String source, String target) { assertThatThrownBy(() -> createTools().camel_transform_route(semanticRoute(source, "0.8"), source, target)) - .isInstanceOf(ToolCallException.class).hasMessageContaining("Keep them in a separate declaration resource"); + .isInstanceOf(ToolCallException.class).hasMessageContaining("keep them in a separate declaration resource"); } @ParameterizedTest @ValueSource(strings = { "xml", "yaml" }) - void emptySemanticRegistryDoesNotPreventConversion(String target) { + void anEmptySemanticDeclarationIsRefusedToo(String target) { String route = """ import org.apache.camel.builder.RouteBuilder; import static org.apache.camel.semantic.SemanticQuestionsBuilder.semanticQuestions; @@ -82,20 +82,23 @@ class TransformToolsTest { } } """; - var result = createTools().camel_transform_route(route, "java", target); - - assertThat(result.supported).isTrue(); - assertThat(result.result).contains("direct:input").doesNotContain("semantic"); + // read without running it, so whether it declares anything is not known: refused, not dropped + assertThatThrownBy(() -> createTools().camel_transform_route(route, "java", target)) + .isInstanceOf(ToolCallException.class).hasMessageContaining("semantic declarations"); } - @ParameterizedTest - @CsvSource({ "yaml,xml", "java,xml", "java,yaml" }) - void semanticConversionReportsMissingNumericProperty(String source, String target) { - String route = semanticRoute(source, "{{semantic.export.missing.threshold}}"); - - assertThatThrownBy(() -> createTools().camel_transform_route(route, source, target)) - .isInstanceOf(ToolCallException.class) - .hasMessageContaining("Property with key [semantic.export.missing.threshold] not found"); + @Test + void placeholdersAreNotResolved() { + // the routes are read, not run: a property that is not there is no error, the placeholder is kept + var result = createTools().camel_transform_route(""" + - route: + from: + uri: "timer:{{my.timer.name}}" + steps: + - log: "{{my.message}}" + """, "yaml", "xml"); + assertThat(result.supported).isTrue(); + assertThat(result.result).contains("timer:{{my.timer.name}}", "{{my.message}}"); } private static String semanticRoute(String source, String threshold) { @@ -251,6 +254,52 @@ class TransformToolsTest { assertThat(result.result).contains("log:foo"); } + @Test + void transformXmlToJava() { + String xml = """ + <routes xmlns="http://camel.apache.org/schema/xml-io"> + <!-- greetings --> + <route id="hello"> + <from uri="timer:tick"/> + <log message="Hello ${body}" loggingLevel="WARN"/> + </route> + </routes> + """; + TransformTools.TransformResult result = createTools().camel_transform_route(xml, "xml", "java"); + assertThat(result.supported).isTrue(); + assertThat(result.result).contains("public class Route extends RouteBuilder", "from(\"timer:tick\")", + ".routeId(\"hello\")", ".log(LoggingLevel.WARN, \"Hello ${body}\")"); + // what is not carried over is said + assertThat(result.notes).anyMatch(n -> n.contains("comments")); + assertThat(result.note).contains("comments"); + } + + @Test + void restsAreCarriedOver() { + String xml = """ + <rests xmlns="http://camel.apache.org/schema/xml-io"> + <rest path="/api"> + <get path="/orders"> + <to uri="direct:orders"/> + </get> + </rest> + </rests> + """; + TransformTools.TransformResult result = createTools().camel_transform_route(xml, "xml", "yaml"); + assertThat(result.supported).isTrue(); + assertThat(result.result).contains("- rest:", "path: /api", "get:", "path: /orders", "direct:orders"); + assertThat(result.notes).isEmpty(); + } + + @Test + void whatOnlyRunsIsRefusedWithTheReason() { + String java = """ + from("timer:tick").process(e -> e.getIn().setBody("x")).to("log:out"); + """; + assertThatThrownBy(() -> createTools().camel_transform_route(java, "java", "yaml")) + .isInstanceOf(ToolCallException.class).hasMessageContaining("cannot be converted without running it"); + } + @Test void sameFormatReturnsInput() { TransformTools tools = createTools();
