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

Reply via email to