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 ce30f3d3cc5ba43cc603bba0a3310c0199859972 Author: Luigi De Masi <[email protected]> AuthorDate: Wed Sep 30 10:25:01 2026 +0200 CAMEL-25138: Discover semantic declarations in ordinary XML resources Automatically install XML declaration support through the existing context service plugin. Detect declarations or their namespace, delegate ordinary XML to the standard loader, and retain application loader precedence. Preserve resource ownership and manage loader startup, shutdown and restart. Use ordinary XML filenames throughout the documentation and cover both layouts, Camel Main, custom registries, namespace handling and reloads. Co-authored-by: Codex <[email protected]> Signed-off-by: Luigi De Masi <[email protected]> --- .../camel/catalog/docs/semantic-language.adoc | 25 ++- .../src/main/docs/semantic-language.adoc | 25 ++- .../camel/semantic/SemanticReloadPlugin.java | 58 ++++++- .../apache/camel/semantic/SemanticXmlLoader.java | 157 ++++++++++++++++++ .../semantic/SemanticXmlRoutesBuilderLoader.java | 9 +- .../camel/semantic/SemanticDeclarationDslTest.java | 65 +++++--- .../semantic/SemanticXmlAutoDiscoveryTest.java | 181 +++++++++++++++++++++ .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 7 + 8 files changed, 480 insertions(+), 47 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 cc9d87d92481..4f37730d403b 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 @@ -142,12 +142,14 @@ key and an empty map to `replace` removes the resource's questions. === XML declarations -The optional XML extension belongs to `camel-semantic`. Include `camel-semantic` and -xref:others:java-xml-io-dsl.adoc[XML DSL] (`camel-xml-io-dsl`), and give declaration files the -extension `*.semantic.xml`. This selects the component's loader without changing the standard -XML loader or Camel core model. +The XML extension belongs to `camel-semantic`. Include `camel-semantic` and +xref:others:java-xml-io-dsl.adoc[XML DSL] (`camel-xml-io-dsl`). Camel discovers the extension +automatically at startup: use ordinary `*.xml` files without registering a loader or changing +the Camel core model. XML documents without semantic declarations are handled by the standard +XML loader, including its bean and route configuration support. XML loaders registered by the +application before route loading retain precedence over automatic discovery. -Both layouts are supported. To keep declarations alongside routes, use `tickets.semantic.xml`: +Both layouts are supported. To keep declarations alongside routes, use `tickets.xml`: [source,xml] ---- @@ -170,7 +172,7 @@ Both layouts are supported. To keep declarations alongside routes, use `tickets. </routes> ---- -To share declarations across route files, use a standalone `questions.semantic.xml`: +To share declarations across route files, use a standalone `questions.xml`: [source,xml] ---- @@ -186,7 +188,7 @@ To share declarations across route files, use a standalone `questions.semantic.x Load this together with ordinary `*.xml`, Java or YAML route resources that use `ref:department`. For example, with Camel Main, set -`camel.main.routes-include-pattern=classpath:questions.semantic.xml,classpath:routes.xml`. +`camel.main.routes-include-pattern=classpath:questions.xml,classpath:routes.xml`. Declarations are registered before consuming routes are configured. Reload replaces the source's questions; removing the `semantic` block, using an empty block, or deleting the resource removes obsolete definitions. XML source keys are the resource locations. @@ -195,8 +197,13 @@ The combined format supports a `routes` root with one optional `semantic` block `route` elements. The standalone format uses a `semantic` root. Namespace-free documents are supported, as are documents consistently using `http://camel.apache.org/schema/semantic`, `http://camel.apache.org/schema/xml-io`, or `http://camel.apache.org/schema/spring`. -These extensions do not validate against Camel's standard core XSDs. Ordinary `*.xml` files -and the JAXB loader do not accept embedded semantic declarations. +A `semantic` block can also declare `xmlns="http://camel.apache.org/schema/semantic"` inside a +standard `routes` document; its question elements inherit that namespace. The extension detects +the declaration block or semantic root namespace automatically. + +These extensions do not validate against Camel's standard core XSDs. Automatic discovery applies +to Camel's route resource loader; it does not extend Spring's XML application-context parser +or direct JAXB unmarshalling. For boolean questions, `threshold`, `uncertainty` and `uncertaintyPolicy` are optional question attributes. For score questions, replace the named `criterion` elements with ordered `level` 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 cc9d87d92481..4f37730d403b 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 @@ -142,12 +142,14 @@ key and an empty map to `replace` removes the resource's questions. === XML declarations -The optional XML extension belongs to `camel-semantic`. Include `camel-semantic` and -xref:others:java-xml-io-dsl.adoc[XML DSL] (`camel-xml-io-dsl`), and give declaration files the -extension `*.semantic.xml`. This selects the component's loader without changing the standard -XML loader or Camel core model. +The XML extension belongs to `camel-semantic`. Include `camel-semantic` and +xref:others:java-xml-io-dsl.adoc[XML DSL] (`camel-xml-io-dsl`). Camel discovers the extension +automatically at startup: use ordinary `*.xml` files without registering a loader or changing +the Camel core model. XML documents without semantic declarations are handled by the standard +XML loader, including its bean and route configuration support. XML loaders registered by the +application before route loading retain precedence over automatic discovery. -Both layouts are supported. To keep declarations alongside routes, use `tickets.semantic.xml`: +Both layouts are supported. To keep declarations alongside routes, use `tickets.xml`: [source,xml] ---- @@ -170,7 +172,7 @@ Both layouts are supported. To keep declarations alongside routes, use `tickets. </routes> ---- -To share declarations across route files, use a standalone `questions.semantic.xml`: +To share declarations across route files, use a standalone `questions.xml`: [source,xml] ---- @@ -186,7 +188,7 @@ To share declarations across route files, use a standalone `questions.semantic.x Load this together with ordinary `*.xml`, Java or YAML route resources that use `ref:department`. For example, with Camel Main, set -`camel.main.routes-include-pattern=classpath:questions.semantic.xml,classpath:routes.xml`. +`camel.main.routes-include-pattern=classpath:questions.xml,classpath:routes.xml`. Declarations are registered before consuming routes are configured. Reload replaces the source's questions; removing the `semantic` block, using an empty block, or deleting the resource removes obsolete definitions. XML source keys are the resource locations. @@ -195,8 +197,13 @@ The combined format supports a `routes` root with one optional `semantic` block `route` elements. The standalone format uses a `semantic` root. Namespace-free documents are supported, as are documents consistently using `http://camel.apache.org/schema/semantic`, `http://camel.apache.org/schema/xml-io`, or `http://camel.apache.org/schema/spring`. -These extensions do not validate against Camel's standard core XSDs. Ordinary `*.xml` files -and the JAXB loader do not accept embedded semantic declarations. +A `semantic` block can also declare `xmlns="http://camel.apache.org/schema/semantic"` inside a +standard `routes` document; its question elements inherit that namespace. The extension detects +the declaration block or semantic root namespace automatically. + +These extensions do not validate against Camel's standard core XSDs. Automatic discovery applies +to Camel's route resource loader; it does not extend Spring's XML application-context parser +or direct JAXB unmarshalling. For boolean questions, `threshold`, `uncertainty` and `uncertaintyPolicy` are optional question attributes. For score questions, replace the named `criterion` elements with ordered `level` diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticReloadPlugin.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticReloadPlugin.java index 95a4ba3d1342..1fb38ad3e502 100644 --- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticReloadPlugin.java +++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticReloadPlugin.java @@ -17,13 +17,67 @@ package org.apache.camel.semantic; import org.apache.camel.CamelContext; +import org.apache.camel.RuntimeCamelException; import org.apache.camel.spi.ContextServicePlugin; +import org.apache.camel.spi.LifecycleStrategy; +import org.apache.camel.spi.RoutesBuilderLoader; +import org.apache.camel.support.LifecycleStrategySupport; +import org.apache.camel.support.service.ServiceHelper; -/** Removes definitions from deleted files before the route watcher loads their replacements. */ +/** Installs optional XML declaration support and removes definitions from deleted files before route reload. */ public class SemanticReloadPlugin implements ContextServicePlugin { + private RoutesBuilderLoader xmlLoader; + private LifecycleStrategy lifecycle; + @Override public void load(CamelContext context) { - // Questions are registered by the route loader or the application. + installXmlLoader(context); + lifecycle = new LifecycleStrategySupport() { + @Override + public void onContextInitializing(CamelContext camelContext) { + // Applications can replace the registry after the context's eager build phase. + installXmlLoader(camelContext); + } + }; + context.addLifecycleStrategy(lifecycle); + } + + private void installXmlLoader(CamelContext context) { + // Keep the language usable without the optional XML/model dependencies, and preserve application overrides. + if (context.getClassResolver().resolveClass("org.apache.camel.xml.in.ModelParser") == null + || context.getRegistry().lookupByName(SemanticXmlLoader.REGISTRY_KEY) != null + || context.getRegistry().findByType(RoutesBuilderLoader.class).stream() + .anyMatch(loader -> loader.isSupportedExtension("xml"))) { + return; + } + if (xmlLoader == null) { + xmlLoader = new SemanticXmlLoader(); + xmlLoader.setCamelContext(context); + } + try { + ServiceHelper.startService(xmlLoader); + context.getRegistry().bind(SemanticXmlLoader.REGISTRY_KEY, xmlLoader); + } catch (Exception e) { + throw RuntimeCamelException.wrapRuntimeException(e); + } + } + + @Override + public void unload(CamelContext context) { + context.getLifecycleStrategies().remove(lifecycle); + lifecycle = null; + if (xmlLoader != null) { + if (context.getRegistry().lookupByName(SemanticXmlLoader.REGISTRY_KEY) == xmlLoader) { + context.getRegistry().unbind(SemanticXmlLoader.REGISTRY_KEY); + } + try { + ServiceHelper.stopAndShutdownService(xmlLoader); + } catch (Exception e) { + throw RuntimeCamelException.wrapRuntimeException(e); + } finally { + xmlLoader = null; + } + } } @Override diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java new file mode 100644 index 000000000000..dd2d06e2e723 --- /dev/null +++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java @@ -0,0 +1,157 @@ +/* + * 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.semantic; + +import java.io.InputStream; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamReader; + +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.builder.RouteBuilderLifecycleStrategy; +import org.apache.camel.spi.Resource; +import org.apache.camel.spi.RoutesBuilderLoader; +import org.apache.camel.support.CachedResource; +import org.apache.camel.support.PluginHelper; +import org.apache.camel.support.ResolverHelper; +import org.apache.camel.support.RoutesBuilderLoaderSupport; +import org.apache.camel.support.service.ServiceHelper; + +/** Detects semantic declarations in ordinary XML route resources and delegates other XML to the configured loader. */ +final class SemanticXmlLoader extends RoutesBuilderLoaderSupport { + static final String REGISTRY_KEY = "semantic-xml-routes-loader"; + private RoutesBuilderLoader delegate; + private final SemanticXmlRoutesBuilderLoader semantic = new SemanticXmlRoutesBuilderLoader(); + + @Override + public String getSupportedExtension() { + return "xml"; + } + + @Override + public boolean isSupportedExtension(String extension) { + // DefaultCamelContext may build before application beans are bound. Give later custom loaders precedence too. + return ("xml".equals(extension) || "camel.xml".equals(extension)) + && getCamelContext().getRegistry().findByType(RoutesBuilderLoader.class).stream() + .noneMatch(loader -> loader != this && loader.isSupportedExtension(extension)); + } + + @Override + public void preParseRoute(Resource resource) throws Exception { + Resource snapshot = snapshot(resource); + if (hasDeclarations(snapshot)) { + semantic.setCamelContext(getCamelContext()); + semantic.preParseRoute(snapshot); + } else { + delegate().preParseRoute(snapshot); + } + } + + @Override + public RoutesBuilder loadRoutesBuilder(Resource resource) throws Exception { + Resource snapshot = snapshot(resource); + if (hasDeclarations(snapshot)) { + semantic.setCamelContext(getCamelContext()); + return semantic.loadRoutesBuilder(snapshot); + } + RoutesBuilder answer = delegate().loadRoutesBuilder(snapshot); + if (answer instanceof RouteBuilder builder) { + // Declarations removed from a resource are discarded only after its replacement configures successfully. + builder.addLifecycleInterceptor(new RouteBuilderLifecycleStrategy() { + @Override + public void afterConfigure(RouteBuilder routeBuilder) { + SemanticQuestions questions + = getCamelContext().getCamelContextExtension().getContextPlugin(SemanticQuestions.class); + if (questions != null) { + questions.remove(resource.getLocation()); + } + } + }); + } + return answer; + } + + private synchronized RoutesBuilderLoader delegate() throws Exception { + if (delegate == null) { + // Resolve the service directly: looking up "xml" through RoutesLoader would find this wrapper again. + var finder + = getCamelContext().getCamelContextExtension().getBootstrapFactoryFinder(RoutesBuilderLoader.FACTORY_PATH); + RoutesBuilderLoader loader = ResolverHelper + .resolveService(getCamelContext(), finder, "xml", RoutesBuilderLoader.class) + .orElseThrow(() -> new IllegalStateException("An XML routes loader such as camel-xml-io-dsl is required")); + loader.setCamelContext(getCamelContext()); + PluginHelper.getRoutesLoader(getCamelContext()).initRoutesBuilderLoader(loader); + ServiceHelper.startService(loader); + delegate = loader; + } + return delegate; + } + + @Override + protected void doStop() throws Exception { + ServiceHelper.stopAndShutdownServices(delegate, semantic); + delegate = null; + super.doStop(); + } + + private static Resource snapshot(Resource resource) { + // Share bytes only within one call. Preserve the original scheme/existence for deletion tracking. + return new CachedResource(resource) { + @Override + public String getScheme() { + return resource.getScheme(); + } + }; + } + + private static boolean hasDeclarations(Resource resource) throws Exception { + XMLInputFactory factory = XMLInputFactory.newFactory(); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); + factory.setProperty(XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false); + try (InputStream stream = resource.getInputStream()) { + XMLStreamReader reader = factory.createXMLStreamReader(stream); + try { + int depth = 0; + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + depth++; + if (depth == 1) { + if ("semantic".equals(reader.getLocalName()) + || SemanticXmlRoutesBuilderLoader.NAMESPACE.equals(reader.getNamespaceURI())) { + return true; + } + if (!"routes".equals(reader.getLocalName())) { + return false; + } + } else if (depth == 2 && "semantic".equals(reader.getLocalName())) { + return true; + } + } else if (event == XMLStreamConstants.END_ELEMENT) { + depth--; + } + } + return false; + } finally { + reader.close(); + } + } + } +} diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java index a4c6b0774d50..7be5c330fa00 100644 --- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java +++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java @@ -41,10 +41,11 @@ import org.apache.camel.spi.annotations.RoutesLoader; import org.apache.camel.support.RoutesBuilderLoaderSupport; import org.apache.camel.xml.in.ModelParser; -/** Loads standalone declarations or declarations alongside routes from {@code *.semantic.xml} resources. */ +/** Parses standalone semantic declarations or declarations alongside XML routes. */ @RoutesLoader("semantic.xml") public class SemanticXmlRoutesBuilderLoader extends RoutesBuilderLoaderSupport { - private static final Set<String> NAMESPACES = Set.of("", "http://camel.apache.org/schema/semantic", + static final String NAMESPACE = "http://camel.apache.org/schema/semantic"; + private static final Set<String> NAMESPACES = Set.of("", NAMESPACE, "http://camel.apache.org/schema/xml-io", "http://camel.apache.org/schema/spring"); @Override @@ -188,7 +189,9 @@ public class SemanticXmlRoutesBuilderLoader extends RoutesBuilderLoaderSupport { List<Element> answer = new ArrayList<>(); for (Node child = parent.getFirstChild(); child != null; child = child.getNextSibling()) { if (child instanceof Element element) { - if (!namespace(parent).equals(namespace(element))) { + boolean semanticBlock = "routes".equals(parent.getLocalName()) + && "semantic".equals(element.getLocalName()) && NAMESPACE.equals(namespace(element)); + if (!semanticBlock && !namespace(parent).equals(namespace(element))) { throw new IllegalArgumentException("Unexpected namespace on " + element.getTagName()); } answer.add(element); 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 8a396b98c76f..9d56c50ff1d2 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 @@ -114,7 +114,8 @@ class SemanticDeclarationDslTest { } @ParameterizedTest - @ValueSource(strings = { "java", "routes", "standalone", "no-namespace", "component-namespace", "xml-io-namespace" }) + @ValueSource(strings = { + "java", "routes", "standalone", "no-namespace", "component-namespace", "xml-io-namespace", "semantic-extension" }) void componentDeclarationsEvaluateSingleQuestionsAndMixedBatches(String dsl) throws Exception { if (dsl.equals("java")) { context.addRoutes(new RouteBuilder() { @@ -139,7 +140,7 @@ class SemanticDeclarationDslTest { if (dsl.equals("standalone")) { PluginHelper.getRoutesLoader(context).loadRoutes(List.of( ResourceHelper.fromString("use.xml", document("routes", route)), - ResourceHelper.fromString("questions.semantic.xml", xmlQuestions()))); + ResourceHelper.fromString("questions.xml", xmlQuestions()))); } else { String xml = document("routes", route + xmlQuestions()); if (dsl.equals("no-namespace")) { @@ -149,7 +150,7 @@ class SemanticDeclarationDslTest { } else if (dsl.equals("xml-io-namespace")) { xml = xml.replace("schema/spring", "schema/xml-io"); } - load("questions.semantic.xml", xml); + load(dsl.equals("semantic-extension") ? "questions.semantic.xml" : "questions.xml", xml); } } assertThat(states).isEmpty(); @@ -180,15 +181,15 @@ class SemanticDeclarationDslTest { @ParameterizedTest @MethodSource("invalidXml") void invalidDeclarationsLeaveThePreviousDefinitionsIntact(String declaration, String message) throws Exception { - load("questions.semantic.xml", document("routes", xmlQuestions())); + load("questions.xml", document("routes", xmlQuestions())); SemanticQuestion previous = SemanticQuestions.get(context).get("department"); assertThatThrownBy(() -> PluginHelper.getRoutesLoader(context).updateRoutes( - ResourceHelper.fromString("questions.semantic.xml", + ResourceHelper.fromString("questions.xml", document("routes", "<semantic>" + declaration + "</semantic>")))) .hasMessageContaining(message); assertThat(SemanticQuestions.get(context).get("department")).isSameAs(previous); assertThat(states).isEmpty(); - PluginHelper.getRoutesLoader(context).updateRoutes(ResourceHelper.fromString("questions.semantic.xml", + PluginHelper.getRoutesLoader(context).updateRoutes(ResourceHelper.fromString("questions.xml", document("routes", xmlQuestions().replace("header.myState", "header.corrected")))); assertThat(SemanticQuestions.get(context).get("department").getState()).isEqualTo("${header.corrected}"); } @@ -289,7 +290,7 @@ class SemanticDeclarationDslTest { questions(semanticQuestions(this)); } }); - assertThatThrownBy(() -> load("duplicate.semantic.xml", document("routes", xmlQuestions()))) + assertThatThrownBy(() -> load("duplicate.xml", document("routes", xmlQuestions()))) .hasMessageContaining("Duplicate semantic question"); assertThat(states).isEmpty(); } @@ -306,13 +307,13 @@ class SemanticDeclarationDslTest { } }); } else { - load("questions.semantic.xml", document("routes", xmlQuestions() + 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.semantic.xml", document("routes", """ + assertThatThrownBy(() -> load("invalid.xml", document("routes", """ <semantic><question name="invalid" type="boolean" threshold="{{threshold:abc}}"> <instructions>Urgent?</instructions> </question></semantic> @@ -327,13 +328,13 @@ class SemanticDeclarationDslTest { + "<instructions>Valid?</instructions></question></semantic>"; var loader = PluginHelper.getRoutesLoader(context); assertThatThrownBy(() -> loader.loadRoutes(List.of( - ResourceHelper.fromString("first.semantic.xml", document("routes", xmlQuestions())), - ResourceHelper.fromString("second.semantic.xml", document("routes", second.replace("boolean", "unknown")))))) + ResourceHelper.fromString("first.xml", document("routes", xmlQuestions())), + ResourceHelper.fromString("second.xml", document("routes", second.replace("boolean", "unknown")))))) .hasMessageContaining("Invalid semantic question"); loader.loadRoutes(List.of( - ResourceHelper.fromString("first.semantic.xml", + ResourceHelper.fromString("first.xml", document("routes", xmlQuestions().replace("header.myState", "header.new"))), - ResourceHelper.fromString("second.semantic.xml", document("routes", second)))); + ResourceHelper.fromString("second.xml", document("routes", second)))); assertThat(SemanticQuestions.get(context).get("department").getState()).isEqualTo("${header.new}"); assertThat(SemanticQuestions.get(context).get("second")).isNotNull(); assertThat(states).isEmpty(); @@ -347,7 +348,7 @@ class SemanticDeclarationDslTest { <setBody><language language="semantic">ref:department</language></setBody> </route> """)), - ResourceHelper.fromString("definitions.semantic.xml", document("routes", xmlQuestions())))); + ResourceHelper.fromString("definitions.xml", document("routes", xmlQuestions())))); assertThat(states).isEmpty(); try (var template = context.createProducerTemplate()) { assertThat(template.requestBodyAndHeader("direct:use", "original", "myState", "invoice")).isEqualTo("billing"); @@ -366,30 +367,30 @@ class SemanticDeclarationDslTest { @Test void reloadingReplacesAndRemovesDeclarationsUsedByExistingExpressions() throws Exception { - load("questions.semantic.xml", document("routes", xmlQuestions())); + load("questions.xml", document("routes", xmlQuestions())); var expression = context.resolveLanguage("semantic").createExpression("ref:department"); var exchange = new DefaultExchange(context); exchange.getMessage().setHeader("myState", "old"); assertThat(expression.evaluate(exchange, String.class)).isEqualTo("billing"); - PluginHelper.getRoutesLoader(context).updateRoutes(ResourceHelper.fromString("questions.semantic.xml", + PluginHelper.getRoutesLoader(context).updateRoutes(ResourceHelper.fromString("questions.xml", document("routes", xmlQuestions().replace("header.myState", "header.updated")))); exchange.getMessage().setHeader("updated", "new"); assertThat(expression.evaluate(exchange, String.class)).isEqualTo("billing"); assertThat(states).containsExactly("old", "new"); PluginHelper.getRoutesLoader(context) - .updateRoutes(ResourceHelper.fromString("questions.semantic.xml", document("routes", ""))); + .updateRoutes(ResourceHelper.fromString("questions.xml", document("routes", ""))); assertThatThrownBy(() -> expression.evaluate(exchange, Object.class)).hasMessageContaining("Unknown semantic question"); } @Test void watcherRemovesDeletedAndRenamedDeclarationResources() throws Exception { - Path original = directory.resolve("questions.semantic.xml"); + Path original = directory.resolve("questions.xml"); Files.writeString(original, document("routes", xmlQuestions())); Resource source = ResourceHelper.resolveResource(context, original.toUri().toString()); PluginHelper.getRoutesLoader(context).loadRoutes(source); TestWatcher watcher = new TestWatcher(); watcher.setCamelContext(context); - Path renamed = Files.move(original, directory.resolve("renamed.semantic.xml")); + Path renamed = Files.move(original, directory.resolve("renamed.xml")); watcher.reload(source); assertThatThrownBy(() -> SemanticQuestions.get(context).get("department")).hasMessageContaining("Unknown"); Resource replacement = ResourceHelper.resolveResource(context, renamed.toUri().toString()); @@ -404,9 +405,9 @@ class SemanticDeclarationDslTest { @ParameterizedTest @ValueSource(strings = { "", "http://camel.apache.org/schema/semantic", "http://camel.apache.org/schema/xml-io" }) void standaloneXmlSupportsOptionalNamespace(String namespace) throws Exception { - load("questions.semantic.xml", xmlQuestions().replace("<semantic>", "<semantic xmlns=\"" + namespace + "\">")); + load("questions.xml", xmlQuestions().replace("<semantic>", "<semantic xmlns=\"" + namespace + "\">")); assertThat(SemanticQuestions.get(context).get("department").getCriteria()).containsKey("billing"); - load("questions.semantic.xml", "<semantic/>"); + load("questions.xml", "<semantic/>"); assertThat(SemanticQuestions.get(context).isEmpty()).isTrue(); } @@ -442,16 +443,16 @@ class SemanticDeclarationDslTest { "<routes><semantic><question name='q' type='boolean'><instructions>Hi</instructions></question></semantic><route><wrong/></route></routes>" }) void malformedXmlDoesNotReplacePreviousDefinitions(String xml) throws Exception { - load("questions.semantic.xml", xmlQuestions()); + load("questions.xml", xmlQuestions()); var previous = SemanticQuestions.get(context).get("department"); - assertThatThrownBy(() -> load("questions.semantic.xml", xml)).isInstanceOf(Exception.class); + assertThatThrownBy(() -> load("questions.xml", xml)).isInstanceOf(Exception.class); assertThat(SemanticQuestions.get(context).get("department")).isSameAs(previous); } @ParameterizedTest @ValueSource(strings = { "java", "xml" }) void loadingPlainBuilderDiscardsDeletedDeclarationResources(String dsl) throws Exception { - Path file = directory.resolve(dsl.equals("java") ? "Questions.java" : "questions.semantic.xml"); + Path file = directory.resolve(dsl.equals("java") ? "Questions.java" : "questions.xml"); Files.writeString(file, xmlQuestions()); Resource resource = ResourceHelper.resolveResource(context, file.toUri().toString()); if (dsl.equals("java")) { @@ -476,6 +477,22 @@ class SemanticDeclarationDslTest { assertThat(SemanticQuestions.get(context).isEmpty()).isTrue(); } + @Test + void semanticNamespaceCanBeUsedInsideStandardXmlRoutes() throws Exception { + load("questions.xml", document("routes", xmlQuestions().replace("<semantic>", + "<semantic xmlns=\"http://camel.apache.org/schema/semantic\">"))); + assertThat(SemanticQuestions.get(context).get("department").getCriteria()).containsKey("billing"); + } + + @Test + void failedOrdinaryReplacementKeepsPreviousDeclarations() throws Exception { + load("questions.xml", xmlQuestions()); + var previous = SemanticQuestions.get(context).get("department"); + assertThatThrownBy(() -> load("questions.xml", "<routes><route><wrong/></route></routes>")) + .isInstanceOf(Exception.class); + assertThat(SemanticQuestions.get(context).get("department")).isSameAs(previous); + } + private void load(String location, String xml) throws Exception { PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString(location, xml)); } diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticXmlAutoDiscoveryTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticXmlAutoDiscoveryTest.java new file mode 100644 index 000000000000..ee784482f1fb --- /dev/null +++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticXmlAutoDiscoveryTest.java @@ -0,0 +1,181 @@ +/* + * 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.semantic; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.camel.dsl.xml.io.XmlRoutesBuilderLoader; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.impl.engine.DefaultClassResolver; +import org.apache.camel.impl.engine.DefaultRoutesLoader; +import org.apache.camel.main.Main; +import org.apache.camel.spi.RoutesBuilderLoader; +import org.apache.camel.spi.RoutesLoader; +import org.apache.camel.support.PluginHelper; +import org.apache.camel.support.ResourceHelper; +import org.apache.camel.support.SimpleRegistry; +import org.apache.camel.support.service.ServiceSupport; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class SemanticXmlAutoDiscoveryTest { + @TempDir + Path directory; + + @ParameterizedTest + @CsvSource({ "false,routes.xml", "true,routes.xml", "false,routes.camel.xml", "true,routes.camel.xml" }) + void mainLoadsOrdinaryXmlWithoutLoaderRegistration(boolean standalone, String filename) throws Exception { + String declarations = """ + <semantic xmlns="http://camel.apache.org/schema/semantic"> + <question name="department" type="choice"> + <instructions>Which department?</instructions> + <criterion key="billing" value="Invoices"/> + </question> + </semantic> + """; + String route = """ + <route id="classify"><from uri="direct:tickets"/> + <setBody><language language="semantic">ref:department</language></setBody> + </route> + """; + Path routes = directory.resolve(filename); + String files; + if (standalone) { + Path questions = directory.resolve("questions.xml"); + Files.writeString(questions, declarations); + Files.writeString(routes, "<routes>" + route + "</routes>"); + // The consumer is deliberately listed first. + files = routes.toUri() + "," + questions.toUri(); + } else { + Files.writeString(routes, "<routes>" + declarations + route + "</routes>"); + files = routes.toUri().toString(); + } + Main main = new Main(); + main.bind("classifier", new SemanticLanguageTest.LabelAdapter()); + main.addProperty("camel.language.semantic.adapter", "classifier"); + main.configure().setRoutesIncludePattern(files); + try { + main.start(); + try (var template = main.getCamelContext().createProducerTemplate()) { + assertThat(template.requestBody("direct:tickets", "invoice", String.class)).isEqualTo("billing"); + } + } finally { + main.stop(); + } + } + + @Test + void ordinaryXmlRetainsBeansRouteConfigurationsAndDelegateLifecycle() throws Exception { + AtomicReference<RoutesBuilderLoader> delegate = new AtomicReference<>(); + try (var context = new DefaultCamelContext()) { + context.getCamelContextExtension().addContextPlugin(RoutesLoader.class, new DefaultRoutesLoader(context) { + @Override + public void initRoutesBuilderLoader(RoutesBuilderLoader loader) { + delegate.set(loader); + } + }); + context.start(); + PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("ordinary.xml", """ + <camel xmlns="http://camel.apache.org/schema/xml-io"> + <bean name="counter" type="java.util.concurrent.atomic.AtomicInteger"/> + <routeConfiguration id="config"> + <onException><exception>java.lang.IllegalArgumentException</exception> + <handled><constant>true</constant></handled> + <setBody><constant>handled</constant></setBody> + </onException> + </routeConfiguration> + <route id="ordinary" routeConfigurationId="config"> + <from uri="direct:ordinary"/> + <bean ref="counter" method="incrementAndGet"/> + </route> + </camel> + """)); + assertThat(context.getRouteConfigurationDefinitions()).hasSize(1); + assertThat(context.getRegistry().lookupByName("counter")).isInstanceOf(AtomicInteger.class); + assertThat(context.getCamelContextExtension().getContextPlugin(SemanticQuestions.class)).isNull(); + try (var template = context.createProducerTemplate()) { + assertThat(template.requestBody("direct:ordinary", "test", Integer.class)).isEqualTo(1); + } + assertThat(delegate.get()).isInstanceOf(XmlRoutesBuilderLoader.class); + assertThat(((ServiceSupport) delegate.get()).isStarted()).isTrue(); + } + assertThat(((ServiceSupport) delegate.get()).isStopped()).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = { "routes-builder-loader-xml", "customXmlLoader" }) + void applicationRegisteredXmlLoaderTakesPrecedence(String name) throws Exception { + XmlRoutesBuilderLoader custom = new XmlRoutesBuilderLoader(); + try (var context = new DefaultCamelContext()) { + context.getRegistry().bind(name, custom); + context.build(); + assertThat(PluginHelper.getRoutesLoader(context).getRoutesLoader("xml")).isSameAs(custom); + assertThat(context.getRegistry().findByType(SemanticXmlLoader.class)) + .allMatch(loader -> !loader.isSupportedExtension("xml")); + } + } + + @Test + void contextWithoutOptionalXmlSupportDoesNotInstallWrapper() throws Exception { + try (var context = new DefaultCamelContext(false)) { + context.setClassResolver(new DefaultClassResolver(context) { + @Override + public Class<?> resolveClass(String name) { + return "org.apache.camel.xml.in.ModelParser".equals(name) ? null : super.resolveClass(name); + } + }); + context.build(); + assertThat(context.getRegistry().lookupByName(SemanticXmlLoader.REGISTRY_KEY)).isNull(); + assertThat(context.resolveLanguage("semantic")).isNotNull(); + } + } + + @Test + void contextWithApplicationRegistryDiscoversXmlDeclarations() throws Exception { + try (var context = new DefaultCamelContext(new SimpleRegistry())) { + context.start(); + PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("questions.xml", """ + <semantic><question name="urgent" type="boolean"><instructions>Urgent?</instructions></question></semantic> + """)); + assertThat(SemanticQuestions.get(context).get("urgent").getInstructions()).isEqualTo("Urgent?"); + } + } + + @Test + void contextRestartReinstallsAutomaticLoader() throws Exception { + try (var context = new DefaultCamelContext()) { + for (int i = 0; i < 2; i++) { + context.start(); + PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("questions.xml", + """ + <semantic><question name="urgent" type="boolean"><instructions>Urgent?</instructions></question></semantic> + """)); + assertThat(SemanticQuestions.get(context).get("urgent").getInstructions()).isEqualTo("Urgent?"); + context.stop(); + assertThat(context.getRegistry().lookupByName(SemanticXmlLoader.REGISTRY_KEY)).isNull(); + } + } + } +} 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 b62a9a431e19..bdef0ae31380 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 @@ -3978,6 +3978,13 @@ JAXP external-access model and remain resolvable. Stylesheet `xsl:include` / `xs are resolved at compile time (they are part of the route definition, authored by the route author) and are unaffected by this change. +=== camel-semantic - automatic XML route loading + +When `camel-semantic` and the XML DSL are on the classpath, Camel now automatically detects +semantic declarations in ordinary XML route resources. XML documents without declarations +continue through the standard XML loader. Explicitly registered XML loaders retain precedence. +This changes route resource discovery only; Spring XML application-context parsing is unaffected. + == ThrottlingExceptionRoutePolicy `ThrottlingExceptionRoutePolicy.setKeepOpen(true)` now opens the circuit immediately and synchronously (the consumer
