This is an automated email from the ASF dual-hosted git repository. luigidemasi pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel.git
commit e3ae9f9a4b314fb393b8d8ff8aaf371105f84859 Author: Luigi De Masi <[email protected]> AuthorDate: Tue Sep 29 16:50:41 2026 +0200 CAMEL-25138: Preserve semantic declarations across DSL loading and export Keep semantic declarations when loading JAXB routes and converting YAML or Java routes through MCP. Export registered question policies and prepare Java expression models without starting routes or invoking providers. Refresh XML preparse caches when resource content changes after a failed batch, while preserving unchanged preparse and deferred bean ownership. Add regression coverage for foreign-loader and earlier-builder failures. Preserve numeric placeholders in Java exports and resolve them before runtime validation. Add numeric catalog types and defaults, move optional component discovery into a helper, relocate the configurer SPI, and keep RoutesDefinition accessors together. Validation: 2978 tests across the affected Java 17 module suites, with no failures or errors and two existing skips. Full repository clean install with tests skipped passes on Java 25. Regenerated artifacts are included. Co-authored-by: Codex <[email protected]> Signed-off-by: Luigi De Masi <[email protected]> --- .../camel/catalog/docs/semantic-language.adoc | 15 ++- .../models-app/semanticQuestionDefinition.json | 6 +- components/camel-ai/camel-semantic/pom.xml | 5 + .../src/main/docs/semantic-language.adoc | 15 ++- .../DefaultSemanticDefinitionConfigurer.java | 37 ++++-- .../apache/camel/semantic/SemanticQuestions.java | 4 + .../yaml/SemanticDefinitionDeserializer.java | 18 +-- .../camel/semantic/SemanticDeclarationDslTest.java | 140 ++++++++++++++++++++- .../model/app/semanticQuestionDefinition.json | 6 +- .../org/apache/camel/builder/RouteBuilder.java | 5 +- .../org/apache/camel/model/RoutesDefinition.java | 16 +-- ...finition.java => SemanticDefinitionHelper.java} | 39 +----- .../apache/camel/model/app/SemanticDefinition.java | 22 ---- .../model/app/SemanticQuestionDefinition.java | 20 ++- .../{app => spi}/SemanticDefinitionConfigurer.java | 3 +- .../camel/java/out/JavaDslModelWriterSupport.java | 4 +- .../apache/camel/java/out/JavaDslCompileTest.java | 15 ++- .../java/org/apache/camel/xml/out/ModelWriter.java | 6 +- dsl/camel-jbang/camel-jbang-mcp/pom.xml | 10 ++ .../jbang/core/commands/mcp/TransformTools.java | 14 +++ .../core/commands/mcp/TransformToolsTest.java | 70 +++++++++++ .../camel/dsl/xml/io/XmlRoutesBuilderLoader.java | 28 +++-- .../dsl/xml/jaxb/JaxbXmlRoutesBuilderLoader.java | 1 + .../camel/dsl/yaml/SemanticQuestionTest.java | 15 +++ 24 files changed, 401 insertions(+), 113 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc index c360bacb879c..06e37072ac63 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc @@ -167,14 +167,25 @@ XML declarations are loaded before route references are initialized, including t resource loaded together. Reload replaces the source's questions; removing its declarations or deleting the resource removes obsolete definitions. +The JAXB XML loader also supports declarations inside a `routes` document using the +`http://camel.apache.org/schema/spring` namespace. With JAXB, load a separate declaration +resource before the routes that reference it. + +The numeric `threshold` and `uncertainty` options accept property placeholders in all three +DSLs. For example, Java accepts `.threshold("{{semantic.threshold:0.5}}")`, and XML accepts +`threshold="{{semantic.threshold:0.5}}"`. Values are resolved and validated when declarations +are registered; Java model exports preserve the placeholder text. + Both Java and XML declarations require `camel-semantic` and use the same context-wide registry, validation, defaults and adapters as YAML. They do not require `camel-yaml-dsl`. Their questions can also be selected together using `refs:name1,name2`, as described below. === Exporting declarations -Direct exports of a `RoutesDefinition` containing semantic declarations preserve them, as does -XML-to-YAML conversion. Runtime route dumps such as `camel.main.dumpRoutes=yaml` contain only +Direct exports of a `RoutesDefinition` containing semantic declarations preserve them. The MCP +route transformation tool also preserves declarations in XML-to-YAML, YAML-to-XML, and +Java-to-YAML/XML conversions. Conversions that load a Camel context export the registered +questions with their effective numeric policies. Runtime route dumps such as `camel.main.dumpRoutes=yaml` contain only the routes and omit the question declarations. Supply the declarations separately before reloading those dumps. diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models-app/semanticQuestionDefinition.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models-app/semanticQuestionDefinition.json index 12d4bbcbe256..a530faab4979 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models-app/semanticQuestionDefinition.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models-app/semanticQuestionDefinition.json @@ -15,9 +15,9 @@ "name": { "index": 0, "kind": "attribute", "displayName": "Name", "group": "common", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The context-wide question name." }, "type": { "index": 1, "kind": "attribute", "displayName": "Type", "group": "common", "required": true, "type": "enum", "javaType": "java.lang.String", "enum": [ "boolean", "choice", "score" ], "deprecated": false, "autowired": false, "secret": false, "description": "The question type." }, "state": { "index": 2, "kind": "attribute", "displayName": "State", "group": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The Simple expression selecting the message state." }, - "threshold": { "index": 3, "kind": "attribute", "displayName": "Threshold", "group": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The boolean decision threshold." }, - "uncertainty": { "index": 4, "kind": "attribute", "displayName": "Uncertainty", "group": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The boolean uncertainty band." }, - "uncertaintyPolicy": { "index": 5, "kind": "attribute", "displayName": "Uncertainty Policy", "group": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "fail", "non-match" ], "deprecated": false, "autowired": false, "secret": false, "description": "The boolean uncertainty policy." }, + "threshold": { "index": 3, "kind": "attribute", "displayName": "Threshold", "group": "common", "required": false, "type": "number", "javaType": "java.lang.Double", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "0.5", "description": "The boolean decision threshold." }, + "uncertainty": { "index": 4, "kind": "attribute", "displayName": "Uncertainty", "group": "common", "required": false, "type": "number", "javaType": "java.lang.Double", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "0", "description": "The boolean uncertainty band." }, + "uncertaintyPolicy": { "index": 5, "kind": "attribute", "displayName": "Uncertainty Policy", "group": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "fail", "non-match" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "fail", "description": "The boolean uncertainty policy." }, "instructions": { "index": 6, "kind": "element", "displayName": "Instructions", "group": "common", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "Instructions describing the judgment to make." }, "criterion": { "index": 7, "kind": "element", "displayName": "Criterion", "group": "common", "required": false, "type": "array", "javaType": "java.util.List<org.apache.camel.model.PropertyDefinition>", "deprecated": false, "autowired": false, "secret": false, "description": "Named choice criteria, or optional true\/false boolean criteria." }, "level": { "index": 8, "kind": "element", "displayName": "Level", "group": "common", "required": false, "type": "array", "javaType": "java.util.List<java.lang.String>", "deprecated": false, "autowired": false, "secret": false, "description": "Ordered descriptive levels for a score question." } diff --git a/components/camel-ai/camel-semantic/pom.xml b/components/camel-ai/camel-semantic/pom.xml index 833a7d9174d3..25e764518c15 100644 --- a/components/camel-ai/camel-semantic/pom.xml +++ b/components/camel-ai/camel-semantic/pom.xml @@ -52,6 +52,11 @@ <artifactId>camel-xml-io-dsl</artifactId> <scope>test</scope> </dependency> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-xml-jaxb-dsl</artifactId> + <scope>test</scope> + </dependency> <dependency> <groupId>org.apache.camel</groupId> <artifactId>camel-main</artifactId> diff --git a/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc b/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc index c360bacb879c..06e37072ac63 100644 --- a/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc +++ b/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc @@ -167,14 +167,25 @@ XML declarations are loaded before route references are initialized, including t resource loaded together. Reload replaces the source's questions; removing its declarations or deleting the resource removes obsolete definitions. +The JAXB XML loader also supports declarations inside a `routes` document using the +`http://camel.apache.org/schema/spring` namespace. With JAXB, load a separate declaration +resource before the routes that reference it. + +The numeric `threshold` and `uncertainty` options accept property placeholders in all three +DSLs. For example, Java accepts `.threshold("{{semantic.threshold:0.5}}")`, and XML accepts +`threshold="{{semantic.threshold:0.5}}"`. Values are resolved and validated when declarations +are registered; Java model exports preserve the placeholder text. + Both Java and XML declarations require `camel-semantic` and use the same context-wide registry, validation, defaults and adapters as YAML. They do not require `camel-yaml-dsl`. Their questions can also be selected together using `refs:name1,name2`, as described below. === Exporting declarations -Direct exports of a `RoutesDefinition` containing semantic declarations preserve them, as does -XML-to-YAML conversion. Runtime route dumps such as `camel.main.dumpRoutes=yaml` contain only +Direct exports of a `RoutesDefinition` containing semantic declarations preserve them. The MCP +route transformation tool also preserves declarations in XML-to-YAML, YAML-to-XML, and +Java-to-YAML/XML conversions. Conversions that load a Camel context export the registered +questions with their effective numeric policies. Runtime route dumps such as `camel.main.dumpRoutes=yaml` contain only the routes and omit the question declarations. Supply the declarations separately before reloading those dumps. diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/DefaultSemanticDefinitionConfigurer.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/DefaultSemanticDefinitionConfigurer.java index 871f378049fb..9971fe18596d 100644 --- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/DefaultSemanticDefinitionConfigurer.java +++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/DefaultSemanticDefinitionConfigurer.java @@ -17,13 +17,14 @@ package org.apache.camel.semantic; import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; import org.apache.camel.CamelContext; import org.apache.camel.model.PropertyDefinition; import org.apache.camel.model.app.SemanticDefinition; -import org.apache.camel.model.app.SemanticDefinitionConfigurer; import org.apache.camel.model.app.SemanticQuestionDefinition; +import org.apache.camel.model.spi.SemanticDefinitionConfigurer; import org.apache.camel.spi.Resource; import org.apache.camel.util.StringHelper; @@ -42,7 +43,7 @@ public class DefaultSemanticDefinitionConfigurer implements SemanticDefinitionCo throw new IllegalArgumentException("Duplicate semantic question: " + name); } try { - questions.put(name, question(question)); + questions.put(name, question(context, question)); } catch (IllegalArgumentException e) { throw new IllegalArgumentException("Invalid semantic question '" + name + "': " + e.getMessage(), e); } @@ -52,7 +53,29 @@ public class DefaultSemanticDefinitionConfigurer implements SemanticDefinitionCo SemanticQuestions.get(context).replace("model:" + source, resource, questions); } - private static SemanticQuestion question(SemanticQuestionDefinition definition) { + /** Export a snapshot of the registered questions and their effective decision policies. */ + public static SemanticDefinition getDefinition(CamelContext context) { + SemanticQuestions questions = context.getCamelContextExtension().getContextPlugin(SemanticQuestions.class); + if (questions == null) { + return null; + } + SemanticDefinition definition = new SemanticDefinition(); + questions.snapshot().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> { + SemanticQuestion question = entry.getValue(); + SemanticQuestionDefinition target = definition.question(entry.getKey()) + .type(question.getType().name().toLowerCase(Locale.ROOT)) + .instructions(question.getInstructions()).state(question.getState()); + question.getCriteria().forEach(target::criterion); + question.getLevels().forEach(target::level); + if (question.getType() == SemanticQuestion.Type.BOOLEAN) { + target.threshold(question.getThreshold()).uncertainty(question.getUncertainty()) + .uncertaintyPolicy(question.getUncertaintyPolicy().name().toLowerCase(Locale.ROOT).replace('_', '-')); + } + }); + return definition; + } + + private static SemanticQuestion question(CamelContext context, SemanticQuestionDefinition definition) { if (definition.getType() == null) { throw new IllegalArgumentException("Question type is required"); } @@ -72,16 +95,16 @@ public class DefaultSemanticDefinitionConfigurer implements SemanticDefinitionCo return new SemanticQuestion( type, definition.getInstructions(), definition.getState(), criteria, definition.getLevels(), - definition.getThreshold() == null ? 0.5 : parseDouble(definition.getThreshold(), "threshold"), - definition.getUncertainty() == null ? 0 : parseDouble(definition.getUncertainty(), "uncertainty"), + definition.getThreshold() == null ? 0.5 : parseDouble(context, definition.getThreshold(), "threshold"), + definition.getUncertainty() == null ? 0 : parseDouble(context, definition.getUncertainty(), "uncertainty"), definition.getUncertaintyPolicy() == null ? SemanticQuestion.UncertaintyPolicy.FAIL : enumeration(definition.getUncertaintyPolicy(), SemanticQuestion.UncertaintyPolicy.class)); } - private static double parseDouble(String value, String field) { + private static double parseDouble(CamelContext context, String value, String field) { try { - return Double.parseDouble(value); + return Double.parseDouble(context.resolvePropertyPlaceholders(value)); } catch (NumberFormatException e) { throw new IllegalArgumentException(field + " must be a valid number: " + value, e); } diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestions.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestions.java index cccc22e48ae6..5114f0107972 100644 --- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestions.java +++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestions.java @@ -90,6 +90,10 @@ public final class SemanticQuestions { .forEach(location -> replace(location, Map.of())); } + Map<String, SemanticQuestion> snapshot() { + return questions; + } + public SemanticQuestion get(String name) { SemanticQuestion question = questions.get(name); if (question == null) { diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/yaml/SemanticDefinitionDeserializer.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/yaml/SemanticDefinitionDeserializer.java index b4f049f8aca5..f2cd3ef54248 100644 --- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/yaml/SemanticDefinitionDeserializer.java +++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/yaml/SemanticDefinitionDeserializer.java @@ -57,7 +57,7 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl @Override public Object construct(Node node) { - read(node); + read(getDeserializationContext(node).getCamelContext(), node); // Registration happens once for the entire resource, including declarations after routes. return (CamelContextCustomizer) context -> { }; @@ -76,7 +76,7 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl } for (NodeTuple tuple : mapping.getValue()) { if ("semantic".equals(asText(tuple.getKeyNode()))) { - read(tuple.getValueNode()).forEach((name, question) -> { + read(dc.getCamelContext(), tuple.getValueNode()).forEach((name, question) -> { if (definitions.putIfAbsent(name, question) != null) { throw new YamlDeserializationException( tuple.getValueNode(), "Duplicate semantic question: " + name); @@ -98,7 +98,7 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl } } - private static Map<String, SemanticQuestion> read(Node node) { + private static Map<String, SemanticQuestion> read(CamelContext context, Node node) { Map<String, Node> semantic = fields(node, "semantic declaration"); if (!semantic.keySet().equals(Set.of("question"))) { throw new YamlDeserializationException(node, "Semantic declaration requires only question"); @@ -109,7 +109,7 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl throw new YamlDeserializationException(definition, "Semantic question requires a nonblank name"); } try { - result.put(name, readQuestion(name, definition)); + result.put(name, readQuestion(context, name, definition)); } catch (IllegalArgumentException e) { throw new YamlDeserializationException( definition, "Invalid semantic question '" + name + "': " + e.getMessage(), e); @@ -118,7 +118,7 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl return result; } - private static SemanticQuestion readQuestion(String name, Node definition) { + private static SemanticQuestion readQuestion(CamelContext context, String name, Node definition) { Map<String, Node> values = fields(definition, "semantic question '" + name + "'"); values.forEach((field, value) -> { if (!FIELDS.contains(field)) { @@ -152,7 +152,8 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl : SemanticQuestion.UncertaintyPolicy.FAIL; return new SemanticQuestion( type, asText(values.get("instructions")), asText(values.get("state")), - criteria, levels, number(values, name, "threshold", 0.5), number(values, name, "uncertainty", 0), policy); + criteria, levels, number(context, values, name, "threshold", 0.5), + number(context, values, name, "uncertainty", 0), policy); } private static <T extends Enum<T>> T enumeration(Node node, String question, String field, Class<T> type) { @@ -164,14 +165,15 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl } } - private static double number(Map<String, Node> values, String question, String name, double fallback) { + private static double number( + CamelContext context, Map<String, Node> values, String question, String name, double fallback) { if (!values.containsKey(name)) { return fallback; } Node node = values.get(name); String raw = asText(node); try { - return Double.parseDouble(raw); + return Double.parseDouble(context.resolvePropertyPlaceholders(raw)); } catch (NumberFormatException e) { throw new YamlDeserializationException( node, diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticDeclarationDslTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticDeclarationDslTest.java index e2b3f13a7f24..6115b5a8c442 100644 --- a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticDeclarationDslTest.java +++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticDeclarationDslTest.java @@ -25,8 +25,11 @@ import java.util.stream.Stream; import org.apache.camel.Exchange; import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.dsl.support.RouteBuilderLoaderSupport; +import org.apache.camel.dsl.xml.jaxb.JaxbXmlRoutesBuilderLoader; import org.apache.camel.impl.DefaultCamelContext; import org.apache.camel.language.semantic.SemanticLanguage; +import org.apache.camel.model.SemanticDefinitionHelper; import org.apache.camel.model.app.SemanticDefinition; import org.apache.camel.spi.Resource; import org.apache.camel.support.DefaultExchange; @@ -113,7 +116,7 @@ class SemanticDeclarationDslTest { } @ParameterizedTest - @ValueSource(strings = { "java", "camel", "routes" }) + @ValueSource(strings = { "java", "camel", "routes", "jaxb" }) void nativeDeclarationsEvaluateSingleQuestionsAndMixedBatches(String dsl) throws Exception { if (dsl.equals("java")) { context.addRoutes(new RouteBuilder() { @@ -135,7 +138,16 @@ class SemanticDeclarationDslTest { <setHeader name="department"><simple>${exchangeProperty.decision[department]}</simple></setHeader> </route> """; - load("questions.xml", document(dsl, route + xmlQuestions())); + if (dsl.equals("jaxb")) { + try (var loader = new JaxbXmlRoutesBuilderLoader()) { + loader.setCamelContext(context); + loader.start(); + context.addRoutes(loader.loadRoutesBuilder( + ResourceHelper.fromString("questions.xml", document("routes", route + xmlQuestions())))); + } + } else { + load("questions.xml", document(dsl, route + xmlQuestions())); + } } assertThat(states).isEmpty(); SemanticQuestion urgent = SemanticQuestions.get(context).get("urgent"); @@ -233,10 +245,10 @@ class SemanticDeclarationDslTest { Resource resource = trackResource ? ResourceHelper.fromString("questions.xml", "") : null; SemanticDefinition definition = new SemanticDefinition(); definition.question("first").type("boolean").instructions("Valid?"); - SemanticDefinition.configure(context, resource, "declared-source", definition); + SemanticDefinitionHelper.configure(context, resource, "declared-source", definition); SemanticDefinition replacement = new SemanticDefinition(); replacement.question("second").type("boolean").instructions("Updated?"); - SemanticDefinition.configure(context, resource, "declared-source", replacement); + SemanticDefinitionHelper.configure(context, resource, "declared-source", replacement); SemanticQuestions questions = SemanticQuestions.get(context); assertThatThrownBy(() -> questions.get("first")).hasMessageContaining("Unknown"); assertThat(questions.get("second")).isNotNull(); @@ -295,6 +307,120 @@ class SemanticDeclarationDslTest { assertThat(states).isEmpty(); } + @ParameterizedTest + @ValueSource(strings = { "java", "xml" }) + void numericPlaceholdersResolveBeforeValidation(String dsl) throws Exception { + if (dsl.equals("java")) { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + semanticQuestions().question("urgent").type("boolean").instructions("Urgent?") + .threshold("{{threshold:0.8}}").uncertainty("{{uncertainty:0.1}}"); + } + }); + } else { + load("questions.xml", document("routes", xmlQuestions() + .replace("threshold=\"0.8\"", "threshold=\"{{threshold:0.8}}\"") + .replace("uncertainty=\"0.1\"", "uncertainty=\"{{uncertainty:0.1}}\""))); + } + assertThat(SemanticQuestions.get(context).get("urgent").getThreshold()).isEqualTo(0.8); + assertThat(SemanticQuestions.get(context).get("urgent").getUncertainty()).isEqualTo(0.1); + assertThatThrownBy(() -> load("invalid.xml", document("routes", """ + <semantic><question name="invalid" type="boolean" threshold="{{threshold:abc}}"> + <instructions>Urgent?</instructions> + </question></semantic> + """))) + .hasMessageContaining("Invalid semantic question 'invalid': threshold must be a valid number") + .hasRootCauseInstanceOf(NumberFormatException.class); + } + + @ParameterizedTest + @ValueSource(strings = { "routes", "camel" }) + void repeatedUnchangedPreparsePreservesDeclarationsAndBeans(String root) throws Exception { + var loader = PluginHelper.getRoutesLoader(context).getRoutesLoader("xml"); + Resource resource = ResourceHelper.fromString("questions.xml", document(root, xmlQuestions() + + (root.equals("camel") + ? "<bean name=\"counter\" type=\"java.util.concurrent.atomic.AtomicInteger\"/>" + : ""))); + loader.preParseRoute(resource); + SemanticQuestion question = SemanticQuestions.get(context).get("urgent"); + Object bean = context.getRegistry().lookupByName("counter"); + loader.preParseRoute(resource); + assertThat(SemanticQuestions.get(context).get("urgent")).isSameAs(question); + if (root.equals("camel")) { + assertThat(bean).isNotNull(); + assertThat(context.getRegistry().lookupByName("counter")).isSameAs(bean); + } + } + + @ParameterizedTest + @MethodSource("externalFailures") + void retryAfterExternalFailureReadsChangedXml(String root, boolean failPreparse) throws Exception { + var failingLoader = new RouteBuilderLoaderSupport("fail") { + @Override + public void preParseRoute(Resource resource) { + if (failPreparse) { + throw new IllegalStateException("foreign preparse failure"); + } + } + + @Override + public RouteBuilder doLoadRouteBuilder(Resource resource) { + return new RouteBuilder() { + @Override + public void configure() { + throw new IllegalStateException("earlier builder failure"); + } + }; + } + }; + context.getRegistry().bind("routes-builder-loader-fail", failingLoader); + Path file = directory.resolve("questions.xml"); + Files.writeString(file, document(root, xmlQuestions())); + Resource resource = ResourceHelper.resolveResource(context, file.toUri().toString()); + Resource broken = ResourceHelper.fromString("broken.fail", ""); + var loader = PluginHelper.getRoutesLoader(context); + assertThatThrownBy(() -> loader.loadRoutes(failPreparse + ? List.of(resource, broken) : List.of(broken, resource))) + .hasMessageContaining(failPreparse ? "foreign preparse failure" : "earlier builder failure"); + assertThat(SemanticQuestions.get(context).get("department").getState()).isEqualTo("${header.myState}"); + Files.writeString(file, document(root, xmlQuestions().replace("header.myState", "header.updated") + """ + <route id="updated"><from uri="direct:updated"/> + <setBody><language language="semantic">ref:department</language></setBody> + </route> + """)); + loader.loadRoutes(resource); + assertThat(SemanticQuestions.get(context).get("department").getState()).isEqualTo("${header.updated}"); + try (var template = context.createProducerTemplate()) { + assertThat(template.requestBodyAndHeader("direct:updated", "old", "updated", "new")).isEqualTo("billing"); + } + assertThat(states).containsExactly("new"); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void deferredBeansKeepTheirOwningResourceWhenAnotherBuilderFails(boolean removeBean) throws Exception { + var loader = PluginHelper.getRoutesLoader(context); + Resource first = ResourceHelper.fromString("first.xml", document("camel", xmlQuestions())); + Resource second = ResourceHelper.fromString("second.xml", document("camel", """ + <bean name="deferred" type="java.lang.String" factoryBean="factory" factoryMethod="create"/> + """)); + assertThatThrownBy(() -> loader.loadRoutes(List.of(first, second))).hasMessageContaining("Error creating bean"); + if (removeBean) { + loader.loadRoutes(ResourceHelper.fromString("second.xml", document("camel", ""))); + assertThat(context.getRegistry().lookupByName("deferred")).isNull(); + } else { + context.getRegistry().bind("factory", new DeferredBeanFactory()); + loader.loadRoutes(second); + assertThat(context.getRegistry().lookupByName("deferred")).isEqualTo("created"); + } + } + + static Stream<Arguments> externalFailures() { + return Stream.of(Arguments.of("routes", true), Arguments.of("routes", false), + Arguments.of("camel", true), Arguments.of("camel", false)); + } + @Test void failedResourceBatchDoesNotReuseEarlierCachedDeclarationsOnRetry() throws Exception { String second = "<semantic><question name=\"second\" type=\"boolean\">" @@ -378,6 +504,12 @@ class SemanticDeclarationDslTest { PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString(location, xml)); } + public static class DeferredBeanFactory { + public static String create() { + return "created"; + } + } + private static class TestWatcher extends RouteWatcherReloadStrategy { void reload(Resource resource) { onRouteReload(List.of(resource), false); diff --git a/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/app/semanticQuestionDefinition.json b/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/app/semanticQuestionDefinition.json index 12d4bbcbe256..a530faab4979 100644 --- a/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/app/semanticQuestionDefinition.json +++ b/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/app/semanticQuestionDefinition.json @@ -15,9 +15,9 @@ "name": { "index": 0, "kind": "attribute", "displayName": "Name", "group": "common", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The context-wide question name." }, "type": { "index": 1, "kind": "attribute", "displayName": "Type", "group": "common", "required": true, "type": "enum", "javaType": "java.lang.String", "enum": [ "boolean", "choice", "score" ], "deprecated": false, "autowired": false, "secret": false, "description": "The question type." }, "state": { "index": 2, "kind": "attribute", "displayName": "State", "group": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The Simple expression selecting the message state." }, - "threshold": { "index": 3, "kind": "attribute", "displayName": "Threshold", "group": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The boolean decision threshold." }, - "uncertainty": { "index": 4, "kind": "attribute", "displayName": "Uncertainty", "group": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The boolean uncertainty band." }, - "uncertaintyPolicy": { "index": 5, "kind": "attribute", "displayName": "Uncertainty Policy", "group": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "fail", "non-match" ], "deprecated": false, "autowired": false, "secret": false, "description": "The boolean uncertainty policy." }, + "threshold": { "index": 3, "kind": "attribute", "displayName": "Threshold", "group": "common", "required": false, "type": "number", "javaType": "java.lang.Double", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "0.5", "description": "The boolean decision threshold." }, + "uncertainty": { "index": 4, "kind": "attribute", "displayName": "Uncertainty", "group": "common", "required": false, "type": "number", "javaType": "java.lang.Double", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "0", "description": "The boolean uncertainty band." }, + "uncertaintyPolicy": { "index": 5, "kind": "attribute", "displayName": "Uncertainty Policy", "group": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "fail", "non-match" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "fail", "description": "The boolean uncertainty policy." }, "instructions": { "index": 6, "kind": "element", "displayName": "Instructions", "group": "common", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "Instructions describing the judgment to make." }, "criterion": { "index": 7, "kind": "element", "displayName": "Criterion", "group": "common", "required": false, "type": "array", "javaType": "java.util.List<org.apache.camel.model.PropertyDefinition>", "deprecated": false, "autowired": false, "secret": false, "description": "Named choice criteria, or optional true\/false boolean criteria." }, "level": { "index": 8, "kind": "element", "displayName": "Level", "group": "common", "required": false, "type": "array", "javaType": "java.util.List<java.lang.String>", "deprecated": false, "autowired": false, "secret": false, "description": "Ordered descriptive levels for a score question." } diff --git a/core/camel-core-model/src/main/java/org/apache/camel/builder/RouteBuilder.java b/core/camel-core-model/src/main/java/org/apache/camel/builder/RouteBuilder.java index 5897665f64f1..5e2e23be0065 100644 --- a/core/camel-core-model/src/main/java/org/apache/camel/builder/RouteBuilder.java +++ b/core/camel-core-model/src/main/java/org/apache/camel/builder/RouteBuilder.java @@ -46,6 +46,7 @@ import org.apache.camel.model.RouteDefinition; import org.apache.camel.model.RouteTemplateDefinition; import org.apache.camel.model.RouteTemplatesDefinition; import org.apache.camel.model.RoutesDefinition; +import org.apache.camel.model.SemanticDefinitionHelper; import org.apache.camel.model.TemplatedRouteDefinition; import org.apache.camel.model.TemplatedRoutesDefinition; import org.apache.camel.model.app.SemanticDefinition; @@ -202,6 +203,8 @@ public abstract class RouteBuilder extends BuilderSupport implements RoutesBuild /** * Declares named semantic questions for use with the semantic language. Requires camel-semantic at runtime. + * + * @since 4.23 */ public SemanticDefinition semanticQuestions() { if (getRouteCollection().getSemantic() == null) { @@ -925,7 +928,7 @@ public abstract class RouteBuilder extends BuilderSupport implements RoutesBuild String source = getResource() != null ? getResource().getLocation() : "java:" + camelContext.getUuidGenerator().generateUuid(); - SemanticDefinition.configure(camelContext, getResource(), source, getRouteCollection().getSemantic()); + SemanticDefinitionHelper.configure(camelContext, getResource(), source, getRouteCollection().getSemantic()); } // remember the source resource diff --git a/core/camel-core-model/src/main/java/org/apache/camel/model/RoutesDefinition.java b/core/camel-core-model/src/main/java/org/apache/camel/model/RoutesDefinition.java index f1ae6086fb01..b171aa164699 100644 --- a/core/camel-core-model/src/main/java/org/apache/camel/model/RoutesDefinition.java +++ b/core/camel-core-model/src/main/java/org/apache/camel/model/RoutesDefinition.java @@ -83,14 +83,6 @@ public class RoutesDefinition extends OptionalIdentifiedDefinition<RoutesDefinit @Metadata(description = "Named semantic question declarations.") private SemanticDefinition semantic; - public SemanticDefinition getSemantic() { - return semantic; - } - - public void setSemantic(SemanticDefinition semantic) { - this.semantic = semantic; - } - public RoutesDefinition() { } @@ -121,6 +113,14 @@ public class RoutesDefinition extends OptionalIdentifiedDefinition<RoutesDefinit this.routes = routes; } + public SemanticDefinition getSemantic() { + return semantic; + } + + public void setSemantic(SemanticDefinition semantic) { + this.semantic = semantic; + } + public List<InterceptFromDefinition> getInterceptFroms() { return interceptFroms; } diff --git a/core/camel-core-model/src/main/java/org/apache/camel/model/app/SemanticDefinition.java b/core/camel-core-model/src/main/java/org/apache/camel/model/SemanticDefinitionHelper.java similarity index 63% copy from core/camel-core-model/src/main/java/org/apache/camel/model/app/SemanticDefinition.java copy to core/camel-core-model/src/main/java/org/apache/camel/model/SemanticDefinitionHelper.java index ac1f2f50c0dd..95f4a8b1c922 100644 --- a/core/camel-core-model/src/main/java/org/apache/camel/model/app/SemanticDefinition.java +++ b/core/camel-core-model/src/main/java/org/apache/camel/model/SemanticDefinitionHelper.java @@ -14,43 +14,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.model.app; - -import java.util.ArrayList; -import java.util.List; - -import jakarta.xml.bind.annotation.XmlAccessType; -import jakarta.xml.bind.annotation.XmlAccessorType; -import jakarta.xml.bind.annotation.XmlElement; -import jakarta.xml.bind.annotation.XmlType; +package org.apache.camel.model; import org.apache.camel.CamelContext; -import org.apache.camel.spi.Metadata; +import org.apache.camel.model.app.SemanticDefinition; +import org.apache.camel.model.spi.SemanticDefinitionConfigurer; import org.apache.camel.spi.Resource; -/** Named, provider-independent semantic question declarations. */ -@Metadata(label = "configuration") -@XmlType(name = "semanticDefinition") -@XmlAccessorType(XmlAccessType.FIELD) -public class SemanticDefinition { - @XmlElement(name = "question") - @Metadata(description = "Named semantic questions shared by routes in this Camel context.") - private List<SemanticQuestionDefinition> questions = new ArrayList<>(); - - public List<SemanticQuestionDefinition> getQuestions() { - return questions; - } - - public void setQuestions(List<SemanticQuestionDefinition> questions) { - this.questions = questions; - } - - /** Declare a named semantic question. */ - public SemanticQuestionDefinition question(String name) { - SemanticQuestionDefinition question = new SemanticQuestionDefinition(); - question.setName(name); - questions.add(question); - return question; +/** Connects semantic declarations to the optional semantic language module. */ +public final class SemanticDefinitionHelper { + private SemanticDefinitionHelper() { } /** Register declarations before route initialization, without requiring semantic support for ordinary routes. */ diff --git a/core/camel-core-model/src/main/java/org/apache/camel/model/app/SemanticDefinition.java b/core/camel-core-model/src/main/java/org/apache/camel/model/app/SemanticDefinition.java index ac1f2f50c0dd..ae3fe8a55e8e 100644 --- a/core/camel-core-model/src/main/java/org/apache/camel/model/app/SemanticDefinition.java +++ b/core/camel-core-model/src/main/java/org/apache/camel/model/app/SemanticDefinition.java @@ -24,9 +24,7 @@ import jakarta.xml.bind.annotation.XmlAccessorType; import jakarta.xml.bind.annotation.XmlElement; import jakarta.xml.bind.annotation.XmlType; -import org.apache.camel.CamelContext; import org.apache.camel.spi.Metadata; -import org.apache.camel.spi.Resource; /** Named, provider-independent semantic question declarations. */ @Metadata(label = "configuration") @@ -52,24 +50,4 @@ public class SemanticDefinition { questions.add(question); return question; } - - /** Register declarations before route initialization, without requiring semantic support for ordinary routes. */ - public static void configure( - CamelContext context, Resource resource, String source, SemanticDefinition definition) { - SemanticDefinitionConfigurer configurer - = context.getCamelContextExtension().getContextPlugin(SemanticDefinitionConfigurer.class); - if (configurer == null) { - if (definition == null || definition.getQuestions().isEmpty()) { - return; - } - // Concurrent discovery may create equivalent instances of the stateless default configurer. - // The semantic module synchronizes access to shared question state. - configurer = context.getCamelContextExtension().getDefaultFactoryFinder() - .newInstance("semantic-configurer", SemanticDefinitionConfigurer.class) - .orElseThrow(() -> new IllegalArgumentException( - "Semantic question declarations require camel-semantic on the classpath")); - context.getCamelContextExtension().addContextPlugin(SemanticDefinitionConfigurer.class, configurer); - } - configurer.configure(context, resource, source, definition); - } } diff --git a/core/camel-core-model/src/main/java/org/apache/camel/model/app/SemanticQuestionDefinition.java b/core/camel-core-model/src/main/java/org/apache/camel/model/app/SemanticQuestionDefinition.java index 1a5c70c833c3..15f4f6787c65 100644 --- a/core/camel-core-model/src/main/java/org/apache/camel/model/app/SemanticQuestionDefinition.java +++ b/core/camel-core-model/src/main/java/org/apache/camel/model/app/SemanticQuestionDefinition.java @@ -43,13 +43,13 @@ public class SemanticQuestionDefinition { @Metadata(description = "The Simple expression selecting the message state.") private String state; @XmlAttribute - @Metadata(description = "The boolean decision threshold.") + @Metadata(javaType = "java.lang.Double", defaultValue = "0.5", description = "The boolean decision threshold.") private String threshold; @XmlAttribute - @Metadata(description = "The boolean uncertainty band.") + @Metadata(javaType = "java.lang.Double", defaultValue = "0", description = "The boolean uncertainty band.") private String uncertainty; @XmlAttribute - @Metadata(enums = "fail,non-match", description = "The boolean uncertainty policy.") + @Metadata(defaultValue = "fail", enums = "fail,non-match", description = "The boolean uncertainty policy.") private String uncertaintyPolicy; @XmlElement(required = true) @Metadata(required = true, description = "Instructions describing the judgment to make.") @@ -147,13 +147,23 @@ public class SemanticQuestionDefinition { /** The boolean decision threshold. */ public SemanticQuestionDefinition threshold(double threshold) { - this.threshold = Double.toString(threshold); + return threshold(Double.toString(threshold)); + } + + /** The boolean decision threshold, allowing property placeholders. */ + public SemanticQuestionDefinition threshold(String threshold) { + this.threshold = threshold; return this; } /** The boolean uncertainty band. */ public SemanticQuestionDefinition uncertainty(double uncertainty) { - this.uncertainty = Double.toString(uncertainty); + return uncertainty(Double.toString(uncertainty)); + } + + /** The boolean uncertainty band, allowing property placeholders. */ + public SemanticQuestionDefinition uncertainty(String uncertainty) { + this.uncertainty = uncertainty; return this; } diff --git a/core/camel-core-model/src/main/java/org/apache/camel/model/app/SemanticDefinitionConfigurer.java b/core/camel-core-model/src/main/java/org/apache/camel/model/spi/SemanticDefinitionConfigurer.java similarity index 93% rename from core/camel-core-model/src/main/java/org/apache/camel/model/app/SemanticDefinitionConfigurer.java rename to core/camel-core-model/src/main/java/org/apache/camel/model/spi/SemanticDefinitionConfigurer.java index 49977893df9d..06485ca98106 100644 --- a/core/camel-core-model/src/main/java/org/apache/camel/model/app/SemanticDefinitionConfigurer.java +++ b/core/camel-core-model/src/main/java/org/apache/camel/model/spi/SemanticDefinitionConfigurer.java @@ -14,9 +14,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.camel.model.app; +package org.apache.camel.model.spi; import org.apache.camel.CamelContext; +import org.apache.camel.model.app.SemanticDefinition; import org.apache.camel.spi.Resource; /** Installs semantic declarations using the optional semantic language module. */ diff --git a/core/camel-java-io/src/main/java/org/apache/camel/java/out/JavaDslModelWriterSupport.java b/core/camel-java-io/src/main/java/org/apache/camel/java/out/JavaDslModelWriterSupport.java index 67d18af71fc0..fa651e1963c8 100644 --- a/core/camel-java-io/src/main/java/org/apache/camel/java/out/JavaDslModelWriterSupport.java +++ b/core/camel-java-io/src/main/java/org/apache/camel/java/out/JavaDslModelWriterSupport.java @@ -1151,10 +1151,10 @@ public abstract class JavaDslModelWriterSupport { result.append(".state(").append(quote(question.getState())).append(")"); } if (question.getThreshold() != null) { - result.append(".threshold(").append(Double.parseDouble(question.getThreshold())).append(")"); + result.append(".threshold(").append(quote(question.getThreshold())).append(")"); } if (question.getUncertainty() != null) { - result.append(".uncertainty(").append(Double.parseDouble(question.getUncertainty())).append(")"); + result.append(".uncertainty(").append(quote(question.getUncertainty())).append(")"); } if (question.getUncertaintyPolicy() != null) { result.append(".uncertaintyPolicy(").append(quote(question.getUncertaintyPolicy())).append(")"); diff --git a/core/camel-java-io/src/test/java/org/apache/camel/java/out/JavaDslCompileTest.java b/core/camel-java-io/src/test/java/org/apache/camel/java/out/JavaDslCompileTest.java index a8a462e986bb..ab3fc4fad7b8 100644 --- a/core/camel-java-io/src/test/java/org/apache/camel/java/out/JavaDslCompileTest.java +++ b/core/camel-java-io/src/test/java/org/apache/camel/java/out/JavaDslCompileTest.java @@ -139,12 +139,25 @@ public class JavaDslCompileTest { RoutesDefinition routes = new ModelParser(input, NAMESPACE).parseRoutesDefinition().orElseThrow(); String java = new LwModelToJavaDumper().dumpModelAsJava(null, routes); assertTrue(java.contains("semanticQuestions().question(\"department\")")); - assertTrue(java.contains(".threshold(0.8)")); + assertTrue(java.contains(".threshold(\"0.8\")")); List<String> errors = compile("SemanticRoutes", wrapInRouteBuilder("SemanticRoutes", List.of(java))); assertTrue(errors.isEmpty(), () -> errors + "\n" + java); } } + @Test + void semanticNumericPlaceholdersSurviveJavaExport() throws Exception { + try (var input = Files.newInputStream(XML_IO_RESOURCES.resolve("semantic-routes.xml"))) { + RoutesDefinition routes = new ModelParser(input, NAMESPACE).parseRoutesDefinition().orElseThrow(); + routes.getSemantic().getQuestions().get(0).threshold("{{threshold}}").uncertainty("{{uncertainty}}"); + String java = new LwModelToJavaDumper().dumpModelAsJava(null, routes); + assertTrue(java.contains(".threshold(\"{{threshold}}\")")); + assertTrue(java.contains(".uncertainty(\"{{uncertainty}}\")")); + List<String> errors = compile("SemanticPlaceholders", wrapInRouteBuilder("SemanticPlaceholders", List.of(java))); + assertTrue(errors.isEmpty(), () -> errors + "\n" + java); + } + } + @ParameterizedTest(name = "{0}") @MethodSource("xmlRouteFiles") void xmlToJavaDslCompiles(String xmlFile) throws Exception { diff --git a/core/camel-xml-io/src/generated/java/org/apache/camel/xml/out/ModelWriter.java b/core/camel-xml-io/src/generated/java/org/apache/camel/xml/out/ModelWriter.java index d6a50e2a08bf..d976097922d7 100644 --- a/core/camel-xml-io/src/generated/java/org/apache/camel/xml/out/ModelWriter.java +++ b/core/camel-xml-io/src/generated/java/org/apache/camel/xml/out/ModelWriter.java @@ -2144,9 +2144,9 @@ public class ModelWriter extends BaseWriter { doWriteAttribute("name", def.getName(), null); doWriteAttribute("type", def.getType(), null); doWriteAttribute("state", def.getState(), null); - doWriteAttribute("threshold", def.getThreshold(), null); - doWriteAttribute("uncertainty", def.getUncertainty(), null); - doWriteAttribute("uncertaintyPolicy", def.getUncertaintyPolicy(), null); + doWriteAttribute("threshold", def.getThreshold(), "0.5"); + doWriteAttribute("uncertainty", def.getUncertainty(), "0"); + doWriteAttribute("uncertaintyPolicy", def.getUncertaintyPolicy(), "fail"); doWriteElement("instructions", def.getInstructions(), this::doWriteString); doWriteList(null, "criterion", def.getCriteria(), this::doWritePropertyDefinition); doWriteList(null, "level", def.getLevels(), this::doWriteString); diff --git a/dsl/camel-jbang/camel-jbang-mcp/pom.xml b/dsl/camel-jbang/camel-jbang-mcp/pom.xml index 1cff39614136..227616abc2bb 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/pom.xml +++ b/dsl/camel-jbang/camel-jbang-mcp/pom.xml @@ -116,6 +116,11 @@ <artifactId>camel-java-joor-dsl</artifactId> </dependency> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-semantic</artifactId> + </dependency> + <!-- Swagger/OpenAPI parser for contract-first OpenAPI tools --> <dependency> <groupId>io.swagger.core.v3</groupId> @@ -147,6 +152,11 @@ </dependency> <!-- test dependencies --> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-xml-io-dsl</artifactId> + <scope>test</scope> + </dependency> <dependency> <groupId>io.quarkus</groupId> <artifactId>quarkus-junit</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 8666b7206d00..92cb87bb0fd2 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 @@ -30,8 +30,12 @@ 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.model.app.SemanticDefinition; +import org.apache.camel.semantic.DefaultSemanticDefinitionConfigurer; import org.apache.camel.spi.Resource; import org.apache.camel.support.PluginHelper; import org.apache.camel.support.ResourceHelper; @@ -154,6 +158,7 @@ public class TransformTools { RoutesDefinition rd = new RoutesDefinition(); rd.setRoutes(routeDefs); + rd.setSemantic(DefaultSemanticDefinitionConfigurer.getDefinition(ctx)); StringWriter sw = new StringWriter(); new org.apache.camel.xml.out.ModelWriter(sw).writeRoutesDefinition(rd); @@ -179,9 +184,17 @@ public class TransformTools { "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<>(); + SemanticDefinition semantic = DefaultSemanticDefinitionConfigurer.getDefinition(ctx); + if (semantic != null) { + roots.add(writer.writeSemanticDefinition(semantic)); + } for (RouteDefinition route : routeDefs) { roots.add(writer.writeRouteDefinition(route)); } @@ -189,6 +202,7 @@ public class TransformTools { } else { RoutesDefinition rd = new RoutesDefinition(); rd.setRoutes(routeDefs); + rd.setSemantic(DefaultSemanticDefinitionConfigurer.getDefinition(ctx)); StringWriter sw = new StringWriter(); new org.apache.camel.xml.out.ModelWriter(sw).writeRoutesDefinition(rd); 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 54a613b56314..4c45f5b9eb78 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 @@ -16,7 +16,13 @@ */ package org.apache.camel.dsl.jbang.core.commands.mcp; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.semantic.SemanticQuestions; +import org.apache.camel.support.PluginHelper; +import org.apache.camel.support.ResourceHelper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import static org.assertj.core.api.Assertions.assertThat; @@ -59,6 +65,70 @@ class TransformToolsTest { assertThat(result.result).contains("semantic:", "question:", "urgent:", "Urgent?", "ref:urgent"); } + @ParameterizedTest + @CsvSource({ "yaml,xml", "java,xml", "java,yaml" }) + void semanticDeclarationsSurviveConversionAndReload(String source, String target) throws Exception { + String yaml = """ + - semantic: + question: + urgent: + type: boolean + instructions: Urgent? + threshold: 0.8 + uncertainty: 0.1 + uncertaintyPolicy: non-match + department: + type: choice + state: ${header.selected} + instructions: Which department? + criteria: + billing: Invoices + technical: Bugs + priority: + type: score + instructions: Priority? + criteria: [Low, High] + - route: + from: + uri: direct:input + steps: + - setBody: + expression: + language: + language: semantic + expression: ref:department + """; + String java = """ + import org.apache.camel.builder.RouteBuilder; + public class SemanticRoute extends RouteBuilder { + public void configure() { + semanticQuestions().question("urgent").type("boolean").instructions("Urgent?") + .threshold(0.8).uncertainty(0.1).uncertaintyPolicy("non-match"); + semanticQuestions().question("department").type("choice").instructions("Which department?") + .state("${header.selected}").criterion("billing", "Invoices").criterion("technical", "Bugs"); + semanticQuestions().question("priority").type("score").instructions("Priority?") + .level("Low").level("High"); + from("direct:input").setBody().language("semantic", "ref:department"); + } + } + """; + var result = createTools().camel_transform_route(source.equals("java") ? java : yaml, source, target); + assertThat(result.supported).isTrue(); + assertThat(result.result).contains("ref:department"); + try (var context = new DefaultCamelContext()) { + context.build(); + PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("result." + target, result.result)); + var questions = SemanticQuestions.get(context); + assertThat(questions.get("urgent").getThreshold()).isEqualTo(0.8); + assertThat(questions.get("urgent").getUncertainty()).isEqualTo(0.1); + assertThat(questions.get("urgent").getUncertaintyPolicy().name()).isEqualTo("NON_MATCH"); + assertThat(questions.get("department").getCriteria()).containsEntry("billing", "Invoices") + .containsEntry("technical", "Bugs"); + assertThat(questions.get("department").getState()).isEqualTo("${header.selected}"); + assertThat(questions.get("priority").getLevels()).containsExactly("Low", "High"); + } + } + @Test void transformYamlToXml() { TransformTools tools = createTools(); diff --git a/dsl/camel-xml-io-dsl/src/main/java/org/apache/camel/dsl/xml/io/XmlRoutesBuilderLoader.java b/dsl/camel-xml-io-dsl/src/main/java/org/apache/camel/dsl/xml/io/XmlRoutesBuilderLoader.java index a945d754496f..2319f7c35648 100644 --- a/dsl/camel-xml-io-dsl/src/main/java/org/apache/camel/dsl/xml/io/XmlRoutesBuilderLoader.java +++ b/dsl/camel-xml-io-dsl/src/main/java/org/apache/camel/dsl/xml/io/XmlRoutesBuilderLoader.java @@ -18,6 +18,7 @@ package org.apache.camel.dsl.xml.io; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -43,6 +44,7 @@ import org.apache.camel.model.RouteDefinition; import org.apache.camel.model.RouteTemplateDefinition; import org.apache.camel.model.RouteTemplatesDefinition; import org.apache.camel.model.RoutesDefinition; +import org.apache.camel.model.SemanticDefinitionHelper; import org.apache.camel.model.TemplatedRouteDefinition; import org.apache.camel.model.TemplatedRoutesDefinition; import org.apache.camel.model.app.BeansDefinition; @@ -94,16 +96,25 @@ public class XmlRoutesBuilderLoader extends RouteBuilderLoaderSupport { public void preParseRoute(Resource resource) throws Exception { // preparsing is done at early stage, so we have a chance to load additional beans and populate // Camel registry - if (preparseDone.getOrDefault(resource.getLocation(), false)) { - return; - } try { - XmlStreamInfo xmlInfo = xmlInfo(resource); + // Main may preparse twice. Reuse unchanged input, but refresh edited resources after a failed batch. + Resource snapshot = new CachedResource(resource); + Resource previous = resourceCache.get(resource.getLocation()); + if (preparseDone.getOrDefault(resource.getLocation(), false) && previous != null) { + try (var current = snapshot.getInputStream(); var cached = previous.getInputStream()) { + if (Arrays.equals(current.readAllBytes(), cached.readAllBytes())) { + return; + } + } + } + clearCaches(resource.getLocation()); + resourceCache.put(resource.getLocation(), snapshot); + XmlStreamInfo xmlInfo = xmlInfo(snapshot); if (xmlInfo.isValid()) { String root = xmlInfo.getRootElementName(); SemanticDefinition semantic = null; if ("beans".equals(root) || "blueprint".equals(root) || "camel".equals(root)) { - new XmlModelParser(resource, xmlInfo.getRootElementNamespace()) + new XmlModelParser(snapshot, xmlInfo.getRootElementNamespace()) .parseBeansDefinition() .ifPresent(bd -> { registerBeans(resource, bd); @@ -114,14 +125,14 @@ public class XmlRoutesBuilderLoader extends RouteBuilderLoaderSupport { semantic = app.getSemantic(); } } else if ("routes".equals(root) || "route".equals(root)) { - RoutesDefinition routes = new XmlModelParser(resource(resource), xmlInfo.getRootElementNamespace()) + RoutesDefinition routes = new XmlModelParser(snapshot, xmlInfo.getRootElementNamespace()) .parseRoutesDefinition().orElse(null); if (routes != null) { routesCache.put(resource.getLocation(), routes); semantic = routes.getSemantic(); } } - SemanticDefinition.configure(getCamelContext(), resource, resource.getLocation(), semantic); + SemanticDefinitionHelper.configure(getCamelContext(), resource, resource.getLocation(), semantic); } preparseDone.put(resource.getLocation(), true); } catch (Exception e) { @@ -131,6 +142,7 @@ public class XmlRoutesBuilderLoader extends RouteBuilderLoaderSupport { camelAppCache.clear(); routesCache.clear(); preparseDone.clear(); + delayedRegistrations.clear(); throw e; } } @@ -141,6 +153,7 @@ public class XmlRoutesBuilderLoader extends RouteBuilderLoaderSupport { camelAppCache.remove(location); routesCache.remove(location); preparseDone.remove(location); + delayedRegistrations.removeIf(def -> def.getResource() != null && location.equals(def.getResource().getLocation())); } @Override @@ -237,7 +250,6 @@ public class XmlRoutesBuilderLoader extends RouteBuilderLoaderSupport { if (!delayedRegistrations.isEmpty()) { // some of the beans were not available yet, so we have to try register them now for (BeanFactoryDefinition<?> def : delayedRegistrations) { - def.setResource(getResource()); registerBeanDefinition(def, false); } delayedRegistrations.clear(); diff --git a/dsl/camel-xml-jaxb-dsl/src/main/java/org/apache/camel/dsl/xml/jaxb/JaxbXmlRoutesBuilderLoader.java b/dsl/camel-xml-jaxb-dsl/src/main/java/org/apache/camel/dsl/xml/jaxb/JaxbXmlRoutesBuilderLoader.java index 639a8ff0ed82..b27786fa4220 100644 --- a/dsl/camel-xml-jaxb-dsl/src/main/java/org/apache/camel/dsl/xml/jaxb/JaxbXmlRoutesBuilderLoader.java +++ b/dsl/camel-xml-jaxb-dsl/src/main/java/org/apache/camel/dsl/xml/jaxb/JaxbXmlRoutesBuilderLoader.java @@ -84,6 +84,7 @@ public class JaxbXmlRoutesBuilderLoader extends RouteBuilderLoaderSupport { try (InputStream is = resourceInputStream(resource)) { RoutesDefinition routes = loadRoutesDefinition(getCamelContext(), is); if (routes != null) { + getRouteCollection().setSemantic(routes.getSemantic()); // xml routes must be prepared in the same way java-dsl (via RoutesDefinition) // so create a copy and use the fluent builder to add the route for (RouteDefinition route : routes.getRoutes()) { diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/SemanticQuestionTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/SemanticQuestionTest.java index 0a4414f55232..71974099b4d5 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/SemanticQuestionTest.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/java/org/apache/camel/dsl/yaml/SemanticQuestionTest.java @@ -341,6 +341,21 @@ class SemanticQuestionTest extends YamlTestSupport { .hasStackTraceContaining("Node type map is invalid, expected array"); } + @Test + void numericPlaceholdersResolveBeforeValidation() throws Exception { + loadRoutesNoValidate(""" + - semantic: + question: + urgent: + type: boolean + instructions: Urgent? + threshold: "{{threshold:0.8}}" + uncertainty: "{{uncertainty:0.1}}" + """); + assertThat(SemanticQuestions.get(context).get("urgent").getThreshold()).isEqualTo(0.8); + assertThat(SemanticQuestions.get(context).get("urgent").getUncertainty()).isEqualTo(0.1); + } + @ParameterizedTest @ValueSource(strings = { "threshold", "uncertainty" }) void invalidNumericValuesIdentifyQuestionFieldAndLocation(String field) {
