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 dd65f599e230a46cd5bf5203d2edd0cec03c4e08
Author: Luigi De Masi <[email protected]>
AuthorDate: Tue Jun 16 22:55:23 2026 +0200

    CAMEL-23775: Discover YAML deserializer resolvers from classpath
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
 .../main/camel-main-configuration-metadata.json    |   2 +-
 .../camel-main-configuration-metadata.json         |   2 +-
 core/camel-main/src/main/docs/main.adoc            |   2 +-
 .../camel/main/DefaultConfigurationProperties.java |   2 +-
 .../ROOT/pages/camel-4x-upgrade-guide-4_21.adoc    |  11 +-
 .../camel-yaml-dsl-validator-maven-plugin.adoc     |   5 +
 .../yaml/common/YamlDeserializationContext.java    | 228 +++++++++-
 .../dsl/yaml/common/YamlDeserializerResolver.java  |  23 +
 .../dsl/yaml/common/ConstructorResolverTest.groovy |  69 ++-
 .../camel-yaml-dsl-validator-maven-plugin.adoc     |   5 +
 .../dsl/yaml/validator/CamelYamlParserTest.java    |  47 ++
 .../org/apache/camel/YamlDeserializerResolver      |   1 +
 .../src/test/resources/custom-parser-step.yaml     |  25 ++
 .../camel-yaml-dsl/src/main/docs/yaml-dsl.adoc     | 134 +++++-
 .../dsl/yaml/YamlRoutesBuilderLoaderSupport.java   |   5 +-
 .../YamlDeserializerResolverDiscoveryTest.groovy   | 496 +++++++++++++++++++++
 .../org/apache/camel/YamlDeserializerResolver      |   5 +
 17 files changed, 1042 insertions(+), 20 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
index e47ba7d7c622..6f476fc39be7 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
@@ -159,7 +159,7 @@
     { "name": "camel.main.useMdcLogging", "required": false, "description": 
"To turn on MDC logging (deprecated, use camel-mdc component instead)", 
"sourceType": "org.apache.camel.main.DefaultConfigurationProperties", "type": 
"boolean", "javaType": "boolean", "defaultValue": false, "secret": false, 
"deprecated": true },
     { "name": "camel.main.uuidGenerator", "required": false, "description": 
"UUID generator to use. default (32 bytes), short (16 bytes), classic (32 bytes 
or longer), simple (long incrementing counter), off (turned off for exchanges - 
only intended for performance profiling)", "sourceType": 
"org.apache.camel.main.DefaultConfigurationProperties", "type": "enum", 
"javaType": "java.lang.String", "defaultValue": "default", "secret": false, 
"enum": [ "classic", "default", "short", "simple",  [...]
     { "name": "camel.main.virtualThreadsEnabled", "required": false, 
"description": "Whether to enable virtual threads when creating thread pools. 
When enabled, Camel will use virtual threads instead of platform threads for 
its thread pools. This can also be enabled via the JVM system property {code 
camel.threads.virtual.enabled=true} . This option must be read early during 
bootstrap, so it is set as a system property before thread pools are created.", 
"sourceType": "org.apache.camel.mai [...]
-    { "name": "camel.main.yamlDslCompactNotationWarn", "required": false, 
"description": "Whether to log a WARN when YAML DSL routes use compact 
(shorthand) notation instead of the canonical (explicit\/normalized) form. The 
canonical style is recommended as it is more tooling and AI friendly. Use Camel 
CLI to normalize existing routes: camel yaml normalize &lt;file&gt;", 
"sourceType": "org.apache.camel.main.DefaultConfigurationProperties", "type": 
"boolean", "javaType": "boolean", "defau [...]
+    { "name": "camel.main.yamlDslCompactNotationWarn", "required": false, 
"description": "Whether to log a WARN when YAML DSL routes use compact 
(shorthand) notation instead of the canonical (explicit\/normalized) form. The 
canonical style is recommended as it is more tooling and AI friendly. Use Camel 
CLI to normalize existing routes: camel validate normalize &lt;file&gt;", 
"sourceType": "org.apache.camel.main.DefaultConfigurationProperties", "type": 
"boolean", "javaType": "boolean", "d [...]
     { "name": "camel.debug.bodyIncludeFiles", "required": false, 
"description": "Whether to include the message body of file based messages. The 
overhead is that the file content has to be read from the file.", "sourceType": 
"org.apache.camel.main.DebuggerConfigurationProperties", "type": "boolean", 
"javaType": "boolean", "defaultValue": true, "secret": false },
     { "name": "camel.debug.bodyIncludeStreams", "required": false, 
"description": "Whether to include the message body of stream based messages. 
If enabled then beware the stream may not be re-readable later. See more about 
Stream Caching.", "sourceType": 
"org.apache.camel.main.DebuggerConfigurationProperties", "type": "boolean", 
"javaType": "boolean", "defaultValue": false, "secret": false },
     { "name": "camel.debug.bodyMaxChars", "required": false, "description": 
"To limit the message body to a maximum size in the traced message. Use 0 or 
negative value to use unlimited size.", "sourceType": 
"org.apache.camel.main.DebuggerConfigurationProperties", "type": "integer", 
"javaType": "int", "defaultValue": 32768, "secret": false },
diff --git 
a/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
 
b/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
index e47ba7d7c622..6f476fc39be7 100644
--- 
a/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
+++ 
b/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
@@ -159,7 +159,7 @@
     { "name": "camel.main.useMdcLogging", "required": false, "description": 
"To turn on MDC logging (deprecated, use camel-mdc component instead)", 
"sourceType": "org.apache.camel.main.DefaultConfigurationProperties", "type": 
"boolean", "javaType": "boolean", "defaultValue": false, "secret": false, 
"deprecated": true },
     { "name": "camel.main.uuidGenerator", "required": false, "description": 
"UUID generator to use. default (32 bytes), short (16 bytes), classic (32 bytes 
or longer), simple (long incrementing counter), off (turned off for exchanges - 
only intended for performance profiling)", "sourceType": 
"org.apache.camel.main.DefaultConfigurationProperties", "type": "enum", 
"javaType": "java.lang.String", "defaultValue": "default", "secret": false, 
"enum": [ "classic", "default", "short", "simple",  [...]
     { "name": "camel.main.virtualThreadsEnabled", "required": false, 
"description": "Whether to enable virtual threads when creating thread pools. 
When enabled, Camel will use virtual threads instead of platform threads for 
its thread pools. This can also be enabled via the JVM system property {code 
camel.threads.virtual.enabled=true} . This option must be read early during 
bootstrap, so it is set as a system property before thread pools are created.", 
"sourceType": "org.apache.camel.mai [...]
-    { "name": "camel.main.yamlDslCompactNotationWarn", "required": false, 
"description": "Whether to log a WARN when YAML DSL routes use compact 
(shorthand) notation instead of the canonical (explicit\/normalized) form. The 
canonical style is recommended as it is more tooling and AI friendly. Use Camel 
CLI to normalize existing routes: camel yaml normalize &lt;file&gt;", 
"sourceType": "org.apache.camel.main.DefaultConfigurationProperties", "type": 
"boolean", "javaType": "boolean", "defau [...]
+    { "name": "camel.main.yamlDslCompactNotationWarn", "required": false, 
"description": "Whether to log a WARN when YAML DSL routes use compact 
(shorthand) notation instead of the canonical (explicit\/normalized) form. The 
canonical style is recommended as it is more tooling and AI friendly. Use Camel 
CLI to normalize existing routes: camel validate normalize &lt;file&gt;", 
"sourceType": "org.apache.camel.main.DefaultConfigurationProperties", "type": 
"boolean", "javaType": "boolean", "d [...]
     { "name": "camel.debug.bodyIncludeFiles", "required": false, 
"description": "Whether to include the message body of file based messages. The 
overhead is that the file content has to be read from the file.", "sourceType": 
"org.apache.camel.main.DebuggerConfigurationProperties", "type": "boolean", 
"javaType": "boolean", "defaultValue": true, "secret": false },
     { "name": "camel.debug.bodyIncludeStreams", "required": false, 
"description": "Whether to include the message body of stream based messages. 
If enabled then beware the stream may not be re-readable later. See more about 
Stream Caching.", "sourceType": 
"org.apache.camel.main.DebuggerConfigurationProperties", "type": "boolean", 
"javaType": "boolean", "defaultValue": false, "secret": false },
     { "name": "camel.debug.bodyMaxChars", "required": false, "description": 
"To limit the message body to a maximum size in the traced message. Use 0 or 
negative value to use unlimited size.", "sourceType": 
"org.apache.camel.main.DebuggerConfigurationProperties", "type": "integer", 
"javaType": "int", "defaultValue": 32768, "secret": false },
diff --git a/core/camel-main/src/main/docs/main.adoc 
b/core/camel-main/src/main/docs/main.adoc
index 6dc9f34881fa..3af1204ab2bc 100644
--- a/core/camel-main/src/main/docs/main.adoc
+++ b/core/camel-main/src/main/docs/main.adoc
@@ -152,7 +152,7 @@ The camel.main supports 129 options, which are listed below.
 | *camel.main.useMdcLogging* | To turn on MDC logging (deprecated, use 
camel-mdc component instead) | false | boolean
 | *camel.main.uuidGenerator* | UUID generator to use. default (32 bytes), 
short (16 bytes), classic (32 bytes or longer), simple (long incrementing 
counter), off (turned off for exchanges - only intended for performance 
profiling) | default | String
 | *camel.main.virtualThreadsEnabled* | Whether to enable virtual threads when 
creating thread pools. When enabled, Camel will use virtual threads instead of 
platform threads for its thread pools. This can also be enabled via the JVM 
system property {code camel.threads.virtual.enabled=true} . This option must be 
read early during bootstrap, so it is set as a system property before thread 
pools are created. | false | boolean
-| *camel.main.yamlDslCompactNotationWarn* | Whether to log a WARN when YAML 
DSL routes use compact (shorthand) notation instead of the canonical 
(explicit/normalized) form. The canonical style is recommended as it is more 
tooling and AI friendly. Use Camel CLI to normalize existing routes: camel yaml 
normalize &lt;file&gt; | true | boolean
+| *camel.main.yamlDslCompactNotationWarn* | Whether to log a WARN when YAML 
DSL routes use compact (shorthand) notation instead of the canonical 
(explicit/normalized) form. The canonical style is recommended as it is more 
tooling and AI friendly. Use Camel CLI to normalize existing routes: camel 
validate normalize &lt;file&gt; | true | boolean
 |===
 
 
diff --git 
a/core/camel-main/src/main/java/org/apache/camel/main/DefaultConfigurationProperties.java
 
b/core/camel-main/src/main/java/org/apache/camel/main/DefaultConfigurationProperties.java
index 3663ee3f1564..01e3034a48db 100644
--- 
a/core/camel-main/src/main/java/org/apache/camel/main/DefaultConfigurationProperties.java
+++ 
b/core/camel-main/src/main/java/org/apache/camel/main/DefaultConfigurationProperties.java
@@ -450,7 +450,7 @@ public abstract class DefaultConfigurationProperties<T> {
     /**
      * Whether to log a WARN when YAML DSL routes use compact (shorthand) 
notation instead of the canonical
      * (explicit/normalized) form. The canonical style is recommended as it is 
more tooling and AI friendly. Use Camel
-     * CLI to normalize existing routes: camel yaml normalize &lt;file&gt;
+     * CLI to normalize existing routes: camel validate normalize &lt;file&gt;
      */
     public void setYamlDslCompactNotationWarn(boolean 
yamlDslCompactNotationWarn) {
         this.yamlDslCompactNotationWarn = yamlDslCompactNotationWarn;
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
index c04f31cc63ed..cf0840c2d10c 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
@@ -349,7 +349,7 @@ documentation for details.
 The `YamlValidator` class now accepts a `boolean canonical` constructor 
parameter to validate against the
 canonical schema.
 
-A new `camel yaml normalize` command has been added to Camel JBang. It 
rewrites YAML routes from the
+A new `camel validate normalize` command has been added to Camel JBang. It 
rewrites YAML routes from the
 classic (shorthand) form to the canonical (explicit) form. The `camel validate 
yaml` command also
 supports a new `--canonical` flag to validate against the canonical schema.
 
@@ -358,6 +358,15 @@ the canonical form. This encourages adopting the canonical 
style which is more t
 The warning is logged once per resource file. To disable this warning, set
 `camel.main.yamlDslCompactNotationWarn = false` in `application.properties`.
 
+The YAML DSL now supports classpath-discovered `YamlDeserializerResolver` 
implementations.
+Optional modules can list resolver classes in 
`META-INF/services/org/apache/camel/YamlDeserializerResolver`
+to contribute custom YAML route step names automatically when the module is on 
the classpath, without changing
+`camel-yaml-dsl` and without requiring users to register resolver beans before 
routes are parsed.
+Registry-provided resolvers remain supported.
+Custom step names are runtime extensions and are not included in the generated 
YAML DSL JSON schemas unless schema
+generation is extended separately.
+See xref:components:others:yaml-dsl.adoc#_extending_yaml_route_steps[Extending 
YAML route steps] for details.
+
 === camel-a2a (new, preview)
 
 A new `camel-a2a` component has been added providing support for the 
Agent-to-Agent (A2A) protocol.
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-yaml-dsl-validator-maven-plugin.adoc
 
b/docs/user-manual/modules/ROOT/pages/camel-yaml-dsl-validator-maven-plugin.adoc
index 2b4f4ca5cf5e..f235056945cb 100644
--- 
a/docs/user-manual/modules/ROOT/pages/camel-yaml-dsl-validator-maven-plugin.adoc
+++ 
b/docs/user-manual/modules/ROOT/pages/camel-yaml-dsl-validator-maven-plugin.adoc
@@ -8,6 +8,11 @@ The Camel YAML DSL Validator Maven Plugin supports the 
following goals
 
 For validating the YAML routes for syntax errors according to the spec.
 
+The validator uses the generated YAML DSL JSON Schema.
+Runtime extensions such as custom `YamlDeserializerResolver` implementations 
are not added to this schema, so the
+validator does not accept module-provided custom YAML step names. For those 
routes, use runtime route loading tests in
+addition to this schema validation.
+
 Then you can run the `validate` goal from the command line or from within your 
Java editor such as IDEA or Eclipse.
 
 [source,bash]
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlDeserializationContext.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlDeserializationContext.java
index 5f6fe334326e..f51ce2f2c4b3 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlDeserializationContext.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlDeserializationContext.java
@@ -16,13 +16,22 @@
  */
 package org.apache.camel.dsl.yaml.common;
 
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
+import java.util.Enumeration;
 import java.util.HashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 
 import org.apache.camel.CamelContext;
 import org.apache.camel.CamelContextAware;
@@ -44,12 +53,13 @@ import org.snakeyaml.engine.v2.nodes.ScalarNode;
 
 public class YamlDeserializationContext extends StandardConstructor implements 
CamelContextAware, Service {
 
-    private final List<YamlDeserializerResolver> resolvers;
+    private final List<ResolverEntry> resolvers;
     private final Map<String, ConstructNode> constructors;
     private CamelContext camelContext;
     private Resource resource;
     private boolean compactNotationWarn = true;
     private boolean compactNotationWarned;
+    private boolean resolversLoaded;
 
     public YamlDeserializationContext(LoadSettings settings) {
         super(settings);
@@ -58,8 +68,17 @@ public class YamlDeserializationContext extends 
StandardConstructor implements C
     }
 
     public void addResolver(YamlDeserializerResolver resolver) {
-        this.resolvers.add(resolver);
-        this.resolvers.sort(OrderedComparator.get());
+        addResolver(resolver, ResolverSource.MANUAL, null);
+    }
+
+    /**
+     * Adds a resolver owned by the YAML DSL runtime itself.
+     * <p/>
+     * Built-in resolvers are protected from accidental classpath or registry 
shadowing. External resolvers can still
+     * override built-in ids by using an order lower than {@link 
YamlDeserializerResolver#ORDER_DEFAULT}.
+     */
+    public void addBuiltinResolver(YamlDeserializerResolver resolver) {
+        addResolver(resolver, ResolverSource.BUILT_IN, null);
     }
 
     public void addResolvers(YamlDeserializerResolver... resolvers) {
@@ -67,8 +86,9 @@ public class YamlDeserializationContext extends 
StandardConstructor implements C
     }
 
     public void addResolvers(Collection<YamlDeserializerResolver> resolvers) {
-        this.resolvers.addAll(resolvers);
-        this.resolvers.sort(OrderedComparator.get());
+        for (YamlDeserializerResolver resolver : resolvers) {
+            addResolver(resolver, ResolverSource.MANUAL, null);
+        }
     }
 
     public Resource getResource() {
@@ -94,7 +114,7 @@ public class YamlDeserializationContext extends 
StandardConstructor implements C
             log.warn("YAML DSL compact notation detected in: {}."
                      + " It is recommended to use canonical/normalized YAML 
DSL notation"
                      + " which is more tooling and AI friendly."
-                     + " Use Camel CLI to normalize: camel yaml normalize 
<file>",
+                     + " Use Camel CLI to normalize: camel validate normalize 
<file>",
                     loc);
         }
     }
@@ -121,14 +141,204 @@ public class YamlDeserializationContext extends 
StandardConstructor implements C
 
     @Override
     public void start() {
+        loadResolvers();
+    }
+
+    private void loadResolvers() {
         ObjectHelper.notNull(camelContext, "camel context");
 
-        
this.resolvers.addAll(getCamelContext().getRegistry().findByType(YamlDeserializerResolver.class));
+        if (resolversLoaded) {
+            return;
+        }
+
+        addResolverEntries(loadResolversFromClasspath());
+        addResolverEntries(loadResolversFromRegistry());
+        resolversLoaded = true;
     }
 
     @Override
     public void stop() {
         this.constructors.clear();
+        this.resolvers.removeIf(entry -> entry.source == 
ResolverSource.CLASSPATH || entry.source == ResolverSource.REGISTRY);
+        this.resolversLoaded = false;
+    }
+
+    private List<ResolverEntry> loadResolversFromClasspath() {
+        List<String> resolverClassNames;
+        resolverClassNames = findResolverClassNames();
+
+        List<ResolverEntry> discoveredResolvers = new 
ArrayList<>(resolverClassNames.size());
+        for (String resolverClassName : resolverClassNames) {
+            discoveredResolvers
+                    .add(new ResolverEntry(newResolver(resolverClassName), 
ResolverSource.CLASSPATH, resolverClassName));
+        }
+        discoveredResolvers.sort(this::compareResolverEntries);
+        return discoveredResolvers;
+    }
+
+    private List<ResolverEntry> loadResolversFromRegistry() {
+        Map<String, YamlDeserializerResolver> resolvers
+                = 
getCamelContext().getRegistry().findByTypeWithName(YamlDeserializerResolver.class);
+        List<ResolverEntry> answer = new ArrayList<>(resolvers.size());
+        for (Map.Entry<String, YamlDeserializerResolver> entry : 
resolvers.entrySet()) {
+            answer.add(new ResolverEntry(entry.getValue(), 
ResolverSource.REGISTRY, entry.getKey()));
+        }
+        answer.sort(this::compareResolverEntries);
+        return answer;
+    }
+
+    private YamlDeserializerResolver newResolver(String resolverClassName) {
+        try {
+            Class<YamlDeserializerResolver> type
+                    = 
getCamelContext().getClassResolver().resolveMandatoryClass(resolverClassName,
+                            YamlDeserializerResolver.class);
+            YamlDeserializerResolver resolver = 
getCamelContext().getInjector().newInstance(type, false);
+            CamelContextAware.trySetCamelContext(resolver, getCamelContext());
+            return resolver;
+        } catch (Exception e) {
+            String message = "Error loading YAML deserializer resolver " + 
resolverClassName + " from "
+                             + YamlDeserializerResolver.RESOURCE_PATH;
+            throw new YamlDeserializationException(
+                    message,
+                    e);
+        }
+    }
+
+    private List<String> findResolverClassNames() {
+        Set<String> resolverClassNames = new LinkedHashSet<>();
+
+        for (URL url : findResolverResources()) {
+            try (BufferedReader reader
+                    = new BufferedReader(new 
InputStreamReader(url.openStream(), StandardCharsets.UTF_8))) {
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    int comment = line.indexOf('#');
+                    if (comment != -1) {
+                        line = line.substring(0, comment);
+                    }
+                    line = line.trim();
+                    if (!line.isEmpty() && !line.startsWith("#")) {
+                        resolverClassNames.add(line);
+                    }
+                }
+            } catch (IOException e) {
+                throw new YamlDeserializationException(
+                        "Error reading YAML deserializer resolver resource " + 
url + " from "
+                                                       + 
YamlDeserializerResolver.RESOURCE_PATH,
+                        e);
+            }
+        }
+
+        List<String> sortedResolverClassNames = new 
ArrayList<>(resolverClassNames);
+        Collections.sort(sortedResolverClassNames);
+        return sortedResolverClassNames;
+    }
+
+    private Set<URL> findResolverResources() {
+        Set<URL> answer = new LinkedHashSet<>();
+
+        try {
+            addResolverResources(answer,
+                    
getCamelContext().getClassResolver().loadAllResourcesAsURL(YamlDeserializerResolver.RESOURCE_PATH));
+            addResolverResources(answer, 
getCamelContext().getApplicationContextClassLoader());
+            for (ClassLoader classLoader : 
getCamelContext().getClassResolver().getClassLoaders()) {
+                addResolverResources(answer, classLoader);
+            }
+        } catch (IOException e) {
+            throw new YamlDeserializationException(
+                    "Error locating YAML deserializer resolvers from " + 
YamlDeserializerResolver.RESOURCE_PATH, e);
+        }
+
+        return answer;
+    }
+
+    private static void addResolverResources(Set<URL> answer, ClassLoader 
classLoader) throws IOException {
+        if (classLoader != null) {
+            addResolverResources(answer, 
classLoader.getResources(YamlDeserializerResolver.RESOURCE_PATH));
+        }
+    }
+
+    private static void addResolverResources(Set<URL> answer, Enumeration<URL> 
resources) {
+        if (resources != null) {
+            while (resources.hasMoreElements()) {
+                answer.add(resources.nextElement());
+            }
+        }
+    }
+
+    private void addResolver(YamlDeserializerResolver resolver, ResolverSource 
source, String name) {
+        this.resolvers.add(new ResolverEntry(resolver, source, name));
+        sortResolverEntries();
+        this.constructors.clear();
+    }
+
+    private void addResolverEntries(Collection<ResolverEntry> entries) {
+        this.resolvers.addAll(entries);
+        sortResolverEntries();
+        this.constructors.clear();
+    }
+
+    private void sortResolverEntries() {
+        this.resolvers.sort(this::compareResolverEntries);
+    }
+
+    private int compareResolverEntries(ResolverEntry entry1, ResolverEntry 
entry2) {
+        int result = compareBuiltInBoundary(entry1, entry2);
+        if (result != 0) {
+            return result;
+        }
+
+        result = OrderedComparator.get().compare(entry1.resolver, 
entry2.resolver);
+        if (result != 0) {
+            return result;
+        }
+
+        result = Integer.compare(entry1.source.rank, entry2.source.rank);
+        if (result != 0) {
+            return result;
+        }
+
+        result = 
entry1.resolver.getClass().getName().compareTo(entry2.resolver.getClass().getName());
+        if (result != 0) {
+            return result;
+        }
+
+        return entry1.name.compareTo(entry2.name);
+    }
+
+    private int compareBuiltInBoundary(ResolverEntry entry1, ResolverEntry 
entry2) {
+        if (entry1.source == ResolverSource.BUILT_IN && entry2.source != 
ResolverSource.BUILT_IN) {
+            return entry2.resolver.getOrder() < 
YamlDeserializerResolver.ORDER_DEFAULT ? 1 : -1;
+        }
+        if (entry1.source != ResolverSource.BUILT_IN && entry2.source == 
ResolverSource.BUILT_IN) {
+            return entry1.resolver.getOrder() < 
YamlDeserializerResolver.ORDER_DEFAULT ? -1 : 1;
+        }
+        return 0;
+    }
+
+    private static final class ResolverEntry {
+        private final YamlDeserializerResolver resolver;
+        private final ResolverSource source;
+        private final String name;
+
+        private ResolverEntry(YamlDeserializerResolver resolver, 
ResolverSource source, String name) {
+            this.resolver = resolver;
+            this.source = source;
+            this.name = name != null ? name : resolver.getClass().getName();
+        }
+    }
+
+    private enum ResolverSource {
+        BUILT_IN(0),
+        MANUAL(1),
+        REGISTRY(2),
+        CLASSPATH(3);
+
+        private final int rank;
+
+        ResolverSource(int rank) {
+            this.rank = rank;
+        }
     }
 
     // *********************************
@@ -229,8 +439,8 @@ public class YamlDeserializationContext extends 
StandardConstructor implements C
         return constructors.computeIfAbsent(id, (String s) -> {
             ConstructNode answer = null;
 
-            for (YamlDeserializerResolver resolver : resolvers) {
-                answer = resolver.resolve(id);
+            for (ResolverEntry entry : resolvers) {
+                answer = entry.resolver.resolve(id);
                 if (answer != null) {
                     break;
                 }
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlDeserializerResolver.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlDeserializerResolver.java
index 43870696a867..a9955e79ea5a 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlDeserializerResolver.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/main/java/org/apache/camel/dsl/yaml/common/YamlDeserializerResolver.java
@@ -21,16 +21,39 @@ import org.snakeyaml.engine.v2.api.ConstructNode;
 
 public interface YamlDeserializerResolver extends Ordered {
 
+    /**
+     * Classpath resource containing YAML deserializer resolver class names to 
load automatically.
+     */
+    String RESOURCE_PATH = 
"META-INF/services/org/apache/camel/YamlDeserializerResolver";
+
     int ORDER_DEFAULT = 0;
     int ORDER_HIGHEST = Ordered.HIGHEST;
     int ORDER_LOWEST = Ordered.LOWEST;
 
+    /**
+     * Resolves a YAML step name or model class name to a SnakeYAML 
constructor.
+     * <p/>
+     * Return {@code null} when this resolver does not own the given id. 
Resolver implementations should not throw for
+     * unsupported ids. If multiple resolvers return a constructor for the 
same id, the first resolver in precedence
+     * order wins. The selected constructor may be cached by the YAML 
deserialization context for the lifetime of that
+     * context.
+     *
+     * @param  id YAML node name, route step name, or Java model class name
+     * @return    constructor for supported ids, or {@code null} for 
unsupported ids
+     */
     ConstructNode resolve(String id);
 
     default ConstructNode resolve(Class<?> type) {
         return resolve(type.getName());
     }
 
+    /**
+     * Gets the resolver precedence.
+     * <p/>
+     * Lower values have higher precedence. Component-provided resolvers 
should normally use an order at or above
+     * {@link #ORDER_DEFAULT}. Use values below {@link #ORDER_DEFAULT} only 
when intentionally overriding a built-in
+     * YAML DSL resolver.
+     */
     @Override
     default int getOrder() {
         return ORDER_DEFAULT;
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/test/groovy/org/apache/camel/dsl/yaml/common/ConstructorResolverTest.groovy
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/test/groovy/org/apache/camel/dsl/yaml/common/ConstructorResolverTest.groovy
index cd8b85ac4f0d..d7956a3ab241 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/test/groovy/org/apache/camel/dsl/yaml/common/ConstructorResolverTest.groovy
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-common/src/test/groovy/org/apache/camel/dsl/yaml/common/ConstructorResolverTest.groovy
@@ -35,7 +35,7 @@ class ConstructorResolverTest extends Specification {
 
     }
 
-    def "test"() {
+    def "preserves same order resolvers for different node ids"() {
         given:
             def settings = LoadSettings.builder().build()
         when:
@@ -65,6 +65,30 @@ class ConstructorResolverTest extends Specification {
 
     }
 
+    def "clears constructor cache when resolver list changes"() {
+        given:
+            def settings = LoadSettings.builder().build()
+            def ctr = new YamlDeserializationContext(settings)
+            ctr.setCamelContext(new DefaultCamelContext())
+            ctr.addResolver(new FixedMyNodeResolver('first', 
YamlDeserializerResolver.ORDER_DEFAULT + 1))
+
+            def load = new Load(settings, ctr)
+
+        when:
+            def first = load.loadFromString('''
+                - my-node: {}
+            '''.stripLeading())
+
+            ctr.addResolver(new FixedMyNodeResolver('second', 
YamlDeserializerResolver.ORDER_DEFAULT))
+            def second = load.loadFromString('''
+                - my-node: {}
+            '''.stripLeading())
+
+        then:
+            first[0].message == 'first'
+            second[0].message == 'second'
+    }
+
 
     static class MyNodeResolver implements YamlDeserializerResolver {
         @Override
@@ -90,6 +114,49 @@ class ConstructorResolverTest extends Specification {
         }
     }
 
+    static class FixedMyNodeResolver implements YamlDeserializerResolver {
+        private final String message
+        private final int order
+
+        FixedMyNodeResolver(String message, int order) {
+            this.message = message
+            this.order = order
+        }
+
+        @Override
+        int getOrder() {
+            return order
+        }
+
+        @Override
+        ConstructNode resolve(String id) {
+            switch (id) {
+                case 'my-node':
+                    return new FixedMyNodeConstructor(message)
+            }
+            return null
+        }
+    }
+
+    static class FixedMyNodeConstructor extends YamlDeserializerBase<MyNode> {
+        private final String message
+
+        FixedMyNodeConstructor(String message) {
+            super(MyNode.class)
+            this.message = message
+        }
+
+        @Override
+        protected MyNode newInstance() {
+            return new MyNode(message: message)
+        }
+
+        @Override
+        protected boolean setProperty(MyNode target, String propertyKey, 
String propertyName, Node value) {
+            return false
+        }
+    }
+
     @ToString
     static class MyNode {
         String message
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator-maven-plugin/src/main/docs/camel-yaml-dsl-validator-maven-plugin.adoc
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator-maven-plugin/src/main/docs/camel-yaml-dsl-validator-maven-plugin.adoc
index 2b4f4ca5cf5e..f235056945cb 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator-maven-plugin/src/main/docs/camel-yaml-dsl-validator-maven-plugin.adoc
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator-maven-plugin/src/main/docs/camel-yaml-dsl-validator-maven-plugin.adoc
@@ -8,6 +8,11 @@ The Camel YAML DSL Validator Maven Plugin supports the 
following goals
 
 For validating the YAML routes for syntax errors according to the spec.
 
+The validator uses the generated YAML DSL JSON Schema.
+Runtime extensions such as custom `YamlDeserializerResolver` implementations 
are not added to this schema, so the
+validator does not accept module-provided custom YAML step names. For those 
routes, use runtime route loading tests in
+addition to this schema validation.
+
 Then you can run the `validate` goal from the command line or from within your 
Java editor such as IDEA or Eclipse.
 
 [source,bash]
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/CamelYamlParserTest.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/CamelYamlParserTest.java
index 084069fc45df..3c5a4b1ce34e 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/CamelYamlParserTest.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/CamelYamlParserTest.java
@@ -18,9 +18,16 @@ package org.apache.camel.dsl.yaml.validator;
 
 import java.io.File;
 
+import org.apache.camel.dsl.yaml.common.YamlDeserializerBase;
+import org.apache.camel.dsl.yaml.common.YamlDeserializerResolver;
+import org.apache.camel.model.StepDefinition;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.snakeyaml.engine.v2.api.ConstructNode;
+import org.snakeyaml.engine.v2.nodes.Node;
+
+import static org.apache.camel.dsl.yaml.common.YamlDeserializerSupport.asText;
 
 public class CamelYamlParserTest {
 
@@ -41,6 +48,11 @@ public class CamelYamlParserTest {
         Assertions.assertTrue(parser.parse(new 
File("src/test/resources/foo2.yaml")).isEmpty());
     }
 
+    @Test
+    public void testParseRuntimeCustomStep() throws Exception {
+        Assertions.assertTrue(parser.parse(new 
File("src/test/resources/custom-parser-step.yaml")).isEmpty());
+    }
+
     @Test
     public void testParseBad() throws Exception {
         var report = parser.parse(new File("src/test/resources/bad.yaml"));
@@ -58,4 +70,39 @@ public class CamelYamlParserTest {
         Assertions.assertTrue(report.get(0).getMessage().contains("Unknown 
node id: setCheese"));
         Assertions.assertTrue(report.get(0).getMessage().contains("- 
setCheese:"));
     }
+
+    public static final class ParserStepResolver implements 
YamlDeserializerResolver {
+        @Override
+        public ConstructNode resolve(String id) {
+            if ("parserStep".equals(id)) {
+                return new ParserStepDeserializer();
+            }
+            return null;
+        }
+    }
+
+    static final class ParserStepDeserializer extends 
YamlDeserializerBase<StepDefinition> {
+        ParserStepDeserializer() {
+            super(StepDefinition.class);
+        }
+
+        @Override
+        protected StepDefinition newInstance() {
+            return new StepDefinition();
+        }
+
+        @Override
+        protected boolean setProperty(StepDefinition target, String 
propertyKey, String propertyName, Node value) {
+            switch (propertyKey) {
+                case "id":
+                    target.setId(asText(value));
+                    return true;
+                case "steps":
+                    setSteps(target, value);
+                    return true;
+                default:
+                    return false;
+            }
+        }
+    }
 }
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/resources/META-INF/services/org/apache/camel/YamlDeserializerResolver
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/resources/META-INF/services/org/apache/camel/YamlDeserializerResolver
new file mode 100644
index 000000000000..cdc8a804ada0
--- /dev/null
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/resources/META-INF/services/org/apache/camel/YamlDeserializerResolver
@@ -0,0 +1 @@
+org.apache.camel.dsl.yaml.validator.CamelYamlParserTest$ParserStepResolver
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/resources/custom-parser-step.yaml
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/resources/custom-parser-step.yaml
new file mode 100644
index 000000000000..8da5db380db1
--- /dev/null
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/resources/custom-parser-step.yaml
@@ -0,0 +1,25 @@
+#
+# 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.
+#
+
+- from:
+    uri: "direct:start"
+    steps:
+      - parserStep:
+          id: "parser-step"
+          steps:
+            - to:
+                uri: "mock:result"
diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/docs/yaml-dsl.adoc 
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/docs/yaml-dsl.adoc
index ba234537b7ad..df4ed3a30ff0 100644
--- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/docs/yaml-dsl.adoc
+++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/docs/yaml-dsl.adoc
@@ -105,6 +105,136 @@ marshal:
 In case you want to use the data-format's default settings, you need to place 
an empty block as data format parameters, like `json: {}`
 ====
 
+=== Extending YAML route steps
+
+Optional Camel modules can contribute YAML route step deserializers without 
changing `camel-yaml-dsl`.
+This is useful when a module provides a custom route model definition, or 
wants to expose a short YAML step name that maps
+to a normal Camel route model definition.
+
+To contribute custom step deserializers, add a resource named:
+
+[source,text]
+----
+META-INF/services/org/apache/camel/YamlDeserializerResolver
+----
+
+The resource contains one 
`org.apache.camel.dsl.yaml.common.YamlDeserializerResolver` implementation 
class name per line.
+Blank lines and comments starting with `#` are ignored.
+When packaging an application as an uber JAR or shaded JAR, merge this service 
resource from all contributing modules.
+
+[source,text]
+----
+com.acme.camel.MyYamlStepResolver
+----
+
+The resolver returns a SnakeYAML `ConstructNode` for the step names, or model 
class names, it supports.
+The deserializer should create and configure the normal Camel model object 
that the custom YAML step represents:
+
+[source,java]
+----
+import org.apache.camel.dsl.yaml.common.YamlDeserializerBase;
+import org.apache.camel.dsl.yaml.common.YamlDeserializerResolver;
+import org.apache.camel.model.StepDefinition;
+import org.snakeyaml.engine.v2.api.ConstructNode;
+import org.snakeyaml.engine.v2.nodes.Node;
+
+import static org.apache.camel.dsl.yaml.common.YamlDeserializerSupport.asText;
+
+public final class MyYamlStepResolver implements YamlDeserializerResolver {
+    @Override
+    public int getOrder() {
+        return YamlDeserializerResolver.ORDER_DEFAULT + 1;
+    }
+
+    @Override
+    public ConstructNode resolve(String id) {
+        if ("myStep".equals(id)) {
+            return new MyStepDeserializer();
+        }
+        return null;
+    }
+}
+
+final class MyStepDeserializer extends YamlDeserializerBase<StepDefinition> {
+    public MyStepDeserializer() {
+        super(StepDefinition.class);
+    }
+
+    @Override
+    protected StepDefinition newInstance() {
+        return new StepDefinition();
+    }
+
+    @Override
+    protected boolean setProperty(StepDefinition target, String propertyKey, 
String propertyName, Node value) {
+        switch (propertyKey) {
+            case "id":
+                target.setId(asText(value));
+                return true;
+            case "steps":
+                setSteps(target, value);
+                return true;
+            default:
+                return false;
+        }
+    }
+}
+----
+
+This allows YAML such as:
+
+[source,yaml]
+----
+- from:
+    uri: "direct:start"
+    steps:
+      - myStep:
+          id: "custom-step"
+          steps:
+            - to:
+                uri: "mock:result"
+----
+
+Resolvers are discovered when YAML routes are parsed. Camel loads the built-in 
YAML DSL resolvers first, then the
+classpath-discovered resolvers from 
`META-INF/services/org/apache/camel/YamlDeserializerResolver`, and then any
+`YamlDeserializerResolver` beans found in the Camel registry.
+Camel looks for resolver resources through the Camel class resolver, the 
application context classloader, and any
+classloaders registered with the Camel class resolver.
+
+All resolvers are ordered by `YamlDeserializerResolver#getOrder()`, where 
lower order values have higher precedence.
+Built-in YAML DSL resolvers win over classpath-discovered and 
registry-provided resolvers unless the external resolver
+uses an order lower than `YamlDeserializerResolver.ORDER_DEFAULT`.
+For external resolvers with the same order, registry-provided resolvers are 
tried before classpath-discovered resolvers.
+Within the same source and order, resolvers are ordered by fully qualified 
class name, and registry resolvers use the
+registry bean name as a final tie-breaker.
+If several resolvers can resolve the same step name, the first matching 
resolver wins.
+
+The built-in resolver for hand-written YAML DSL conveniences uses 
`YamlDeserializerResolver.ORDER_DEFAULT`, and the
+generated model resolver is also treated as a built-in resolver.
+Use an order lower than `ORDER_DEFAULT` only when a custom resolver 
intentionally needs to override any built-in YAML
+name.
+
+Resolver classes are instantiated through Camel's injector. If the resolver 
implements `CamelContextAware`, Camel sets
+the current `CamelContext` before the resolver is used.
+The same discovery mechanism is used by the YAML routes loader and the Kamelet 
routes loader, so custom route steps can
+be used in regular YAML routes and in Kamelet template step lists.
+
+When a custom step maps to a model definition that supports child outputs, the 
deserializer can parse nested YAML
+`steps` using `YamlDeserializerSupport.setSteps`.
+Nested `steps` are only meaningful for model definitions that can contain 
outputs, such as `Block` implementations.
+If the optional module that provides the resolver is not on the classpath, 
Camel does not load that resolver. YAML files
+that use the custom step name then fail with an unknown node error; YAML files 
that do not use the custom step are
+unaffected.
+
+Custom YAML step names are runtime extensions.
+The generated YAML DSL JSON schemas only describe the built-in DSL and do not 
validate module-provided step names.
+This also applies to IDE validation and schema-based tooling.
+
+YAML routes and resolver providers are trusted route-author and deployment 
inputs under Camel's security model.
+This SPI is not a sandbox for processing untrusted YAML documents.
+Resolver implementations should not fetch external resources, load classes 
named by route fields, or evaluate route
+fields as expressions while parsing YAML.
+
 == Defining endpoints
 
 To define an endpoint with the YAML dsl you have two options:
@@ -695,7 +825,7 @@ The warning is logged once per resource file and looks like:
 ----
 YAML DSL compact notation detected in: myroute.yaml.
 It is recommended to use canonical/normalized YAML DSL notation which is more 
tooling and AI friendly.
-Use Camel CLI to normalize: camel yaml normalize <file>
+Use Camel CLI to normalize: camel validate normalize <file>
 ----
 
 To disable this warning, set the following property in 
`application.properties`:
@@ -734,5 +864,3 @@ which demonstrate creating Camel Routes with YAML.
 
 Another way to find examples of YAML DSL is to look in 
https://github.com/apache/camel-kamelets[Camel Kamelets]
 where each Kamelet is defined using YAML.
-
-
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/YamlRoutesBuilderLoaderSupport.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/YamlRoutesBuilderLoaderSupport.java
index 1e3bb0d5ec70..1b0ea4f5fd46 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/YamlRoutesBuilderLoaderSupport.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/YamlRoutesBuilderLoaderSupport.java
@@ -56,8 +56,9 @@ public abstract class YamlRoutesBuilderLoaderSupport extends 
RouteBuilderLoaderS
 
         ctx.setResource(resource);
         ctx.setCamelContext(getCamelContext());
-        ctx.addResolvers(new CustomResolver(beansDeserializer));
-        ctx.addResolvers(new ModelDeserializersResolver());
+        ctx.addBuiltinResolver(new CustomResolver(beansDeserializer));
+        ctx.addBuiltinResolver(new ModelDeserializersResolver());
+        ctx.start();
         // check if compact notation warning is disabled
         try {
             String v = 
getCamelContext().resolvePropertyPlaceholders("{{?camel.main.yamlDslCompactNotationWarn}}");
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/YamlDeserializerResolverDiscoveryTest.groovy
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/YamlDeserializerResolverDiscoveryTest.groovy
new file mode 100644
index 000000000000..84058e7620b4
--- /dev/null
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/groovy/org/apache/camel/dsl/yaml/YamlDeserializerResolverDiscoveryTest.groovy
@@ -0,0 +1,496 @@
+/*
+ * 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.dsl.yaml
+
+import org.apache.camel.component.mock.MockEndpoint
+import org.apache.camel.dsl.yaml.common.YamlDeserializerBase
+import org.apache.camel.dsl.yaml.common.YamlDeserializerResolver
+import org.apache.camel.dsl.yaml.common.exception.UnknownNodeIdException
+import org.apache.camel.dsl.yaml.common.exception.YamlDeserializationException
+import org.apache.camel.dsl.yaml.support.YamlTestSupport
+import org.apache.camel.model.LogDefinition
+import org.apache.camel.model.StepDefinition
+import org.apache.camel.model.ToDefinition
+import org.snakeyaml.engine.v2.api.ConstructNode
+import org.snakeyaml.engine.v2.nodes.Node
+import org.snakeyaml.engine.v2.nodes.ScalarNode
+
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.Path
+
+class YamlDeserializerResolverDiscoveryTest extends YamlTestSupport {
+
+    def "discover custom route step resolver from classpath"() {
+        when:
+            loadRoutesNoValidate '''
+                - from:
+                    uri: "direct:start"
+                    steps:
+                      - customStep:
+                          id: "custom-step"
+                          steps:
+                            - log:
+                                message: "nested"
+                            - to:
+                                uri: "mock:result"
+            '''
+
+        then:
+            with(context.routeDefinitions[0].outputs[0], StepDefinition) {
+                id == 'custom-step'
+
+                with(outputs[0], LogDefinition) {
+                    message == 'nested'
+                }
+                with(outputs[1], ToDefinition) {
+                    endpointUri == 'mock:result'
+                }
+            }
+    }
+
+    def "discovered custom route step executes through nested outputs"() {
+        given:
+            loadRoutesNoValidate '''
+                - from:
+                    uri: "direct:start"
+                    steps:
+                      - customStep:
+                          steps:
+                            - to:
+                                uri: "mock:result"
+            '''
+
+            withMock('mock:result') {
+                expectedBodiesReceived 'hello'
+            }
+
+        when:
+            context.start()
+            withTemplate {
+                to('direct:start').withBody('hello').send()
+            }
+
+        then:
+            MockEndpoint.assertIsSatisfied(context)
+    }
+
+    def "registry resolver contributes custom route step"() {
+        given:
+            context.registry.bind('registryStepResolver',
+                    new FixedStepResolver('registryStep', 'registry-step', 
YamlDeserializerResolver.ORDER_DEFAULT))
+
+        when:
+            loadRoutesNoValidate '''
+                - from:
+                    uri: "direct:start"
+                    steps:
+                      - registryStep: {}
+            '''
+
+        then:
+            with(context.routeDefinitions[0].outputs[0], StepDefinition) {
+                id == 'registry-step'
+            }
+    }
+
+    def "registry resolver wins over classpath resolver at same order"() {
+        given:
+            context.registry.bind('registryCustomStepResolver',
+                    new FixedStepResolver('customStep', 
'registry-custom-step', YamlDeserializerResolver.ORDER_DEFAULT + 1))
+
+        when:
+            loadRoutesNoValidate '''
+                - from:
+                    uri: "direct:start"
+                    steps:
+                      - customStep: {}
+            '''
+
+        then:
+            with(context.routeDefinitions[0].outputs[0], StepDefinition) {
+                id == 'registry-custom-step'
+            }
+    }
+
+    def "lower order classpath resolver wins over higher order registry 
resolver"() {
+        given:
+            context.registry.bind('registryCustomStepResolver',
+                    new FixedStepResolver('customStep', 
'registry-custom-step', YamlDeserializerResolver.ORDER_DEFAULT + 2))
+
+        when:
+            loadRoutesNoValidate '''
+                - from:
+                    uri: "direct:start"
+                    steps:
+                      - customStep: {}
+            '''
+
+        then:
+            with(context.routeDefinitions[0].outputs[0], StepDefinition) {
+                id == 'classpath-custom-step'
+            }
+    }
+
+    def "same order classpath resolvers use deterministic class name 
ordering"() {
+        when:
+            loadRoutesNoValidate '''
+                - from:
+                    uri: "direct:start"
+                    steps:
+                      - sameOrderStep: {}
+            '''
+
+        then:
+            with(context.routeDefinitions[0].outputs[0], StepDefinition) {
+                id == 'alpha-same-order'
+            }
+    }
+
+    def "resolver order chooses highest precedence resolver"() {
+        given:
+            context.registry.bind('lowestOrderResolver',
+                    new FixedStepResolver('orderedStep', 'lowest-order', 
YamlDeserializerResolver.ORDER_LOWEST))
+            context.registry.bind('defaultOrderResolver',
+                    new FixedStepResolver('orderedStep', 'default-order', 
YamlDeserializerResolver.ORDER_DEFAULT))
+            context.registry.bind('highestOrderResolver',
+                    new FixedStepResolver('orderedStep', 'highest-order', 
YamlDeserializerResolver.ORDER_HIGHEST))
+
+        when:
+            loadRoutesNoValidate '''
+                - from:
+                    uri: "direct:start"
+                    steps:
+                      - orderedStep: {}
+            '''
+
+        then:
+            with(context.routeDefinitions[0].outputs[0], StepDefinition) {
+                id == 'highest-order'
+            }
+    }
+
+    def "same class same order registry resolvers use registry name tie 
breaker"() {
+        given:
+            context.registry.bind('alphaRegistryResolver',
+                    new FixedStepResolver('sameClassRegistryStep', 
'alpha-registry', YamlDeserializerResolver.ORDER_DEFAULT + 1))
+            context.registry.bind('betaRegistryResolver',
+                    new FixedStepResolver('sameClassRegistryStep', 
'beta-registry', YamlDeserializerResolver.ORDER_DEFAULT + 1))
+
+        when:
+            loadRoutesNoValidate '''
+                - from:
+                    uri: "direct:start"
+                    steps:
+                      - sameClassRegistryStep: {}
+            '''
+
+        then:
+            with(context.routeDefinitions[0].outputs[0], StepDefinition) {
+                id == 'alpha-registry'
+            }
+    }
+
+    def "default order classpath resolver does not shadow generated built in 
step"() {
+        when:
+            loadRoutesNoValidate '''
+                - from:
+                    uri: "direct:start"
+                    steps:
+                      - to:
+                          uri: "mock:result"
+            '''
+
+        then:
+            with(context.routeDefinitions[0].outputs[0], ToDefinition) {
+                endpointUri == 'mock:result'
+            }
+    }
+
+    def "explicit lower order registry resolver can override generated built 
in step"() {
+        given:
+            context.registry.bind('overrideToResolver',
+                    new FixedStepResolver('to', 'override-to', 
YamlDeserializerResolver.ORDER_DEFAULT - 1))
+
+        when:
+            loadRoutesNoValidate '''
+                - from:
+                    uri: "direct:start"
+                    steps:
+                      - to:
+                          uri: "mock:result"
+            '''
+
+        then:
+            with(context.routeDefinitions[0].outputs[0], StepDefinition) {
+                id == 'override-to'
+            }
+    }
+
+    def "application context classloader resolver resource is discovered"() {
+        given:
+            def classLoader = resolverResourceClassLoader(
+                    ApplicationContextStepResolver.name + ' # inline comments 
are allowed')
+            context.applicationContextClassLoader = classLoader
+
+        when:
+            loadRoutesNoValidate '''
+                - from:
+                    uri: "direct:start"
+                    steps:
+                      - applicationStep: {}
+            '''
+
+        then:
+            with(context.routeDefinitions[0].outputs[0], StepDefinition) {
+                id == 'application-context-step'
+            }
+
+        cleanup:
+            classLoader?.close()
+    }
+
+    def "kamelet route template discovers custom route step"() {
+        when:
+            loadKamelets '''
+                apiVersion: camel.apache.org/v1
+                kind: Kamelet
+                metadata:
+                  name: custom-step-source
+                spec:
+                  definition:
+                    title: "Custom Step Source"
+                    type: object
+                    properties: {}
+                  template:
+                    from:
+                      uri: "kamelet:source"
+                      steps:
+                        - customStep:
+                            id: "kamelet-custom-step"
+                            steps:
+                              - to:
+                                  uri: "mock:result"
+            '''
+
+        then:
+            context.routeTemplateDefinitions.size() == 1
+            with(context.routeTemplateDefinitions[0].route.outputs[0], 
StepDefinition) {
+                id == 'kamelet-custom-step'
+                with(outputs[0], ToDefinition) {
+                    endpointUri == 'mock:result'
+                }
+            }
+    }
+
+    def "unknown custom step reports node id"() {
+        when:
+            loadRoutesNoValidate '''
+                - from:
+                    uri: "direct:start"
+                    steps:
+                      - missingOptionalStep: {}
+            '''
+
+        then:
+            def e = thrown(UnknownNodeIdException)
+            e.message.contains('Unknown node id: missingOptionalStep')
+    }
+
+    def "broken resolver service entry reports provider class"() {
+        given:
+            def classLoader = 
resolverResourceClassLoader('com.acme.camel.MissingYamlStepResolver')
+            context.applicationContextClassLoader = classLoader
+
+        when:
+            loadRoutesNoValidate '''
+                - from:
+                    uri: "direct:start"
+                    steps:
+                      - to:
+                          uri: "mock:result"
+            '''
+
+        then:
+            def e = thrown(YamlDeserializationException)
+            e.message.contains('com.acme.camel.MissingYamlStepResolver')
+            e.message.contains(YamlDeserializerResolver.RESOURCE_PATH)
+
+        cleanup:
+            classLoader?.close()
+    }
+
+    private static URLClassLoader resolverResourceClassLoader(String 
resolverLine) {
+        Path root = Files.createTempDirectory('yaml-resolver-provider')
+        Path serviceDirectory = 
root.resolve('META-INF/services/org/apache/camel')
+        Files.createDirectories(serviceDirectory)
+        Files.writeString(
+                serviceDirectory.resolve('YamlDeserializerResolver'),
+                resolverLine + System.lineSeparator(),
+                StandardCharsets.UTF_8)
+        return new URLClassLoader([root.toUri().toURL()] as URL[], 
Thread.currentThread().contextClassLoader)
+    }
+
+    static class CustomStepResolver implements YamlDeserializerResolver {
+        @Override
+        int getOrder() {
+            return YamlDeserializerResolver.ORDER_DEFAULT + 1
+        }
+
+        @Override
+        ConstructNode resolve(String id) {
+            if (id == 'customStep') {
+                return new CustomStepDeserializer('classpath-custom-step')
+            }
+            return null
+        }
+    }
+
+    static class LowerPrecedenceCustomStepResolver implements 
YamlDeserializerResolver {
+        @Override
+        int getOrder() {
+            return YamlDeserializerResolver.ORDER_DEFAULT + 2
+        }
+
+        @Override
+        ConstructNode resolve(String id) {
+            if (id == 'customStep') {
+                return new LowerPrecedenceCustomStepDeserializer()
+            }
+            return null
+        }
+    }
+
+    static class CustomStepDeserializer extends 
YamlDeserializerBase<StepDefinition> {
+        private final String defaultId
+
+        CustomStepDeserializer(String defaultId) {
+            super(StepDefinition.class)
+            this.defaultId = defaultId
+        }
+
+        @Override
+        protected StepDefinition newInstance() {
+            def step = new StepDefinition()
+            step.setId(defaultId)
+            return step
+        }
+
+        @Override
+        protected boolean setProperty(StepDefinition target, String 
propertyKey, String propertyName, Node value) {
+            switch (propertyKey) {
+                case 'id':
+                    target.setId(((ScalarNode)value).value)
+                    break
+                case 'steps':
+                    setSteps(target, value)
+                    break
+                default:
+                    return false
+            }
+            return true
+        }
+    }
+
+    static class LowerPrecedenceCustomStepDeserializer extends 
YamlDeserializerBase<StepDefinition> {
+        LowerPrecedenceCustomStepDeserializer() {
+            super(StepDefinition.class)
+        }
+
+        @Override
+        protected StepDefinition newInstance() {
+            def step = new StepDefinition()
+            step.setId('lower-precedence')
+            return step
+        }
+
+        @Override
+        protected boolean setProperty(StepDefinition target, String 
propertyKey, String propertyName, Node value) {
+            return true
+        }
+    }
+
+    static class FixedStepResolver implements YamlDeserializerResolver {
+        private final String stepName
+        private final String stepId
+        private final int order
+
+        FixedStepResolver(String stepName, String stepId, int order) {
+            this.stepName = stepName
+            this.stepId = stepId
+            this.order = order
+        }
+
+        @Override
+        int getOrder() {
+            return order
+        }
+
+        @Override
+        ConstructNode resolve(String id) {
+            if (id == stepName) {
+                return new FixedStepDeserializer(stepId)
+            }
+            return null
+        }
+    }
+
+    static class FixedStepDeserializer extends 
YamlDeserializerBase<StepDefinition> {
+        private final String stepId
+
+        FixedStepDeserializer(String stepId) {
+            super(StepDefinition.class)
+            this.stepId = stepId
+        }
+
+        @Override
+        protected StepDefinition newInstance() {
+            def step = new StepDefinition()
+            step.setId(stepId)
+            return step
+        }
+
+        @Override
+        protected boolean setProperty(StepDefinition target, String 
propertyKey, String propertyName, Node value) {
+            return true
+        }
+    }
+
+    static class AlphaSameOrderStepResolver extends FixedStepResolver {
+        AlphaSameOrderStepResolver() {
+            super('sameOrderStep', 'alpha-same-order', 
YamlDeserializerResolver.ORDER_DEFAULT + 1)
+        }
+    }
+
+    static class BetaSameOrderStepResolver extends FixedStepResolver {
+        BetaSameOrderStepResolver() {
+            super('sameOrderStep', 'beta-same-order', 
YamlDeserializerResolver.ORDER_DEFAULT + 1)
+        }
+    }
+
+    static class GeneratedToCollisionResolver extends FixedStepResolver {
+        GeneratedToCollisionResolver() {
+            super('to', 'shadowed-to', YamlDeserializerResolver.ORDER_DEFAULT 
+ 1)
+        }
+    }
+
+    static class ApplicationContextStepResolver extends FixedStepResolver {
+        ApplicationContextStepResolver() {
+            super('applicationStep', 'application-context-step', 
YamlDeserializerResolver.ORDER_DEFAULT + 1)
+        }
+    }
+}
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/resources/META-INF/services/org/apache/camel/YamlDeserializerResolver
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/resources/META-INF/services/org/apache/camel/YamlDeserializerResolver
new file mode 100644
index 000000000000..7d6dbf9585f2
--- /dev/null
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/test/resources/META-INF/services/org/apache/camel/YamlDeserializerResolver
@@ -0,0 +1,5 @@
+org.apache.camel.dsl.yaml.YamlDeserializerResolverDiscoveryTest$LowerPrecedenceCustomStepResolver
+org.apache.camel.dsl.yaml.YamlDeserializerResolverDiscoveryTest$CustomStepResolver
+org.apache.camel.dsl.yaml.YamlDeserializerResolverDiscoveryTest$BetaSameOrderStepResolver
+org.apache.camel.dsl.yaml.YamlDeserializerResolverDiscoveryTest$AlphaSameOrderStepResolver
+org.apache.camel.dsl.yaml.YamlDeserializerResolverDiscoveryTest$GeneratedToCollisionResolver

Reply via email to