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 4a4bed08ffa9e9603b32ec646c69e0fd8225961e Author: Luigi De Masi <[email protected]> AuthorDate: Thu Sep 24 13:25:54 2026 +0200 CAMEL-24977: Discover semantic adapters with Camel FactoryFinder Generate the TypeSafe adapter descriptor using Camel service metadata and resolve the selected implementation through FactoryFinder. Preserve ambiguity checks before construction and Camel Injector lifecycle ownership. Cover descriptor validation, constructor injection and explicit selection. Co-authored-by: Codex <[email protected]> Signed-off-by: Luigi De Masi <[email protected]> --- .../camel/catalog/docs/semantic-language.adoc | 17 ++- .../src/main/docs/semantic-language.adoc | 17 ++- .../camel/language/semantic/SemanticLanguage.java | 65 ++++++---- .../camel/semantic/SemanticLanguageTest.java | 137 +++++++++++++++++++-- .../services/org/apache/camel/semantic-adapter | 2 + .../typesafeai/TypeSafeAiSemanticAdapter.java | 2 + .../org.apache.camel.semantic.SemanticAdapter | 1 - ...mel-factoryfinder-known-dependencies.properties | 1 + 8 files changed, 198 insertions(+), 44 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 187ad719cdf8..1e50cb70781e 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 @@ -236,10 +236,19 @@ and in an implementation-dependent order. == Implementing an adapter -Implement `org.apache.camel.semantic.SemanticAdapter`. Advertise the class name in -`META-INF/services/org.apache.camel.semantic.SemanticAdapter`, one class per line. Discovery -reads class names without constructing unselected adapters. Explicit class selection is -also available for runtimes whose packaging does not expose discovery resources. +Implement `org.apache.camel.semantic.SemanticAdapter`. Advertise the implementation in +`META-INF/services/org/apache/camel/semantic-adapter` using Camel's `FactoryFinder` format: + +[source,properties] +---- +class=com.example.MySemanticAdapter +---- + +Camel components can generate this descriptor with `@JdkService("semantic-adapter")`. +Discovery checks the declarations for ambiguity before resolving the implementation through +`FactoryFinder`. Repeated declarations of the same class are accepted; different classes require +explicit selection. Only the selected adapter is constructed, through Camel's `Injector`. +Explicit bean or class selection also works when packaging does not expose discovery resources. `validate` rejects unsupported questions and decision policies before traffic. `evaluate` must be thread-safe, bound time and resource consumption, honor interruption and propagate 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 187ad719cdf8..1e50cb70781e 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 @@ -236,10 +236,19 @@ and in an implementation-dependent order. == Implementing an adapter -Implement `org.apache.camel.semantic.SemanticAdapter`. Advertise the class name in -`META-INF/services/org.apache.camel.semantic.SemanticAdapter`, one class per line. Discovery -reads class names without constructing unselected adapters. Explicit class selection is -also available for runtimes whose packaging does not expose discovery resources. +Implement `org.apache.camel.semantic.SemanticAdapter`. Advertise the implementation in +`META-INF/services/org/apache/camel/semantic-adapter` using Camel's `FactoryFinder` format: + +[source,properties] +---- +class=com.example.MySemanticAdapter +---- + +Camel components can generate this descriptor with `@JdkService("semantic-adapter")`. +Discovery checks the declarations for ambiguity before resolving the implementation through +`FactoryFinder`. Repeated declarations of the same class are accepted; different classes require +explicit selection. Only the selected adapter is constructed, through Camel's `Injector`. +Explicit bean or class selection also works when packaging does not expose discovery resources. `validate` rejects unsupported questions and decision policies before traffic. `evaluate` must be thread-safe, bound time and resource consumption, honor interruption and propagate diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/language/semantic/SemanticLanguage.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/language/semantic/SemanticLanguage.java index 0d97c29d8a8e..b88dbee4bdbd 100644 --- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/language/semantic/SemanticLanguage.java +++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/language/semantic/SemanticLanguage.java @@ -16,13 +16,13 @@ */ package org.apache.camel.language.semantic; -import java.io.BufferedReader; -import java.io.InputStreamReader; +import java.io.IOException; +import java.io.InputStream; import java.net.URL; -import java.nio.charset.StandardCharsets; import java.util.Enumeration; import java.util.List; import java.util.Map; +import java.util.Properties; import java.util.Set; import java.util.TreeSet; @@ -36,12 +36,14 @@ import org.apache.camel.semantic.SemanticAdapter; import org.apache.camel.semantic.SemanticQuestion; import org.apache.camel.semantic.SemanticQuestions; import org.apache.camel.semantic.SemanticResult; +import org.apache.camel.spi.FactoryFinder; import org.apache.camel.spi.Metadata; import org.apache.camel.spi.annotations.Language; import org.apache.camel.support.ExpressionAdapter; import org.apache.camel.support.LanguageSupport; import org.apache.camel.support.service.ServiceHelper; import org.apache.camel.support.service.ServiceSupport; +import org.apache.camel.util.IOHelper; /** Evaluates a named, provider-independent question against selected message state. */ @Language(value = "semantic", modelName = "language") @@ -50,7 +52,8 @@ import org.apache.camel.support.service.ServiceSupport; public class SemanticLanguage extends LanguageSupport { public static final String RESULT = "CamelSemanticResult"; public static final String ADAPTER_NAME = "camelSemanticAdapter"; - public static final String ADAPTER_RESOURCE = "META-INF/services/org.apache.camel.semantic.SemanticAdapter"; + public static final String ADAPTER_FACTORY = "semantic-adapter"; + public static final String ADAPTER_RESOURCE = FactoryFinder.DEFAULT_PATH + ADAPTER_FACTORY; private String adapter; private String defaultState = "${body}"; @@ -129,28 +132,10 @@ public class SemanticLanguage extends LanguageSupport { } ManagedAdapter owned = null; try { - String className = configured; - if (className == null) { - Set<String> candidates = new TreeSet<>(); - Enumeration<URL> resources = context.getClassResolver().loadAllResourcesAsURL(ADAPTER_RESOURCE); - while (resources.hasMoreElements()) { - try (BufferedReader reader = new BufferedReader( - new InputStreamReader( - resources.nextElement().openStream(), StandardCharsets.UTF_8))) { - reader.lines().map(line -> line.split("#", 2)[0].trim()).filter(line -> !line.isEmpty()) - .forEach(candidates::add); - } - } - if (candidates.size() != 1) { - throw new IllegalArgumentException( - "Semantic language requires exactly one advertised adapter; found " - + candidates + ". Configure camel.language.semantic.adapter explicitly"); - } - className = candidates.iterator().next(); - } - Class<?> resolved = context.getClassResolver().resolveClass(className); + Class<?> resolved = configured == null + ? discoverAdapter(context) : context.getClassResolver().resolveClass(configured); if (resolved == null) { - throw new IllegalArgumentException("No semantic adapter bean or class found: " + className); + throw new IllegalArgumentException("No semantic adapter bean or class found: " + configured); } Class<? extends SemanticAdapter> type = resolved.asSubclass(SemanticAdapter.class); AdapterLock lock = AdapterLock.get(context); @@ -179,6 +164,36 @@ public class SemanticLanguage extends LanguageSupport { } } + private Class<?> discoverAdapter(CamelContext context) throws IOException { + // FactoryFinder resolves one descriptor; check all declarations first to avoid classpath-order selection. + Set<String> candidates = new TreeSet<>(); + Enumeration<URL> resources = context.getClassResolver().loadAllResourcesAsURL(ADAPTER_RESOURCE); + while (resources.hasMoreElements()) { + URL resource = resources.nextElement(); + try (InputStream input = resource.openStream()) { + Properties properties = new Properties(); + properties.load(IOHelper.buffered(input)); + String className = properties.getProperty("class"); + if (className == null || className.isBlank()) { + throw new IllegalArgumentException("Semantic adapter descriptor requires a class property: " + resource); + } + candidates.add(className); + } + } + if (candidates.size() != 1) { + throw new IllegalArgumentException( + "Semantic language requires exactly one advertised adapter; found " + + candidates + ". Configure camel.language.semantic.adapter explicitly"); + } + Class<?> resolved = context.getCamelContextExtension().getDefaultFactoryFinder().findClass(ADAPTER_FACTORY) + .orElseThrow(() -> new IllegalArgumentException("Cannot resolve advertised semantic adapter: " + candidates)); + if (!candidates.contains(resolved.getName())) { + throw new IllegalArgumentException( + "Resolved semantic adapter " + resolved.getName() + " does not match advertised adapter: " + candidates); + } + return resolved; + } + private static final class AdapterLock { private static final Object CREATION_LOCK = new Object(); private final Object monitor = new Object(); diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticLanguageTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticLanguageTest.java index c33ab842ca30..4dab738ed1ce 100644 --- a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticLanguageTest.java +++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticLanguageTest.java @@ -16,14 +16,19 @@ */ package org.apache.camel.semantic; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Collections; import java.util.Enumeration; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; import org.apache.camel.Expression; @@ -31,13 +36,18 @@ import org.apache.camel.Predicate; import org.apache.camel.RuntimeCamelException; import org.apache.camel.impl.DefaultCamelContext; import org.apache.camel.impl.engine.DefaultClassResolver; +import org.apache.camel.impl.engine.DefaultFactoryFinder; +import org.apache.camel.impl.engine.DefaultInjector; import org.apache.camel.language.semantic.SemanticLanguage; +import org.apache.camel.spi.FactoryFinder; import org.apache.camel.support.DefaultExchange; import org.apache.camel.support.service.ServiceSupport; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; 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.ValueSource; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -120,27 +130,118 @@ class SemanticLanguageTest { @Test void discoversOneAdapterAndRejectsMissingOrAmbiguousWithoutConstruction() throws Exception { language.setAdapter(null); - discovery(""); + discovery(); assertThatThrownBy(() -> language.createExpression("ref:q")).hasMessageContaining("exactly one"); - discovery(CountingAdapter.class.getName() + "\n" + LabelAdapter.class.getName()); + discovery("class=" + CountingAdapter.class.getName(), "class=" + LabelAdapter.class.getName()); assertThatThrownBy(() -> language.createExpression("ref:q")).hasMessageContaining("explicitly"); assertThat(CountingAdapter.constructed).hasValue(0); - discovery(CountingAdapter.class.getName() + "\n# comment\n" + CountingAdapter.class.getName()); + discovery("class=" + CountingAdapter.class.getName(), "# same provider\nclass: " + CountingAdapter.class.getName()); language.createExpression("ref:q"); assertThat(CountingAdapter.constructed).hasValue(1); + assertThat(CountingAdapter.started).hasValue(1); + context.stop(); + assertThat(CountingAdapter.stopped).hasValue(1); + } + + @ParameterizedTest + @ValueSource(strings = { "", "# no implementation", "class=", "class= " }) + void invalidDiscoveryDescriptorFailsBeforeConstruction(String declaration) throws Exception { + language.setAdapter(null); + discovery(declaration); + assertThatThrownBy(() -> language.createExpression("ref:q")) + .isInstanceOf(RuntimeCamelException.class).hasCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("requires a class property").hasMessageContaining("adapter-0"); + assertThat(CountingAdapter.constructed).hasValue(0); + } + + @Test + void discoveredClassIsTypeCheckedBeforeConstruction() throws Exception { + NotAnAdapter.constructed.set(0); + language.setAdapter(null); + discovery("class=" + NotAnAdapter.class.getName()); + assertThatThrownBy(() -> language.createExpression("ref:q")) + .isInstanceOf(RuntimeCamelException.class).hasCauseInstanceOf(ClassCastException.class); + assertThat(NotAnAdapter.constructed).hasValue(0); } - private void discovery(String declarations) throws Exception { - Path descriptor = directory.resolve("adapters"); - Files.writeString(descriptor, declarations); - URL url = descriptor.toUri().toURL(); - context.setClassResolver(new DefaultClassResolver() { + @Test + void factoryFinderCannotSelectADifferentAdvertisedClass() throws Exception { + language.setAdapter(null); + discovery("class=" + CountingAdapter.class.getName()); + context.getCamelContextExtension() + .setDefaultFactoryFinder(new DefaultFactoryFinder(context.getClassResolver(), FactoryFinder.DEFAULT_PATH) { + @Override + public Optional<Class<?>> findClass(String key) { + return Optional.of(FailingAdapter.class); + } + }); + assertThatThrownBy(() -> language.createExpression("ref:q")) + .isInstanceOf(RuntimeCamelException.class).hasCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not match advertised adapter"); + assertThat(CountingAdapter.constructed).hasValue(0); + } + + @Test + void discoveryUsesCamelInjectorForConstructorArguments() throws Exception { + language.setAdapter(null); + discovery("class=" + InjectedAdapter.class.getName()); + context.setInjector(new DefaultInjector(context) { + @Override + public <T> T newInstance(Class<T> type, boolean postProcessBean) { + return type == InjectedAdapter.class + ? type.cast(new InjectedAdapter("injected")) : super.newInstance(type, postProcessBean); + } + }); + language.createExpression("ref:q"); + InjectedAdapter instance + = context.getRegistry().lookupByNameAndType(SemanticLanguage.ADAPTER_NAME, InjectedAdapter.class); + assertThat(instance.dependency).isEqualTo("injected"); + assertThat(CountingAdapter.constructed).hasValue(1); + assertThat(CountingAdapter.started).hasValue(1); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void explicitSelectionBypassesAmbiguousDiscovery(boolean bean) throws Exception { + discovery("class=" + CountingAdapter.class.getName(), "class=" + LabelAdapter.class.getName()); + if (bean) { + context.getRegistry().bind("custom", new CountingAdapter()); + language.setAdapter("custom"); + } + language.createExpression("ref:q"); + assertThat(CountingAdapter.constructed).hasValue(1); + assertThat(CountingAdapter.started).hasValue(bean ? 0 : 1); + } + + private void discovery(String... declarations) throws Exception { + List<URL> urls = new ArrayList<>(); + for (int i = 0; i < declarations.length; i++) { + Path descriptor = directory.resolve("adapter-" + i); + Files.writeString(descriptor, declarations[i]); + urls.add(descriptor.toUri().toURL()); + } + var resolver = new DefaultClassResolver() { @Override public Enumeration<URL> loadAllResourcesAsURL(String name) { return SemanticLanguage.ADAPTER_RESOURCE.equals(name) - ? Collections.enumeration(List.of(url)) : super.loadAllResourcesAsURL(name); + ? Collections.enumeration(urls) : super.loadAllResourcesAsURL(name); } - }); + + @Override + public InputStream loadResourceAsStream(String name) { + if (SemanticLanguage.ADAPTER_RESOURCE.equals(name)) { + try { + return urls.isEmpty() ? null : urls.get(0).openStream(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + return super.loadResourceAsStream(name); + } + }; + context.setClassResolver(resolver); + context.getCamelContextExtension() + .setDefaultFactoryFinder(new DefaultFactoryFinder(resolver, FactoryFinder.DEFAULT_PATH)); } @Test @@ -342,6 +443,22 @@ class SemanticLanguageTest { } } + public static class InjectedAdapter extends CountingAdapter { + final String dependency; + + public InjectedAdapter(String dependency) { + this.dependency = dependency; + } + } + + public static class NotAnAdapter { + static final AtomicInteger constructed = new AtomicInteger(); + + public NotAnAdapter() { + constructed.incrementAndGet(); + } + } + public static class LabelAdapter implements SemanticAdapter { @Override public void validate(SemanticQuestion question) { diff --git a/components/camel-ai/camel-typesafe-ai/src/generated/resources/META-INF/services/org/apache/camel/semantic-adapter b/components/camel-ai/camel-typesafe-ai/src/generated/resources/META-INF/services/org/apache/camel/semantic-adapter new file mode 100644 index 000000000000..75cfc423465d --- /dev/null +++ b/components/camel-ai/camel-typesafe-ai/src/generated/resources/META-INF/services/org/apache/camel/semantic-adapter @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.typesafeai.TypeSafeAiSemanticAdapter diff --git a/components/camel-ai/camel-typesafe-ai/src/main/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapter.java b/components/camel-ai/camel-typesafe-ai/src/main/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapter.java index 404d6dd6d355..f486e7e4729c 100644 --- a/components/camel-ai/camel-typesafe-ai/src/main/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapter.java +++ b/components/camel-ai/camel-typesafe-ai/src/main/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapter.java @@ -24,9 +24,11 @@ import org.apache.camel.CamelContextAware; import org.apache.camel.semantic.SemanticAdapter; import org.apache.camel.semantic.SemanticQuestion; import org.apache.camel.semantic.SemanticResult; +import org.apache.camel.spi.annotations.JdkService; import org.apache.camel.util.json.JsonObject; /** Maps common questions to TypeSafe AI using the component's configured, managed transport. */ +@JdkService("semantic-adapter") public class TypeSafeAiSemanticAdapter implements SemanticAdapter, CamelContextAware { private CamelContext camelContext; private volatile TypeSafeAiEndpoint endpoint; diff --git a/components/camel-ai/camel-typesafe-ai/src/main/resources/META-INF/services/org.apache.camel.semantic.SemanticAdapter b/components/camel-ai/camel-typesafe-ai/src/main/resources/META-INF/services/org.apache.camel.semantic.SemanticAdapter deleted file mode 100644 index c3e3578b60de..000000000000 --- a/components/camel-ai/camel-typesafe-ai/src/main/resources/META-INF/services/org.apache.camel.semantic.SemanticAdapter +++ /dev/null @@ -1 +0,0 @@ -org.apache.camel.component.typesafeai.TypeSafeAiSemanticAdapter diff --git a/dsl/camel-kamelet-main/src/generated/resources/camel-factoryfinder-known-dependencies.properties b/dsl/camel-kamelet-main/src/generated/resources/camel-factoryfinder-known-dependencies.properties index d443ddd53974..58b7b8f320df 100644 --- a/dsl/camel-kamelet-main/src/generated/resources/camel-factoryfinder-known-dependencies.properties +++ b/dsl/camel-kamelet-main/src/generated/resources/camel-factoryfinder-known-dependencies.properties @@ -70,6 +70,7 @@ META-INF/services/org/apache/camel/rest-client-request-validator-factory=camel:o META-INF/services/org/apache/camel/rest-client-response-validator-factory=camel:openapi-validator META-INF/services/org/apache/camel/rest-registry-factory=camel:rest META-INF/services/org/apache/camel/route-diagram-dumper=camel:diagram +META-INF/services/org/apache/camel/semantic-adapter=camel:typesafe-ai META-INF/services/org/apache/camel/startup-step-recorder=camel:jfr META-INF/services/org/apache/camel/telemetry-dev-tracer=camel:telemetry-dev META-INF/services/org/apache/camel/thread-factory-listener=camel:opentelemetry
